chore(release): v0.10.0 (#400)
Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed. See CHANGELOG.md for the full list and the health-check upgrade note.
This commit is contained in:
@@ -495,6 +495,90 @@ describe('ImmichClient', () => {
|
||||
album = await immich.getAlbum('album-1');
|
||||
expect(album?.assets.map((a) => a.id)).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
/**
|
||||
* Immich's timeline sorts on localDateTime, not on the UTC instant. A
|
||||
* 09:00 shot in Tokyo and a 09:00 shot in Berlin sit next to each other in
|
||||
* the Immich UI even though they are eight hours apart in UTC.
|
||||
*/
|
||||
it('sorts by local capture time, matching the Immich timeline', async () => {
|
||||
const items = [
|
||||
{
|
||||
id: 'berlin',
|
||||
isTrashed: false,
|
||||
localDateTime: '2024-05-01T09:00:00.000Z',
|
||||
fileCreatedAt: '2024-05-01T07:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'tokyo',
|
||||
isTrashed: false,
|
||||
localDateTime: '2024-05-01T08:00:00.000Z',
|
||||
fileCreatedAt: '2024-04-30T23:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
|
||||
// UTC order would be tokyo → berlin; local capture order is the reverse.
|
||||
expect(album?.assets.map((a) => a.id)).toEqual(['tokyo', 'berlin']);
|
||||
});
|
||||
|
||||
it('falls back to fileCreatedAt when localDateTime is missing', async () => {
|
||||
const items = [
|
||||
asset('no-local-late', '2024-01-03T00:00:00Z'),
|
||||
{
|
||||
id: 'has-local',
|
||||
isTrashed: false,
|
||||
localDateTime: '2024-01-02T00:00:00Z',
|
||||
fileCreatedAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
asset('no-local-early', '2024-01-01T00:00:00Z'),
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
|
||||
// Missing localDateTime sorts as 0, so those two lead — but they stay
|
||||
// ordered among themselves by fileCreatedAt rather than at random.
|
||||
expect(album?.assets.map((a) => a.id)).toEqual([
|
||||
'no-local-early',
|
||||
'no-local-late',
|
||||
'has-local',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Date.parse on an unusable value yields NaN, and a comparator returning
|
||||
* NaN leaves sort() free to produce any permutation — including dropping
|
||||
* the valid assets out of order.
|
||||
*/
|
||||
it('keeps a coherent order when a date is unparseable', async () => {
|
||||
const items = [
|
||||
asset('broken', 'not-a-date'),
|
||||
asset('late', '2024-01-03T00:00:00Z'),
|
||||
asset('early', '2024-01-01T00:00:00Z'),
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
const ids = album?.assets.map((a) => a.id) ?? [];
|
||||
|
||||
expect(ids).toHaveLength(3);
|
||||
expect(ids.indexOf('early')).toBeLessThan(ids.indexOf('late'));
|
||||
});
|
||||
|
||||
it('preserves sub-second precision within a burst', async () => {
|
||||
const items = [
|
||||
asset('frame-2', '2024-01-01T14:36:53.500Z'),
|
||||
asset('frame-3', '2024-01-01T14:36:53.900Z'),
|
||||
asset('frame-1', '2024-01-01T14:36:53.000Z'),
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
'studio-modern': {
|
||||
preset: 'studio-modern',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#000000',
|
||||
|
||||
@@ -250,18 +250,18 @@ export function serializeEssayMarkdown(essay: ParsedEssay): string {
|
||||
}
|
||||
case 'quote': {
|
||||
const authorSuffix = block.author ? ` -- ${block.author}` : '';
|
||||
const text = block.text.replace(/<[^>]+>/g, '');
|
||||
const text = block.text.replace(/[\r\n]+/g, ' ');
|
||||
lines.push(`> ${text}${authorSuffix}`);
|
||||
break;
|
||||
}
|
||||
case 'photo': {
|
||||
const layoutSuffix = block.layout !== 'contained' ? `:${block.layout}` : '';
|
||||
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
|
||||
const caption = block.caption ? block.caption.replace(/[\r\n]+/g, ' ') : '';
|
||||
lines.push(``);
|
||||
break;
|
||||
}
|
||||
case 'photo-pair': {
|
||||
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
|
||||
const caption = block.caption ? block.caption.replace(/[\r\n]+/g, ' ') : '';
|
||||
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ImmichAsset {
|
||||
originalMimeType: string;
|
||||
thumbhash: string | null;
|
||||
fileCreatedAt: string;
|
||||
/** Capture time in the photographer's local zone — Immich's timeline sort key. */
|
||||
localDateTime?: string;
|
||||
exifInfo?: ImmichExifInfo;
|
||||
isTrashed: boolean;
|
||||
}
|
||||
@@ -362,10 +364,14 @@ class ImmichClient {
|
||||
* Get ALL configured albums (filtered by the full allowlist).
|
||||
* Uses ?shared=true to only fetch albums that have been shared in Immich.
|
||||
*/
|
||||
async getAlbums(): Promise<ImmichAlbum[]> {
|
||||
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
||||
const cacheKey = 'albums-list';
|
||||
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (forceFresh) {
|
||||
cache.delete(cacheKey);
|
||||
} else {
|
||||
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
if (this.pendingAlbumsPromise) {
|
||||
return this.pendingAlbumsPromise;
|
||||
@@ -439,8 +445,8 @@ class ImmichClient {
|
||||
/**
|
||||
* Get only standalone albums (not in any subpage) — for the homepage.
|
||||
*/
|
||||
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
|
||||
const albums = await this.getAlbums();
|
||||
async getStandaloneAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
// The API returns albums in its own order; gallery.yaml order wins.
|
||||
const orderIdx = new Map(this.config.standaloneAlbums.map((id, i) => [id, i]));
|
||||
return albums
|
||||
@@ -451,11 +457,11 @@ class ImmichClient {
|
||||
/**
|
||||
* Get enriched subpage summaries for the homepage.
|
||||
*/
|
||||
async getSubpages(): Promise<SubpageSummary[]> {
|
||||
async getSubpages(forceFresh = false): Promise<SubpageSummary[]> {
|
||||
const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false);
|
||||
if (activeSubpages.length === 0) return [];
|
||||
|
||||
const albums = await this.getAlbums();
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
|
||||
return activeSubpages.map((sp) => {
|
||||
@@ -478,13 +484,14 @@ class ImmichClient {
|
||||
*/
|
||||
async getSubpageAlbums(
|
||||
subpageSlug: string,
|
||||
forceFresh = false,
|
||||
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||
const subpage = this.config.subpages.find(
|
||||
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
|
||||
);
|
||||
if (!subpage) return null;
|
||||
|
||||
const allAlbums = await this.getAlbums();
|
||||
const allAlbums = await this.getAlbums(forceFresh);
|
||||
const subpageAlbumIds = new Set(subpage.albumIds);
|
||||
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
|
||||
|
||||
@@ -537,7 +544,7 @@ class ImmichClient {
|
||||
return assets;
|
||||
}
|
||||
|
||||
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
|
||||
async getAlbum(albumId: string, forceFresh = false): Promise<ImmichAlbum | null> {
|
||||
// Security: only serve configured albums
|
||||
if (!this.config.albums.includes(albumId)) {
|
||||
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
|
||||
@@ -545,8 +552,12 @@ class ImmichClient {
|
||||
}
|
||||
|
||||
const cacheKey = `album-${albumId}`;
|
||||
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
|
||||
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
|
||||
if (forceFresh) {
|
||||
cache.delete(cacheKey);
|
||||
} else {
|
||||
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
|
||||
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
|
||||
}
|
||||
|
||||
if (this.pendingAlbumPromises.has(albumId)) {
|
||||
return this.pendingAlbumPromises.get(albumId)!;
|
||||
@@ -581,9 +592,25 @@ class ImmichClient {
|
||||
// The album endpoint used to return assets in the album's configured
|
||||
// order. Metadata search happens to default to fileCreatedAt desc, but
|
||||
// that is not contractual — sort explicitly so 'asc' albums are right.
|
||||
//
|
||||
// Mirrors Immich's own timeline query: primary key `localDateTime`
|
||||
// (capture time in the photographer's local zone), tie-broken by
|
||||
// `fileCreatedAt` (the same instant in UTC). The two only diverge for
|
||||
// albums spanning time zones, and there local time is what the Immich
|
||||
// UI shows. Sorting on `exifInfo.dateTimeOriginal` instead would be
|
||||
// near-pointless: Immich already derives `fileCreatedAt` from that
|
||||
// same EXIF chain, and it is null for assets without EXIF.
|
||||
//
|
||||
// Compare parsed instants, not strings — string order breaks on
|
||||
// fractional seconds ('.' collates before '+'), which misorders bursts
|
||||
// where only some frames carry sub-second precision. Unparseable dates
|
||||
// fall back to 0 rather than NaN, which would make sort() incoherent.
|
||||
const dir = album.order === 'asc' ? 1 : -1;
|
||||
const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
|
||||
album.assets.sort(
|
||||
(a, b) => dir * (Date.parse(a.fileCreatedAt) - Date.parse(b.fileCreatedAt)),
|
||||
(a, b) =>
|
||||
dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
|
||||
dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt)),
|
||||
);
|
||||
|
||||
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
||||
@@ -609,8 +636,12 @@ class ImmichClient {
|
||||
* Find an album by its URL slug.
|
||||
* If subpageSlug is provided, only search within that subpage's albums.
|
||||
*/
|
||||
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
|
||||
const albums = await this.getAlbums();
|
||||
async getAlbumBySlug(
|
||||
slug: string,
|
||||
subpageSlug?: string,
|
||||
forceFresh = false,
|
||||
): Promise<ImmichAlbum | null> {
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
|
||||
let searchSet = albums;
|
||||
if (subpageSlug) {
|
||||
@@ -622,7 +653,7 @@ class ImmichClient {
|
||||
|
||||
const match = searchSet.find((a) => a.slug === slug);
|
||||
if (!match) return null;
|
||||
return this.getAlbum(match.id);
|
||||
return this.getAlbum(match.id, forceFresh);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user