Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adjust content search when switching between transcripts #532

Merged
merged 5 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
20 changes: 8 additions & 12 deletions src/components/Transcript/Transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,7 @@ const NO_SUPPORT = 'Transcript format is not supported, please check again.';
const buildSpeakerText = (item) => {
let text = item.text;
if (item.match) {
text = item.match.reduce((acc, match, i) => {
if (i % 2 === 0) {
acc += match;
} else {
acc += `<span class="ramp--transcript_highlight">${match}</span>`;
}
return acc;
}, '');
text = item.match;
}
if (item.speaker) {
return `<u>${item.speaker}:</u> ${text}`;
Expand Down Expand Up @@ -237,6 +230,7 @@ const Transcript = ({ playerID, manifestUrl, showNotes = false, search = {}, tra
isMachineGen: false,
tError: null,
});
const [selectedTranscript, setSelectedTranscript] = React.useState();
const [isLoading, setIsLoading] = React.useState(true);
// Store transcript data in state to avoid re-requesting file contents
const [cachedTranscripts, setCachedTranscripts] = React.useState([]);
Expand Down Expand Up @@ -264,12 +258,12 @@ const Transcript = ({ playerID, manifestUrl, showNotes = false, search = {}, tra
query: searchQuery,
transcripts: transcript,
canvasIndex: canvasIndexRef.current,
selectedTranscript: transcriptInfo.tUrl,
selectedTranscript: selectedTranscript,
});

const { focusedMatchId, setFocusedMatchId, focusedMatchIndex, setFocusedMatchIndex } = useFocusedMatch({ searchResults });

const { tanscriptHitCounts } = useSearchCounts({ searchResults, canvasTranscripts });
const tanscriptHitCounts = useSearchCounts({ searchResults, canvasTranscripts, searchQuery });

const [isEmpty, setIsEmpty] = React.useState(true);
const [_autoScrollEnabled, _setAutoScrollEnabled] = React.useState(true);
Expand Down Expand Up @@ -398,12 +392,12 @@ const Transcript = ({ playerID, manifestUrl, showNotes = false, search = {}, tra
}
};

const selectTranscript = (selectedId) => {
const selectTranscript = React.useCallback((selectedId) => {
const selectedTranscript = canvasTranscripts.filter((tr) => (
tr.id === selectedId
));
setStateVar(selectedTranscript[0]);
};
}, [canvasTranscripts]);

const setStateVar = async (transcript) => {
// When selected transcript is null or undefined display error message
Expand All @@ -428,6 +422,7 @@ const Transcript = ({ playerID, manifestUrl, showNotes = false, search = {}, tra
const { tData, tFileExt, tType, tError } = cached[0];
setTranscript(tData);
setTranscriptInfo({ title, filename, id, isMachineGen, tType, tUrl: url, tFileExt, tError });
setSelectedTranscript(url);
} else {
// Parse new transcript data from the given sources
await Promise.resolve(
Expand All @@ -447,6 +442,7 @@ const Transcript = ({ playerID, manifestUrl, showNotes = false, search = {}, tra
}
setTranscript(tData);
setTranscriptInfo({ title, filename, id, isMachineGen, tType, tUrl, tFileExt, tError: newError });
setSelectedTranscript(tUrl);
transcript = {
...transcript,
tType: tType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ TranscriptSelector.propTypes = {
noTranscript: PropTypes.bool.isRequired
};

export default TranscriptSelector;
export default React.memo(TranscriptSelector);
129 changes: 90 additions & 39 deletions src/services/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,29 @@ import { useRef, useEffect, useState, useMemo, useCallback, useContext } from 'r
import { PlayerDispatchContext } from '../context/player-context';
import { ManifestStateContext } from '../context/manifest-context';
import { getSearchService } from './iiif-parser';
import { getMatchedParts, getMatchedTranscriptLines, parseContentSearchResponse } from './transcript-parser';
import { getMatchedTranscriptLines, parseContentSearchResponse } from './transcript-parser';

export const defaultMatcherFactory = (items) => {
const mappedItems = items.map(item => item.text.toLocaleLowerCase());
return (query, abortController) => {
const queryRegex = new RegExp(String.raw`\b${query}\b`, 'i');
const qStr = query.trim().toLocaleLowerCase();
const matchedItems = mappedItems.reduce((results, mappedText, idx) => {
const matchOffset = mappedText.indexOf(qStr);
const matchOffset = mappedText.search(queryRegex);
if (matchOffset !== -1) {
const matchedItem = items[idx];
const matchParts = getMatchedParts(matchOffset, matchedItem.text, qStr);

// Always takes only the first search hit
const matchCount = 1;
const [prefix, hit, suffix] = [
matchedItem.text.slice(0, matchOffset),
matchedItem.text.slice(matchOffset, matchOffset + qStr.length),
matchedItem.text.slice(matchOffset + qStr.length)
];
// Add highlight to the search match
const match = `${prefix}<span class="ramp--transcript_highlight">${hit}</span>${suffix}`;
return [
...results,
{ ...matchedItem, score: idx, match: matchParts }
{ ...matchedItem, score: idx, match, matchCount }
];
} else {
return results;
Expand All @@ -26,7 +34,7 @@ export const defaultMatcherFactory = (items) => {
};
};

const contentSearchFactory = (searchService, items, selectedTranscript) => {
export const contentSearchFactory = (searchService, items, selectedTranscript) => {
return async (query, abortController) => {
try {
const res = await fetch(`${searchService}?q=${query}`,
Expand Down Expand Up @@ -96,7 +104,7 @@ export function useFilteredTranscripts({
matcher = contentSearchFactory(searchService, itemsWithIds, selectedTranscript);
}
return { matcher, itemsWithIds, itemsIndexed };
}, [transcripts, matcherFactory]);
}, [transcripts, matcherFactory, selectedTranscript]);

const playerDispatch = useContext(PlayerDispatchContext);
const manifestState = useContext(ManifestStateContext);
Expand All @@ -108,53 +116,71 @@ export function useFilteredTranscripts({
let serviceId = getSearchService(manifest, canvasIndex);
setSearchService(serviceId);
}
// Reset cached search hits on Canvas change
setAllSearchResults(null);
}, [canvasIndex]);

useEffect(() => {
// abort any existing search operations
if (abortControllerRef.current) {
abortControllerRef.current.abort('Cancelling content search request');
}
// Invoke the search factory when query is changed
if (query) {
callSearchFactory();
}
}, [query]);

useEffect(() => {
if (!itemsWithIds.length) {
if (playerDispatch) playerDispatch({ type: 'setSearchMarkers', payload: [] });
setSearchResults({ results: {}, matchingIds: [], ids: [] });
// Update searchResult instead of replacing to preserve the hit count
setSearchResults({
...searchResults,
results: {}, matchingIds: [], ids: []
});
return;
} else if (!enabled || !query) {
if (playerDispatch) playerDispatch({ type: 'setSearchMarkers', payload: [] });
const sortedIds = sorter([...itemsWithIds]).map(item => item.id);
setSearchResults({
...searchResults,
results: itemsIndexed,
matchingIds: [],
ids: sortedIds
});
setAllSearchResults(null);
// When query is cleared; clear cached search results
if (!query) {
setAllSearchResults(null);
}
return;
}

// Use cached search results to find matches when switching between transcripts with same query
if (allSearchResults != null) {
const transcriptSearchResults = allSearchResults[selectedTranscript];
const searchHits = getMatchedTranscriptLines(transcriptSearchResults, query, itemsWithIds);
markMatchedItems(searchHits, searchResults?.counts, allSearchResults);
} else {
const abortController = new AbortController();
abortControllerRef.current = abortController;

(Promise.resolve(matcher(query, abortControllerRef.current))
.then(({ matchedTranscriptLines, hitCounts, allSearchHits }) => {
if (abortController.signal.aborted) return;
markMatchedItems(matchedTranscriptLines, hitCounts, allSearchHits);
})
.catch(e => {
console.error('search failed', e, query, transcripts);
})
);
// Invoke search factory call when there are no cached search results
callSearchFactory();
}

}, [matcher, query, enabled, sorter, matchesOnly, showMarkers, playerDispatch, selectedTranscript]);

const callSearchFactory = () => {
const abortController = new AbortController();
abortControllerRef.current = abortController;

(Promise.resolve(matcher(query, abortControllerRef.current))
.then(({ matchedTranscriptLines, hitCounts, allSearchHits }) => {
if (abortController.signal.aborted) return;
markMatchedItems(matchedTranscriptLines, hitCounts, allSearchHits);
})
.catch(e => {
console.error('search failed', e, query, transcripts);
})
);
};
/**
* Generic function to prepare a list of search hits to be displayed in the transcript
* component either from a reponse from a content search API call (using content search factory)
Expand All @@ -165,17 +191,48 @@ export function useFilteredTranscripts({
* @returns
*/
const markMatchedItems = (matchedTranscriptLines, hitCounts = [], allSearchHits = null) => {
if (matchedTranscriptLines === undefined) return;
/**
* Set all search results and hit counts for each transcript before compiling the
* matching search hit list for transcript lines. When there are no matches for the
* current transcript, but there are for others this needs to be set here to avoid
* duplicate API requests for content search when switching between transcripts.
*/
setAllSearchResults(allSearchHits);
let searchResults = {
results: itemsWithIds,
matchingIds: [],
ids: sorter([...itemsWithIds]).map(item => item.id),
counts: hitCounts?.length > 0 ? hitCounts : [],
};
if (matchedTranscriptLines === undefined) {
setSearchResults({
...searchResults
});
return;
};
const matchingItemsIndexed = matchedTranscriptLines.reduce((acc, match) => ({
...acc,
[match.id]: match
}), {});
const sortedMatchIds = sorter([...matchedTranscriptLines], true).map(item => item.id);

// Use matchCount for each cue to get the results count corrent in UI
let sortedMatchIds = [];
sorter([...matchedTranscriptLines], true).map(item => {
if (item.matchCount != undefined) {
let count = 0;
while (count < item.matchCount) {
sortedMatchIds.push(item.id);
count++;
}
}
});

if (matchesOnly) {
setSearchResults({
...searchResults,
results: matchingItemsIndexed,
ids: sortedMatchIds,
matchingIds: sortedMatchIds
matchingIds: sortedMatchIds,
});
} else {
const joinedIndexed = {
Expand All @@ -184,19 +241,13 @@ export function useFilteredTranscripts({
};
const sortedItemIds = sorter(Object.values(joinedIndexed), false).map(item => item.id);

const searchResults = {
searchResults = {
...searchResults,
results: joinedIndexed,
ids: sortedItemIds,
matchingIds: sortedMatchIds
matchingIds: sortedMatchIds,
};
setSearchResults(searchResults);
if (hitCounts?.length > 0) {
setSearchResults({
...searchResults,
counts: hitCounts,
});
}
setAllSearchResults(allSearchHits);

if (playerDispatch) {
if (showMarkers) {
Expand Down Expand Up @@ -228,14 +279,14 @@ export function useFilteredTranscripts({

/**
* Calculate the search hit count for each transcript in the canvas, when use type-in a search
* query
* query. Hit counts are cleared when search query is reset.
* @param {Object.searchResults} searchResults search result object from useFilteredTranscripts hook
* @param {Object.canvasTranscripts} canvasTranscripts a list of all the transcripts in the canvas
* @returns a list of all transcripts in the canvas with number of search hits for each transcript
*/
export const useSearchCounts = ({ searchResults, canvasTranscripts }) => {
if (!searchResults?.counts || canvasTranscripts?.length === 0) {
return { tanscriptHitCounts: canvasTranscripts };
export const useSearchCounts = ({ searchResults, canvasTranscripts, searchQuery }) => {
if (!searchResults?.counts || canvasTranscripts?.length === 0 || searchQuery === null) {
return canvasTranscripts;
}

const hitCounts = searchResults.counts;
Expand All @@ -245,7 +296,7 @@ export const useSearchCounts = ({ searchResults, canvasTranscripts }) => {
canvasTranscriptsWithCount.push({ ...ct, numberOfHits });
});

return { tanscriptHitCounts: canvasTranscriptsWithCount };
return canvasTranscriptsWithCount;
};

export const useFocusedMatch = ({ searchResults }) => {
Expand Down
Loading