mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix(import): route image pages by source (#2718)
This commit is contained in:
+18
-7
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user