Files
immich-folio/app/admin/components/page-builder/albumEntries.ts

93 lines
3.7 KiB
TypeScript
Raw Normal View History

/**
* The gallery.yaml ↔ page-builder translation for album entries.
*
* Extracted from PageBuilder.tsx so it can be tested on its own: this is the
* code that decides what survives a save, and a field it does not know about
* is discarded silently rather than failing (#614).
*/
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
import { type AlbumEntryObject } from '@/lib/config/schema';
import type { AlbumEntry } from './types';
export type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
export function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition ||
entry.location ||
2026-10-02 18:14:23 +02:00
entry.download ||
// `zoom: false` is an option too: it switches zoom off for this album.
entry.zoom !== undefined,
);
}
export function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value };
return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
location: value.location,
download: value.download,
2026-10-02 18:14:23 +02:00
zoom: typeof value.zoom === 'boolean' ? value.zoom : undefined,
};
});
}
export function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
// No UI sets this yet; it is preserved so that saving an unrelated change
// cannot quietly republish exact coordinates for an album deliberately
// placed at city level or hidden from the map (#469).
if (entry.location) val.location = entry.location;
// Same story as location: set by hand in YAML since #475, with no UI, so
// the builder was turning original downloads back off on the next save.
if (entry.download) val.download = entry.download;
2026-10-02 18:14:23 +02:00
// Both values mean something: false switches zoom off for this album.
if (entry.zoom !== undefined) val.zoom = entry.zoom;
return { [entry.id]: val };
});
}