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>
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
/**
|
||
* i18n — locale resolution and dictionary parity.
|
||
*
|
||
* The parity walk is the point: `de.ts` is typed as `Dictionary`, so a *missing*
|
||
* key is already a compile error. What the type cannot catch is a key that was
|
||
* copied over but never translated, or a plural function that ignores its
|
||
* argument — both of which ship an English string on a German site.
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
|
||
import { en } from '../i18n/locales/en';
|
||
import { de } from '../i18n/locales/de';
|
||
import { fr } from '../i18n/locales/fr';
|
||
import { es } from '../i18n/locales/es';
|
||
import { it as itIT } from '../i18n/locales/it';
|
||
import { nl } from '../i18n/locales/nl';
|
||
import type { Dictionary } from '../i18n';
|
||
|
||
describe('resolveLocale', () => {
|
||
it('accepts the supported locales', () => {
|
||
for (const locale of SUPPORTED_LOCALES) {
|
||
expect(resolveLocale(locale)).toBe(locale);
|
||
}
|
||
});
|
||
|
||
it('drops the region subtag', () => {
|
||
expect(resolveLocale('de-DE')).toBe('de');
|
||
expect(resolveLocale('de-AT')).toBe('de');
|
||
expect(resolveLocale('en_GB')).toBe('en');
|
||
expect(resolveLocale('fr-CA')).toBe('fr');
|
||
expect(resolveLocale('es-MX')).toBe('es');
|
||
expect(resolveLocale('it-CH')).toBe('it');
|
||
expect(resolveLocale('nl-BE')).toBe('nl');
|
||
});
|
||
|
||
it('is case- and whitespace-insensitive', () => {
|
||
expect(resolveLocale(' DE ')).toBe('de');
|
||
expect(resolveLocale('De-Ch')).toBe('de');
|
||
});
|
||
|
||
it('falls back to English for languages without a dictionary', () => {
|
||
expect(resolveLocale('ja-JP')).toBe('en');
|
||
expect(resolveLocale('pt')).toBe('en');
|
||
expect(resolveLocale('')).toBe('en');
|
||
expect(resolveLocale(undefined)).toBe('en');
|
||
expect(resolveLocale(null)).toBe('en');
|
||
});
|
||
});
|
||
|
||
describe('getDictionary', () => {
|
||
it('returns a dictionary for every supported locale', () => {
|
||
for (const locale of SUPPORTED_LOCALES) {
|
||
expect(getDictionary(locale)).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it('dictionaryFor resolves and looks up in one step', () => {
|
||
expect(dictionaryFor('de-DE')).toBe(de);
|
||
expect(dictionaryFor('fr-FR')).toBe(fr);
|
||
expect(dictionaryFor('ja')).toBe(en);
|
||
});
|
||
});
|
||
|
||
/** Every leaf of a dictionary, as `['nav.home', 'Home']` pairs. */
|
||
function leaves(obj: object, prefix = ''): [string, unknown][] {
|
||
return Object.entries(obj).flatMap(([key, value]) => {
|
||
const path = prefix ? `${prefix}.${key}` : key;
|
||
return value !== null && typeof value === 'object'
|
||
? leaves(value, path)
|
||
: ([[path, value]] as [string, unknown][]);
|
||
});
|
||
}
|
||
|
||
/** Call a dictionary function with plausible arguments for its arity. */
|
||
function invoke(fn: (...args: never[]) => string): string {
|
||
const args = Array.from({ length: fn.length }, (_, i) => (i === 0 ? 2 : `arg${i}`));
|
||
return (fn as (...a: unknown[]) => string)(...args);
|
||
}
|
||
|
||
/**
|
||
* Keys a locale legitimately shares with English — proper nouns, loanwords,
|
||
* units and cognates that read the same in both languages. Anything not listed
|
||
* here and still identical to English is an untranslated string.
|
||
*/
|
||
const SHARED_EVERYWHERE = [
|
||
'lightbox.iso',
|
||
// Pure interpolation with no words of its own.
|
||
'subpage.coverAria',
|
||
];
|
||
|
||
const TRANSLATIONS: [string, Dictionary, string[]][] = [
|
||
[
|
||
'de',
|
||
de,
|
||
[
|
||
'nav.journal',
|
||
'journal.title',
|
||
'lightbox.copyLinkShort',
|
||
'lightbox.downloadShort',
|
||
'common.website',
|
||
'contact.name',
|
||
],
|
||
],
|
||
[
|
||
'fr',
|
||
fr,
|
||
[
|
||
'nav.journal',
|
||
'journal.title',
|
||
'about.portraitAlt',
|
||
'legal.contact',
|
||
'lightbox.downloadShort',
|
||
'subpage.sectionsNav',
|
||
'subpage.collectionKicker',
|
||
// French shares the words "photo(s)", "album(s)" and "collection(s)".
|
||
'common.photos',
|
||
'common.albums',
|
||
'common.collections',
|
||
'contact.navLabel',
|
||
'contact.title',
|
||
'contact.message',
|
||
],
|
||
],
|
||
['es', es, ['lightbox.downloadShort', 'lightbox.info']],
|
||
[
|
||
'it',
|
||
itIT,
|
||
['nav.home', 'common.home', 'lightbox.info', 'lightbox.copyLinkShort', 'privacy.navLabel'],
|
||
],
|
||
[
|
||
'nl',
|
||
nl,
|
||
[
|
||
'nav.journal',
|
||
'journal.title',
|
||
'common.website',
|
||
'common.albums',
|
||
'lightbox.camera',
|
||
'lightbox.info',
|
||
'lightbox.copyLinkShort',
|
||
'legal.contact',
|
||
'contact.navLabel',
|
||
'contact.title',
|
||
'privacy.navLabel',
|
||
],
|
||
],
|
||
];
|
||
|
||
describe('supported locales', () => {
|
||
it('have a parity check below for every non-English dictionary', () => {
|
||
expect(TRANSLATIONS.map(([code]) => code).sort()).toEqual(
|
||
SUPPORTED_LOCALES.filter((l) => l !== 'en').sort(),
|
||
);
|
||
});
|
||
|
||
it('each dictionary is the one getDictionary serves', () => {
|
||
for (const [code, dict] of TRANSLATIONS) {
|
||
expect(getDictionary(code as (typeof SUPPORTED_LOCALES)[number])).toBe(dict);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe.each(TRANSLATIONS)('dictionary parity: %s', (code, dict, sharedKeys) => {
|
||
const enLeaves = leaves(en);
|
||
const otherLeaves = new Map(leaves(dict));
|
||
|
||
it('covers every English key', () => {
|
||
expect([...otherLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
|
||
});
|
||
|
||
it('matches value kinds — a string never stands in for an interpolator', () => {
|
||
for (const [path, value] of enLeaves) {
|
||
expect(typeof otherLeaves.get(path), path).toBe(typeof value);
|
||
}
|
||
});
|
||
|
||
it('leaves no English string untranslated', () => {
|
||
const shared = new Set([...SHARED_EVERYWHERE, ...sharedKeys]);
|
||
|
||
const identical = enLeaves
|
||
.filter(([path]) => !shared.has(path))
|
||
.filter(([path, value]) => {
|
||
const other = otherLeaves.get(path);
|
||
if (typeof value === 'function') {
|
||
return invoke(other as (...a: never[]) => string) === invoke(value as never);
|
||
}
|
||
return other === value;
|
||
})
|
||
.map(([path]) => path);
|
||
|
||
expect(identical).toEqual([]);
|
||
});
|
||
|
||
it('lists only keys that really are identical, so the allowance cannot go stale', () => {
|
||
const english = new Map(enLeaves);
|
||
const stale = sharedKeys.filter((path) => {
|
||
const value = english.get(path);
|
||
const other = otherLeaves.get(path);
|
||
return typeof value === 'function'
|
||
? invoke(other as (...a: never[]) => string) !== invoke(value as never)
|
||
: other !== value;
|
||
});
|
||
expect(stale).toEqual([]);
|
||
});
|
||
|
||
it('formats dates for its own language', () => {
|
||
expect(dict.dateLocale.split('-')[0]).toBe(code);
|
||
});
|
||
});
|
||
|
||
describe('plural helpers', () => {
|
||
it.each([['en', en] as const, ...TRANSLATIONS.map(([c, d]) => [c, d] as const)])(
|
||
'vary on their count in %s',
|
||
(_code, dict) => {
|
||
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
|
||
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
|
||
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
|
||
},
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Count strings at the CLDR boundaries. French puts 0 in the singular ("0 photo"),
|
||
* the other five use the singular for exactly 1 — so a shared `n === 1` helper
|
||
* was wrong for French, and a hard-coded plural noun ("(1 items)") was wrong for
|
||
* everyone at 1. Italian "foto"/"album" and the Dutch/German participles are
|
||
* invariable, which the table spells out rather than skips.
|
||
*/
|
||
type Counted = (d: Dictionary, n: number) => string;
|
||
const COUNTED = {
|
||
photos: (d, n) => d.common.photos(n),
|
||
albums: (d, n) => d.common.albums(n),
|
||
collections: (d, n) => d.common.collections(n),
|
||
selected: (d, n) => d.proofing.selected(n),
|
||
mailSubject: (d, n) => d.proofing.mailSubject(n),
|
||
downloadsLeft: (d, n) => d.proofSession.downloadsLeft(n),
|
||
} satisfies Record<string, Counted>;
|
||
type CountedKey = keyof typeof COUNTED;
|
||
|
||
const PLURAL_TABLE: [string, Dictionary, Record<CountedKey, [string, string, string]>][] = [
|
||
[
|
||
'en',
|
||
en,
|
||
{
|
||
photos: ['0 photos', '1 photo', '2 photos'],
|
||
albums: ['0 albums', '1 album', '2 albums'],
|
||
collections: ['0 collections', '1 collection', '2 collections'],
|
||
selected: ['❤️ 0 Selected', '❤️ 1 Selected', '❤️ 2 Selected'],
|
||
mailSubject: [
|
||
'Photo Selection (0 items)',
|
||
'Photo Selection (1 item)',
|
||
'Photo Selection (2 items)',
|
||
],
|
||
downloadsLeft: ['0 downloads left', '1 download left', '2 downloads left'],
|
||
},
|
||
],
|
||
[
|
||
'de',
|
||
de,
|
||
{
|
||
photos: ['0 Fotos', '1 Foto', '2 Fotos'],
|
||
albums: ['0 Alben', '1 Album', '2 Alben'],
|
||
collections: ['0 Sammlungen', '1 Sammlung', '2 Sammlungen'],
|
||
selected: ['❤️ 0 ausgewählt', '❤️ 1 ausgewählt', '❤️ 2 ausgewählt'],
|
||
mailSubject: ['Fotoauswahl (0 Fotos)', 'Fotoauswahl (1 Foto)', 'Fotoauswahl (2 Fotos)'],
|
||
downloadsLeft: ['Noch 0 Downloads', 'Noch 1 Download', 'Noch 2 Downloads'],
|
||
},
|
||
],
|
||
[
|
||
'fr',
|
||
fr,
|
||
{
|
||
photos: ['0 photo', '1 photo', '2 photos'],
|
||
albums: ['0 album', '1 album', '2 albums'],
|
||
collections: ['0 collection', '1 collection', '2 collections'],
|
||
selected: ['❤️ 0 sélectionnée', '❤️ 1 sélectionnée', '❤️ 2 sélectionnées'],
|
||
mailSubject: [
|
||
'Sélection de photos (0)',
|
||
'Sélection de photos (1)',
|
||
'Sélection de photos (2)',
|
||
],
|
||
downloadsLeft: [
|
||
'0 téléchargement restant',
|
||
'1 téléchargement restant',
|
||
'2 téléchargements restants',
|
||
],
|
||
},
|
||
],
|
||
[
|
||
'es',
|
||
es,
|
||
{
|
||
photos: ['0 fotos', '1 foto', '2 fotos'],
|
||
albums: ['0 álbumes', '1 álbum', '2 álbumes'],
|
||
collections: ['0 colecciones', '1 colección', '2 colecciones'],
|
||
selected: ['❤️ 0 seleccionadas', '❤️ 1 seleccionada', '❤️ 2 seleccionadas'],
|
||
mailSubject: ['Selección de fotos (0)', 'Selección de fotos (1)', 'Selección de fotos (2)'],
|
||
downloadsLeft: ['0 descargas restantes', '1 descarga restante', '2 descargas restantes'],
|
||
},
|
||
],
|
||
[
|
||
'it',
|
||
itIT,
|
||
{
|
||
photos: ['0 foto', '1 foto', '2 foto'],
|
||
albums: ['0 album', '1 album', '2 album'],
|
||
collections: ['0 collezioni', '1 collezione', '2 collezioni'],
|
||
selected: ['❤️ 0 selezionate', '❤️ 1 selezionata', '❤️ 2 selezionate'],
|
||
mailSubject: ['Selezione di foto (0)', 'Selezione di foto (1)', 'Selezione di foto (2)'],
|
||
downloadsLeft: ['0 download rimasti', '1 download rimasto', '2 download rimasti'],
|
||
},
|
||
],
|
||
[
|
||
'nl',
|
||
nl,
|
||
{
|
||
photos: ['0 foto’s', '1 foto', '2 foto’s'],
|
||
albums: ['0 albums', '1 album', '2 albums'],
|
||
collections: ['0 collecties', '1 collectie', '2 collecties'],
|
||
selected: ['❤️ 0 geselecteerd', '❤️ 1 geselecteerd', '❤️ 2 geselecteerd'],
|
||
mailSubject: ['Fotoselectie (0)', 'Fotoselectie (1)', 'Fotoselectie (2)'],
|
||
downloadsLeft: ['Nog 0 downloads', 'Nog 1 download', 'Nog 2 downloads'],
|
||
},
|
||
],
|
||
];
|
||
|
||
describe('plural forms at 0, 1 and 2', () => {
|
||
it('cover every supported locale', () => {
|
||
expect(PLURAL_TABLE.map(([code]) => code).sort()).toEqual([...SUPPORTED_LOCALES].sort());
|
||
});
|
||
|
||
describe.each(PLURAL_TABLE)('%s', (_code, dict, expected) => {
|
||
it.each(Object.keys(COUNTED) as CountedKey[])('%s', (key) => {
|
||
expect([0, 1, 2].map((n) => COUNTED[key](dict, n))).toEqual(expected[key]);
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
['en', en, ['0 days.', '1 day.', '2 days.']],
|
||
['de', de, ['0 Tagen gelöscht.', '1 Tag gelöscht.', '2 Tagen gelöscht.']],
|
||
['fr', fr, ['0 jour.', '1 jour.', '2 jours.']],
|
||
['es', es, ['0 días.', '1 día.', '2 días.']],
|
||
['it', itIT, ['0 giorni.', '1 giorno.', '2 giorni.']],
|
||
['nl', nl, ['0 dagen verwijderd.', '1 dag verwijderd.', '2 dagen verwijderd.']],
|
||
] as const)('contact retention days in %s', (_code, dict, endings) => {
|
||
const got = [0, 1, 2].map((n) => dict.contact.privacy(n));
|
||
got.forEach((text, i) => expect(text.endsWith(` ${endings[i]}`), text).toBe(true));
|
||
});
|
||
});
|
||
|
||
describe('German terminology', () => {
|
||
// The carousel said "Diashow", the lightbox's shortcut panel and live
|
||
// region "Diaschau" for the same feature.
|
||
it('calls the slideshow "Diashow" everywhere', () => {
|
||
const strings = [
|
||
de.home.pauseSlideshow,
|
||
de.home.playSlideshow,
|
||
de.lightbox.shortcutSlideshow,
|
||
de.lightbox.shortcutSlideshowRunning(5),
|
||
de.lightbox.slideshowStopped,
|
||
de.lightbox.slideshowRunning(5),
|
||
];
|
||
for (const s of strings) expect(s).toContain('Diashow');
|
||
});
|
||
|
||
it('leaves no "Diaschau" in any string', () => {
|
||
const walk = (value: unknown): string[] =>
|
||
typeof value === 'string'
|
||
? [value]
|
||
: typeof value === 'function'
|
||
? [String((value as (n: number) => string)(2))]
|
||
: value && typeof value === 'object'
|
||
? Object.values(value).flatMap(walk)
|
||
: [];
|
||
expect(walk(de).filter((s) => s.includes('Diaschau'))).toEqual([]);
|
||
});
|
||
});
|