Ctrl K
ComunidadGuías…

routes/backupRoutes.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import express from 'express';
import mongoose from 'mongoose';
import multer from 'multer';
import os from 'os';
import { promises as fs } from 'fs';
import Item from '../models/Item';
import User from '../models/User';
import LoginLog from '../models/LoginLog';
import Settings from '../models/Settings';
import Collection from '../models/Collection';
import CustomPlugin from '../models/CustomPlugin';
import PriceHistory from '../models/PriceHistory';
import InstanceSettings from '../models/InstanceSettings';
import Furniture from '../models/Furniture';
import List from '../models/List';
import { invalidateInstanceSettingsCache } from '../utils/instanceSettings';
import { invalidateAiConfigCache } from '../core/ai/instance';
import { requireAuth, requireAdmin, requireCollectionRole } from '../middleware/authMiddleware';
import { registry } from '../core/registry';
import { buildSortTitle, stringifyCsv, getPublicProtocol } from '../core/helpers';
import { importableFields, fieldValue, ImportTargetField } from '../core/csvMapping';

// Stamped into every dump so a restore log says which build produced the file.
// Read from package.json rather than copied, which is how it came to say 3.1.0 on 3.1.1.
const pkg = require('../package.json');
import { migrateDatabase, normalizeThemePresets } from '../utils/migrate';
import { migrateExtraFieldIdentities } from '../utils/migrateExtraFieldIdentities';
import { seedFurnitureFromLocations } from '../core/shelfStore';
import { findDuplicateCellKey } from '../utils/shelfHelpers';
import { collectionInfoFromBackup, collectionInfoOf } from '../core/collectionInfo';
import { applyCustomPluginsFromDB } from '../core/customPluginSync';
import { collectExtraDateFields, reviveExtraDates } from '../core/pluginExtraFields';
import { deleteUnusedManagedItemImages, managedItemImageFile, managedItemImagesForQuery } from '../core/itemImageStorage';
import { MAX_BACKUP_UPLOAD_BYTES, readBackupArchive, sendBackupArchive } from '../core/backupArchive';
import { BASE_URL } from '../config/constants';

const router = express.Router();
const backupArchiveUpload = multer({
    dest: os.tmpdir(),
    limits: { files: 1, fileSize: MAX_BACKUP_UPLOAD_BYTES }
});

const requireInstanceImportAccess = async (req: any, res: any, next: any) => {
    try {
        const userCount = await User.countDocuments();
        if (userCount === 0 || res.locals.user?.isAdmin) return next();
        console.warn(`[SECURITY] import unauthorized : ${req.ip}`);
        return res.status(403).json({ error: 'Import unauthorized.' });
    } catch (err) {
        next(err);
    }
};

const receiveBackupArchive = (req: any, res: any, next: any) => {
    backupArchiveUpload.single('backup')(req, res, (err: any) => {
        if (!err) return next();
        const tooLarge = err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE';
        return res.status(tooLarge ? 413 : 400).json({ error: tooLarge ? 'Backup archive too large' : 'Invalid backup archive' });
    });
};

const loadBackupArchive = async (req: any, res: any, next: any) => {
    if (!req.file?.path) return res.status(400).json({ error: 'Backup archive missing' });
    try {
        const imported = await readBackupArchive(req.file.path);
        req.body = imported.data;
        req.importedManagedImagePaths = imported.importedImages;
        next();
    } catch (err) {
        console.warn('[BACKUP] Invalid archive:', err);
        res.status(400).json({ error: 'Backup archive corrupted or invalid' });
    } finally {
        try {
            await fs.unlink(req.file.path);
        } catch (err: any) {
            if (err?.code !== 'ENOENT') console.warn('[BACKUP] Temporary archive cleanup failed:', err);
        }
    }
};

async function cleanupImportedImages(req: any): Promise<void> {
    const images = Array.isArray(req.importedManagedImagePaths) ? req.importedManagedImagePaths : [];
    if (images.length === 0) return;
    try {
        await deleteUnusedManagedItemImages(images);
    } catch (err) {
        console.warn('[BACKUP] Imported image cleanup failed:', err);
    }
}

// ============ WHOLE-INSTANCE BACKUP (instance admin) ============

async function buildInstanceBackup(): Promise<any> {
    return {
        users: await User.find({}).lean(),
        albums: await Item.find({}).lean(),
        logs: await LoginLog.find({}).lean(),
        settings: await Settings.find({}).lean(),
        collections: await Collection.find({}).lean(),
        furniture: await Furniture.find({}).lean(),
        lists: await List.find({}).lean(),
        customPlugins: await CustomPlugin.find({}).lean(),
        priceHistory: await PriceHistory.find({}).lean(),
        instanceSettings: await InstanceSettings.findOne({ key: 'instance' }).lean(),
        metadata: {
            version: pkg.version,
            date: new Date()
        }
    };
}

router.get('/export', requireAuth, requireAdmin, async (req, res) => {
    try {
        const data = await buildInstanceBackup();

        const fileName = `dvinyl_instance_${new Date().toISOString().split('T')[0]}.json`;
        console.log(`[BACKUP] Instance export: ${data.users.length} user(s), ${data.albums.length} item(s), ${data.collections.length} collection(s)`);
        res.setHeader('Content-disposition', 'attachment; filename=' + fileName);
        res.setHeader('Content-type', 'application/json');
        res.send(JSON.stringify(data, null, 2));
    } catch (err) {
        console.error("[BACKUP] Instance export failed:", err);
        res.status(500).send("Export failed");
    }
});

router.get('/export-archive', requireAuth, requireAdmin, async (req, res) => {
    try {
        const data = await buildInstanceBackup();
        const fileName = `dvinyl_instance_${new Date().toISOString().split('T')[0]}.zip`;
        console.log(`[BACKUP] Instance archive export: ${data.users.length} user(s), ${data.albums.length} item(s), ${data.collections.length} collection(s)`);
        await sendBackupArchive(res, data, fileName);
    } catch (err) {
        console.error('[BACKUP] Instance archive export failed:', err);
        if (!res.headersSent) res.status(500).send('Export failed');
        else res.destroy(err as Error);
    }
});

/**
 * POST /import - whole-instance restore (wipe & replace).
 * Supports three dump generations:
 *  - v3.1 (has `collections` + `settings` as array): restored verbatim.
 *  - v2/v3.0 (single global `settings`, no collections): collection-related
 *    fields are stripped and the boot migration is re-run to rebuild a default
 *    collection and re-stamp everything.
 */
const importInstanceBackup = async (req: any, res: any) => {
    try {
        // Setup
        let data = req.body;

        if (data.backupData) {
            try {
                data = typeof data.backupData === 'string' ? JSON.parse(data.backupData) : data.backupData;
            } catch (e) {
                await cleanupImportedImages(req);
                return res.status(400).json({ error: "Invalid JSON format" });
            }
        }

        if (!data || (!Array.isArray(data.users) && !Array.isArray(data.albums))) {
            await cleanupImportedImages(req);
            return res.status(400).json({ error: "Backup file missing required fields" });
        }

        // A collection's own backup (what a collection's admin page exports) carries no
        // accounts. Restored here, it would wipe every user and every other collection and
        // leave its items in none, with nobody left able to sign in. Refused before
        // anything is touched, with a message saying where it goes instead.
        const isCollectionDump = typeof data.collectionName === 'string' || data.metadata?.type === 'collection';
        if (isCollectionDump || !Array.isArray(data.users) || data.users.length === 0) {
            await cleanupImportedImages(req);
            return res.status(400).json({
                error: "Collection backup (no user accounts) posted to the instance import",
                userMessage: req.t('admin.backup.error_collection_dump')
            });
        }

        const hasCollections = Array.isArray(data.collections) && data.collections.length > 0;
        // Lists name items by id, and a whole-instance restore keeps every id as it was, so
        // they go back untouched. Settled here, before the wipe.
        const listsToRestore = hasCollections ? restorableLists(data.lists, list => list) : [];
        const replacedImagePaths = await managedItemImagesForQuery({});
        // The wipe below takes the info pages with it, so their uploads are candidates
        // for release too, exactly like the items' own images.
        for (const current of await Collection.find({}).select('info.images').lean()) {
            replacedImagePaths.push(...collectionInfoOf(current).images);
        }

        console.log(`[BACKUP] Instance import started (dump version ${data.metadata?.version || 'unknown'}, ${hasCollections ? 'with' : 'without'} collections): ${data.users?.length || 0} user(s), ${data.albums?.length || 0} item(s). Wiping current data...`);

        await Promise.all([
            LoginLog.deleteMany({}),
            Item.deleteMany({}),
            User.deleteMany({}),
            Settings.deleteMany({}),
            Collection.deleteMany({}),
            Furniture.deleteMany({}),
            List.deleteMany({}),
            CustomPlugin.deleteMany({}),
            PriceHistory.deleteMany({}),
            InstanceSettings.deleteMany({})
        ]);
        // The singleton is cached in memory; the wipe above must not leave a stale copy
        // authorizing (or blocking) collection creation until the next restart.
        invalidateInstanceSettingsCache();
        invalidateAiConfigCache();

        if (hasCollections) {
            await Collection.insertMany(data.collections);
        }

        // Furniture belongs to a collection, so it only means anything alongside them.
        if (hasCollections && Array.isArray(data.furniture) && data.furniture.length > 0) {
            const toId = (v: any) => (typeof v === 'string' && mongoose.Types.ObjectId.isValid(v))
                ? new mongoose.Types.ObjectId(v) : v;
            // Cast back to their BSON types like the items below: the native insert skips the
            // schema, and `created_at` orders the pieces of one collection after `order`.
            await Furniture.collection.insertMany(data.furniture.map((piece: any) => ({
                ...piece,
                _id: toId(piece._id),
                collection: toId(piece.collection),
                createdBy: piece.createdBy ? toId(piece.createdBy) : undefined,
                ...(piece.created_at ? { created_at: new Date(piece.created_at) } : {}),
                ...(piece.updated_at ? { updated_at: new Date(piece.updated_at) } : {})
            })));
        }

        // Whether the migration below still has free-text locations to turn into shelves.
        // A dump that carries a furniture list, even an empty one, comes from an instance
        // where that already happened: its collections hold the shelves they want, and an
        // empty list is furniture taken down on purpose, which stays that way. Whatever
        // flag each collection was dumped with is not trusted for this, since one created
        // after its instance's last boot could carry it unset. A dump from before the
        // shelves carries no list at all: its collections are handed to the migration,
        // which rebuilds the shelves from those very locations.
        if (hasCollections) {
            await Collection.updateMany({}, { $set: { shelvesSeeded: Array.isArray(data.furniture) } });
        }

        // Inserted through the model so the ids a JSON dump carries as strings are cast
        // back to ObjectIds.
        if (listsToRestore.length > 0) {
            await List.insertMany(listsToRestore);
        }

        if (data.users && data.users.length > 0) {
            const cleanUsers = hasCollections
                ? data.users
                : data.users.map((u: any) => {
                    const { lastActiveCollectionId, homeCollectionId, ...rest } = u;
                    return rest;
                });
            await User.insertMany(cleanUsers);
        }

        if (data.albums && data.albums.length > 0) {
            // Legacy backups may hold items without a `kind`; assign the plugin that claims legacy items
            const legacyKind = registry.getAll().find(p => p.matchesLegacyItems)?.kind || 'Music';
            const toId = (v: any) => (typeof v === 'string' && mongoose.Types.ObjectId.isValid(v))
                ? new mongoose.Types.ObjectId(v) : v;
            const extraDateFields = collectExtraDateFields(
                Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
            );
            const cleanAlbums = data.albums.map((album: any) => {
                const fixed: any = album.kind ? { ...album } : { ...album, kind: legacyKind };
                // Without the collections themselves, stale collection ids would orphan items
                if (!hasCollections) delete fixed.collection;
                // Cast the ref/date fields back to their BSON types (they are strings in JSON).
                if (fixed._id) fixed._id = toId(fixed._id);
                if (fixed.owner) fixed.owner = toId(fixed.owner);
                if (fixed.modified_by) fixed.modified_by = toId(fixed.modified_by);
                // Ids are preserved on a whole-instance restore, so a containment link only
                // needs its BSON type back.
                if (fixed.parent) fixed.parent = toId(fixed.parent);
                if (fixed.collection) fixed.collection = toId(fixed.collection);
                if (fixed.added_at) fixed.added_at = new Date(fixed.added_at);
                if (fixed.updated_at) fixed.updated_at = new Date(fixed.updated_at);
                if (fixed.modified_at) fixed.modified_at = new Date(fixed.modified_at);
                if (fixed.synced_at) fixed.synced_at = new Date(fixed.synced_at);
                // Same treatment for the date-typed user-defined fields, which sit in a
                // Mixed path and would otherwise come back as strings
                if (fixed.extra) fixed.extra = { ...fixed.extra };
                reviveExtraDates(fixed, extraDateFields);
                // The native insert below skips the schema middleware that normally derives
                // this, and a dump older than the field carries none at all.
                fixed.sort_title = buildSortTitle(fixed.title);
                return fixed;
            });
            // Insert with the native driver, bypassing Mongoose validation. A backup is
            // authoritative: re-validating restored items against the live (possibly stricter)
            // discriminator schema (e.g. a custom type whose `creator` became required) would
            // reject legitimately-saved items and, since the wipe already ran, gut the instance.
            await Item.collection.insertMany(cleanAlbums);
        }

        if (data.logs && data.logs.length > 0) {
            await LoginLog.insertMany(data.logs);
        }

        // Value snapshots are collection-scoped, so a dump restored without its collections
        // has nothing for them to describe: they are dropped rather than left pointing at
        // ids this instance does not have. A dump older than the feature simply has none.
        if (hasCollections && Array.isArray(data.priceHistory) && data.priceHistory.length > 0) {
            const toId = (v: any) => (typeof v === 'string' && mongoose.Types.ObjectId.isValid(v))
                ? new mongoose.Types.ObjectId(v) : v;
            const cleanHistory = data.priceHistory.map((snapshot: any) => {
                const fixed: any = { ...snapshot };
                if (fixed._id) fixed._id = toId(fixed._id);
                if (fixed.collection) fixed.collection = toId(fixed.collection);
                if (fixed.capturedAt) fixed.capturedAt = new Date(fixed.capturedAt);
                return fixed;
            });
            await PriceHistory.collection.insertMany(cleanHistory);
        }

        // v3.1 exports settings as an array (one per collection); older dumps as one object
        const settingsDocs = Array.isArray(data.settings)
            ? data.settings
            : (data.settings ? [data.settings] : []);
        for (const s of settingsDocs) {
            const clean = { ...s };
            if (!hasCollections) delete clean.collection;
            // A legacy Settings doc can hold theme.<key>.preset as an object (e.g. the games
            // plugin's { default: 'default' }). Settings.create() validates and would throw a
            // CastError here, after the wipe already ran, before migrateDatabase() gets to
            // normalize it, leaving the instance half-restored. Normalize up front, like the
            // boot migration does via the native driver.
            normalizeThemePresets(clean.theme);
            await Settings.create(clean);
        }

        // No-code plugin definitions. Absent from dumps predating v3.1.
        if (Array.isArray(data.customPlugins) && data.customPlugins.length > 0) {
            await CustomPlugin.insertMany(data.customPlugins);
        }

        // Instance-wide policy singleton. Absent from older dumps, in which case the
        // schema defaults (self-service off) apply on the next read.
        if (data.instanceSettings) {
            const { _id, created_at, updated_at, __v, ...values } = data.instanceSettings;
            await InstanceSettings.updateOne(
                { key: 'instance' },
                { $set: { ...values, key: 'instance' } },
                { upsert: true }
            );
            invalidateInstanceSettingsCache();
            invalidateAiConfigCache();
        }

        // Rebuild the multi-collection invariants (default collection, item/user/settings
        // stamps, memberships). Idempotent; also heals legacy dumps. migrateDatabase()
        // only logs on failure (it must not crash server startup when called at boot),
        // so check its core invariant here rather than trusting a bare "it didn't throw".
        await migrateDatabase();

        // Reconcile no-code plugins with the freshly imported DB: re-materialize the
        // plugins/<id>/ folders and hot-register them, pruning any from the old instance.
        await applyCustomPluginsFromDB();
        await upgradeRestoredExtraFields();

        // A JSON restore can keep paths already present on this installation, while a ZIP
        // restore has already rewritten its files to fresh paths. In both cases, remove only
        // files that the replacement no longer references.
        try {
            await deleteUnusedManagedItemImages(replacedImagePaths);
        } catch (cleanupError) {
            console.warn('[ITEM IMAGE] Post-import cleanup failed:', cleanupError);
        }

        const restoredCollectionCount = await Collection.countDocuments();
        console.log(`[BACKUP] Instance import finished: ${restoredCollectionCount} collection(s) rebuilt, ${await Item.countDocuments()} item(s) restored`);

        res.cookie('jwt', '', { maxAge: 1 });
        if (restoredCollectionCount === 0) {
            return res.status(200).json({
                success: true,
                warning: "Import completed but no collection could be rebuilt (the dump may be missing an admin user). Items may be inaccessible until an admin account exists.",
                message: "Import successful"
            });
        }
        res.status(200).json({ success: true, message: "Import successful" });

    } catch (err: any) {
        console.error("[ERR] Import :", err);
        await cleanupImportedImages(req);
        res.status(500).json({ error: err.message });
    }
};

router.post('/import', requireInstanceImportAccess, importInstanceBackup);
router.post(
    '/import-archive',
    requireInstanceImportAccess,
    receiveBackupArchive,
    loadBackupArchive,
    importInstanceBackup
);

// ============ PER-COLLECTION BACKUP (collection admin) ============

async function buildCollectionBackup(activeCollectionId: any, collection: any): Promise<any> {
    const settings = await Settings.findOne({ collection: activeCollectionId }).lean() as any;
    if (settings) {
        delete settings._id;
        delete settings.collection;
        delete settings.__v;
    }

    const albums = (await Item.find({ collection: activeCollectionId }).lean()).map((a: any) => {
            // Owner and collection are re-stamped at import time (a dump may be restored
            // into another collection, or another instance), so they go. The id stays: a
            // season points at the show that holds it, and the import can only rebuild
            // that link if it can tell which item was which. It is never restored as is,
            // the import draws a new one and rewrites the links against it.
            const { __v, owner, collection, ...rest } = a;
            return rest;
    });

    // Stripped of what the import re-stamps. Everything a shelf is made of is its name
    // and its place in the grid, and the items find it by name, so nothing else is needed
    // for the restore to put the collection back exactly as it stood.
    const furniture = (await Furniture.find({ collection: activeCollectionId }).lean()).map((piece: any) => {
        const { _id, __v, collection: owner, createdBy, created_at, updated_at, ...rest } = piece;
        return rest;
    });

    // Kept with the item ids they point at: the import draws new ids for the items and
    // rewrites these against them, like it does for a season and its show.
    const lists = (await List.find({ collection: activeCollectionId }).lean()).map((list: any) => {
        const { _id, __v, collection: owner, createdBy, ...rest } = list;
        return rest;
    });

    return {
        collectionName: collection?.name || 'Collection',
        albums,
        furniture,
        lists,
        // The collection's own page travels with it: it is written about these items,
        // and its pictures are picked up by the archive alongside theirs.
        info: collectionInfoOf(collection),
        settings: settings || null,
        metadata: {
            version: pkg.version,
            type: 'collection',
            date: new Date()
        }
    };
}

router.get('/collection/export', requireAuth, requireCollectionRole('admin'), async (req: any, res: any) => {
    try {
        const activeCollectionId = res.locals.activeCollectionId;
        const collection = res.locals.activeCollection;
        const data = await buildCollectionBackup(activeCollectionId, collection);

        const slug = collection?.slug || 'collection';
        const fileName = `dvinyl_collection-${slug}_${new Date().toISOString().split('T')[0]}.json`;
        res.setHeader('Content-disposition', 'attachment; filename=' + fileName);
        res.setHeader('Content-type', 'application/json');
        res.send(JSON.stringify(data, null, 2));
    } catch (err) {
        console.error("[ERR] Collection export:", err);
        res.status(500).send("Export failed");
    }
});

router.get('/collection/export-archive', requireAuth, requireCollectionRole('admin'), async (req: any, res: any) => {
    try {
        const activeCollectionId = res.locals.activeCollectionId;
        const collection = res.locals.activeCollection;
        const data = await buildCollectionBackup(activeCollectionId, collection);
        const slug = collection?.slug || 'collection';
        const fileName = `dvinyl_collection-${slug}_${new Date().toISOString().split('T')[0]}.zip`;
        await sendBackupArchive(res, data, fileName);
    } catch (err) {
        console.error('[BACKUP] Collection archive export failed:', err);
        if (!res.headersSent) res.status(500).send('Export failed');
        else res.destroy(err as Error);
    }
});

/**
 * GET /collection/export-csv - the same collection, as a flat spreadsheet instead of a
 * restorable dump. One row per item; columns are the union of the importable fields
 * (base + plugin + user-defined) of every kind actually present, so a single-type
 * collection reads as a clean sheet and a mixed one simply carries more (sparse)
 * columns. Fields shared by several plugins share a column only when they are the same
 * field, see the identity check below. Read-only: unlike the JSON export this never
 * round-trips through /import.
 */
router.get('/collection/export-csv', requireAuth, requireCollectionRole('admin'), async (req: any, res: any) => {
    try {
        const activeCollectionId = res.locals.activeCollectionId;
        const collection = res.locals.activeCollection;
        const settings = res.locals.settings;

        const albums = await Item.find({ collection: activeCollectionId }).lean();

        // Registry order rather than the order Mongo happened to return the items in,
        // so the same collection always exports its columns in the same order.
        const present = new Set(albums.map((a: any) => a.kind));
        const kinds = registry.getAll().map(p => p.kind).filter(kind => present.has(kind));

        // A column is one field of one plugin. Two plugins merge into a single column
        // only when their field is the same thing under the same name: the base fields
        // (Title, Year...) do, media_type does not, since it holds vinyl/cd for music
        // and movie/tv for DVDs. Merging those would print one plugin's labels and the
        // other's raw values in a column headed with a name that fits half the rows.
        const identity = (f: ImportTargetField) => JSON.stringify([
            f.name, f.type, f.label, f.extraField === true,
            (f.options || []).map(o => [o.value, o.label])
        ]);

        interface ExportColumn {
            field: ImportTargetField;
            label: string;
            /** Left empty for any other kind, even one owning a field of the same name. */
            kinds: Set<string>;
            /** Names the module in the header when two columns end up reading alike. */
            owner: string;
        }

        const columns: ExportColumn[] = [];
        const byIdentity = new Map<string, ExportColumn>();

        for (const kind of kinds) {
            const plugin = registry.getByKind(kind);
            if (!plugin) continue; // Cannot happen, kinds come from the registry: narrows the type.
            const pluginLabel = req.t(plugin.label, { defaultValue: plugin.id });
            for (const field of importableFields(plugin, settings, req.t)) {
                const key = identity(field);
                const known = byIdentity.get(key);
                if (known) {
                    known.kinds.add(kind);
                    continue;
                }
                const column: ExportColumn = { field, label: field.label, kinds: new Set([kind]), owner: pluginLabel };
                byIdentity.set(key, column);
                columns.push(column);
            }
        }

        const typeLabel = req.t('admin.backup.csv.type_column');
        const wishlistLabel = req.t('admin.backup.csv.wishlist_column');

        // Four modules label a field "Format", each with its own options, and the DVD
        // plugin calls its own field "Type" like the module column added here: without
        // this the sheet repeats a header and nobody can tell the columns apart. The two
        // columns this route adds itself are counted in, and win the bare name.
        const labelUses = new Map<string, number>([[typeLabel, 1], [wishlistLabel, 1]]);
        for (const column of columns) labelUses.set(column.label, (labelUses.get(column.label) || 0) + 1);
        for (const column of columns) {
            if ((labelUses.get(column.label) || 0) > 1) column.label = `${column.label} (${column.owner})`;
        }

        const header = [typeLabel, ...columns.map(c => c.label), wishlistLabel];

        // An image uploaded from an item form is stored as a portable `/uploads/items/...`
        // path, which is what the item document and the ZIP archive want: neither is tied
        // to the address this instance answers on. A spreadsheet is read outside the app,
        // where that path resolves nowhere, so the column headed "Cover (URL)" is given the
        // absolute URL this deployment actually serves the file at. Linked images already
        // carry their own absolute URL and are handed through untouched.
        const publicOrigin = `${getPublicProtocol(req)}://${req.get('host')}${BASE_URL.replace(/\/+$/, '')}`;
        const asPublicUrl = (value: string) => (managedItemImageFile(value) ? `${publicOrigin}${value}` : value);

        const rows: string[][] = [header];
        for (const album of albums) {
            const kind = String((album as any).kind || '');
            const plugin = registry.getByKind(kind);
            const typeName = plugin ? req.t(plugin.label, { defaultValue: plugin.id }) : kind;
            const cells = columns.map(c => (c.kinds.has(kind) ? asPublicUrl(fieldValue(album, c.field)) : ''));
            rows.push([typeName, ...cells, (album as any).in_wishlist ? 'true' : 'false']);
        }

        // Leading BOM so Excel (which guesses ANSI otherwise) opens accented labels
        // and titles as UTF-8 instead of mojibake.
        const csv = '' + stringifyCsv(rows);
        const slug = collection?.slug || 'collection';
        const fileName = `dvinyl_collection-${slug}_${new Date().toISOString().split('T')[0]}.csv`;
        res.setHeader('Content-disposition', 'attachment; filename=' + fileName);
        res.setHeader('Content-type', 'text/csv; charset=utf-8');
        res.send(csv);
    } catch (err) {
        console.error("[ERR] Collection CSV export:", err);
        res.status(500).send("Export failed");
    }
});

/**
 * The custom-field identity upgrade, run on data a restore has just finished writing.
 * Logged rather than thrown: the restore itself went through, and reporting it as failed
 * would also release the images the archive brought in for it. The migration is
 * idempotent, so the next boot takes it up again.
 */
async function upgradeRestoredExtraFields(options: { collectionId?: any } = {}): Promise<void> {
    try {
        await migrateExtraFieldIdentities(options);
    } catch (err) {
        console.error('[BACKUP] Custom field identity migration failed after the restore:', err);
    }
}

/**
 * The furniture a collection restore puts back as it is, or null when the shelves have
 * to be rebuilt from the restored items' locations instead.
 *
 * Rebuilt when the dump has no furniture of this collection to offer: one from before
 * the shelves, or a whole-instance dump, whose pieces belong to several collections and
 * can name the same shelf twice once poured into one. Rebuilt too when the pieces would
 * not pass the model or claim one shelf twice (a file edited by hand): the insert would
 * only refuse them after the collection's items were already replaced.
 *
 * An empty list comes back empty: that collection had its furniture taken down on purpose.
 */
function restorableFurniture(data: any, collectionId: any, userId: any): any[] | null {
    if (!Array.isArray(data.furniture) || Array.isArray(data.collections)) return null;

    const pieces = data.furniture.map((piece: any, index: number) => {
        // The export strips these already. A file that still carries them must not bring
        // back an id this instance may hold for another piece.
        const { _id, __v, collection: _owner, createdBy: _by, created_at, updated_at, ...rest } = piece || {};
        return {
            ...rest,
            collection: collectionId,
            order: typeof rest.order === 'number' ? rest.order : 100 + index,
            createdBy: userId
        };
    });

    const cells = pieces.flatMap((piece: any) => (Array.isArray(piece.cells) ? piece.cells : []));
    const duplicate = findDuplicateCellKey(cells);
    const invalid = pieces.find((piece: any) => new Furniture(piece).validateSync());
    if (duplicate || invalid) {
        console.warn(`[BACKUP] The dump's furniture cannot be restored as it is (${duplicate ? `shelf "${duplicate}" appears twice` : 'invalid piece'}); rebuilding the shelves from the item locations.`);
        return null;
    }
    return pieces;
}

// An id as a dump writes one: 24 hex digits, or already an ObjectId. isValid() alone
// would also take any 12-character string.
const isDumpId = (value: unknown) =>
    value instanceof mongoose.Types.ObjectId || /^[a-f0-9]{24}$/i.test(String(value ?? ''));

/**
 * The lists of a dump that can go back, each in the shape `place` gives it. Settled
 * before anything is wiped, for the same reason as the furniture: a list the model
 * refuses (a kind this version does not know, a name left blank in a file edited by
 * hand) would only fail its insert once the replacement had already started, and a
 * whole-instance restore inserts the lists before the accounts. Such a list is left out
 * rather than costing the rest of the restore, and so is a line naming no valid item.
 */
function restorableLists(lists: unknown, place: (list: any) => any): any[] {
    if (!Array.isArray(lists)) return [];

    const kept: any[] = [];
    let dropped = 0;
    for (const raw of lists) {
        if (!raw || typeof raw !== 'object') {
            dropped++;
            continue;
        }
        const entries = (Array.isArray(raw.entries) ? raw.entries : []).filter((entry: any) =>
            entry && isDumpId(entry.item) && (entry.track === undefined || entry.track === null || isDumpId(entry.track))
        );
        const list = place({ ...raw, entries });
        if (new List(list).validateSync()) {
            dropped++;
            continue;
        }
        kept.push(list);
    }
    if (dropped > 0) {
        console.warn(`[BACKUP] ${dropped} list(s) of the dump could not be restored as they are and were left out.`);
    }
    return kept;
}

/**
 * POST /collection/import - replaces the ACTIVE collection's items (and settings,
 * when present in the file) with the backup's content. Accepts both per-collection
 * dumps (albums pre-stripped) and whole-instance dumps (albums re-stamped here).
 */
const importCollectionBackup = async (req: any, res: any) => {
    try {
        const activeCollectionId = res.locals.activeCollectionId;

        let data = req.body;
        if (data.backupData) {
            try {
                data = typeof data.backupData === 'string' ? JSON.parse(data.backupData) : data.backupData;
            } catch (e) {
                await cleanupImportedImages(req);
                return res.status(400).json({ error: "Invalid JSON format" });
            }
        }

        if (!data || !Array.isArray(data.albums)) {
            await cleanupImportedImages(req);
            return res.status(400).json({ error: "Backup file missing required fields" });
        }

        // Settled before anything is wiped, so furniture that cannot go back as it is
        // turns into a rebuild rather than an error halfway through the replacement.
        const furnitureToRestore = restorableFurniture(data, activeCollectionId, req.user._id);

        // Old item id -> new one. Ids are reassigned (the same dump may be restored twice
        // into different collections), which would leave every "contained in" and every
        // list line pointing at an item that no longer exists. So the new ids are drawn up
        // front and the links rewritten against them, keeping a show and its seasons, and
        // the lists, together through the restore.
        const idMap = new Map<string, mongoose.Types.ObjectId>();
        for (const album of data.albums) {
            if (album?._id) idMap.set(String(album._id), new mongoose.Types.ObjectId());
        }

        const legacyKind = registry.getAll().find(p => p.matchesLegacyItems)?.kind || 'Music';
        const extraDateFields = collectExtraDateFields(
            Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
        );
        // A file edited by hand can name one id twice. The second copy gets an id of its
        // own, since sharing one would fail the insert after the wipe.
        const usedIds = new Set<string>();
        const cleanAlbums = data.albums.map((album: any) => {
            const { _id, __v, ...rest } = album || {};
            const fixed: any = {
                ...rest,
                kind: rest.kind || legacyKind,
                owner: req.user._id,
                collection: activeCollectionId
            };
            const newId = _id ? idMap.get(String(_id)) : undefined;
            if (newId && !usedIds.has(String(newId))) {
                fixed._id = newId;
                usedIds.add(String(newId));
            }
            // A holder left outside the dump would strand the item in no listing at all,
            // so it becomes standalone rather than invisible.
            fixed.parent = rest.parent ? idMap.get(String(rest.parent)) : undefined;
            if (!fixed.parent) delete fixed.parent;
            if (fixed.extra) fixed.extra = { ...fixed.extra };
            reviveExtraDates(fixed, extraDateFields);
            return fixed;
        });

        // Checked against their models before the wipe: the insert below validates too,
        // and an item it refused there would leave the collection emptied. Refused here,
        // the collection stays exactly as it was.
        for (const album of cleanAlbums) {
            const Model: any = (Item as any).discriminators?.[album.kind] || Item;
            const invalid = new Model(album).validateSync();
            if (invalid) {
                await cleanupImportedImages(req);
                const field = Object.keys(invalid.errors || {})[0] || '?';
                return res.status(400).json({
                    error: invalid.message,
                    userMessage: req.t('admin.collection_backup.error_invalid_item', {
                        title: String(album.title || '?').slice(0, 100),
                        field
                    })
                });
            }
        }

        // A line whose item did not come back with the dump has nothing to point at and is
        // left out; tracks keep their own ids through the restore, so only the item half of
        // a playlist line is rewritten.
        const listsToRestore = restorableLists(data.lists, (list: any) => {
            const { _id, __v, collection: _owner, createdBy: _by, ...rest } = list;
            return {
                ...rest,
                collection: activeCollectionId,
                createdBy: req.user._id,
                entries: rest.entries
                    .filter((entry: any) => idMap.has(String(entry.item)))
                    .map((entry: any) => ({ ...entry, item: idMap.get(String(entry.item)) }))
            };
        });

        // Replacement semantics: the collection's current items are wiped first.
        const replacedImagePaths = await managedItemImagesForQuery({ collection: activeCollectionId });
        await Item.deleteMany({ collection: activeCollectionId });
        if (cleanAlbums.length > 0) {
            await Item.insertMany(cleanAlbums);
        }

        // Replacement semantics for the lists too.
        await List.deleteMany({ collection: activeCollectionId });
        if (listsToRestore.length > 0) {
            await List.insertMany(listsToRestore);
        }

        // Replacement semantics again: the collection's own furniture goes with its items.
        await Furniture.deleteMany({ collection: activeCollectionId });

        if (furnitureToRestore) {
            if (furnitureToRestore.length > 0) await Furniture.insertMany(furnitureToRestore);
        } else {
            // The restored items still say where each one is kept. Rebuilt into furniture
            // the same way the boot migration does it, so the restore does not land a
            // collection whose every item is unsorted.
            const seeded = await seedFurnitureFromLocations(
                activeCollectionId,
                res.locals.activeCollection?.name || 'Collection'
            );
            if (seeded.shelves > 0) {
                console.log(`[BACKUP] ${seeded.shelves} shelf/shelves rebuilt from the restored locations.`);
            }
        }

        // The collection's shelves are now whatever this restore made of them, so the boot
        // migration has nothing left to convert here, and must not rebuild pieces its
        // owners take down from now on.
        await Collection.updateOne({ _id: activeCollectionId }, { $set: { shelvesSeeded: true } });

        // The info page is replaced like everything else, and the pictures the previous
        // one held are released once the new page is in place (nothing else points at
        // them, and the release checks every other reference before it unlinks a file).
        const previousInfo = collectionInfoOf(await Collection.findById(activeCollectionId).lean());
        if (data.info && typeof data.info === 'object') {
            const info = collectionInfoFromBackup(data.info);
            await Collection.updateOne({ _id: activeCollectionId }, { $set: { info } });
            replacedImagePaths.push(...previousInfo.images.filter(image => !info.images.includes(image)));
        }

        // Restore the collection's settings container when the dump carries one
        const settingsDoc = Array.isArray(data.settings) ? data.settings[0] : data.settings;
        if (settingsDoc) {
            const clean = { ...settingsDoc };
            delete clean._id;
            delete clean.__v;
            clean.collection = activeCollectionId;
            await Settings.deleteMany({ collection: activeCollectionId });
            await Settings.create(clean);
        }

        // Collection restores do not run the whole boot migration. Apply the same
        // custom-field identity upgrade explicitly before the restored data is used.
        await upgradeRestoredExtraFields({ collectionId: activeCollectionId });

        try {
            await deleteUnusedManagedItemImages(replacedImagePaths);
        } catch (cleanupError) {
            console.warn('[ITEM IMAGE] Collection import cleanup failed:', cleanupError);
        }

        res.status(200).json({ success: true, message: "Import successful", count: data.albums.length });
    } catch (err: any) {
        console.error("[ERR] Collection import:", err);
        await cleanupImportedImages(req);
        res.status(500).json({ error: err.message });
    }
};

router.post('/collection/import', requireAuth, requireCollectionRole('admin'), importCollectionBackup);
router.post(
    '/collection/import-archive',
    requireAuth,
    requireCollectionRole('admin'),
    receiveBackupArchive,
    loadBackupArchive,
    importCollectionBackup
);

export = router;