Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.0 KiB
JavaScript
91 lines
3.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// Dumps a legacy mule-image Postgres into the snapshot JSON shape `run.mjs`
|
|
// consumes. Intended to run on the homecloud server where the legacy stack
|
|
// lives (since `DATABASE_URL` points at the in-network Postgres there).
|
|
//
|
|
// DATABASE_URL=postgres://mulita:mulita@db:5432/mulita \
|
|
// node migrate/legacy_export.mjs > snapshot.json
|
|
//
|
|
// Uses the `pg` npm package which the legacy mule-image already ships in
|
|
// its backend image; if you're running this from a separate workstation,
|
|
// `npm install pg` first.
|
|
|
|
import pg from 'pg';
|
|
import process from 'node:process';
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
console.error('DATABASE_URL required');
|
|
process.exit(2);
|
|
}
|
|
|
|
const client = new pg.Client({ connectionString: DATABASE_URL });
|
|
await client.connect();
|
|
|
|
// ── photos ───────────────────────────────────────────────────────────────────
|
|
|
|
// Mule-image's photos table is the source of all per-file metadata. The
|
|
// migrator only reads fields that have a clean PhotoPrism mapping; the
|
|
// rest (auto-tags, ML-derived fields, etc.) are skipped per the merge plan.
|
|
const photoRows = await client.query(`
|
|
SELECT
|
|
p.filepath,
|
|
p.user_title,
|
|
p.user_notes,
|
|
p.rating,
|
|
p.is_picked,
|
|
p.is_discarded,
|
|
p.is_hidden,
|
|
p.taken_at,
|
|
COALESCE(
|
|
array_agg(t.name) FILTER (WHERE t.name IS NOT NULL),
|
|
'{}'
|
|
) AS tags
|
|
FROM photos p
|
|
LEFT JOIN photo_tags pt ON pt.photo_id = p.id
|
|
LEFT JOIN tags t ON t.id = pt.tag_id
|
|
WHERE p.is_active IS NULL OR p.is_active = true
|
|
GROUP BY p.id
|
|
`);
|
|
|
|
// ── heaps + memberships ─────────────────────────────────────────────────────
|
|
|
|
const heapRows = await client.query(`
|
|
SELECT h.id, h.name FROM heaps h WHERE h.is_active = true
|
|
`);
|
|
|
|
const heapMembers = await client.query(`
|
|
SELECT hp.heap_id, p.filepath
|
|
FROM heap_photos hp
|
|
JOIN photos p ON p.id = hp.photo_id
|
|
`);
|
|
|
|
const membersByHeap = new Map();
|
|
for (const r of heapMembers.rows) {
|
|
if (!membersByHeap.has(r.heap_id)) membersByHeap.set(r.heap_id, []);
|
|
membersByHeap.get(r.heap_id).push(r.filepath);
|
|
}
|
|
|
|
// ── assemble ─────────────────────────────────────────────────────────────────
|
|
|
|
const snapshot = {
|
|
photos: photoRows.rows.map((r) => ({
|
|
filepath: r.filepath,
|
|
user_title: r.user_title,
|
|
user_notes: r.user_notes,
|
|
rating: r.rating,
|
|
is_picked: Boolean(r.is_picked),
|
|
is_discarded: Boolean(r.is_discarded),
|
|
is_hidden: Boolean(r.is_hidden),
|
|
taken_at: r.taken_at ? new Date(r.taken_at).toISOString() : null,
|
|
tags: r.tags ?? []
|
|
})),
|
|
heaps: heapRows.rows.map((h) => ({
|
|
name: h.name,
|
|
photo_filepaths: membersByHeap.get(h.id) ?? []
|
|
}))
|
|
};
|
|
|
|
process.stdout.write(JSON.stringify(snapshot, null, 2));
|
|
await client.end();
|