feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
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>
This commit is contained in:
90
migrate/legacy_export.mjs
Normal file
90
migrate/legacy_export.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user