diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts index 08ca2562e..d4fed438a 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -187,6 +187,29 @@ describe('detectProviderImportCandidates', () => { }); }); + it('folds regional-indicator fancy text but leaves real flag emoji alone', () => { + // πŸ‡ΊπŸ‡ΈπŸ‡ͺπŸ‡· / πŸ‡΅πŸ‡¦πŸ‡ΈπŸ‡Έ are runs of 3+ regional indicators β€” fancy text + // for USER / PASS. πŸ‡ΉπŸ‡· is a run of exactly two β€” a real flag that + // must not fold, or the glued "TR" would break the PORT label's + // word boundary. + const candidates = detectProviderImportCandidates( + [ + 'Server: iptv.example.tv', + 'πŸ‡ΉπŸ‡·PORT: 8080', + 'πŸ‡ΊπŸ‡ΈπŸ‡ͺπŸ‡· : carol', + 'πŸ‡΅πŸ‡¦πŸ‡ΈπŸ‡Έ : pa55', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://iptv.example.tv:8080', + username: 'carol', + password: 'pa55', + }); + }); + it('does not read a hyphenated word as a labeled value', () => { expect( detectProviderImportCandidates( diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts index f45ffa4f8..d598e5a1f 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -98,19 +98,56 @@ const isHexIdentity = (value: string) => HEX_IDENTITY_PATTERN.test(value); * ASCII. */ export function foldDecorativeAlphabets(text: string): string { + const chars = Array.from(text); let result = ''; - for (const char of text) { - result += - foldDecorativeCodePoint(char.codePointAt(0) as number) ?? char; + for (let index = 0; index < chars.length; ) { + const code = chars[index].codePointAt(0) as number; + if (isRegionalIndicator(code)) { + // Regional indicators are ambiguous: a run of EXACTLY two is a + // real flag emoji (πŸ‡³πŸ‡±) that must survive untouched β€” folding it + // would glue "NL" onto whatever follows and break an adjacent + // label's word boundary. Fancy-text words (πŸ‡ΊπŸ‡ΈπŸ‡ͺπŸ‡·, πŸ‡ΊπŸ‡·πŸ‡±) are runs + // of three or more, so only those fold to letters. + let end = index; + while ( + end < chars.length && + isRegionalIndicator(chars[end].codePointAt(0) as number) + ) { + end += 1; + } + const run = chars.slice(index, end); + result += + run.length >= 3 + ? run + .map((char) => + String.fromCharCode( + 0x41 + + ((char.codePointAt(0) as number) - + REGIONAL_INDICATOR_A) + ) + ) + .join('') + : run.join(''); + index = end; + continue; + } + result += foldDecorativeCodePoint(code) ?? chars[index]; + index += 1; } return result; } +const REGIONAL_INDICATOR_A = 0x1f1e6; +const REGIONAL_INDICATOR_Z = 0x1f1ff; + +function isRegionalIndicator(code: number): boolean { + return code >= REGIONAL_INDICATOR_A && code <= REGIONAL_INDICATOR_Z; +} + const DECORATIVE_LATIN_RANGES: Array<[number, number]> = [ [0x1f130, 0x1f149], // πŸ„° squared A–Z [0x1f150, 0x1f169], // πŸ… negative circled A–Z [0x1f170, 0x1f189], // πŸ…° negative squared A–Z - [0x1f1e6, 0x1f1ff], // πŸ‡¦ regional indicator A–Z ]; const DECORATIVE_DIGIT_RANGES: Array<[number, number]> = [