feat(history): surlignage des mots-cles recherches (mode highlight)
CI / build-and-test (push) Successful in 14m19s
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:
@@ -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'),
|
||||
'<script>alert(1)</script> <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&b<"c">', '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, '&')
|
||||
.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 + `<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 {
|
||||
|
||||
Reference in New Issue
Block a user