release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
268
lib/__tests__/journal-photo-caption.test.ts
Normal file
268
lib/__tests__/journal-photo-caption.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateReadingTime,
|
||||
parseJournalMarkdown,
|
||||
renderInlineMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
} from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
|
||||
const ID2 = '7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d';
|
||||
const ID3 = 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e';
|
||||
|
||||
const serialize = (blocks: JournalBlock[]) =>
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] });
|
||||
const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source));
|
||||
|
||||
/** No block that renders as text may carry an asset id. */
|
||||
function expectNoIdInText(blocks: JournalBlock[], ids: string[]) {
|
||||
for (const block of blocks) {
|
||||
const text =
|
||||
block.type === 'paragraph'
|
||||
? block.html
|
||||
: block.type === 'heading'
|
||||
? block.text
|
||||
: block.type === 'quote'
|
||||
? block.text
|
||||
: 'caption' in block
|
||||
? (block.caption ?? '')
|
||||
: '';
|
||||
for (const id of ids) expect(text).not.toContain(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Captions that broke, or could break, the `` framing. */
|
||||
const TRICKY_CAPTIONS = [
|
||||
'Gipfel (560 m)',
|
||||
'(560 m)',
|
||||
'Ende :)',
|
||||
'Anfang :(',
|
||||
'((doppelt))',
|
||||
')',
|
||||
'(',
|
||||
'a) b (c',
|
||||
'Siehe [Karte](https://example.com/map_(1)) oben',
|
||||
'[Link](https://immich.app)',
|
||||
'**fett (mit Klammer)** und *kursiv*',
|
||||
'eckige [Klammern] ohne Link',
|
||||
'] mitten [',
|
||||
'Pfad C:\\Fotos\\',
|
||||
'Backslash vor Klammer \\) und \\(',
|
||||
'Emoji 🏔️ (Gipfel) 📷',
|
||||
'Umlaute: Größe, Übersicht, Äpfel (Öl)',
|
||||
'Anna\'s "Zitat" & <Tag>',
|
||||
'',
|
||||
];
|
||||
|
||||
describe('photo captions with parentheses (A-1)', () => {
|
||||
it('keeps a wide photo with `(560 m)` in its caption a photo block', () => {
|
||||
const source = `)`;
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'photo', assetId: ID, layout: 'wide', caption: 'Gipfel (560 m)' },
|
||||
]);
|
||||
expect(roundTrip(source)).toBe(source);
|
||||
});
|
||||
|
||||
it('keeps pair and grid captions with parentheses intact', () => {
|
||||
const pair = `, rechts (Süd))`;
|
||||
expect(parseJournalMarkdown(pair).blocks[0]).toEqual({
|
||||
type: 'photo-pair',
|
||||
assetIds: [ID, ID2],
|
||||
caption: 'Links (Nord), rechts (Süd)',
|
||||
});
|
||||
expect(roundTrip(pair)).toBe(pair);
|
||||
|
||||
const grid = `)`;
|
||||
expect(parseJournalMarkdown(grid).blocks[0]).toEqual({
|
||||
type: 'photo-grid',
|
||||
assetIds: [ID, ID2, ID3],
|
||||
caption: 'Drei (3)',
|
||||
});
|
||||
expect(roundTrip(grid)).toBe(grid);
|
||||
});
|
||||
|
||||
it('renders a link inside a caption', () => {
|
||||
const parsed = parseJournalMarkdown(`)`);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'photo',
|
||||
caption:
|
||||
'Mehr auf <a href="https://immich.app" target="_blank" rel="noopener noreferrer">Immich</a>',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps balanced parentheses inside a link URL', () => {
|
||||
const source = `, siehe [Karte](https://example.com/map_(1)))`;
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'photo',
|
||||
caption:
|
||||
'Gipfel (560 m), siehe <a href="https://example.com/map_(1)" target="_blank" rel="noopener noreferrer">Karte</a>',
|
||||
});
|
||||
expect(roundTrip(source)).toBe(source);
|
||||
// Outside a link, a parenthesis after the URL is still text.
|
||||
expect(renderInlineMarkdown('[a](https://x.y) (b)')).toBe(
|
||||
'<a href="https://x.y" target="_blank" rel="noopener noreferrer">a</a> (b)',
|
||||
);
|
||||
});
|
||||
|
||||
for (const caption of TRICKY_CAPTIONS) {
|
||||
it(`round-trips the caption ${JSON.stringify(caption)} for every photo block type`, () => {
|
||||
const html = renderInlineMarkdown(caption);
|
||||
const blocks: JournalBlock[] = [
|
||||
{ type: 'photo', assetId: ID, layout: 'fullbleed', caption: html },
|
||||
{ type: 'photo', assetId: ID, layout: 'contained', caption: html },
|
||||
{ type: 'photo-pair', assetIds: [ID, ID2], caption: html },
|
||||
{ type: 'photo-grid', assetIds: [ID, ID2, ID3], caption: html },
|
||||
];
|
||||
for (const block of blocks) {
|
||||
const once = serialize([block]);
|
||||
const parsed = parseJournalMarkdown(once);
|
||||
// The framing is exact: the caption comes back as the markdown that
|
||||
// was written, rendered once, and the block keeps its type and ids.
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
const [back] = parsed.blocks;
|
||||
expect(back.type).toBe(block.type);
|
||||
expect(back).toMatchObject(
|
||||
block.type === 'photo'
|
||||
? { assetId: ID, layout: block.layout }
|
||||
: { assetIds: 'assetIds' in block ? block.assetIds : [] },
|
||||
);
|
||||
const writtenCaption = once.slice(once.indexOf('](') + 2, -1);
|
||||
expect('caption' in back ? back.caption : undefined).toBe(
|
||||
renderInlineMarkdown(writtenCaption.trim()),
|
||||
);
|
||||
// And a second save writes the same file.
|
||||
expect(serialize(parsed.blocks)).toBe(once);
|
||||
expectNoIdInText(
|
||||
parsed.blocks.filter((b) => b.type === 'paragraph'),
|
||||
[ID, ID2, ID3],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('parses any single-line caption exactly (seeded fuzz)', () => {
|
||||
const alphabet = ['(', ')', '[', ']', '\\', '*', '_', ' ', 'a', 'Ü', '🏔️', '!', ':', ','];
|
||||
let seed = 0x5eed;
|
||||
const next = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed;
|
||||
};
|
||||
for (let n = 0; n < 500; n++) {
|
||||
const length = next() % 24;
|
||||
let caption = '';
|
||||
for (let i = 0; i < length; i++) caption += alphabet[next() % alphabet.length];
|
||||
const source = ``;
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID, layout: 'wide' });
|
||||
const expected = caption.trim() ? renderInlineMarkdown(caption.trim()) : undefined;
|
||||
expect((parsed.blocks[0] as { caption?: string }).caption).toBe(expected);
|
||||
// Idempotence of a save. Stray `*`/`_` runs are left out: the inline
|
||||
// emphasis renderer normalises those on its own (`__*` → `***`), which is
|
||||
// a property of renderInlineMarkdown, not of the photo-line framing.
|
||||
if (!/[*_]/.test(caption)) {
|
||||
const once = serializeJournalMarkdown(parsed);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo lines the old pattern accepted parse exactly as before', () => {
|
||||
// The pattern before A-1, kept here as the reference for backward compatibility.
|
||||
const legacy = (line: string) => {
|
||||
const m = line.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
||||
return m ? { target: m[1].trim(), caption: m[2].trim() } : null;
|
||||
};
|
||||
|
||||
const lines = [
|
||||
`![${ID}]()`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
`![:wide]()`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
it(JSON.stringify(line), () => {
|
||||
const old = legacy(line);
|
||||
expect(old).not.toBeNull();
|
||||
const blocks = parseJournalMarkdown(line).blocks;
|
||||
expect(blocks).toHaveLength(1);
|
||||
const expectedCaption = old!.caption ? renderInlineMarkdown(old!.caption) : undefined;
|
||||
expect((blocks[0] as { caption?: string }).caption).toBe(expectedCaption);
|
||||
const ids =
|
||||
blocks[0].type === 'photo'
|
||||
? [blocks[0].assetId]
|
||||
: 'assetIds' in blocks[0]
|
||||
? blocks[0].assetIds
|
||||
: [];
|
||||
expect(ids.join(', ')).toBe(old!.target.replace(/:(wide|fullbleed)$/, ''));
|
||||
// Unchanged content saves to the same line.
|
||||
expect(roundTrip(line)).toBe(roundTrip(roundTrip(line)));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('a malformed photo line never prints asset ids (A-1)', () => {
|
||||
it('splits two photo lines written without a blank line between them', () => {
|
||||
const parsed = parseJournalMarkdown(`\n)`);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'photo', assetId: ID, layout: 'contained', caption: 'Erstes' },
|
||||
{ type: 'photo', assetId: ID2, layout: 'wide', caption: 'Zweites (2)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits a heading or sentence directly above a photo', () => {
|
||||
const parsed = parseJournalMarkdown(`## Tag 1\n`);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'heading', level: 2, text: 'Tag 1' },
|
||||
{ type: 'photo', assetId: ID, layout: 'contained', caption: 'Gipfel' },
|
||||
]);
|
||||
|
||||
const para = parseJournalMarkdown(`Wir stiegen auf.\n `);
|
||||
expect(para.blocks.map((b) => b.type)).toEqual(['paragraph', 'photo-pair']);
|
||||
expectNoIdInText(para.blocks, [ID, ID2]);
|
||||
});
|
||||
|
||||
it('keeps text after the closing parenthesis in the caption, not a paragraph', () => {
|
||||
const parsed = parseJournalMarkdown(` und weiter`);
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID });
|
||||
expectNoIdInText(parsed.blocks, [ID]);
|
||||
// Stable from the first save on.
|
||||
const once = roundTrip(` und weiter`);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('treats a reference without a caption group as a photo', () => {
|
||||
for (const source of [`![${ID}]`, `![${ID}:wide] Text`, `![${ID}`]) {
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID });
|
||||
expectNoIdInText(parsed.blocks, [ID]);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves directive chunks alone', () => {
|
||||
const parsed = parseJournalMarkdown(`::album abc\ncaption: `);
|
||||
expect(parsed.blocks).toHaveLength(1);
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'album', albumId: 'abc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading time ignores photo lines', () => {
|
||||
it('drops the whole line, parentheses in the caption included', () => {
|
||||
const { words } = calculateReadingTime(`Eins zwei.\n\n oben)`);
|
||||
expect(words).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user