From ec3910afc424f7291daa20548c27500c6cb49546 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:31:50 +0800 Subject: [PATCH] fix(import): route image pages by source (#2718) --- src/core/import-file.ts | 25 ++++++++++++++++++------- test/import-image-file.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/core/import-file.ts b/src/core/import-file.ts index 9ba0c574b..4e7722abb 100644 --- a/src/core/import-file.ts +++ b/src/core/import-file.ts @@ -1315,6 +1315,8 @@ const NEEDS_DECODE = new Set(['.heic', '.heif', '.avif']); export interface ImportTransactionSpec { slug: string; hadExisting: boolean; + /** Source containing the page, chunks, file row, and type-specific writes. */ + sourceId?: string; page: PageInput; /** When undefined, no chunk write happens. When [], deletes any prior chunks. */ chunks?: ChunkInput[]; @@ -1328,23 +1330,26 @@ export async function withImportTransaction( engine: BrainEngine, spec: ImportTransactionSpec, ): Promise { + const sourceId = spec.sourceId ?? 'default'; + const txOpts = spec.sourceId ? { sourceId: spec.sourceId } : undefined; await engine.transaction(async (tx) => { - if (spec.hadExisting) await tx.createVersion(spec.slug); - await tx.putPage(spec.slug, spec.page); + if (spec.hadExisting) await tx.createVersion(spec.slug, txOpts); + await tx.putPage(spec.slug, spec.page, txOpts); if (spec.file) { // page_id resolution after putPage so the new row's id is available. - const stored = await tx.getPage(spec.slug); + const stored = await tx.getPage(spec.slug, txOpts); await tx.upsertFile({ ...spec.file, + source_id: sourceId, page_slug: spec.slug, page_id: stored?.id ?? null, }); } if (spec.chunks !== undefined) { if (spec.chunks.length > 0) { - await tx.upsertChunks(spec.slug, spec.chunks); + await tx.upsertChunks(spec.slug, spec.chunks, txOpts); } else { - await tx.deleteChunks(spec.slug); + await tx.deleteChunks(spec.slug, txOpts); } } if (spec.after) await spec.after(tx); @@ -1574,10 +1579,14 @@ export async function importImageFile( // and slugifyPath would already preserve it). Recompute with the file // extension preserved so the page slug is stable + collision-free. const imageSlug = relativePath.replace(/[\\\/]/g, '/').toLowerCase(); + const sourceOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined; + const linkOpts = opts.sourceId + ? { fromSourceId: opts.sourceId, toSourceId: opts.sourceId, originSourceId: opts.sourceId } + : undefined; const buf = readFileSync(filePath); const hash = createHash('sha256').update(buf).digest('hex'); - const existing = await engine.getPage(imageSlug); + const existing = await engine.getPage(imageSlug, sourceOpts); if (existing?.content_hash === hash) { return { slug: imageSlug, status: 'skipped', chunks: 0 }; } @@ -1653,6 +1662,7 @@ export async function importImageFile( await withImportTransaction(engine, { slug: imageSlug, hadExisting: !!existing, + sourceId: opts.sourceId, page: { type: 'image', page_kind: 'image', @@ -1670,13 +1680,14 @@ export async function importImageFile( // throws when the target doesn't exist; we silently skip for now and // let `gbrain reconcile-links` pick up later additions. for (const candidate of imageOfCandidates(imageSlug)) { - const sibling = await tx.getPage(candidate); + const sibling = await tx.getPage(candidate, sourceOpts); if (sibling) { try { await tx.addLink( imageSlug, candidate, filename, 'image_of', 'manual', imageSlug, 'frontmatter', + linkOpts, ); } catch { /* sibling vanished mid-tx; skip */ } break; // one canonical link per image diff --git a/test/import-image-file.test.ts b/test/import-image-file.test.ts index 8542e1d83..1fef8228b 100644 --- a/test/import-image-file.test.ts +++ b/test/import-image-file.test.ts @@ -120,6 +120,29 @@ describe('importImageFile happy path (noEmbed)', () => { expect(r2.status).toBe('skipped'); }); + test('routes page, chunks, and file metadata to the requested source (#2706)', async () => { + await engine.executeRaw( + `INSERT INTO sources (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, + ['image-source', 'Image Source'], + ); + const target = join(tmpDir, 'source-photo.png'); + writeFileSync(target, Buffer.from('fake-png-bytes-source-scoped')); + + const result = await importImageFile(engine, target, 'photos/source-photo.png', { + noEmbed: true, + sourceId: 'image-source', + }); + + expect(result.status).toBe('imported'); + expect(await engine.getPage('photos/source-photo.png', { sourceId: 'default' })).toBeNull(); + const page = await engine.getPage('photos/source-photo.png', { sourceId: 'image-source' }); + expect(page).not.toBeNull(); + expect(await engine.getChunks('photos/source-photo.png', { sourceId: 'default' })).toHaveLength(0); + expect(await engine.getChunks('photos/source-photo.png', { sourceId: 'image-source' })).toHaveLength(1); + expect(await engine.getFile('default', 'photos/source-photo.png')).toBeNull(); + expect(await engine.getFile('image-source', 'photos/source-photo.png')).not.toBeNull(); + }); + test('refuses oversized files (>20MB)', async () => { const target = join(tmpDir, 'huge.png'); // Write a 21MB file. Buffer.alloc is fast.