/** * Pure compose functions for F5 (Export). * * No filesystem, no I/O, no Date.now() — every output is a function of the input. * Caller layer (Task 2+) handles fs writes, atomic rename, zip packaging. */ export interface ExportNoteMedia { rel: string; mime: string; bytes: number; } export interface ExportNoteTag { name: string; source: 'ai' | 'user'; } export interface ExportNote { id: string; createdAt: string; updatedAt: string; rawText: string; aiTitle: string | null; aiSummary: string | null; titleEditedByUser: boolean; summaryEditedByUser: boolean; aiProvider: string | null; aiGeneratedAt: string | null; userIntent: string | null; intentPromptedAt: string | null; // v0.3.0 Cut E — Cut B (status), Cut C (dueDate via m002), and dueDate user-edited flag // need to round-trip through F5 export and Cut E sync. status: 'active' | 'completed' | 'archived' | 'trashed'; statusChangedAt: string | null; moveReason: string | null; dueDate: string | null; dueDateEditedByUser: boolean; tags: ExportNoteTag[]; media: ExportNoteMedia[]; } const FORBIDDEN_FS_CHARS_REGEX = /[/\\:*?"<>|]/g; const WHITESPACE_RUN_REGEX = /\s+/g; const SLUG_MAX_CODEPOINTS = 32; export function slugifyTitle(title: string | null): string { if (title === null || title === undefined) return 'untitled'; // Strip forbidden characters first. const stripped = title.replace(FORBIDDEN_FS_CHARS_REGEX, ''); // Collapse whitespace runs to a single hyphen. const hyphenated = stripped.replace(WHITESPACE_RUN_REGEX, '-'); // Trim leading/trailing hyphens. const trimmed = hyphenated.replace(/^-+|-+$/g, ''); if (trimmed.length === 0) return 'untitled'; // Truncate to 32 unicode codepoints (handle Korean / emoji properly). const cps = [...trimmed]; const truncated = cps.length > SLUG_MAX_CODEPOINTS ? cps.slice(0, SLUG_MAX_CODEPOINTS).join('') : trimmed; // Re-trim hyphens in case truncation landed on one. const finalSlug = truncated.replace(/^-+|-+$/g, ''); return finalSlug.length === 0 ? 'untitled' : finalSlug; } export function composeFilename(input: { id: string; createdAt: string; aiTitle: string | null; }): string { const date = input.createdAt.slice(0, 10); const id8 = input.id.slice(0, 8); const slug = slugifyTitle(input.aiTitle); return `${date}-${id8}-${slug}.md`; } // --------------------------------------------------------------------------- // YAML quoting helpers // --------------------------------------------------------------------------- const YAML_SPECIAL_CHARS = [':', "'", '"', '#', '[', ']', '{', '}']; const YAML_LEADING_INDICATORS = ['-', '?', '!', '&', '*', '>']; function needsQuoting(value: string): boolean { if (value.length === 0) return true; // Leading/trailing whitespace. if (value !== value.trim()) return true; // Embedded special chars that conflict with plain-scalar parsing. for (const ch of YAML_SPECIAL_CHARS) { if (value.includes(ch)) return true; } // Leading indicators. const first = value.charAt(0); if (YAML_LEADING_INDICATORS.includes(first)) return true; return false; } function singleQuote(value: string): string { return `'${value.replace(/'/g, "''")}'`; } /** * Format a string scalar for YAML. * - Multiline → block scalar `|-` with given indent. * - Otherwise → plain or single-quoted depending on contents. * * `indent` is the indent applied to body lines of the block scalar (default 2 spaces). */ function formatScalar(value: string, indent = 2): string { if (value.includes('\n')) { const pad = ' '.repeat(indent); const lines = value.split('\n').map((l) => `${pad}${l}`); return `|-\n${lines.join('\n')}`; } return needsQuoting(value) ? singleQuote(value) : value; } function formatTagName(name: string): string { // Names can't be multiline in flow style — fall back to single-quote when needed. if (name.includes('\n') || needsQuoting(name)) return singleQuote(name); return name; } // --------------------------------------------------------------------------- // composeFrontmatter // --------------------------------------------------------------------------- export function composeFrontmatter(note: ExportNote): string { const lines: string[] = []; lines.push('---'); lines.push(`id: ${note.id}`); lines.push(`created_at: ${note.createdAt}`); lines.push(`updated_at: ${note.updatedAt}`); if (note.aiTitle !== null) { lines.push(`title: ${formatScalar(note.aiTitle)}`); lines.push(`title_source: ${note.titleEditedByUser ? 'user' : 'ai'}`); } if (note.aiSummary !== null) { lines.push(`summary: ${formatScalar(note.aiSummary)}`); lines.push(`summary_source: ${note.summaryEditedByUser ? 'user' : 'ai'}`); } if (note.tags.length > 0) { lines.push('tags:'); for (const tag of note.tags) { lines.push(` - { name: ${formatTagName(tag.name)}, source: ${tag.source} }`); } } if (note.userIntent !== null) { lines.push(`user_intent: ${formatScalar(note.userIntent)}`); } if (note.intentPromptedAt !== null) { lines.push(`intent_prompted_at: ${note.intentPromptedAt}`); } if (note.aiProvider !== null) { lines.push(`ai_provider: ${formatScalar(note.aiProvider)}`); } if (note.aiGeneratedAt !== null) { lines.push(`ai_generated_at: ${note.aiGeneratedAt}`); } lines.push(`status: ${note.status}`); if (note.statusChangedAt !== null) { lines.push(`status_changed_at: ${note.statusChangedAt}`); } if (note.moveReason !== null) { lines.push(`move_reason: ${formatScalar(note.moveReason)}`); } if (note.dueDate !== null) { lines.push(`due_date: ${note.dueDate}`); lines.push(`due_date_source: ${note.dueDateEditedByUser ? 'user' : 'ai'}`); } if (note.media.length > 0) { lines.push('images:'); for (const m of note.media) { lines.push(` - rel: ${formatScalar(m.rel)}`); lines.push(` mime: ${formatScalar(m.mime)}`); lines.push(` bytes: ${m.bytes}`); } } lines.push('inkling_export_version: 1'); lines.push('---'); return lines.join('\n') + '\n'; } // --------------------------------------------------------------------------- // composeMarkdown // --------------------------------------------------------------------------- function extFromMime(mime: string): string { switch (mime) { case 'image/png': return 'png'; case 'image/jpeg': return 'jpg'; default: return 'bin'; } } export function composeMarkdown(note: ExportNote): string { const fm = composeFrontmatter(note); const heading = `# ${note.aiTitle ?? '(제목 없음)'}`; const id8 = note.id.slice(0, 8); const sections: string[] = []; sections.push(fm.trimEnd()); // remove trailing newline so we control spacing sections.push(heading); if (note.aiSummary !== null) { sections.push(`> ${note.aiSummary}`); } sections.push(note.rawText); if (note.media.length > 0) { const imageLines = note.media.map((m, idx) => { const n = idx + 1; const ext = extFromMime(m.mime); return `![](media/${id8}__${n}.${ext})`; }); sections.push(imageLines.join('\n')); } return sections.join('\n\n') + '\n'; } // --------------------------------------------------------------------------- // composeIndexJsonl // --------------------------------------------------------------------------- export function composeIndexJsonl( entries: Array<{ note: ExportNote; path: string }> ): string { if (entries.length === 0) return ''; const lines = entries.map(({ note, path }) => JSON.stringify({ id: note.id, path, created_at: note.createdAt, tags: note.tags.map((t) => t.name), embedding_text: note.rawText }) ); return lines.join('\n') + '\n'; } // --------------------------------------------------------------------------- // composeManifest // --------------------------------------------------------------------------- export function composeManifest(input: { noteCount: number; mediaCount: number; }): string { // exported_at 필드 의도적 제외 — note 변경 없이도 git sync 가 매 호출마다 // timestamp 갱신 1줄 commit 을 만들어 history 노이즈와 불필요한 push 유발. // import path 는 inkling_export_version 만 read 하므로 안전. return JSON.stringify( { inkling_export_version: 1, note_count: input.noteCount, media_count: input.mediaCount }, null, 2 ); }