fix(portals): a failed pin read must not export as "no pins"

Three findings, all in code from this session.

Backup called the lenient `listForPlaylist`, which turns a failed read
into `[]`. Since `e0ebbeaf` made restore treat `sourcePins` as
authoritative — clearing the playlist's pins before applying it — an
export whose read failed produced a file that looks complete and wipes
every pin on restore. Losing them is bad; losing them through the one
feature meant to protect them is worse. Backup now uses a strict listing
that throws, so the export fails instead.

The diacritic map stopped at U+024F, which is tidy and leaves Vietnamese
out: `ố` is U+1ED1, the normalizer folds it to `o`, and the scan filtered
those rows out before confirmation. Latin Extended Additional is included
now; the filter decides what belongs, so the range only has to be wide.

And two panels spelling one language differently (`eng` vs `en`, or
`en-US`) raised a dub warning between identical tracks. Tags are
canonicalized before comparison — 639-2 collapses to 639-1, both German
forms meet at `de`, regions drop, and `und` becomes nothing. Anything
that survives longer than three characters is not a language code, so the
comparison is declined rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 21:10:53 +02:00
1 parent 960a33940f
commit 76804ee1bc
10 files changed
+267 -38

No files matched your search

@@ -135,6 +135,19 @@ describeWithSqlite('caseInsensitiveGlobPattern against SQLite', () => {
expect(sqliteGlob('Ca', pattern)).toBe(true);
});
it('reaches beyond Latin Extended-B for the fold', () => {
// "Bố" (U+1ED1) is Vietnamese and normalizes to "bo" like any other
// accented o. A range that stops at Latin Extended-B looks tidy and
// leaves a whole language filtered out before confirmation.
const pattern = caseInsensitiveGlobPattern('bo', {
foldDiacritics: true,
}) as string;
expect(sqliteGlob('Bố', pattern)).toBe(true);
expect(sqliteGlob('bồ', pattern)).toBe(true);
expect(sqliteGlob('Bo', pattern)).toBe(true);
});
it('does not fold diacritics unless asked', () => {
const pattern = caseInsensitiveGlobPattern('ca') as string;
@@ -25,23 +25,39 @@ const GLOB_METACHARACTERS = /[*?[\]^-]/;
*/
const ACCENTED_BY_BASE = ((): Map<string, string[]> => {
const byBase = new Map<string, string[]>();
// Latin-1 Supplement through Latin Extended-B, which is where the accented
// forms of ASCII letters live.
for (let codePoint = 0xc0; codePoint <= 0x24f; codePoint += 1) {
const character = String.fromCodePoint(codePoint);
const base = character.normalize('NFD').replace(/\p{M}/gu, '');
if (base.length !== 1 || !/[a-z]/i.test(base)) {
continue;
// Latin-1 Supplement through Latin Extended-B, PLUS Latin Extended
// Additional — which is where Vietnamese lives (ố is U+1ED1). Stopping at
// Latin Extended-B looks tidy and silently leaves a whole language out,
// while the normalizer folds it perfectly well. The filter below decides
// what actually belongs, so a range only has to be wide enough.
const ranges: ReadonlyArray<[number, number]> = [
[0x00c0, 0x024f],
[0x1e00, 0x1eff],
];
for (const [first, last] of ranges) {
for (let codePoint = first; codePoint <= last; codePoint += 1) {
addAccentedForm(byBase, String.fromCodePoint(codePoint));
}
const key = base.toLowerCase();
const forms = byBase.get(key) ?? [];
forms.push(character);
byBase.set(key, forms);
}
return byBase;
})();
/** Files one character under the plain ASCII letter it decomposes to. */
function addAccentedForm(
byBase: Map<string, string[]>,
character: string
): void {
const base = character.normalize('NFD').replace(/\p{M}/gu, '');
if (base.length !== 1 || !/[a-z]/i.test(base)) {
return;
}
const key = base.toLowerCase();
const forms = byBase.get(key) ?? [];
forms.push(character);
byBase.set(key, forms);
}
/**
* A `*…*` containment pattern matching `token` in any case, or `null` when the
* token cannot be expressed safely.