The day the modifier shipped, we already knew the next question: how do I hook my own logic in?
Every tool that grows a template language meets this moment. The usual answers are a plugin API, a scripts folder, a config DSL that slowly reinvents a programming language. We picked none of them. In Mergic, a custom rule is one JavaScript function.
The hook fills a token, not the whole path
The tempting design is to let user code compute the entire destination path. We refused, for the same reason the modifier itself does not touch conflict handling: composition is the feature. A custom token slots into the template like any built-in one:
Your function returns a string — that is the token's value. It returns null — the token is unresolvable, and everything you already know applies: the |Misc fallback if you declared one, a logged failure and a skipped file if you did not. The output passes through the same sanitization that strips .. and empty segments, so a custom rule cannot write outside the destination, not even by accident. Conflicts at the computed path? Same MD5 machinery, same name-2.ext, same idempotent re-runs. One new concept, zero new rules.
The whole EXIF object, not our selection of it
The built-in tokens cover capture date and camera make/model, because those are what most people sort by. But the moment you allow code, curating fields becomes a bottleneck: whatever we pick, someone needs the one we did not. So the function receives everything ImageIO can read, as one object:
file.exif is the full metadata dictionary — TIFF, Exif, GPS, IPTC, every group, every attribute. Alongside it: relativePath, name, dir, ext, size, mtime, and captureDate with the same EXIF-first precedence the built-in date tokens use. Route by aperture, by GPS hemisphere, by a client name embedded in the folder structure — we no longer have to predict what your rule needs.
The sandbox is a promise, not a limitation
The function runs in a bare JavaScriptCore context: no filesystem, no network, no timers. It can only compute from the object we hand it. That sounds restrictive until you notice what it buys: the function is pure by construction. Dry run executes your custom tokens for real — it must, to show you real paths — and a pure function makes that safe by definition, not by documentation. There is no "please don't do side effects in your hook" paragraph in our manual, because there is nothing to do them with.
The while-true problem
Arbitrary user code means someone will eventually write an infinite loop, and a merge of a million files cannot hang on file number three. JavaScriptCore has an execution-time limit API — in a private header, which a Mac App Store app cannot touch. So the public-API version: all JS runs on a dedicated queue, each call guarded by a watchdog timeout. A call that blows the budget marks the engine dead; that token and all subsequent ones resolve as unresolvable, which flows into the fallback/failed semantics you already know. The test suite runs a while (true) {} token over a real merge: the merge completes, files take their fallbacks, and exactly one worker thread is sacrificed to the runaway script. The merge never hangs. That is the contract.
Custom tokens ship with the modifier in the next update. Defined in the modifier editor — name, function, inline compile errors, live preview against your actual files. Still on the list: external process hooks over a long-running NDJSON protocol, for rules that need a database or a model.
任意のコードを許せば、いつか誰かが無限ループを書きます。そして百万ファイルのマージが 3 番目のファイルで止まるわけにはいきません。JavaScriptCore には実行時間制限の API があります——プライベートヘッダの中に。Mac App Store のアプリは触れません。そこで公開 API 版:すべての JS は専用キューで動き、各呼び出しはウォッチドッグが見張ります。予算を超えた呼び出しはエンジンを死亡としてマークし、そのトークンと以降のすべては解決不能として、既知のフォールバック/失敗の意味論に流れ込みます。テストスイートは while (true) {} トークンで実際のマージを走らせます:マージは完走し、ファイルはフォールバックを取り、暴走スクリプトに捧げられるのはワーカースレッド 1 本だけ。マージは決してハングしません。それが契約です。
함수가 문자열을 반환하면 그것이 토큰의 값. null을 반환하면 토큰은 해석 불능 — 그리고 이미 아는 모든 것이 그대로 적용됩니다. |Misc를 선언했다면 폴백, 아니면 실패로 기록되고 파일은 건너뜁니다. 출력은 ..과 빈 세그먼트를 벗겨내는 동일한 정제 파이프라인을 지나므로, 커스텀 규칙은 실수로라도 대상 폴더 밖에 쓸 수 없습니다. 계산된 경로에서의 충돌? 같은 MD5 메커니즘, 같은 name-2.ext, 같은 멱등 재실행. 새 개념 하나, 새 규칙 제로.
EXIF 전체를 드립니다. 우리가 고른 몇 가지가 아니라
내장 토큰은 촬영 날짜와 카메라 제조사·기종을 다룹니다. 대부분 그걸로 정리하니까요. 하지만 코드를 허용하는 순간 '우리가 필드를 골라 주는' 방식은 병목이 됩니다. 무엇을 고르든, 누군가는 고르지 않은 그것이 필요합니다. 그래서 함수는 ImageIO가 읽을 수 있는 전부를 하나의 객체로 받습니다:
file.exif는 완전한 메타데이터 사전입니다 — TIFF, Exif, GPS, IPTC, 모든 그룹, 모든 속성. 곁에는 relativePath, name, dir, ext, size, mtime, 그리고 내장 날짜 토큰과 같은 EXIF 우선순위를 따르는 captureDate. 조리개로 나누고, GPS 남북으로 나누고, 폴더 구조에 숨은 클라이언트 이름으로 나누고 — 당신의 규칙에 무엇이 필요할지 우리가 더는 예측하지 않아도 됩니다.
샌드박스는 약속이지 제한이 아니다
함수는 맨몸의 JavaScriptCore 컨텍스트에서 돕니다. 파일시스템 없음, 네트워크 없음, 타이머 없음. 건네받은 객체로 계산하는 것만 가능합니다. 답답하게 들리지만, 그것이 무엇과 맞바꾼 것인지 알면 다르게 보입니다. 함수는 구조적으로 순수합니다. 드라이 런은 커스텀 토큰을 실제로 실행합니다 — 진짜 경로를 보여 주려면 그래야 하니까 — 그리고 순수 함수는 그것을 문서의 당부가 아닌 정의로 안전하게 만듭니다. 우리 매뉴얼에는 '훅에서 부수 효과를 일으키지 마세요'라는 문단이 없습니다. 일으킬 수단 자체가 없기 때문입니다.
while(true) 문제
임의의 코드를 허용하면 언젠가 누군가는 무한 루프를 씁니다. 그리고 백만 파일의 병합이 세 번째 파일에서 멈출 수는 없습니다. JavaScriptCore에는 실행 시간 제한 API가 있습니다 — 프라이빗 헤더 안에. Mac App Store 앱은 건드릴 수 없습니다. 그래서 공개 API 버전: 모든 JS는 전용 큐에서 돌고, 각 호출은 워치독이 지킵니다. 예산을 넘긴 호출은 엔진을 사망으로 표시하고, 그 토큰과 이후 전부는 해석 불능으로 — 이미 익숙한 폴백/실패 의미론으로 흘러갑니다. 테스트 스위트는 while (true) {} 토큰으로 실제 병합을 돌립니다. 병합은 완주하고, 파일들은 폴백을 타고, 폭주 스크립트에 바쳐지는 것은 워커 스레드 하나뿐. 병합은 결코 멈추지 않습니다. 그것이 계약입니다.
커스텀 토큰은 모디파이어와 함께 다음 업데이트로 출시됩니다. 모디파이어 편집기에서 정의 — 이름, 함수, 인라인 컴파일 오류, 실제 파일로 도는 라이브 프리뷰. 목록에는 아직: 데이터베이스나 모델이 필요한 규칙을 위한, 장수 프로세스 NDJSON 외부 훅.