dogfood: 노트 변경이 0건이어도 자동 sync 가 매번 commit + push 를 생성. 원인은 manifest.json 의 exported_at timestamp 가 매 export 마다 갱신되어 git diff 가 항상 1줄 발생. 해결: composeManifest 의 exportedAt 입력 제거 + 출력 JSON 에서 필드 삭제. 이 필드는 ImportService 가 read 하지 않고 UI 표시도 없는 cosmetic 정보였음. 이제 노트 변경 있을 때만 commit/push 가 일어난다. 회귀 테스트: 같은 input 으로 두 번 호출 시 stable 출력 invariant 추가. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
272 lines
8.3 KiB
TypeScript
272 lines
8.3 KiB
TypeScript
/**
|
|
* 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 ``;
|
|
});
|
|
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
|
|
);
|
|
}
|