As requested, I’m sharing the JXA Smart Rule script I currently use. This script updates DEVONthink custom metadata from the YAML frontmatter in Markdown files and employs a YAML hash to process only changed files.
Key dependencies are yq
for YAML parsing (the YQ_PATH
variable in the script needs to be set to your yq
executable’s path) and my ShellHelper.scptd
JXA library for running shell commands. The AppleScript for ShellHelper.scptd
is provided below; it should be placed in ~/Library/Script Libraries/
.
Please configure the METADATA_MAPPINGS
, HASH_MD_IDENTIFIER
, and RENEWAL_DATE_MD_IDENTIFIER
constants in the JXA script. You will also need to pre-define these corresponding custom metadata fields in DEVONthink (Preferences > Data) with the data types as specified in the script’s METADATA_MAPPINGS
.
To quickly test
First, in DEVONthink, set up a custom metadata field with the identifier price
and select Integer
as its type.
Next, ensure the METADATA_MAPPINGS
in the JXA script includes an entry like \"price\": { id: \"price\", type: \"integer\" }
.
Then, create a Markdown file with the following YAML frontmatter:
---
price: 27900
---
Finally, apply this JXA script (e.g., via a Smart Rule using ‘Apply Script > Javascript’) to the Markdown file. The price
custom metadata field for that record should then be populated with the value 27900.
For debugging, setting DEBUG_MODE = true;
at the top of the JXA script enables logging to /tmp/Update_Markdown_YAML_to_Metadata.log
.
The JXA script is first, then the ShellHelper
AppleScript.
JXA
:
function performsmartrule(records) {
'use strict';
// 설정
const DEBUG_MODE = false; // true 시 상세 로그 기록
const LOG_FILE_NAME = "Update_Markdown_YAML_to_Metadata.log"; // 디버그 로그 파일명
// DT 커스텀 메타데이터 ID
const HASH_MD_IDENTIFIER = "YAMLHash"; // YAML 해시용 메타데이터 ID
const RENEWAL_DATE_MD_IDENTIFIER = "YAMLUpdateDate"; // 최종 업데이트 날짜용 메타데이터 ID
// YAML와 커스텀 메타데이터 매핑
// 형식: "YAML키": { id: "DT메타데이터ID", type: "데이터타입" }
// 데이터타입: "string", "integer", "date", "boolean", "real" 등
const METADATA_MAPPINGS = {
// 아래 목록을 필요에 따라 추가/수정/삭제
"name": { id: "name", type: "string" }, // 아이템명
"originalName": { id: "originalname", type: "string" }, // 아이템명/원문
"category": { id: "category", type: "string" }, // 분류
"quantity": { id: "quantity", type: "integer" }, // 수량
"price": { id: "price", type: "integer" }, // 구매 가격
"purchaseDate": { id: "purchasedate", type: "date" }, // 구매일
"additionalPurchaseDates": { id: "additionalPurchaseDates", type: "string" }, // 추가 구매일
"vendor": { id: "vendor", type: "string" }, // 구매처
"condition": { id: "condition", type: "string" }, // 상태 (새것, 중고 등)
"isSold": { id: "issold", type: "boolean" }, // 판매 여부 (체크박스)
"rating": { id: "rating", type: "integer" }, // 개인 평점
"notes": { id: "notes", type: "string" }, // 추가 메모
"warrantyExpiryDate": { id: "warrantyexpirydate", type: "date" }, // 보증 만료일
// --- 아래는 필요시 주석 해제 또는 다른 필드 추가 ---
// "salePrice": { id: "saleprice", type: "integer" }, // 판매 가격
// "saleDate": { id: "saledate", type: "date" }, // 판매일
// "serialNumber": { id: "serialnumber", type: "string" }, // 시리얼 번호
};
const YQ_PATH = "/opt/homebrew/bin/yq"; // yq 실행 경로
const SHELL_HELPER_ERROR_PREFIX = "SHELL_HELPER_ERROR:::"; // ShellHelper 오류 식별자
// 설정 끝
const DEVONthinkApp = Application("DEVONthink"); // DT 앱 객체
try {
ObjC.import('Foundation'); // Foundation 프레임워크
} catch(e) {
const errorMsg = `ObjC 프레임워크 로드 오류: ${e.message}`;
console.log(errorMsg); try { DEVONthinkApp.logMessage(`[YAML Script CRITICAL] ${errorMsg}`); } catch (e2) {} return false;
}
function logToFile(message) { // 파일 로깅 함수
if (!DEBUG_MODE) return;
try {
const logPath = "/tmp/" + LOG_FILE_NAME; const timestamp = new Date().toISOString(); const logEntry = `${timestamp} [JXA_YAML_Updater] - ${message}\n`;
const nsLogEntry = $.NSString.alloc.initWithUTF8String(logEntry); const nsLogPath = $(logPath).stringByStandardizingPath;
const fileManager = $.NSFileManager.defaultManager;
if (!fileManager.fileExistsAtPath(nsLogPath.js)) { nsLogEntry.writeToFileAtomicallyEncodingError(nsLogPath, true, $.NSUTF8StringEncoding, $()); }
else { const fileHandle = $.NSFileHandle.fileHandleForWritingAtPath(nsLogPath); if (fileHandle) { fileHandle.seekToEndOfFile; fileHandle.writeData(nsLogEntry.dataUsingEncoding($.NSUTF8StringEncoding)); fileHandle.closeFile; } else if (DEBUG_MODE) { console.log(`파일 로그: 핸들 열기 실패 - ${logPath}`); } }
} catch (e) { if (DEBUG_MODE) { console.log(`파일 로그: 로깅 중 오류 (${message.substring(0,100)}...): ${e.message}`); }}
}
function isShellError(resultString) { return typeof resultString === 'string' && resultString.startsWith(SHELL_HELPER_ERROR_PREFIX); }
function parseShellError(errorString) { if (!isShellError(errorString)) return errorString; return `쉘 실행 오류: ${errorString.substring(SHELL_HELPER_ERROR_PREFIX.length)}`; }
function extractFrontmatterText(fullText) { // YAML 프론트매터 추출 함수
if (typeof fullText !== 'string' || fullText.length === 0) return ""; let textToSearch = fullText;
if (textToSearch.charCodeAt(0) === 0xFEFF) textToSearch = textToSearch.substring(1); else if (textToSearch.startsWith('\u00EF\u00BB\u00BF')) textToSearch = textToSearch.substring(3);
const startMarker = "---"; const endMarker = "\n---"; if (!textToSearch.startsWith(startMarker)) { logToFile("프론트매터: '---'로 시작 안 함"); return ""; }
const firstMarkerEndPos = textToSearch.indexOf('\n', startMarker.length); if (firstMarkerEndPos === -1) { logToFile("프론트매터: 첫 '---' 이후 개행 없음. 유효하지 않음."); return ""; }
let endPosition = textToSearch.indexOf(endMarker, firstMarkerEndPos); if (endPosition === -1) { if (textToSearch.trimRight().endsWith(endMarker.trimLeft())) { endPosition = textToSearch.lastIndexOf(endMarker, textToSearch.length - endMarker.length -1 ); } }
if (endPosition > 0 && endPosition > firstMarkerEndPos) return textToSearch.substring(0, endPosition + endMarker.length);
logToFile("프론트매터: 유효한 종료 '---' 마커 없음."); return "";
}
function writeToTempFile(content, shellHelper, baseName = 'dt_yaml_fm_') { // 임시 파일 쓰기 함수
let tempFilePath = null;
try {
logToFile(`uuidgen (ShellHelper)으로 ${baseName} 임시 파일명 생성 중...`);
const uuidResult = shellHelper.runShellCommand("uuidgen");
if (isShellError(uuidResult) || uuidResult.trim() === "") throw new Error(`UUID 생성 실패: ${isShellError(uuidResult) ? parseShellError(uuidResult) : "uuidgen 결과 없음"}`);
const uniqueName = uuidResult.trim().toLowerCase();
const tempFileName = `${baseName}${uniqueName}.tmp`; // 기본 파일명 사용
const nsTempDirectoryPath = ObjC.unwrap($.NSTemporaryDirectory());
if (!nsTempDirectoryPath) throw new Error("임시 디렉토리 경로 가져오기 실패 (NSTemporaryDirectory null).");
const nsTempDir = $(nsTempDirectoryPath).stringByStandardizingPath;
const nsTempFilePath = nsTempDir.stringByAppendingPathComponent(tempFileName);
tempFilePath = ObjC.unwrap(nsTempFilePath);
logToFile(`임시 파일 경로: ${tempFilePath}`);
const nsContent = $.NSString.alloc.initWithUTF8String(content);
if (!nsContent) throw new Error("NSString 내용 생성 실패.");
const errorPtr = $();
const success = nsContent.writeToFileAtomicallyEncodingError(nsTempFilePath, true, $.NSUTF8StringEncoding, errorPtr);
if (success) { logToFile(`임시 파일 쓰기 성공: ${tempFilePath}`); return tempFilePath; }
else { const writeErrMsg = `임시 파일 쓰기 실패: ${tempFilePath}`; logToFile(`${writeErrMsg} (writeToFileAtomically... false 반환)`); throw new Error(writeErrMsg); }
} catch(e) {
logToFile(`writeToTempFile (${baseName}) 오류: ${e.message}${e.stack ? `\n스택: ${e.stack}` : ""}`);
if (tempFilePath && shellHelper) deleteTempFile(tempFilePath, shellHelper);
return null;
}
}
function deleteTempFile(filePath, shellHelper) { // (임시 파일 삭제 함수 내용은 이전과 동일)
if (!filePath || !shellHelper) return;
try {
const escapedFilePath = filePath.replace(/'/g, "'\\''"); const rmCommand = `rm -f '${escapedFilePath}'`;
logToFile(`임시 파일 삭제 시도 (ShellHelper): ${rmCommand}`); const rmResult = shellHelper.runShellCommand(rmCommand);
if (isShellError(rmResult)) logToFile(`경고: 임시 파일 삭제 실패 (${filePath}): ${parseShellError(rmResult)}`);
else logToFile(`임시 파일 삭제 완료 (ShellHelper): ${filePath}`);
} catch(e) { logToFile(`임시 파일 삭제 중 오류 (${filePath}): ${e.message}`); }
}
function calculateHash(textToHash, shellHelper) { // 해시 계산 (내부적으로 임시파일 사용)
const tempFilePath = writeToTempFile(textToHash, shellHelper, 'dt_yaml_hash_'); // 해시용 임시 파일
if (!tempFilePath) { logToFile("calculateHash: writeToTempFile 실패, 해시 계산 불가."); return null; }
const escapedFilePath = tempFilePath.replace(/'/g, "'\\''");
const hashCommand = `shasum -a 256 '${escapedFilePath}' | awk '{print $1}'`;
logToFile(`임시 파일 해시 계산 중: ${tempFilePath}`);
const hashResult = shellHelper.runShellCommand(hashCommand);
deleteTempFile(tempFilePath, shellHelper);
if (isShellError(hashResult)) { logToFile(`해시 계산 오류: ${parseShellError(hashResult)}`); return null; }
const trimmedHash = hashResult.trim();
if (trimmedHash === "" || trimmedHash.length !== 64) { logToFile(`해시 결과 무효 (길이 ${trimmedHash.length}): "${trimmedHash}"`); return null; }
return trimmedHash;
}
logToFile(`스크립트 시작. 레코드 ${records ? records.length : 0}개 처리`);
if (!records || records.length === 0) { logToFile("처리할 레코드 없음. 종료."); return true; }
let ShellHelper;
try {
ShellHelper = Library("ShellHelper"); logToFile("ShellHelper 라이브러리 로드 성공.");
} catch (e) {
const criticalMsg = `치명적 오류: ShellHelper 로드 실패 (${e.message}). 중단.`; logToFile(criticalMsg);
try { DEVONthinkApp.logMessage(`[YAML Script CRITICAL] ShellHelper 로드 실패: ${e.message}`); } catch (e2) {}
try { Application.currentApplication().displayAlert("라이브러리 로드 실패", { message: `ShellHelper 라이브러리 로드 실패.\n오류: ${e.message}`, as: "critical" }); } catch (e3) {}
return false;
}
records.forEach(record => { // 레코드 반복 처리
const recordName = (typeof record.name === "function") ? record.name() : (record.id ? `ID ${record.id()}` : "이름없는 레코드");
logToFile(`\n레코드 처리: "${recordName}" (경로: ${record.path ? record.path() : 'N/A'})`);
let yamlTempFilePath = null; // <<< yq 처리를 위한 임시 파일 경로 변수 추가
try { // 레코드별 오류 처리
if (record.type() !== "markdown" && record.type() !== "txt") { logToFile(`건너뜀 (Markdown/TXT 아님): "${recordName}" (타입: ${record.type()})`); return; }
const plainText = record.plainText();
if (plainText === null || typeof plainText === 'undefined' || plainText.trim() === "") { logToFile(`건너뜀 (내용 없음): "${recordName}"`); return; }
const frontmatterText = extractFrontmatterText(plainText); // 프론트매터 추출
if (frontmatterText === "") { logToFile(`유효한 YAML 프론트매터 없음: "${recordName}". 건너뜀.`); return; }
logToFile(`프론트매터 추출됨 (길이 ${frontmatterText.length}): "${recordName}"`);
const currentHash = calculateHash(frontmatterText, ShellHelper); // 추출된 텍스트로 해시 계산
if (!currentHash) { logToFile(`현재 해시 계산 실패: "${recordName}". 건너뜀.`); DEVONthinkApp.logMessage(`[YAML Script ERROR] 해시 계산 실패: "${recordName}"`); return; }
logToFile(`현재 해시: ${currentHash} ("${recordName}")`);
let storedHash = ""; // 저장된 해시 읽기
try {
const mdValue = DEVONthinkApp.getCustomMetaData({for: HASH_MD_IDENTIFIER, from: record});
if (typeof mdValue === 'string') { storedHash = mdValue; }
else if (mdValue !== undefined && mdValue !== null) { logToFile(`경고: 저장된 해시가 문자열 아님 (타입: ${typeof mdValue}). 무시.`); }
} catch (e) {
if (e.message && e.message.includes("Can't convert types")) { logToFile(`저장된 해시 읽기 중 'Can't convert types' 오류. 첫 실행 간주.`); }
else { logToFile(`저장된 해시 읽기 실패 ("${HASH_MD_IDENTIFIER}", "${recordName}"). 오류: ${e.message}`); }
}
logToFile(`저장된 해시: ${storedHash || "(없음)"} ("${recordName}")`);
if (currentHash !== storedHash) { // 해시 다를 시 업데이트
logToFile(`해시 다름. 메타데이터 업데이트: "${recordName}"`);
DEVONthinkApp.logMessage(`[YAML Script INFO] YAML 변경 감지, 업데이트 시작: "${recordName}"`);
let metadataActuallyUpdatedByScript = false;
// 추출된 프론트매터를 임시 파일에 쓰고 yq가 읽도록 함
yamlTempFilePath = writeToTempFile(frontmatterText, ShellHelper, 'dt_yaml_content_'); // YAML 내용용 임시 파일 생성
if (!yamlTempFilePath) { // 임시 파일 생성 실패 시
logToFile(`오류: yq 처리를 위한 임시 파일 생성 실패: "${recordName}". 업데이트 건너뜀.`);
DEVONthinkApp.logMessage(`[YAML Script ERROR] YAML 임시 파일 생성 실패: "${recordName}"`);
} else { // 임시 파일 생성 성공 시
const escapedYamlTempFilePath = yamlTempFilePath.replace(/'/g, "'\\''"); // 경로 이스케이프
for (const yamlKey in METADATA_MAPPINGS) { // 매핑별 처리
if (Object.hasOwnProperty.call(METADATA_MAPPINGS, yamlKey)) {
const mappingConfig = METADATA_MAPPINGS[yamlKey];
const dtMetadataIdentifier = mappingConfig.id;
const expectedDtType = mappingConfig.type.toLowerCase();
// yq 명령어가 임시 파일을 읽도록 수정
const yqCommand = `${YQ_PATH} eval '.${yamlKey} // ""' '${escapedYamlTempFilePath}'`; // 파일 경로 변경됨
logToFile(`yq 값 가져오기 (키: "${yamlKey}"): ${yqCommand}`);
const yqOutputResult = ShellHelper.runShellCommand(yqCommand);
let extractedValueString = "";
if (isShellError(yqOutputResult)) {
logToFile(`경고: yq 명령어 실패 (키 "${yamlKey}"): ${parseShellError(yqOutputResult)}. "${dtMetadataIdentifier}" 값 "" 처리.`);
DEVONthinkApp.logMessage(`[YAML Script WARNING] yq 처리 실패 ("${recordName}", 키 "${yamlKey}"). 값 "" 처리.`);
extractedValueString = "";
} else {
extractedValueString = yqOutputResult.trim();
logToFile(`yq 결과 (키 "${yamlKey}"): "${extractedValueString}"`);
}
// 빈 값 / 타입 변환 처리: "" 사용
let finalValueToSet = "";
if (extractedValueString === "") {
finalValueToSet = "";
logToFile(`YAML 값 비어 있거나 키 없음 (키 "${yamlKey}"). 메타데이터를 ""로 설정.`);
} else {
finalValueToSet = extractedValueString;
// 문제 있으면 v3.1.3으로 돌아가자
if (expectedDtType === "date") { try { const d=new Date(extractedValueString); if(!isNaN(d.getTime())) finalValueToSet=d; else finalValueToSet=""; } catch(e){finalValueToSet=""}; if(finalValueToSet==="") logToFile(`경고: 날짜 형식 무효 ("${yamlKey}":"${extractedValueString}"). "" 설정.`);}
else if (expectedDtType === "integer") { const n=parseInt(extractedValueString,10); if(!isNaN(n)) finalValueToSet=n; else {logToFile(`경고: 정수 형식 무효 ("${yamlKey}":"${extractedValueString}"). "" 설정.`); finalValueToSet="";}}
else if (expectedDtType === "real") { const n=parseFloat(extractedValueString); if(!isNaN(n)) finalValueToSet=n; else {logToFile(`경고: 실수 형식 무효 ("${yamlKey}":"${extractedValueString}"). "" 설정.`); finalValueToSet="";}}
else if (expectedDtType === "boolean") { const l=extractedValueString.toLowerCase(); if(l==="true"||l==="yes"||l==="1") finalValueToSet=true; else if (l==="false"||l==="no"||l==="0") finalValueToSet=false; else {logToFile(`경고: 불리언 값 모호 ("${yamlKey}":"${extractedValueString}"). false 설정.`); finalValueToSet=false;}}
}
// 메타데이터 설정 시도
try {
const valueToLog = (finalValueToSet instanceof Date) ? finalValueToSet.toISOString() : finalValueToSet;
logToFile(`메타데이터 ID "${dtMetadataIdentifier}" 업데이트 시도 (타입: ${expectedDtType}, 값: ${valueToLog})`);
DEVONthinkApp.addCustomMetaData(finalValueToSet, {for: dtMetadataIdentifier, to: record, as: expectedDtType});
} catch(metaError) {
logToFile(`메타데이터 설정 오류 ("${dtMetadataIdentifier}"): ${metaError.message}`);
DEVONthinkApp.logMessage(`[YAML Script ERROR] 메타데이터 설정 오류 ("${recordName}", ID "${dtMetadataIdentifier}"): ${metaError.message}`);
}
metadataActuallyUpdatedByScript = true; // 처리 시도 플래그
}
} // 매핑 루프 끝
// YAML 내용용 임시 파일 삭제
deleteTempFile(yamlTempFilePath, ShellHelper); // 루프 끝난 후 임시 파일 삭제
yamlTempFilePath = null; // 경로 초기화
} // if (yamlTempFilePath) 끝 (임시 파일 처리 성공 시)
if (metadataActuallyUpdatedByScript) { // 업데이트 시도가 한 번이라도 있었으면 해시/갱신날짜 저장
logToFile(`저장된 해시 업데이트 (${currentHash}): "${recordName}"`);
DEVONthinkApp.addCustomMetaData(currentHash, {for: HASH_MD_IDENTIFIER, to: record, as: "string"});
const currentDate = new Date();
logToFile(`${RENEWAL_DATE_MD_IDENTIFIER} 업데이트 (${currentDate.toISOString()}): "${recordName}"`);
DEVONthinkApp.addCustomMetaData(currentDate, {for: RENEWAL_DATE_MD_IDENTIFIER, to: record, as: "date"});
logToFile(`메타데이터 업데이트 시도 완료: "${recordName}"`);
DEVONthinkApp.logMessage(`[YAML Script OK] 메타데이터 업데이트 완료: "${recordName}"`);
} else { // 해시는 다르나 임시 파일 생성 실패 등 처리할 내용이 없었던 경우
logToFile(`해시 달랐으나, 업데이트 시도된 메타데이터 필드 없음. 해시/갱신날짜 미변경: "${recordName}"`);
DEVONthinkApp.logMessage(`[YAML Script INFO] 해시 변경, 실제 업데이트 내용 없어 메타데이터 미변경: "${recordName}"`);
}
} else { // 해시 같을 시
logToFile(`해시 일치. 업데이트 불필요: "${recordName}"`);
}
logToFile(`--- 레코드 처리 완료: "${recordName}" ---`);
} catch (recordError) { // 레코드 처리 예외
const errorMsg = `!!!!!!!! 레코드 처리 중 심각한 오류 "${recordName}": ${recordError.message} !!!!!!!!\n스택: ${recordError.stack}`;
logToFile(errorMsg);
try { DEVONthinkApp.logMessage(`[YAML Script CRITICAL ERROR] ${recordName}: ${recordError.message}`); } catch (e2) {}
} finally { // try-catch 블록의 finally 추가
// 루프 내에서 오류 발생 시에도 임시 YAML 파일이 남아있을 수 있으므로 여기서 한번 더 확인 후 삭제
if (yamlTempFilePath && ShellHelper) {
logToFile(`최종 정리: 남은 YAML 임시 파일 삭제 시도: ${yamlTempFilePath}`);
deleteTempFile(yamlTempFilePath, ShellHelper);
}
}
}); // 레코드 루프 끝
logToFile("----- 스크립트 실행 종료 -----");
return true;
}
ShellHelper.scptd
:
use AppleScript version "2.4"
use scripting additions
-- --- 설정 ---
property debugMode : false
property logFileName : "shellhelper_debug.log"
-- --- 설정 끝 ---
-- --- 로그 파일에 메시지 추가하는 핸들러 ---
on logToFile(logMessage as text)
if not debugMode then return
try
set logFilePath to "/tmp/" & logFileName
set timestamp to do shell script "date '+%Y-%m-%dT%H:%M:%S%z'"
set logEntry to timestamp & " [AS] - " & logMessage & return
set fileRef to open for access file POSIX file logFilePath with write permission
write logEntry to fileRef starting at eof
close access fileRef
on error errMsg number errNum
log "[ShellHelper LOG ERROR] Failed to write to " & logFileName & ": (" & errNum & ") " & errMsg
end try
end logToFile
-- --- 로깅 핸들러 끝 ---
-- --- 메인 핸들러 (JXA에서 'runShellCommand' 이름으로 호출) ---
on runShellCommand(commandString as text)
my logToFile("runShellCommand received: " & commandString)
try
set commandResult to do shell script commandString
try
my logToFile("Shell command success. Result length: " & (count of commandResult))
on error
my logToFile("Shell command success. (Result length check failed)")
end try
return commandResult
on error errMsg number errNum
my logToFile("Shell command FAILED! Number: " & errNum & ", Message: " & errMsg)
return "SHELL_HELPER_ERROR:::" & errNum & ":::" & errMsg
end try
end runShellCommand
-- --- 메인 핸들러 끝