훅 시스템
훅은 Workflow의 확장 가능성 지점입니다. Workflow 실행의 여러 단계에서 플러그인의 Workflow Runtime이 해당 훅 스크립트를 호출하여 JavaScript로 실행 흐름에 개입하고 제어할 수 있습니다.
하나의 Workflow에는 최대 4개의 훅을 포함할 수 있으며, 이 중 applyResult는 유일하게 필수인 훅입니다.
입력 필터링에 관한 참고사항: 기존
filterInputs훅은 선언적validateSelection메커니즘으로 대체되었습니다.workflow.json에서validateSelection을 사용하면 JavaScript를 작성하지 않고도 입력 제약을 정의할 수 있습니다. 자세한 내용은 매니페스트 파일 작성을 참조하세요.
훅 스크립트 구조
각 훅 스크립트는 이름을 가진 함수를 내보내는 .mjs(ES Module) 파일입니다:
// hooks/buildRequest.mjs
export function buildRequest({ selectionContext, preflight, manifest, executionOptions, runtime }) {
// 구현 로직
return requestSpec;
}
런타임 컨텍스트 (runtime)
모든 훅은 Zotero와 다양한 도구에 직접 접근할 수 있는 runtime 파라미터를 받습니다.
runtime = {
zotero, // Zotero 전역 객체
handlers, // 저수준 데이터 처리 핸들러
hostApi, // 고수준 호스트 API (권장)
helpers, // 훅 보조 유틸리티 함수
addon, // 플러그인 설정
workflowId, // 현재 Workflow ID
workflowRootDir, // workflow.json이 포함된 디렉터리의 절대 경로
workflowSourceKind, // "official" | "dev-local" | "user" | ""
packageId, // 소유 패키지 ID (Workflow 패키지 내에서만 사용 가능)
packageRootDir, // 패키지 루트 디렉터리의 절대 경로
hostApiVersion, // 호스트 API 버전 번호
hookName, // 현재 훅 이름: "preflight" | "buildRequest" | "applyResult" | ""
debugMode, // 디버그 모드 여부
fetch, // 전역 fetch (사용 가능한 경우)
Buffer, // Node.js Buffer (사용 가능한 경우)
btoa, // Base64 인코딩 (사용 가능한 경우)
atob, // Base64 디코딩 (사용 가능한 경우)
TextEncoder, // 텍스트 인코더 (사용 가능한 경우)
TextDecoder, // 텍스트 디코더 (사용 가능한 경우)
FileReader, // 파일 리더 (사용 가능한 경우)
navigator, // Navigator 객체 (사용 가능한 경우)
}
모범 사례: runtime.hostApi(고수준 API)를 우선 사용하고, hostApi가 요구를 충족하지 못하는 경우에만 runtime.handlers 또는 runtime.zotero를 사용하세요.
1. buildRequest — 요청 구축
workflow.json의 선언적 request만으로는 복잡한 요청을 기술하기에 부족할 때, buildRequest를 사용하여 요청 페이로드를 동적으로 구성합니다.
시그니처:
function buildRequest({
selectionContext, // 필터링된 선택 컨텍스트
preflight, // 선택적 preflight plan/unit/context
manifest, // workflow.json
executionOptions, // { workflowParams, providerOptions }
runtime, // 런타임 컨텍스트
}): unknown
선언적 요청과의 관계: buildRequest는 workflow.json의 request 필드와 상호 배타적입니다. 둘 다 존재하면 buildRequest가 우선합니다.
Workflow가 hooks.preflight를 선언하면, 런타임은 정규화된 preflight 컨텍스트를 preflight로 buildRequest에 전달합니다. 이 컨텍스트는 selectionContext에 병합되지 않으며, 별도의 실행 계획 메타데이터로 취급합니다.
예시: pass-through 요청
export function buildRequest({ selectionContext, executionOptions, runtime }) {
return {
kind: "pass-through.run.v1",
selectionContext,
parameter: executionOptions?.workflowParams || {},
};
}
예시: Preflight 유닛 컨텍스트를 사용한 요청
export function buildRequest({ selectionContext, preflight, runtime }) {
const attachment = selectionContext.items.attachments[0];
return {
kind: "generic-http.steps.v1",
file: {
path: runtime.helpers.getAttachmentFilePath(attachment),
page_ranges: preflight?.unit?.context?.page_ranges,
},
};
}
예시: 다단계 시퀀스 요청
export async function buildRequest({ selectionContext, executionOptions, runtime }) {
const sourcePath = resolveAttachmentPath(selectionContext, runtime);
const language = executionOptions?.workflowParams?.language || "en-US";
return {
kind: "skillrunner.sequence.v1",
sequence: {
steps: [
{
id: "step1",
skill_id: "my-analysis-skill",
mode: "auto",
workspace: "new",
parameter: { language, source_path: sourcePath },
},
{
id: "step2",
skill_id: "my-enrichment-skill",
mode: "auto",
workspace: "reuse-workflow",
handoff: {
bindings: [
{
kind: "value",
source: "output_field_name",
target: "/input/field_name",
step: "step1",
},
],
},
},
],
},
};
}
2. preflight — 계획 수립 또는 실행 단축
preflight는 선언적 선택 해석 후, buildRequest 또는 선언적 요청 구축 전에 실행됩니다. 해석된 입력 유닛이 필요하지만 메뉴 활성화에는 관여하지 않아야 하는 가벼운 로컬 결정에 사용합니다.
preflight는 Zotero 데이터를 쓰면 안 되고, provider 요청을 구성하면 안 되며, validateSelection을 대체해서는 안 됩니다. 모든 Zotero 쓰기는 여전히 applyResult에, 모든 provider 요청 페이로드는 여전히 buildRequest 또는 매니페스트 request 필드에 속합니다.
시그니처:
function preflight({
selectionContext, // 해석된 입력 유닛 컨텍스트
parent, // 현재 유닛의 부모 항목 (사용 가능한 경우)
attachment, // 현재 유닛의 첨부파일 항목 (사용 가능한 경우)
note, // 현재 유닛의 노트 항목 (사용 가능한 경우)
manifest, // workflow.json
executionOptions, // { workflowParams, providerOptions }
runtime, // 런타임 컨텍스트
}): PreflightOutcome
아웃컴: Continue
정상적인 요청 구축을 계속하고 선택적으로 계획 컨텍스트를 첨부합니다:
export async function preflight({ parent }) {
return {
kind: "continue",
context: {
doi: parent?.DOI || "",
source: "selected-parent",
},
};
}
context는 buildRequest에서 preflight.context로, applyResult에서 resultContext.preflight.context로 접근할 수 있습니다.
아웃컴: Skip
현재 입력 유닛만 건너뜁니다:
export function preflight({ parent }) {
if (!parent?.DOI) {
return { kind: "skip", reason: "missing DOI" };
}
return { kind: "continue" };
}
모든 입력 유닛이 건너뛰어지면, provider 작업을 제출하지 않고 실행이 종료됩니다.
아웃컴: Short-Circuit Apply
Provider 실행을 건너뛰고 표준 applyResult 경로를 직접 호출합니다:
export async function preflight({ parent, runtime }) {
const metadata = await lookupMetadataLocally(parent?.DOI, runtime);
if (!metadata) {
return { kind: "continue" };
}
return {
kind: "short-circuit-apply",
apply: {
result: { ok: true, source: "local-metadata", item: metadata },
request: { kind: "local.metadata.preflight.v1" },
runResult: { status: "success" },
},
context: { source: "local-metadata" },
};
}
이는 메타데이터 큐레이터와 같은 Workflow에 유용합니다: 신뢰할 수 있는 식별자 조회가 로컬에서 성공하면, 백엔드 호출 없이 applyResult로 부모 항목을 업데이트할 수 있습니다. 조회 결과가 없거나 품질이 낮으면 continue를 반환하여 buildRequest가 정상적인 백엔드 요청을 구성하도록 합니다.
아웃컴: Replace Units
하나의 해석된 입력 유닛을 여러 가상 요청 유닛으로 교체합니다:
export function preflight({ attachment }) {
const chunks = [
{ id: "part-1", order: 0, context: { page_ranges: "1-200" } },
{ id: "part-2", order: 1, context: { page_ranges: "201-360" } },
];
return {
kind: "replace-units",
units: chunks,
};
}
각 가상 유닛은 정상적인 buildRequest 경로를 통해 실행됩니다. 유닛별 컨텍스트는 preflight.unit.context에서 접근할 수 있습니다.
Aggregate Single Apply
여러 provider 결과를 하나의 최종 Zotero 쓰기로 병합해야 하는 분할 입력 Workflow의 경우, aggregate 계획을 추가합니다:
export function preflight() {
return {
kind: "replace-units",
units: [
{ id: "part-1", order: 0, context: { page_ranges: "1-200" } },
{ id: "part-2", order: 1, context: { page_ranges: "201-360" } },
],
aggregate: {
id: "pdf-pages",
mode: "single-apply",
applyWhen: "all-succeeded",
orderBy: "unit.order",
},
};
}
v1에서 aggregate apply는 mode: "single-apply", applyWhen: "all-succeeded", orderBy: "unit.order"만 지원합니다. 자식 provider 작업이 수집되고 모든 자식이 성공한 후 applyResult가 한 번 호출됩니다. 자식 중 하나라도 실패하면 부분 aggregate apply는 수행되지 않습니다.
3. normalizeSettings — 파라미터 정규화
설정이 저장되기 전 또는 실행되기 전에 파라미터를 정규화합니다.
시그니처: 이 훅은 단계에 따라 서로 다른 파라미터를 받습니다:
function normalizeSettings(args: {
// persisted 단계: 파라미터가 환경설정에 저장될 때
phase: "persisted";
workflowId: string;
manifest: WorkflowManifest;
previous: { backendId?, workflowParams?, providerOptions? };
incoming: { backendId?, workflowParams?, providerOptions? };
merged: { backendId?, workflowParams?, providerOptions? };
} | {
// execution 단계: 실행 전
phase: "execution";
workflowId: string;
manifest: WorkflowManifest;
rawWorkflowParams: Record<string, unknown>;
normalizedWorkflowParams: Record<string, unknown>;
}): unknown
사용 사례:
- 파라미터 간 교차 검증 (예: 옵션 A가 특정 값으로 설정되면 옵션 B의 기본값이 변경되어야 하는 경우)
- 파라미터 다운그레이드 처리 (예: 이전 파라미터를 새 버전으로 마이그레이션)
- 실행 전 유효하지 않은 값 정리
4. applyResult — 결과 처리 (필수)
이것은 Workflow의 유일하게 필수인 훅으로, 백엔드의 실행 결과를 Zotero에 기록하는 역할을 합니다.
시그니처:
function applyResult({
parent, // 부모 Zotero 항목
bundleReader, // 결과 번들 리더
resultContext, // 구조화된 결과 컨텍스트 (preflight/aggregate 메타데이터 포함)
sequenceStep, // 시퀀스 단계 메타데이터 (시퀀스 실행 시 존재)
productStorage, // 아티팩트 저장 API
request, // 전송된 원본 요청
runResult, // 실행 결과 메타데이터
manifest, // workflow.json
runtime, // 런타임 컨텍스트
}): unknown
// sequenceStep 형태:
// {
// id: string; // 단계 ID
// index: number; // 시퀀스 내 0부터 시작하는 인덱스
// workflowId: string; // 이 단계의 하위 Workflow ID
// skillId: string; // 이 단계에서 실행된 Skill ID
// finalStep: boolean; // 마지막 단계인지 여부
// phase: "sequence-step";
// }
preflight가 선언된 경우, resultContext.preflight는 현재 apply 호출에 대한 실행 계획, 유닛 id, 유닛 컨텍스트 및 공유 컨텍스트를 노출합니다. selectionContext는 preflight에 의해 변경되지 않습니다.
replace-units가 aggregate.single-apply를 사용하는 경우, resultContext.aggregate.children에는 정렬된 자식 결과가 포함됩니다:
resultContext.aggregate.children = [
{
unitId: "part-1",
order: 0,
request,
runResult,
resultContext,
bundleReader,
},
{
unitId: "part-2",
order: 1,
request,
runResult,
resultContext,
bundleReader,
},
];
Aggregate applyResult는 각 자식 번들을 child.bundleReader에서 읽고, 순서대로 아티팩트를 병합한 후 최종 Zotero 결과를 한 번 기록해야 합니다. 예를 들어, MinerU 스타일의 Workflow는 하나의 PDF를 여러 page_ranges 작업으로 제출한 다음, full.md 파일을 병합하고 이미지 경로에 네임스페이스를 부여한 후 최종 Markdown 첨부파일을 하나 생성할 수 있습니다.
bundleReader 사용:
// 아티팩트 ZIP 번들의 파일 읽기
const digestMd = await bundleReader.readText("artifacts/digest.md");
// 추출된 아티팩트 디렉터리의 경로 가져오기
const extractedDir = await bundleReader.getExtractedDir();
예시: 번들에서 노트 작성
export async function applyResult({ parent, bundleReader, runtime }) {
if (!parent) return { applied: false };
const parentItem = runtime.helpers.resolveItemRef(parent);
const digestMd = await bundleReader.readText("artifacts/digest.md");
const htmlContent = runtime.helpers.toHtmlNote("Paper Digest", digestMd);
const newNote = await runtime.hostApi.mutations.execute({
operation: "note.createChild",
parentItem: parentItem.getField("id"),
data: { content: htmlContent },
});
return { applied: true, noteId: newNote.id };
}
예시: 번들에서 디스크로 파일 추출 (MinerU 스타일)
export async function applyResult({ parent, bundleReader, runtime }) {
if (!parent) return { applied: false };
const extractedDir = await bundleReader.getExtractedDir();
const { file } = runtime.hostApi;
const mdContent = await bundleReader.readText("full.md");
const targetPath = `/path/to/output.md`;
await file.writeText(targetPath, mdContent);
return { applied: true, output_path: targetPath };
}
훅 헬퍼 함수 (helpers)
runtime.helpers는 보조 함수 모음을 제공합니다:
| 함수 | 설명 |
|---|---|
getAttachmentParentId(entry) | 첨부파일의 부모 항목 ID 가져오기 |
getAttachmentFilePath(entry) | 첨부파일의 로컬 파일 경로 가져오기 |
getAttachmentFileName(entry) | 첨부파일 이름 가져오기 |
getAttachmentFileStem(entry) | 첨부파일 이름 가져오기 (확장자 제외) |
getAttachmentDateAdded(entry) | 첨부파일의 dateAdded 타임스탬프 가져오기 |
basenameOrFallback(path, fallback) | 기본 이름 추출 또는 대체 문자열 반환 |
isMarkdownAttachment(entry) | Markdown 첨부파일인지 확인 |
isPdfAttachment(entry) | PDF 첨부파일인지 확인 |
pickEarliestPdfAttachment(entries) | 첨부파일 목록에서 가장 오래된 PDF 선택 |
cloneSelectionContext(ctx) | 선택 컨텍스트의 깊은 복사 |
withFilteredAttachments(ctx, items) | 컨텍스트에서 지정된 첨부파일만 유지 |
resolveItemRef(ref) | 항목 참조를 Zotero.Item으로 해석 |
toHtmlNote(title, body) | Markdown을 HTML 노트 콘텐츠로 변환 |
normalizeReferenceAuthors(value) | 참조 저자 목록 정규화 |
normalizeReferenceEntry(entry, index) | 단일 참조 항목 정규화 |
normalizeReferencesArray(value) | 참조 배열 정규화 |
normalizeReferencesPayload(payload) | 참조 페이로드 객체 정규화 |
replacePayloadReferences(payload, refs) | 페이로드의 참조 교체 |
resolveReferenceSource(entry) | 참조의 source 필드 해석 |
renderReferenceLocator(entry) | volume/issue/pages 로케이터 문자열 렌더링 |
renderReferencesTable(references) | 참조를 HTML 테이블로 렌더링 |
다음 단계
- 선택 컨텍스트 — selectionContext의 상세 구조
- 호스트 API 레퍼런스 — 완전한 API 레퍼런스
- 패키징 및 배포 — Workflow를 패키징하고 배포하는 방법