From 8e09464d5e0d3f69d160c3c959ca7a77a3529f3d Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:39:32 +0900 Subject: [PATCH] feat(export): pure frontmatter + slug + markdown + jsonl + manifest composers Pure compose layer for F5 (Export). slugifyTitle, composeFilename, composeFrontmatter, composeMarkdown, composeIndexJsonl, composeManifest + ExportNote/ExportNoteMedia/ExportNoteTag types. No fs deps. 24 unit tests covering normal cases + edge cases (null title, forbidden chars, multiline summary needing block scalar, colon needing single-quote, image numbering by id8__n.ext). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/exportFormat.ts | 251 ++++++++++++++++++++++++++++++ tests/unit/exportFormat.test.ts | 192 +++++++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 src/main/services/exportFormat.ts create mode 100644 tests/unit/exportFormat.test.ts diff --git a/src/main/services/exportFormat.ts b/src/main/services/exportFormat.ts new file mode 100644 index 0000000..1116629 --- /dev/null +++ b/src/main/services/exportFormat.ts @@ -0,0 +1,251 @@ +/** + * 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; + 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}`); + } + + 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: { + exportedAt: string; + noteCount: number; + mediaCount: number; +}): string { + return JSON.stringify( + { + inkling_export_version: 1, + exported_at: input.exportedAt, + note_count: input.noteCount, + media_count: input.mediaCount + }, + null, + 2 + ); +} diff --git a/tests/unit/exportFormat.test.ts b/tests/unit/exportFormat.test.ts new file mode 100644 index 0000000..2212ae4 --- /dev/null +++ b/tests/unit/exportFormat.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; +import { + slugifyTitle, + composeFilename, + composeFrontmatter, + composeMarkdown, + composeIndexJsonl, + composeManifest, + type ExportNote +} from '@main/services/exportFormat.js'; + +const baseNote: ExportNote = { + id: '014a3b9c-1234-7890-abcd-000000000001', + createdAt: '2026-04-25T14:23:11.000Z', + updatedAt: '2026-04-25T14:24:02.000Z', + rawText: '회고 메모 본문', + aiTitle: '주간 회고 PR 리뷰', + aiSummary: '회고 양식 통일을 위한 메모.', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'local-ollama/gemma4:e4b', + aiGeneratedAt: '2026-04-25T14:23:34.000Z', + userIntent: null, + intentPromptedAt: null, + tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }], + media: [] +}; + +describe('slugifyTitle', () => { + it('converts spaces to single hyphen, preserves Korean', () => { + expect(slugifyTitle('주간 회고 PR 리뷰')).toBe('주간-회고-PR-리뷰'); + }); + it('returns "untitled" for null', () => { + expect(slugifyTitle(null)).toBe('untitled'); + }); + it('returns "untitled" for empty string', () => { + expect(slugifyTitle('')).toBe('untitled'); + }); + it('returns "untitled" for whitespace-only', () => { + expect(slugifyTitle(' ')).toBe('untitled'); + }); + it('strips filesystem-forbidden chars', () => { + expect(slugifyTitle('foo/bar:baz*qux"<>|?\\')).toBe('foobarbazqux'); + }); + it('collapses multiple whitespace to single hyphen', () => { + expect(slugifyTitle('a b c')).toBe('a-b-c'); + }); + it('trims leading/trailing hyphens', () => { + expect(slugifyTitle(' hello ')).toBe('hello'); + }); + it('truncates to 32 codepoints (Korean counted properly)', () => { + const long = '가'.repeat(50); + expect([...slugifyTitle(long)].length).toBeLessThanOrEqual(32); + }); +}); + +describe('composeFilename', () => { + it('combines date prefix + id8 + slug + .md', () => { + expect(composeFilename({ + id: '014a3b9c-1234-7890-abcd-000000000001', + createdAt: '2026-04-25T14:23:11.000Z', + aiTitle: '주간 회고' + })).toBe('2026-04-25-014a3b9c-주간-회고.md'); + }); + it('uses untitled slug for null title', () => { + expect(composeFilename({ + id: '01234567-aaaa-bbbb-cccc-000000000000', + createdAt: '2026-04-25T14:23:11.000Z', + aiTitle: null + })).toBe('2026-04-25-01234567-untitled.md'); + }); +}); + +describe('composeFrontmatter', () => { + it('produces frontmatter with delimiters and inkling_export_version=1', () => { + const fm = composeFrontmatter(baseNote); + expect(fm.startsWith('---\n')).toBe(true); + expect(fm.trimEnd().endsWith('---')).toBe(true); + expect(fm).toContain('inkling_export_version: 1'); + }); + it('includes title and source=ai for non-edited title', () => { + const fm = composeFrontmatter(baseNote); + expect(fm).toContain('title: 주간 회고 PR 리뷰'); + expect(fm).toContain('title_source: ai'); + }); + it('marks source=user when edited', () => { + const fm = composeFrontmatter({ ...baseNote, titleEditedByUser: true }); + expect(fm).toContain('title_source: user'); + }); + it('omits null fields', () => { + const fm = composeFrontmatter(baseNote); + expect(fm).not.toContain('user_intent:'); + expect(fm).not.toContain('intent_prompted_at:'); + }); + it('uses block scalar |- for multiline summary', () => { + const fm = composeFrontmatter({ ...baseNote, aiSummary: 'line1\nline2' }); + expect(fm).toContain('summary: |-'); + expect(fm).toContain(' line1'); + expect(fm).toContain(' line2'); + }); + it('single-quotes title containing colon', () => { + const fm = composeFrontmatter({ ...baseNote, aiTitle: 'a: b' }); + expect(fm).toContain("title: 'a: b'"); + }); + it('emits tags array with inline flow style', () => { + const fm = composeFrontmatter(baseNote); + expect(fm).toContain('tags:'); + expect(fm).toContain('- { name: pr, source: ai }'); + expect(fm).toContain('- { name: review, source: user }'); + }); + it('omits tags section when empty', () => { + const fm = composeFrontmatter({ ...baseNote, tags: [] }); + expect(fm).not.toContain('tags:'); + }); + it('emits images array when media present', () => { + const fm = composeFrontmatter({ + ...baseNote, + media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 }] + }); + expect(fm).toContain('images:'); + expect(fm).toContain('rel: media/014a3b9c__1.png'); + expect(fm).toContain('mime: image/png'); + expect(fm).toContain('bytes: 1234'); + }); +}); + +describe('composeMarkdown', () => { + it('includes h1 with title, blockquote summary, body', () => { + const md = composeMarkdown(baseNote); + expect(md).toContain('# 주간 회고 PR 리뷰'); + expect(md).toContain('> 회고 양식 통일을 위한 메모.'); + expect(md).toContain('회고 메모 본문'); + }); + it('uses fallback heading when title null', () => { + const md = composeMarkdown({ ...baseNote, aiTitle: null }); + expect(md).toContain('# (제목 없음)'); + }); + it('omits blockquote when summary null', () => { + const md = composeMarkdown({ ...baseNote, aiSummary: null }); + expect(md).not.toContain('>'); + }); + it('appends image refs with id8__n.ext naming', () => { + const md = composeMarkdown({ + ...baseNote, + media: [ + { rel: 'media/old/1.png', mime: 'image/png', bytes: 100 }, + { rel: 'media/old/2.jpg', mime: 'image/jpeg', bytes: 200 } + ] + }); + expect(md).toContain('![](media/014a3b9c__1.png)'); + expect(md).toContain('![](media/014a3b9c__2.jpg)'); + }); +}); + +describe('composeIndexJsonl', () => { + it('emits one JSON line per entry with trailing newline', () => { + const out = composeIndexJsonl([ + { note: baseNote, path: 'notes/2026-04-25-014a3b9c-주간-회고.md' } + ]); + expect(out.endsWith('\n')).toBe(true); + const lines = out.trimEnd().split('\n'); + expect(lines.length).toBe(1); + const obj = JSON.parse(lines[0]!); + expect(obj.id).toBe(baseNote.id); + expect(obj.path).toBe('notes/2026-04-25-014a3b9c-주간-회고.md'); + expect(obj.tags).toEqual(['pr', 'review']); + expect(obj.embedding_text).toBe('회고 메모 본문'); + }); + + it('emits two lines for two entries', () => { + const out = composeIndexJsonl([ + { note: baseNote, path: 'notes/a.md' }, + { note: { ...baseNote, id: '02xxxxxx-...', rawText: 'b' }, path: 'notes/b.md' } + ]); + expect(out.trimEnd().split('\n').length).toBe(2); + }); +}); + +describe('composeManifest', () => { + it('emits pretty JSON with required fields', () => { + const m = composeManifest({ + exportedAt: '2026-04-26T00:00:00.000Z', + noteCount: 42, + mediaCount: 17 + }); + const obj = JSON.parse(m); + expect(obj.inkling_export_version).toBe(1); + expect(obj.exported_at).toBe('2026-04-26T00:00:00.000Z'); + expect(obj.note_count).toBe(42); + expect(obj.media_count).toBe(17); + }); +});