release: v0.15.0 (#585)

Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
Ralksta
2026-09-19 22:24:10 +02:00
committed by GitHub
parent e99d3f2f1b
commit c3f92ca4ec
74 changed files with 4791 additions and 2146 deletions

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
albumPaths,
buildAltTextReport,
immichWebUrl,
pickImmichWebUrl,
type AltTextAssetInput,
} from '../admin/alt-text';
const photo = (id: string, description: string | null = null): AltTextAssetInput => ({
id,
type: 'IMAGE',
originalFileName: `${id}.jpg`,
exifInfo: { description },
});
describe('buildAltTextReport', () => {
it('lists photos without a description, per album', () => {
const report = buildAltTextReport(
[
{
id: 'a1',
name: 'Island',
path: '/travel',
assets: [photo('p1', 'A fjord'), photo('p2')],
},
],
true,
);
expect(report.total).toBe(2);
expect(report.described).toBe(1);
expect(report.albums).toEqual([
{
albumId: 'a1',
albumName: 'Island',
path: '/travel',
total: 2,
missing: [{ assetId: 'p2', fileName: 'p2.jpg' }],
},
]);
});
/** A description of spaces renders as an empty alt all the same. */
it('treats a blank description as missing', () => {
const report = buildAltTextReport(
[{ id: 'a1', name: 'A', path: '/', assets: [photo('p1', ' ')] }],
true,
);
expect(report.described).toBe(0);
expect(report.albums[0].missing).toHaveLength(1);
});
/** Videos have no alt attribute to fill. */
it('does not count videos', () => {
const report = buildAltTextReport(
[
{
id: 'a1',
name: 'A',
path: '/',
assets: [{ id: 'v1', type: 'VIDEO', originalFileName: 'v1.mp4' }, photo('p1', 'x')],
},
],
true,
);
expect(report.total).toBe(1);
expect(report.albums).toEqual([]);
});
it('counts a photo that sits in two albums once', () => {
const shared = photo('p1');
const report = buildAltTextReport(
[
{ id: 'a1', name: 'A', path: '/', assets: [shared] },
{ id: 'a2', name: 'B', path: '/', assets: [shared] },
],
true,
);
expect(report.total).toBe(1);
expect(report.albums).toHaveLength(2);
});
it('puts the album with the most gaps first and leaves out complete ones', () => {
const report = buildAltTextReport(
[
{ id: 'few', name: 'Few', path: '/', assets: [photo('p1')] },
{ id: 'done', name: 'Done', path: '/', assets: [photo('p2', 'x')] },
{ id: 'many', name: 'Many', path: '/', assets: [photo('p3'), photo('p4')] },
],
true,
);
expect(report.albums.map((a) => a.albumId)).toEqual(['many', 'few']);
});
it('passes the caption setting through', () => {
expect(buildAltTextReport([], false).captionsEnabled).toBe(false);
});
});
describe('albumPaths', () => {
it('uses the first subpage that lists an album', () => {
const paths = albumPaths([
{ slug: 'travel', albumIds: ['a1'] },
{ slug: 'best-of', albumIds: ['a1', 'a2'] },
]);
expect(paths.get('a1')).toBe('/travel');
expect(paths.get('a2')).toBe('/best-of');
expect(paths.get('a3')).toBeUndefined();
});
});
describe('immichWebUrl', () => {
it('drops the /api suffix Folio appends', () => {
expect(immichWebUrl('http://immich:2283/api')).toBe('http://immich:2283');
expect(immichWebUrl('https://photos.example.com/api/')).toBe('https://photos.example.com');
});
});
describe('pickImmichWebUrl', () => {
it("prefers Immich's external domain", () => {
expect(pickImmichWebUrl('https://photos.example.com/', 'http://immich:2283/api')).toEqual({
url: 'https://photos.example.com',
source: 'external-domain',
});
});
it('falls back to the API address when the domain is unset or not a URL', () => {
for (const domain of ['', ' ', null, undefined, 'photos.example.com']) {
expect(pickImmichWebUrl(domain, 'http://immich:2283/api')).toEqual({
url: 'http://immich:2283',
source: 'api-url',
});
}
});
});

View File

@@ -36,6 +36,8 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
'gallery.yaml', // the live file, not a backup
'gallery.yaml.2026-05-31T17-30-00-000Z.bak.txt',
'evil-gallery.yaml.2026-05-31T17-30-00-000Z.bak',
'about.yaml.2026-05-31T17-30-00-000Z.bak',
'trip.md.2026-05-31T17-30-00-000Z.bak', // journal backups go through the journal service
'',
];
@@ -66,6 +68,13 @@ describe('restoreBackup accepts the names it writes', () => {
expect(fs.copyFile).toHaveBeenCalled();
});
it('restores an about.md backup onto content/about.md', async () => {
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
const calls = vi.mocked(fs.copyFile).mock.calls;
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
});
it('restores a pre-restore backup', async () => {
await expect(
restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.pre-restore.bak'),

View File

@@ -110,6 +110,7 @@ describe('checkAlbumIds', () => {
const f = checkAlbumIds(['a', 'ghost'], known);
expect(f.level).toBe('error');
expect(f.detail).toContain('ghost');
expect(f.albumIds).toEqual(['ghost']);
});
it('flags an empty gallery', () => {
@@ -132,6 +133,7 @@ describe('checkAlbumsShared', () => {
const f = checkAlbumsShared(['a', 'b'], known);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Private trip');
expect(f.albumIds).toEqual(['b']);
});
/** An older Immich may not report the flag; silence beats a false alarm. */

View File

@@ -142,4 +142,45 @@ A closing paragraph with **bold** details.`;
expect(markdown).toContain('![photo-1:fullbleed](Caption)');
expect(markdown).toContain('![p1, p2](Pair caption)');
});
it('keeps a markdown link in the quote attribution for rendering (#559)', () => {
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
const parsed = parseEssayMarkdown(markdown);
const quote = parsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type !== 'quote') return;
// The attribution stays Markdown; EssayView renders it with
// renderInlineMarkdown, which is what turns the link into an anchor.
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
expect(renderInlineMarkdown(quote.author!)).toContain(
'<a href="https://example.com/archive" target="_blank" rel="noopener noreferrer">BBC Archive</a>',
);
});
it('survives a serialize/parse round-trip without losing the attribution link', () => {
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
const once = serializeEssayMarkdown(parseEssayMarkdown(markdown));
expect(once).toContain('> Silence was absolute. -- [BBC Archive](https://example.com/archive)');
const reparsed = parseEssayMarkdown(once);
const quote = reparsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type === 'quote') {
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
}
});
it('does not turn an unsafe scheme in the attribution into a link', () => {
const markdown = '> Careful. -- [x](javascript:alert(1))';
const parsed = parseEssayMarkdown(markdown);
const quote = parsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type !== 'quote') return;
const html = renderInlineMarkdown(quote.author!);
expect(html).not.toContain('<a');
expect(html).not.toContain('href');
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
/**
* Deleting a journal entry used to be a bare unlink, while saving rotated a
* backup — the one irreversible action was the one without a way back, and the
* Backup Manager could not restore journal backups at all.
*
* The service resolves content/ from process.cwd() at import time, so each test
* points cwd at a fresh temp directory and re-imports it.
*/
let root: string;
let journalDir: string;
let backupDir: string;
let service: typeof import('../admin/journal-service');
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
journalDir = path.join(root, 'content', 'journal');
backupDir = path.join(journalDir, '.backups');
await mkdir(journalDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
service = await import('../admin/journal-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const entry = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
describe('deleteJournalEntry', () => {
it('keeps a .deleted.bak copy before removing the file', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
await expect(readdir(journalDir)).resolves.not.toContain('trip.md');
const backups = await readdir(backupDir);
expect(backups).toHaveLength(1);
expect(backups[0]).toMatch(/^trip\.md\.[\w-]+\.deleted\.bak$/);
await expect(readFile(path.join(backupDir, backups[0]), 'utf8')).resolves.toBe(entry('Trip'));
});
it('returns false for a missing entry without writing a backup', async () => {
await expect(service.deleteJournalEntry('nope')).resolves.toBe(false);
await expect(readdir(backupDir)).rejects.toThrow();
});
it('refuses to delete when the backup cannot be written', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
// A file where the backup directory should be makes mkdir and copy fail.
await writeFile(backupDir, 'not a directory');
await expect(service.deleteJournalEntry('trip')).rejects.toThrow();
await expect(readdir(journalDir)).resolves.toContain('trip.md');
});
});
describe('restoreJournalBackup', () => {
it('brings a deleted entry back under its slug', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await service.deleteJournalEntry('trip');
const [deleted] = await service.listJournalBackups();
expect(deleted).toMatchObject({ slug: 'trip', kind: 'deleted' });
await expect(service.restoreJournalBackup(deleted.filename)).resolves.toBe('trip');
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Trip'));
});
it('snapshots the current version before overwriting it', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
await service.writeJournalEntry('trip', entry('New'));
const [saved] = await service.listJournalBackups();
await service.restoreJournalBackup(saved.filename);
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
const kinds = (await service.listJournalBackups()).map((b) => b.kind).sort();
expect(kinds).toEqual(['pre-restore', 'save']);
});
const rejected = [
'../../../../etc/passwd',
'trip.md',
'../trip.md.2026-05-31T17-30-00-000Z.bak',
'a/trip.md.2026-05-31T17-30-00-000Z.bak',
'trip.md.2026-05-31T17-30-00-000Z.bak.txt',
'gallery.yaml.2026-05-31T17-30-00-000Z.bak',
'',
];
for (const name of rejected) {
it(`refuses ${JSON.stringify(name)}`, async () => {
await expect(service.restoreJournalBackup(name)).rejects.toThrow(/unrecognised backup name/i);
});
}
});
describe('backup rotation', () => {
// Backup names carry a millisecond timestamp; saves in a tight loop would
// share one and overwrite each other, which a person clicking Save cannot.
let clock = Date.parse('2026-09-01T00:00:00Z');
beforeEach(() => vi.useFakeTimers({ toFake: ['Date'] }));
afterEach(() => vi.useRealTimers());
const save = async (slug: string, body: string) => {
vi.setSystemTime((clock += 1000));
await service.writeJournalEntry(slug, body);
};
it('prunes save backups but never the deleted snapshot', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Gone'));
await service.deleteJournalEntry('trip');
// Re-create the slug and save it well past the rotation limit.
await save('trip', entry('v0'));
for (let i = 1; i <= 12; i++) {
await save('trip', entry(`v${i}`));
}
const backups = await service.listJournalBackups();
expect(backups.filter((b) => b.kind === 'save')).toHaveLength(10);
expect(backups.filter((b) => b.kind === 'deleted')).toHaveLength(1);
});
it('does not prune another entry whose slug shares a prefix', async () => {
await save('trip-2', entry('Other v0'));
await save('trip-2', entry('Other v1'));
await save('trip', entry('v0'));
for (let i = 1; i <= 12; i++) {
await save('trip', entry(`v${i}`));
}
const other = (await service.listJournalBackups()).filter((b) => b.slug === 'trip-2');
expect(other).toEqual([expect.objectContaining({ kind: 'save' })]);
});
});

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import {
findJpegMetadataRanges,
findIsobmffMetadataRanges,
findMetadataRanges,
stripMetadata,
isStrippable,
} from '../metadataStrip';
// ── kleine Bau-Helfer ───────────────────────────────────────────────────────
const cat = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
};
const bytes = (...n: number[]) => new Uint8Array(n);
const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
/** JPEG-Segment: FFxx <len inkl. Längenfeld> <payload> */
const seg = (marker: number, payload: Uint8Array) =>
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
/** ISOBMFF-Box mit korrekt berechneter Größe. */
const box = (type: string, ...content: Uint8Array[]) => {
const body = cat(...content);
return cat(be32(body.length + 8), ascii(type), body);
};
describe('isStrippable', () => {
it('kennt JPEG und die ISOBMFF-Familie', () => {
expect(isStrippable('image/jpeg')).toBe(true);
expect(isStrippable('IMAGE/AVIF')).toBe(true);
expect(isStrippable('image/jpeg; charset=binary')).toBe(true);
expect(isStrippable('image/png')).toBe(false);
expect(isStrippable('application/octet-stream')).toBe(false);
});
});
describe('JPEG', () => {
const exifPayload = cat(ascii('Exif\0\0'), ascii('MM\0*GPS 54.156, 15.3717'));
const iccPayload = cat(ascii('ICC_PROFILE\0'), bytes(1, 1, 2, 3, 4, 5));
const jfifPayload = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
const bildDaten = bytes(0x12, 0x34, 0x56, 0x78, 0x9a);
const bauJpeg = () =>
cat(
bytes(0xff, 0xd8), // SOI
seg(0xe0, jfifPayload), // APP0 JFIF — bleibt
seg(0xe1, exifPayload), // APP1 EXIF — muss weg
seg(0xe2, iccPayload), // APP2 ICC — bleibt (Farbprofil!)
seg(0xed, ascii('Photoshop 3.0\0IPTC')), // APP13 — muss weg
seg(0xfe, ascii('ein Kommentar')), // COM — muss weg
bytes(0xff, 0xda),
be16(8),
bytes(1, 0, 0, 63, 0), // SOS
bildDaten,
bytes(0xff, 0xd9), // EOI
);
it('findet EXIF, IPTC und Kommentar, aber nicht JFIF/ICC', () => {
const b = bauJpeg();
const ranges = findJpegMetadataRanges(b);
expect(ranges).toHaveLength(3);
// Das EXIF-Segment muss vollständig erfasst sein
const exifStart = b.indexOf(0x45); // 'E' von 'Exif'
expect(ranges[0].start).toBe(exifStart);
expect(ranges[0].end - ranges[0].start).toBe(exifPayload.length);
});
it('nullt die GPS-Daten und lässt alles andere unangetastet', () => {
const original = bauJpeg();
const gestrippt = stripMetadata(bauJpeg(), 'image/jpeg')!;
expect(gestrippt).not.toBeNull();
// Länge unverändert -> Content-Length bleibt gültig
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GPS 54.156');
expect(alsText).not.toContain('Exif');
expect(alsText).not.toContain('IPTC');
expect(alsText).not.toContain('ein Kommentar');
// Farbprofil und JFIF müssen erhalten bleiben, sonst kippen die Farben
expect(alsText).toContain('ICC_PROFILE');
expect(alsText).toContain('JFIF');
// Bilddaten hinter SOS Byte für Byte identisch
const sos = original.length - bildDaten.length - 2;
expect(Array.from(gestrippt.slice(sos))).toEqual(Array.from(original.slice(sos)));
});
it('weist etwas zurück, das kein JPEG ist', () => {
expect(() => findJpegMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03))).toThrow();
expect(findMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03), 'image/jpeg')).toBeNull();
});
});
describe('AVIF / ISOBMFF', () => {
const exifNutzlast = ascii('MM\0*GEHEIME-GPS-KOORDINATEN');
const bildNutzlast = ascii('AV1-BILDDATEN-UNANTASTBAR');
/** infe v2: version+flags, item_ID(16), protection(16), item_type(4) */
const infe = (id: number, typ: string) =>
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(typ), bytes(0));
const bauAvif = () => {
// Erst ohne mdat bauen, um die Offsets zu kennen.
const ftyp = box('ftyp', ascii('avif'), be32(0), ascii('avifmif1'));
const iinf = box('iinf', bytes(0, 0, 0, 0), be16(2), infe(1, 'Exif'), infe(2, 'av01'));
// iloc v0: offset_size=4, length_size=4, base_offset_size=0
const ilocEintrag = (id: number, offset: number, len: number) =>
cat(be16(id), be16(0), be16(1), be32(offset), be32(len));
// Platzhalter, um die Gesamtlänge des Headers zu bestimmen
const ilocRoh = (o1: number, o2: number) =>
box(
'iloc',
bytes(0, 0, 0, 0),
bytes(0x44, 0x00),
be16(2),
ilocEintrag(1, o1, exifNutzlast.length),
ilocEintrag(2, o2, bildNutzlast.length),
);
const kopfLaenge = (iloc: Uint8Array) =>
ftyp.length + box('meta', bytes(0, 0, 0, 0), iinf, iloc).length + 8; // +8 mdat-Header
let iloc = ilocRoh(0, 0);
const basis = kopfLaenge(iloc);
iloc = ilocRoh(basis, basis + exifNutzlast.length);
const meta = box('meta', bytes(0, 0, 0, 0), iinf, iloc);
const mdat = box('mdat', exifNutzlast, bildNutzlast);
return cat(ftyp, meta, mdat);
};
it('findet das Exif-Item über iinf und iloc', () => {
const b = bauAvif();
const ranges = findIsobmffMetadataRanges(b);
expect(ranges).toHaveLength(1);
const gefunden = new TextDecoder('latin1').decode(b.slice(ranges[0].start, ranges[0].end));
expect(gefunden).toBe('MM\0*GEHEIME-GPS-KOORDINATEN');
});
it('nullt EXIF, lässt die Bilddaten und die Länge unberührt', () => {
const original = bauAvif();
const gestrippt = stripMetadata(bauAvif(), 'image/avif')!;
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GEHEIME-GPS-KOORDINATEN');
expect(alsText).toContain('AV1-BILDDATEN-UNANTASTBAR');
// Die Container-Struktur muss intakt bleiben
expect(alsText).toContain('ftyp');
expect(alsText).toContain('iloc');
});
it('gibt null zurück, wenn es kein ISOBMFF ist', () => {
expect(findMetadataRanges(ascii('nicht mal ansatzweise'), 'image/avif')).toBeNull();
});
});
describe('unbekannte Formate', () => {
it('liefern null — lieber nicht ausliefern als leaken', () => {
expect(findMetadataRanges(bytes(0x89, 0x50, 0x4e, 0x47), 'image/png')).toBeNull();
expect(stripMetadata(bytes(1, 2, 3), 'application/octet-stream')).toBeNull();
});
});

View File

@@ -168,6 +168,58 @@ describe('assetAspectRatio', () => {
}),
).toBeUndefined();
});
/**
* A portrait frame off a Sony A7 V: the sensor is read out in landscape and
* the quarter turn lives in the orientation flag, so the stored dimensions
* are the displayed ones swapped.
*/
const rotated = (orientation?: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 7008,
exifImageHeight: 4672,
orientation,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
it.each([
['6', 'rotated 90 CW'],
['8', 'rotated 90 CCW'],
['5', 'mirrored and rotated 90 CCW'],
['7', 'mirrored and rotated 90 CW'],
])('swaps width and height for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(4672 / 7008);
});
it.each([
['1', 'upright'],
['2', 'mirrored horizontally'],
['3', 'rotated 180'],
['4', 'mirrored vertically'],
])('leaves the ratio alone for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
it.each([
['null', null],
['absent', undefined],
])('treats a %s orientation as upright', (_label, orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
});
describe('assetCaption', () => {