diff --git a/package.json b/package.json index e1156c2..058f90a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:suggest": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/suggest.spec.ts && node ./server/tests/suggest.test.mjs", "test:transcript": "node --test server/tests/transcript.test.mjs", "test:history": "node server/tests/history_filters.test.mjs", + "test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts", "test:ytscrape": "node server/tests/youtube-scrape.test.mjs", "test:ytinnertube": "node server/tests/youtube-innertube.test.mjs", "ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\"" diff --git a/src/components/account/history/highlight.pipe.ts b/src/components/account/history/highlight.pipe.ts new file mode 100644 index 0000000..0f966ff --- /dev/null +++ b/src/components/account/history/highlight.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { buildHighlightedHtml } from './highlight.util'; + +/** + * Surligne les mots-clés d'une requête dans un texte d'historique. + * Usage : `[innerHTML]="item.title | histHighlight: query()"`. + * Le HTML est construit par `buildHighlightedHtml` (texte source échappé), + * sanitisé ici pour Angular. Pipe pur : recalculé quand texte/requête changent. + */ +@Pipe({ name: 'histHighlight', standalone: true }) +export class HistHighlightPipe implements PipeTransform { + private sanitizer = inject(DomSanitizer); + + transform(value: string | null | undefined, query: string | null | undefined): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(buildHighlightedHtml(value, query)); + } +} diff --git a/src/components/account/history/highlight.util.spec.ts b/src/components/account/history/highlight.util.spec.ts new file mode 100644 index 0000000..2c15fd0 --- /dev/null +++ b/src/components/account/history/highlight.util.spec.ts @@ -0,0 +1,77 @@ +import { buildHighlightedHtml, countOccurrences, escapeHtml, highlightTerms } from './highlight.util'; + +/** + * Surlignage des mots-clés de l'Historique. + * Run with: npm run test:highlight + */ + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual); + const b = JSON.stringify(expected); + if (a !== b) { + console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`); + process.exitCode = 1; + } else { + console.log(`ok: ${message}`); + } +} + +// Termes : uniques, minuscules, plus longs d'abord +assertEqual(highlightTerms('lofi LOFI mix'), ['lofi', 'mix'], 'termes dédupliqués + minuscules'); +assertEqual(highlightTerms(' '), [], 'requête vide -> aucun terme'); +assertEqual(highlightTerms('a bb ccc'), ['ccc', 'bb', 'a'], 'tri par longueur décroissante'); + +// HTML de base +assertEqual( + buildHighlightedHtml('Documentaire Océans', 'océans'), + 'Documentaire Océans', + 'match insensible à la casse, casse d origine préservée', +); +assertEqual( + buildHighlightedHtml('lofi hip hop mix', 'lofi mix'), + 'lofi hip hop mix', + 'multi-mots surlignés séparément', +); +assertEqual( + buildHighlightedHtml('Bonjour', ''), + 'Bonjour', + 'sans requête : texte intact, sans mark', +); +assertEqual( + buildHighlightedHtml('Bonjour', 'xyz'), + 'Bonjour', + 'sans match : texte intact, sans mark', +); + +// Sécurité : le texte source est échappé, la requête aussi +assertEqual( + buildHighlightedHtml(' test', 'test'), + '<script>alert(1)</script> test', + 'HTML source échappé, pas d injection', +); +assertEqual( + buildHighlightedHtml('100% bio_pur', '100%'), + '100% bio_pur', + 'caractères spéciaux requête traités littéralement', +); + +// Chevauchements fusionnés en un seul mark +assertEqual( + buildHighlightedHtml('intelligence artificielle', 'intelligence artificielle intel'), + 'intelligence artificielle', + 'terme inclus (intel) fusionné, mots adjacents = 2 marks', +); +assertEqual( + buildHighlightedHtml('abcd', 'abcd abc'), + 'abcd', + 'termes strictement imbriqués fusionnés en 1 mark', +); + +// Compteur d'occurrences +assertEqual(countOccurrences('Lofi lofi LOFI mix', 'lofi'), 3, 'compte insensible à la casse'); +assertEqual(countOccurrences('rien ici', 'xyz'), 0, 'zéro occurrence'); +assertEqual(countOccurrences('abc', ''), 0, 'requête vide -> 0'); +assertEqual(escapeHtml('a&b<"c">'), 'a&b<"c">', 'escapeHtml complet'); + +if (process.exitCode) console.error('highlight.util: FAILURES'); +else console.log('highlight.util: ALL OK'); diff --git a/src/components/account/history/highlight.util.ts b/src/components/account/history/highlight.util.ts new file mode 100644 index 0000000..fd11f9b --- /dev/null +++ b/src/components/account/history/highlight.util.ts @@ -0,0 +1,77 @@ +// Surlignage des mots-clés recherchés dans l'Historique (pur, sans Angular). +// Le HTML source est TOUJOURS échappé avant insertion des : aucune +// injection possible via titres/textes conservés. + +/** Échappe le HTML d'un texte brut. */ +export function escapeHtml(s: string): string { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Découpe la requête en termes uniques, plus longs d'abord (mots entiers ou fragments). */ +export function highlightTerms(query: string | null | undefined): string[] { + const words = String(query ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean); + return [...new Set(words)].sort((a, b) => b.length - a.length); +} + +/** + * Construit le HTML surligné : segments échappés + `` + * autour de chaque occurrence (insensible à la casse, chevauchements fusionnés). + * Sans requête ou sans match : texte échappé, aucun . + */ +export function buildHighlightedHtml(text: string | null | undefined, query: string | null | undefined): string { + const raw = String(text ?? ''); + const terms = highlightTerms(query); + if (!raw || !terms.length) return escapeHtml(raw); + const lower = raw.toLowerCase(); + const ranges: Array<[number, number]> = []; + for (const term of terms) { + let from = 0; + for (;;) { + const i = lower.indexOf(term, from); + if (i === -1) break; + ranges.push([i, i + term.length]); + from = i + term.length; + if (from >= lower.length) break; + } + } + if (!ranges.length) return escapeHtml(raw); + ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]); + const merged: Array<[number, number]> = []; + for (const r of ranges) { + const last = merged[merged.length - 1]; + if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]); + else merged.push([r[0], r[1]]); + } + let out = ''; + let pos = 0; + for (const [s, e] of merged) { + out += escapeHtml(raw.slice(pos, s)); + out += `${escapeHtml(raw.slice(s, e))}`; + pos = e; + } + return out + escapeHtml(raw.slice(pos)); +} + +/** Nombre d'occurrences (insensible à la casse, sans chevauchement) dans un texte. */ +export function countOccurrences(text: string | null | undefined, query: string | null | undefined): number { + const raw = String(text ?? ''); + const terms = highlightTerms(query); + if (!raw || !terms.length) return 0; + const lower = raw.toLowerCase(); + let n = 0; + for (const term of terms) { + let from = 0; + for (;;) { + const i = lower.indexOf(term, from); + if (i === -1) break; + n++; + from = i + term.length; + if (from >= lower.length) break; + } + } + return n; +} diff --git a/src/components/account/history/history.component.html b/src/components/account/history/history.component.html index 2d62d75..25eb717 100644 --- a/src/components/account/history/history.component.html +++ b/src/components/account/history/history.component.html @@ -156,12 +156,13 @@
  • -
    {{ s.query }}
    +
    {{ getSearchProvider(s).name }} {{ formatDate(s.created_at) }} + {{ searchMatchCount(s) }} occurrence{{ searchMatchCount(s) > 1 ? 's' : '' }}