Files
mule-image/migrate/legacy_export.mjs
dtoro 8c2526d982 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>
2026-05-17 16:06:58 +02:00

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();