feat(history): surlignage des mots-cles recherches (mode highlight)
CI / build-and-test (push) Successful in 14m19s

Pipe histHighlight + utilitaire pur (HTML source echappe, matchs
insensibles a la casse, multi-mots, chevauchements fusionnes) :
titres/queries/apercus des 3 onglets + lignes des transcripts
deplies, avec badge de comptage d occurrences. test:highlight.
This commit is contained in:
2026-09-27 22:03:49 -04:00
parent 71278eb0b1
commit 23d5e58a8e
6 changed files with 227 additions and 6 deletions
+1
View File
@@ -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\""
@@ -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));
}
}
@@ -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 <mark class="hist-mark">Océans</mark>',
'match insensible à la casse, casse d origine préservée',
);
assertEqual(
buildHighlightedHtml('lofi hip hop mix', 'lofi mix'),
'<mark class="hist-mark">lofi</mark> hip hop <mark class="hist-mark">mix</mark>',
'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('<script>alert(1)</script> test', 'test'),
'&lt;script&gt;alert(1)&lt;/script&gt; <mark class="hist-mark">test</mark>',
'HTML source échappé, pas d injection',
);
assertEqual(
buildHighlightedHtml('100% bio_pur', '100%'),
'<mark class="hist-mark">100%</mark> 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'),
'<mark class="hist-mark">intelligence</mark> <mark class="hist-mark">artificielle</mark>',
'terme inclus (intel) fusionné, mots adjacents = 2 marks',
);
assertEqual(
buildHighlightedHtml('abcd', 'abcd abc'),
'<mark class="hist-mark">abcd</mark>',
'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&amp;b&lt;&quot;c&quot;&gt;', 'escapeHtml complet');
if (process.exitCode) console.error('highlight.util: FAILURES');
else console.log('highlight.util: ALL OK');
@@ -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 <mark> : 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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 + `<mark class="hist-mark">`
* autour de chaque occurrence (insensible à la casse, chevauchements fusionnés).
* Sans requête ou sans match : texte échappé, aucun <mark>.
*/
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 += `<mark class="hist-mark">${escapeHtml(raw.slice(s, e))}</mark>`;
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;
}
@@ -156,12 +156,13 @@
<li *ngFor="let s of filteredSearch()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)" class="block p-4 pr-24 hover:no-underline">
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">{{ s.query }}</div>
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors" [innerHTML]="s.query | histHighlight: searchQuery()"></div>
<div class="flex items-center mt-1 text-xs text-slate-400 gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(getSearchProvider(s).id)">
{{ getSearchProvider(s).name }}
</span>
<span>{{ formatDate(s.created_at) }}</span>
<span *ngIf="searchQuery().trim() && searchMatchCount(s) > 0" class="hist-count">{{ searchMatchCount(s) }} occurrence{{ searchMatchCount(s) > 1 ? 's' : '' }}</span>
</div>
</a>
<button (click)="onDeleteSearch(s)" [disabled]="deletingSearchId() === s.id"
@@ -199,12 +200,13 @@
</div>
</div>
<div class="ml-4 flex-1 min-w-0">
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 truncate">{{ w.title || 'Sans titre' }}</h4>
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 truncate" [innerHTML]="(w.title || 'Sans titre') | histHighlight: watchQuery()"></h4>
<div class="mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border" [ngStyle]="getProviderColors(w.provider)">
{{ getProviderDisplayName(w.provider) }}
</span>
<span>Visionné le {{ formatDate(w.watched_at) }}</span>
<span *ngIf="watchQuery().trim() && watchMatchCount(w) > 0" class="hist-count ml-2">{{ watchMatchCount(w) }} occurrence{{ watchMatchCount(w) > 1 ? 's' : '' }}</span>
</div>
<div class="mt-1 text-xs text-slate-500">
Progression : {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}
@@ -245,7 +247,7 @@
@else { <span class="text-slate-500 text-xs">TXT</span> }
</div>
<div class="flex-1 min-w-0">
<div class="text-slate-100 font-medium truncate">{{ t.title || t.video_id }}</div>
<div class="text-slate-100 font-medium truncate" [innerHTML]="(t.title || t.video_id) | histHighlight: transcriptQuery()"></div>
<div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(t.provider)">
{{ getProviderDisplayName(t.provider) }}
@@ -253,8 +255,9 @@
<span class="px-2 py-0.5 rounded bg-emerald-600/20 text-emerald-300 border border-emerald-500/30">{{ t.lang }}</span>
<span>{{ t.line_count }} passages</span>
<span>{{ formatDate(t.updated_at) }}</span>
<span *ngIf="transcriptLineQuery().trim() && transcriptMatchCount(t) > 0" class="hist-count">{{ transcriptMatchCount(t) }} occurrence{{ transcriptMatchCount(t) > 1 ? 's' : '' }}</span>
</div>
<p class="mt-1 text-xs text-slate-400 line-clamp-2">{{ t.preview || '—' }}</p>
<p class="mt-1 text-xs text-slate-400 line-clamp-2" [innerHTML]="(t.preview || '—') | histHighlight: transcriptQuery()"></p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
@@ -272,7 +275,7 @@
<div *ngIf="expandedTranscriptId() === t.id" class="mt-3 max-h-64 overflow-y-auto rounded bg-slate-900/60 p-3 space-y-1">
<div *ngFor="let l of expandedTranscriptLines()" class="text-xs text-slate-300 flex gap-2">
<span class="text-slate-500 font-mono shrink-0">[{{ formatDuration(l.t) }}]</span>
<span>{{ l.text }}</span>
<span [innerHTML]="l.text | histHighlight: transcriptLineQuery()"></span>
</div>
</div>
</div>
@@ -7,6 +7,8 @@ import { Subject } from 'rxjs';
import {
HistoryService, SearchHistoryItem, WatchHistoryItem, TranscriptHistoryItem,
} from '../../../services/history.service';
import { HistHighlightPipe } from './highlight.pipe';
import { countOccurrences } from './highlight.util';
export type HistoryTab = 'search' | 'watch' | 'transcripts';
export type DateFilter = 'all' | 'today' | 'week' | 'month';
@@ -27,7 +29,25 @@ const PROVIDER_OPTIONS = [
standalone: true,
templateUrl: './history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, FormsModule]
imports: [CommonModule, RouterModule, FormsModule, HistHighlightPipe],
styles: [`
.hist-mark {
background: rgba(250, 204, 21, 0.75);
color: #0f172a;
border-radius: 0.25rem;
padding: 0 0.15rem;
}
.hist-count {
font-size: 0.7rem;
line-height: 1;
padding: 0.2rem 0.45rem;
border-radius: 9999px;
background: rgba(250, 204, 21, 0.15);
color: rgb(253, 224, 71);
border: 1px solid rgba(250, 204, 21, 0.35);
white-space: nowrap;
}
`]
})
export class HistoryComponent {
private history = inject(HistoryService);
@@ -313,6 +333,31 @@ export class HistoryComponent {
return !!(this.transcriptQuery() || this.transcriptProvider() || this.transcriptLang() || this.transcriptDate() !== 'all' || this.transcriptSort() !== 'recent' || this.transcriptInnerSearch());
});
// ============ Surlignage des mots-clés (mode highlight) ============
/** Occurrences de la requête dans le libellé d'une recherche. */
searchMatchCount(s: SearchHistoryItem): number {
return countOccurrences(s?.query, this.searchQuery());
}
/** Occurrences de la requête dans le titre d'une vidéo visionnée. */
watchMatchCount(w: WatchHistoryItem): number {
return countOccurrences(w?.title || '', this.watchQuery());
}
/** Requête effective pour le texte d'un transcript : filtre interne d'abord. */
transcriptLineQuery(): string {
const inner = this.transcriptInnerSearch().trim();
return inner || this.transcriptQuery();
}
/** Occurrences dans titre + aperçu + lignes conservées d'un transcript. */
transcriptMatchCount(t: TranscriptHistoryItem): number {
const q = this.transcriptLineQuery();
if (!q.trim()) return 0;
const haystack = `${t?.title || ''}\n${t?.preview || ''}\n${(t?.lines || []).map((l) => l?.text || '').join('\n')}`;
return countOccurrences(haystack, q);
}
// ============ Helpers affichage (conservés) ============
/** Miniature : stockée, sinon déterministe YouTube (imports Takeout sans vignette). */
thumbOf(w: WatchHistoryItem): string {