Skip to content

Commit

Permalink
Merge pull request #532 from samvera-labs/refine-content-search
Browse files Browse the repository at this point in the history
Adjust content search when switching between transcripts
  • Loading branch information
Dananji authored Jun 27, 2024
2 parents 793a756 + 0a96e1b commit 0322404
Show file tree
Hide file tree
Showing 7 changed files with 462 additions and 139 deletions.
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

0 comments on commit 0322404

Please sign in to comment.