Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions src/components/library/SearchResultRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AudioLines, Mic, Monitor } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

import { SEARCH_SNIPPET_HIGHLIGHT_END, SEARCH_SNIPPET_HIGHLIGHT_START } from '../../lib/searchSnippet';
import type { SearchResult } from '../../types';
import { formatDate, formatDuration, normalizeAudioSource, statusClasses } from './meetingUtils';

Expand All @@ -10,7 +12,6 @@ type SearchResultRowProps = {
selectionMode: boolean;
onOpen: (id: number) => void;
onSelect: (id: number) => void;
renderSnippet: (html: string) => { __html: string };
};

function sourceIcon(audioSource: string | null) {
Expand All @@ -22,13 +23,50 @@ function sourceIcon(audioSource: string | null) {
return null;
}


function renderSnippet(snippet: string): ReactNode[] {
const nodes: ReactNode[] = [];
let remaining = snippet;
let keyIndex = 0;

while (remaining.length > 0) {
const start = remaining.indexOf(SEARCH_SNIPPET_HIGHLIGHT_START);

if (start === -1) {
Comment on lines +33 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle literal highlight sentinel collisions

renderSnippet parses any occurrence of SEARCH_SNIPPET_HIGHLIGHT_START/END as markup boundaries, but those tokens now come from normal searchable content too, so a meeting transcript/title that literally contains these strings is mis-rendered. With an unmatched start token, the marker is stripped and text can be dropped (end === -1 path), and with both tokens present, arbitrary content is shown as highlighted even when it was not matched, which makes search results misleading for user-controlled data.

Useful? React with 👍 / 👎.

nodes.push(remaining);
break;
}

if (start > 0) {
nodes.push(remaining.slice(0, start));
}

remaining = remaining.slice(start + SEARCH_SNIPPET_HIGHLIGHT_START.length);
const end = remaining.indexOf(SEARCH_SNIPPET_HIGHLIGHT_END);

if (end === -1) {
nodes.push(remaining);
break;
}

nodes.push(
<mark key={`snippet-mark-${keyIndex++}`}>
{remaining.slice(0, end)}
</mark>,
);

remaining = remaining.slice(end + SEARCH_SNIPPET_HIGHLIGHT_END.length);
}

return nodes;
}

export function SearchResultRow({
result,
selected,
selectionMode,
onOpen,
onSelect,
renderSnippet,
}: SearchResultRowProps) {
const { t } = useTranslation('library');

Expand Down Expand Up @@ -70,10 +108,9 @@ export function SearchResultRow({
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{formatDate(result.started_at)} &middot; {formatDuration(result.duration_seconds)}
</p>
<p
className="mt-2 text-sm leading-relaxed text-gray-600 [&_mark]:rounded-md [&_mark]:bg-accent/10 [&_mark]:px-0.5 [&_mark]:text-accent dark:text-gray-300 dark:[&_mark]:bg-accent/20 dark:[&_mark]:text-accent-muted"
dangerouslySetInnerHTML={renderSnippet(result.snippet)}
/>
<p className="mt-2 text-sm leading-relaxed text-gray-600 [&_mark]:rounded-md [&_mark]:bg-accent/10 [&_mark]:px-0.5 [&_mark]:text-accent dark:text-gray-300 dark:[&_mark]:bg-accent/20 dark:[&_mark]:text-accent-muted">
{renderSnippet(result.snippet)}
</p>
</button>
</div>
</article>
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';

import { getDb } from '../lib/db';
import { buildMeetingFilterParams } from '../lib/libraryFilterParams';
import { SEARCH_SNIPPET_HIGHLIGHT_END, SEARCH_SNIPPET_HIGHLIGHT_START } from '../lib/searchSnippet';
import type {
DateSection,
LibraryFilters,
Expand Down Expand Up @@ -247,14 +248,14 @@ export function useLibrary(options: UseLibraryOptions = {}) {
m.status,
m.duration_seconds,
m.audio_sources,
snippet(meetings_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
snippet(meetings_fts, 1, $2, $3, '...', 32) AS snippet
FROM meetings_fts
JOIN meetings m ON m.id = meetings_fts.rowid
WHERE meetings_fts MATCH $1
AND m.deleted_at IS NULL
ORDER BY bm25(meetings_fts)
LIMIT 50`,
[sanitizeFtsQuery(cleaned)],
[sanitizeFtsQuery(cleaned), SEARCH_SNIPPET_HIGHLIGHT_START, SEARCH_SNIPPET_HIGHLIGHT_END],
);

setSearchResults(rows);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/searchSnippet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const SEARCH_SNIPPET_HIGHLIGHT_START = '__OPENNOTES_HIGHLIGHT_START__';
export const SEARCH_SNIPPET_HIGHLIGHT_END = '__OPENNOTES_HIGHLIGHT_END__';
5 changes: 0 additions & 5 deletions src/views/LibraryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ import { useLibrary } from '../hooks/useLibrary';
import { bulkExportZip, exportMeeting, type ExportFormat } from '../lib/export';
import type { MeetingWithPreview, SortDirection, SortField, ViewMode } from '../types';

function renderSearchSnippet(html: string): { __html: string } {
return { __html: html };
}

function SkeletonLine({ className }: { className: string }) {
return <div className={`animate-pulse rounded-md bg-gray-200/70 dark:bg-gray-700/60 ${className}`} />;
}
Expand Down Expand Up @@ -387,7 +383,6 @@ export function LibraryView({ scope = 'library' }: LibraryViewProps) {
selectionMode={selectionEnabled ? isSelectionMode : false}
onOpen={onOpenMeeting}
onSelect={selectionEnabled ? toggleSelect : () => undefined}
renderSnippet={renderSearchSnippet}
/>
))}
</div>
Expand Down
Loading