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) <noreply@anthropic.com>
This commit is contained in:
251
src/main/services/exportFormat.ts
Normal file
251
src/main/services/exportFormat.ts
Normal file
@@ -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 ``;
|
||||
});
|
||||
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
|
||||
);
|
||||
}
|
||||
192
tests/unit/exportFormat.test.ts
Normal file
192
tests/unit/exportFormat.test.ts
Normal file
@@ -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('');
|
||||
expect(md).toContain('');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user