import { Tooltip } from '@mui/material'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils } from '../../../ClientUtils'; import { Doc, DocListCastAsync, Field, FieldType } from '../../../fields/Doc'; import { DirectLinks, DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { DocCast, StrCast } from '../../../fields/Types'; import { DocUtils } from '../../documents/DocUtils'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { SearchUtil } from '../../util/SearchUtil'; import { SnappingManager } from '../../util/SnappingManager'; import { undoBatch } from '../../util/UndoManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { ObservableReactComponent } from '../ObservableReactComponent'; import { CollectionDockingView } from '../collections/CollectionDockingView'; // import { IRecommendation, Recommendation } from '../newlightbox/components'; // import { fetchRecommendations } from '../newlightbox/utils'; import { DocumentView } from '../nodes/DocumentView'; import { FieldView, FieldViewProps } from '../nodes/FieldView'; import './SearchBox.scss'; const DAMPENING_FACTOR = 0.9; const MAX_ITERATIONS = 25; const ERROR = 0.03; export interface SearchBoxItemProps { Doc: Doc; searchString: string; isLinkSearch: boolean; matchedKeys: string[]; className: string; linkFrom: Doc | undefined; selectItem: (doc: Doc) => void; linkCreateAnchor?: () => Doc | undefined; linkCreated?: (link: Doc) => void; } @observer export class SearchBoxItem extends ObservableReactComponent { constructor(props: SearchBoxItemProps) { super(props); makeObservable(this); } /** * @param {Doc} doc - doc to be selected * * This method selects a doc by either jumping to it (centering/zooming in on it) * or opening it in a new tab. */ selectElement = (doc: Doc, finishFunc: () => void) => DocumentView.showDocument(doc, { willPan: true }, finishFunc); /** * @param {Doc} doc - doc of the search result that has been clicked on * * This method is called when the user clicks on a search result. The _selectedResult is * updated accordingly and the doc is highlighted with the selectElement method. */ onResultClick = action(async (doc: Doc) => { this._props.selectItem(doc); this.selectElement(doc, () => DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.(this._props.searchString, undefined, false)); }); componentWillUnmount(): void { const doc = this._props.Doc; DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.('', undefined, true); } @undoBatch makeLink = action((linkTo: Doc) => { const linkFrom = this._props.linkCreateAnchor?.(); if (linkFrom) { const link = DocUtils.MakeLink(linkFrom, linkTo, {}); link && this._props.linkCreated?.(link); } }); render() { // eslint-disable-next-line no-use-before-define const formattedType = SearchBox.formatType(StrCast(this._props.Doc.type), StrCast(this._props.Doc.type_collection)); const { title } = this._props.Doc; return ( {title as string}}>
this.makeLink(this._props.Doc) : e => { this.onResultClick(this._props.Doc); e.stopPropagation(); } } style={{ fontWeight: Doc.Links(this._props.linkFrom).find( link => Doc.AreProtosEqual(Doc.getOppositeAnchor(link, this._props.linkFrom!), this._props.Doc) || Doc.AreProtosEqual(DocCast(Doc.getOppositeAnchor(link, this._props.linkFrom!)?.annotationOn), this._props.Doc) ) ? 'bold' : '', }} className={this._props.className}>
{title as string}
{formattedType}
{this._props.matchedKeys.join(', ')}
); } } export interface SearchBoxProps extends FieldViewProps { linkSearch: boolean; linkFrom?: (() => Doc | undefined) | undefined; linkCreateAnchor?: () => Doc | undefined; linkCreated?: (link: Doc) => void; } /** * This is the SearchBox component. It represents the search box input and results in * the search panel on the left side of the screen. */ @observer export class SearchBox extends ViewBoxBaseComponent() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(SearchBox, fieldKey); } // eslint-disable-next-line no-use-before-define public static Instance: SearchBox; private _inputRef = React.createRef(); @observable _searchString = ''; @observable _docTypeString = 'all'; @observable _results: Map = new Map(); // @observable _recommendations: IRecommendation[] = []; @observable _pageRanks: Map = new Map(); @observable _linkedDocsOut: Map> = new Map>(); @observable _linkedDocsIn: Map> = new Map>(); @observable _selectedResult: Doc | undefined = undefined; @observable _deletedDocsStatus: boolean = false; @observable _onlyEmbeddings: boolean = true; constructor(props: SearchBoxProps) { super(props); makeObservable(this); SearchBox.Instance = this; } /** * This method is called when the SearchBox component is first mounted. When the user opens * the search panel, the search input box is automatically selected. This allows the user to * type in the search input box immediately, without needing clicking on it first. */ componentDidMount() { if (this._inputRef.current) { this._inputRef.current.focus(); } } /** * This method is called when the SearchBox component is about to be unmounted. When the user * closes the search panel, the search and its results are reset. */ componentWillUnmount() { this.resetSearch(); } /** * This method is called when the text in the search input box is modified by the user. The * _searchString is updated to the new value of the text in the input box and submitSearch * is called to update the search results accordingly. * * (Note: There is no longer a need to press enter to submit a search. Any update to the input * causes a search to be submitted automatically.) */ _timeout: NodeJS.Timeout | undefined = undefined; onInputChange = action((e: React.ChangeEvent) => { this._searchString = e.target.value; this._timeout && clearTimeout(this._timeout); this._timeout = setTimeout(() => this.submitSearch(), 300); }); /** * This method is called when the option in the select drop-down menu is changed. The * _docTypeString is updated to the new value of the option in the drop-down menu. This * is used to filter the results of the search to documents of a specific type. * * (Note: This doesn't affect the results array, so there is no need to submit a new * search here. The results of the search on the _searchString query are simply filtered * by type directly before rendering them.) */ onSelectChange = action((e: React.ChangeEvent) => { this._docTypeString = e.target.value; }); /** * @param {Doc[]} docs - docs to be searched through recursively * @param {number, Doc => void} func - function to be called on each doc * * This method iterates asynchronously through an array of docs and all docs within those * docs, calling the function func on each doc. */ static async foreachRecursiveDocAsync(docsIn: Doc[], func: (depth: number, doc: Doc) => void) { let docs = docsIn; let newarray: Doc[] = []; let depth = 0; while (docs.length > 0) { newarray = []; // eslint-disable-next-line no-await-in-loop await Promise.all( docs .filter(d => d) // eslint-disable-next-line no-loop-func .map(async d => { const fieldKey = Doc.LayoutDataKey(d); const annos = !Field.toString(Doc.LayoutField(d) as FieldType).includes('CollectionView'); const data = d[annos ? fieldKey + '_annotations' : fieldKey]; const dataDocs = await DocListCastAsync(data); dataDocs && newarray.push(...dataDocs); func(depth, d); }) ); docs = newarray; depth++; } } /** * @param {String} type - string representing the type of a doc * * This method converts a doc type string of any length to a 3-letter doc type string in * which the first letter is capitalized. This is used when displaying the type on the * right side of each search result. */ static formatType(type: string, colType: string): string { switch (type) { case DocumentType.PDF : return 'PDF'; case DocumentType.IMG : return 'Img'; case DocumentType.RTF : return 'Rtf'; case DocumentType.COL : return 'Col:'+colType.substring(0,3); default: } // prettier-ignore return type.charAt(0).toUpperCase() + type.substring(1, 3); } /** * @param {String} query - search query string * * This method searches the CollectionDockingView instance for a certain query and puts * the matching results in the results array. Docs are considered to be matching results * when the query is a substring of many different pieces of its metadata (title, text, * author, etc). */ @action searchCollection(query: string) { this._selectedResult = undefined; this._results = SearchUtil.SearchCollection(CollectionDockingView.Instance?.Document, query, this._docTypeString === 'keys'); this.computePageRanks(); } /** * This method initializes the page rank of every document to the reciprocal * of the number of documents in the collection. */ @action initializePageRanks() { this._pageRanks.clear(); this._linkedDocsOut.clear(); this._results.forEach((_, doc) => { this._linkedDocsIn.set(doc, new Set()); }); this._results.forEach((_, doc) => { this._pageRanks.set(doc, 1.0 / this._results.size); if (doc[DocData][DirectLinks].size === 0) { this._linkedDocsOut.set(doc, new Set(this._results.keys())); this._results.forEach((__, linkedDoc) => { this._linkedDocsIn.get(linkedDoc)?.add(doc); }); } else { const linkedDocSet = new Set(); doc[DocData][DirectLinks].forEach(link => { const d1 = link?.link_anchor_1 as Doc; const d2 = link?.link_anchor_2 as Doc; if (doc === d1 && this._results.has(d2)) { linkedDocSet.add(d2); this._linkedDocsIn.get(d2)?.add(doc); } else if (doc === d2 && this._results.has(d1)) { linkedDocSet.add(d1); this._linkedDocsIn.get(d1)?.add(doc); } }); this._linkedDocsOut.set(doc, linkedDocSet); } }); } /** * This method runs one complete iteration of the page rank algorithm. It * returns true iff all page ranks have converged (i.e. changed by less than * the _error value), which means that the algorithm should terminate. * * @return true if page ranks have converged; false otherwise */ @action pageRankIteration(): boolean { let converged = true; const pageRankFromAll = (1 - DAMPENING_FACTOR) / this._results.size; const nextPageRanks = new Map(); this._results.forEach((_, doc) => { let nextPageRank = pageRankFromAll; this._linkedDocsIn.get(doc)?.forEach(linkedDoc => { nextPageRank += (DAMPENING_FACTOR * (this._pageRanks.get(linkedDoc) ?? 0)) / (this._linkedDocsOut.get(linkedDoc)?.size ?? 1); }); nextPageRanks.set(doc, nextPageRank); if (Math.abs(nextPageRank - (this._pageRanks.get(doc) ?? 0)) > ERROR) { converged = false; } }); this._pageRanks = nextPageRanks; return converged; } /** * This method performs the page rank algorithm on the graph of documents * that match the search query. Vertices are documents and edges are links * between documents. */ @action computePageRanks() { this.initializePageRanks(); for (let i = 0; i < MAX_ITERATIONS; i++) { if (this.pageRankIteration()) { break; } } } /** * This method submits a search with the _searchString as its query and updates * the results array accordingly. */ @action submitSearch = async () => { this.resetSearch(); const query = StrCast(this._searchString); Doc.SetSearchQuery(query); if (!this._props.linkSearch) Array.from(this._results.keys()).forEach(doc => DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.(this._searchString, undefined, true)); this._results.clear(); if (query) { this.searchCollection(query); // const response = await fetchRecommendations('', query, [], true); // const recs = response.recommendations as any[]; // const recommendations: IRecommendation[] = []; // recs.forEach(rec => { // const { title, url, type, text, transcript, previewUrl, embedding, distance, source, related_concepts: relatedConcepts, doc_id: docId } = rec; // recommendations.push({ // title, // data: url, // type, // text, // transcript, // previewUrl, // embedding, // distance: Math.round(distance * 100) / 100, // source: source, // related_concepts: relatedConcepts, // docId, // }); // }); // const setRecommendations = action(() => { // this._recommendations = recommendations; // }); // setRecommendations(); } }; /** * This method resets the search by iterating through each result and removing all * brushes and highlights. All search matches are cleared as well. */ resetSearch = action(() => { this._timeout && clearTimeout(this._timeout); this._timeout = undefined; this._results.forEach((_, doc) => { DocumentView.getFirstDocumentView(doc)?.ComponentView?.search?.('', undefined, true); Doc.UnBrushDoc(doc); Doc.UnHighlightDoc(doc); Doc.ClearSearchMatches(); }); }); /** * This method returns a JSX list of the options in the select drop-down menu, which * is used to filter the types of documents that appear in the search results. */ @computed public get selectOptions() { const selectValues = ['all', DocumentType.RTF, DocumentType.IMG, DocumentType.PDF, DocumentType.WEB, DocumentType.VID, DocumentType.AUDIO, DocumentType.COL, 'keys']; return selectValues.map(value => ( )); } /** * This method renders the search input box, select drop-down menu, and search results. */ render() { const isLinkSearch: boolean = this._props.linkSearch; const sortedResults = Array.from(this._results.entries()).sort((a, b) => (this._pageRanks.get(b[0]) ?? 0) - (this._pageRanks.get(a[0]) ?? 0)); // sorted by page rank const resultsJSX = [] as JSX.Element[]; const linkFrom = this._props.linkFrom?.(); let validResults = 0; sortedResults.forEach(([Document, matchedKeys]) => { let className = 'searchBox-results-scroll-view-result'; if (this._selectedResult === Document) { className += ' searchBox-results-scroll-view-result-selected'; } if (this._docTypeString === 'keys' || this._docTypeString === 'all' || this._docTypeString === Document.type) { validResults++; resultsJSX.push( { this._selectedResult = doc; })} isLinkSearch={isLinkSearch} searchString={this._searchString} matchedKeys={matchedKeys} linkFrom={linkFrom} className={className} linkCreateAnchor={this._props.linkCreateAnchor} linkCreated={this._props.linkCreated} /> ); } }); const recommendationsJSX: JSX.Element[] = []; // this._recommendations.map(props => ); return (
{isLinkSearch ? null : ( )} { e.key === 'Enter' ? this.submitSearch() : null; e.stopPropagation(); }} type="text" placeholder="Search..." id="search-input" className="searchBox-input" style={{ width: isLinkSearch ? '100%' : undefined, borderRadius: isLinkSearch ? '5px' : undefined }} ref={this._inputRef} />
{resultsJSX.length > 0 && (
Results
{`${validResults} result` + (validResults === 1 ? '' : 's')}
{resultsJSX}
)} {recommendationsJSX.length > 0 && (
Recommendations
{`${validResults} result` + (validResults === 1 ? '' : 's')}
{recommendationsJSX}
)}
); } } Docs.Prototypes.TemplateMap.set(DocumentType.SEARCH, { layout: { view: SearchBox, dataField: 'data' }, options: { acl: '', _width: 400 }, });