feat(v030): ImportService.applySyncFromDir + frontmatter status/dueDate/moveReason round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,11 @@ function noteToExportNote(n: Note): ExportNote {
|
||||
aiGeneratedAt: n.aiGeneratedAt,
|
||||
userIntent: n.userIntent,
|
||||
intentPromptedAt: n.intentPromptedAt,
|
||||
status: n.status,
|
||||
statusChangedAt: n.statusChangedAt,
|
||||
moveReason: n.moveReason,
|
||||
dueDate: n.dueDate,
|
||||
dueDateEditedByUser: n.dueDateEditedByUser,
|
||||
tags: n.tags.map((t) => ({ name: t.name, source: t.source })),
|
||||
media: n.media.map((m, idx) => ({
|
||||
rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`,
|
||||
|
||||
@@ -130,6 +130,37 @@ export class ImportService {
|
||||
};
|
||||
}
|
||||
|
||||
async applySyncFromDir(dir: string): Promise<{ changedCount: number }> {
|
||||
const files = await this.scanNotes(dir);
|
||||
let changedCount = 0;
|
||||
for (const f of files) {
|
||||
const content = await readFile(f, 'utf8');
|
||||
const parsed = parseExportNote(content);
|
||||
const r = this.repo.upsertFromSync({
|
||||
id: parsed.id,
|
||||
rawText: parsed.rawText,
|
||||
createdAt: parsed.createdAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
aiTitle: parsed.aiTitle,
|
||||
aiSummary: parsed.aiSummary,
|
||||
titleEditedByUser: parsed.titleEditedByUser,
|
||||
summaryEditedByUser: parsed.summaryEditedByUser,
|
||||
aiProvider: parsed.aiProvider,
|
||||
aiGeneratedAt: parsed.aiGeneratedAt,
|
||||
userIntent: parsed.userIntent,
|
||||
intentPromptedAt: parsed.intentPromptedAt,
|
||||
tags: parsed.tags,
|
||||
status: parsed.status,
|
||||
statusChangedAt: parsed.statusChangedAt,
|
||||
moveReason: parsed.moveReason,
|
||||
dueDate: parsed.dueDate,
|
||||
dueDateEditedByUser: parsed.dueDateEditedByUser
|
||||
});
|
||||
if (r.status !== 'skipped') changedCount += 1;
|
||||
}
|
||||
return { changedCount };
|
||||
}
|
||||
|
||||
private async scanNotes(sourceDir: string): Promise<string[]> {
|
||||
const notesDir = join(sourceDir, 'notes');
|
||||
let entries: string[];
|
||||
|
||||
@@ -29,6 +29,13 @@ export interface ExportNote {
|
||||
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[];
|
||||
}
|
||||
@@ -155,6 +162,18 @@ export function composeFrontmatter(note: ExportNote): string {
|
||||
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) {
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface ParsedNote {
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
deletedAt: string | null; // 신규 v0.2.3 #4
|
||||
// v0.3.0 Cut E — round-trip status / due_date / move_reason from frontmatter.
|
||||
// Default to 'active' / null / false when absent (older exports pre-Cut E).
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ParsedNoteTag[];
|
||||
images: ParsedNoteImage[];
|
||||
exportVersion: number;
|
||||
@@ -335,6 +342,13 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
const versionRaw = get('inkling_export_version');
|
||||
const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0;
|
||||
|
||||
const statusRaw = get('status');
|
||||
const validStatuses = ['active', 'completed', 'archived', 'trashed'] as const;
|
||||
const status = (validStatuses as readonly string[]).includes(statusRaw ?? 'active')
|
||||
? ((statusRaw ?? 'active') as ParsedNote['status'])
|
||||
: 'active';
|
||||
const dueDateSource = get('due_date_source');
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
@@ -349,6 +363,11 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
userIntent: get('user_intent'),
|
||||
intentPromptedAt: get('intent_prompted_at'),
|
||||
deletedAt: get('deleted_at'),
|
||||
status,
|
||||
statusChangedAt: get('status_changed_at'),
|
||||
moveReason: get('move_reason'),
|
||||
dueDate: get('due_date'),
|
||||
dueDateEditedByUser: dueDateSource === 'user',
|
||||
tags: fm.tags,
|
||||
images: fm.images,
|
||||
exportVersion
|
||||
|
||||
106
tests/unit/ImportService.applySyncFromDir.test.ts
Normal file
106
tests/unit/ImportService.applySyncFromDir.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { ImportService } from '@main/services/ImportService.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
|
||||
describe('ImportService.applySyncFromDir', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let svc: ImportService;
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-'));
|
||||
const mediaStore = new MediaStore(workDir);
|
||||
svc = new ImportService(repo, mediaStore);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('inserts new notes and reports changedCount', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: title\ntitle_source: ai\nsummary: summary\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# title\n\n> summary\n\nbody\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(1);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000001');
|
||||
expect(note?.rawText).toBe('body');
|
||||
});
|
||||
|
||||
it('skips unchanged notes (no changedCount increment)', async () => {
|
||||
const created = repo.create({ rawText: 'body' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns changedCount=0 for an empty notes directory', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('updates a note when source updatedAt is newer', async () => {
|
||||
const created = repo.create({ rawText: 'old body' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-01T00:00:00Z', created.id);
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nnew body\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(1);
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('new body');
|
||||
});
|
||||
|
||||
it('preserves status field from frontmatter', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000002\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: archived\nstatus_changed_at: 2026-05-08T00:00:00Z\nmove_reason: done\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000002');
|
||||
expect(note?.status).toBe('archived');
|
||||
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
|
||||
expect(note?.moveReason).toBe('done');
|
||||
});
|
||||
|
||||
it('preserves dueDate from frontmatter', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000003\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ndue_date: 2026-06-01\ndue_date_source: user\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000003');
|
||||
expect(note?.dueDate).toBe('2026-06-01');
|
||||
expect(note?.dueDateEditedByUser).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ const baseNote: ExportNote = {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
@@ -122,6 +127,54 @@ describe('composeFrontmatter', () => {
|
||||
expect(fm).toContain('mime: image/png');
|
||||
expect(fm).toContain('bytes: 1234');
|
||||
});
|
||||
|
||||
it('always emits status: active for a default note', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).toContain('status: active');
|
||||
});
|
||||
|
||||
it('emits due_date and due_date_source together when dueDate present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: true });
|
||||
expect(fm).toContain('due_date: 2026-06-01');
|
||||
expect(fm).toContain('due_date_source: user');
|
||||
});
|
||||
|
||||
it('emits due_date_source: ai when dueDateEditedByUser is false', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: false });
|
||||
expect(fm).toContain('due_date: 2026-06-01');
|
||||
expect(fm).toContain('due_date_source: ai');
|
||||
});
|
||||
|
||||
it('omits due_date and due_date_source when dueDate is null', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).not.toContain('due_date:');
|
||||
expect(fm).not.toContain('due_date_source:');
|
||||
});
|
||||
|
||||
it('emits move_reason when present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, status: 'archived', moveReason: 'done for now' });
|
||||
expect(fm).toContain('status: archived');
|
||||
expect(fm).toContain('move_reason: done for now');
|
||||
});
|
||||
|
||||
it('emits status_changed_at when present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, statusChangedAt: '2026-05-01T00:00:00Z' });
|
||||
expect(fm).toContain('status_changed_at: 2026-05-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('status/due_date/move_reason fields appear before images: in frontmatter', () => {
|
||||
const fm = composeFrontmatter({
|
||||
...baseNote,
|
||||
dueDate: '2026-06-01',
|
||||
dueDateEditedByUser: false,
|
||||
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1 }]
|
||||
});
|
||||
const statusPos = fm.indexOf('status:');
|
||||
const imagesPos = fm.indexOf('images:');
|
||||
expect(statusPos).toBeGreaterThan(-1);
|
||||
expect(imagesPos).toBeGreaterThan(-1);
|
||||
expect(statusPos).toBeLessThan(imagesPos);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeMarkdown', () => {
|
||||
|
||||
@@ -18,6 +18,11 @@ const baseNote: ExportNote = {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
@@ -180,6 +185,66 @@ describe('parseExportNote — provenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExportNote — status/dueDate/moveReason round-trip (v0.3.0 Cut E)', () => {
|
||||
it('round-trips status=active (default)', () => {
|
||||
const md = composeMarkdown(baseNote);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('active');
|
||||
expect(parsed.statusChangedAt).toBeNull();
|
||||
expect(parsed.moveReason).toBeNull();
|
||||
expect(parsed.dueDate).toBeNull();
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips status=archived with statusChangedAt and moveReason', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
status: 'archived',
|
||||
statusChangedAt: '2026-05-01T10:00:00Z',
|
||||
moveReason: 'project done'
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('archived');
|
||||
expect(parsed.statusChangedAt).toBe('2026-05-01T10:00:00Z');
|
||||
expect(parsed.moveReason).toBe('project done');
|
||||
});
|
||||
|
||||
it('round-trips dueDate with dueDateEditedByUser=true', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
dueDate: '2026-06-15',
|
||||
dueDateEditedByUser: true
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.dueDate).toBe('2026-06-15');
|
||||
expect(parsed.dueDateEditedByUser).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips dueDate with dueDateEditedByUser=false (ai source)', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
dueDate: '2026-07-01',
|
||||
dueDateEditedByUser: false
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.dueDate).toBe('2026-07-01');
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to status=active for older exports without status field', () => {
|
||||
// Simulate a pre-Cut E export that has no status line
|
||||
const md = `---\nid: 014a3b9c-1234-7890-abcd-000000000001\ncreated_at: 2026-04-25T14:23:11.000Z\nupdated_at: 2026-04-25T14:24:02.000Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`;
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('active');
|
||||
expect(parsed.dueDate).toBeNull();
|
||||
expect(parsed.moveReason).toBeNull();
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExportNote — edge cases', () => {
|
||||
it('preserves user_intent when present', () => {
|
||||
const md = composeMarkdown({
|
||||
|
||||
Reference in New Issue
Block a user