From c848718fef8a3c5e5274b7880de04e9f087b5dd7 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Sat, 11 Nov 2023 13:05:03 +0500 Subject: [PATCH] web: implement virtualized search for all items --- apps/web/src/components/field/index.tsx | 5 +- .../src/components/route-container/index.tsx | 152 +++++++----- apps/web/src/components/search/index.jsx | 48 ---- apps/web/src/hooks/use-search.ts | 42 ++++ apps/web/src/navigation/routes.tsx | 18 +- .../{search-store.js => search-store.ts} | 27 ++- apps/web/src/views/all-notes.tsx | 22 +- apps/web/src/views/notebook.tsx | 11 +- apps/web/src/views/notebooks.tsx | 8 +- apps/web/src/views/notes.tsx | 14 +- apps/web/src/views/reminders.tsx | 8 +- apps/web/src/views/search.jsx | 217 ------------------ apps/web/src/views/tags.tsx | 5 +- apps/web/src/views/trash.tsx | 5 +- packages/core/package-lock.json | 9 + packages/core/package.json | 1 + packages/core/src/api/index.ts | 10 +- packages/core/src/api/lookup.ts | 144 ++++++++---- packages/core/src/database/sql-collection.ts | 31 +-- 19 files changed, 325 insertions(+), 452 deletions(-) delete mode 100644 apps/web/src/components/search/index.jsx create mode 100644 apps/web/src/hooks/use-search.ts rename apps/web/src/stores/{search-store.js => search-store.ts} (65%) delete mode 100644 apps/web/src/views/search.jsx diff --git a/apps/web/src/components/field/index.tsx b/apps/web/src/components/field/index.tsx index 22bace8aa..b9e30d72c 100644 --- a/apps/web/src/components/field/index.tsx +++ b/apps/web/src/components/field/index.tsx @@ -126,13 +126,10 @@ function Field(props: FieldProps) { sx={{ position: "absolute", right: "2px", - top: "2px", - cursor: "pointer", - bottom: "2px", px: 1, borderRadius: "default", ":hover": { bg: "border" }, - height: "calc(100% - 4px)" + height: "100%" }} disabled={action.disabled} > diff --git a/apps/web/src/components/route-container/index.tsx b/apps/web/src/components/route-container/index.tsx index 474a8e89a..b1ef2f9fc 100644 --- a/apps/web/src/components/route-container/index.tsx +++ b/apps/web/src/components/route-container/index.tsx @@ -19,11 +19,13 @@ along with this program. If not, see . import { PropsWithChildren } from "react"; import { Flex, Text } from "@theme-ui/components"; -import { ArrowLeft, Menu, Search, Plus } from "../icons"; +import { ArrowLeft, Menu, Search, Plus, Close } from "../icons"; import { useStore } from "../../stores/app-store"; +import { useStore as useSearchStore } from "../../stores/search-store"; import useMobile from "../../hooks/use-mobile"; -import { navigate } from "../../navigation"; import usePromise from "../../hooks/use-promise"; +import { debounce } from "@notesnook/common"; +import Field from "../field"; export type RouteContainerButtons = { search?: { @@ -64,63 +66,99 @@ function Header(props: RouteContainerProps) { ); const toggleSideMenu = useStore((store) => store.toggleSideMenu); const isMobile = useMobile(); + const isSearching = useSearchStore((store) => store.isSearching); + + if (isSearching) + return ( + + useSearchStore.setState({ query: e.target.value }), + 250 + )} + action={{ + icon: Close, + testId: "search-button", + onClick: () => + useSearchStore.setState({ + isSearching: false, + searchType: undefined + }) + }} + /> + + ); return ( - - - - {buttons?.back ? ( - - ) : ( - toggleSideMenu(true)} - sx={{ - flexShrink: 0, - ml: 0, - mr: 4, - mt: 1, - display: ["block", "none", "none"] - }} - size={30} - /> - )} - {titlePromise.status === "fulfilled" && titlePromise.value && ( - - {titlePromise.value} - - )} - - - {buttons?.search && ( - navigate(`/search/${type}`)} - /> - )} - {!isMobile && buttons?.create && ( - - )} - + + + {buttons?.back ? ( + + ) : ( + toggleSideMenu(true)} + sx={{ + flexShrink: 0, + ml: 0, + mr: 4, + mt: 1, + display: ["block", "none", "none"] + }} + size={30} + /> + )} + {titlePromise.status === "fulfilled" && titlePromise.value && ( + + {titlePromise.value} + + )} + + + {buttons?.search && ( + + useSearchStore.setState({ isSearching: true, searchType: type }) + } + sx={{ + size: 24, + cursor: "pointer" + }} + /> + )} + {!isMobile && buttons?.create && ( + + )} ); diff --git a/apps/web/src/components/search/index.jsx b/apps/web/src/components/search/index.jsx deleted file mode 100644 index 933f09028..000000000 --- a/apps/web/src/components/search/index.jsx +++ /dev/null @@ -1,48 +0,0 @@ -/* -This file is part of the Notesnook project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -import { Search } from "../icons"; -import Field from "../field"; -import { debounce } from "@notesnook/common"; - -function SearchBox({ onSearch }) { - return ( - onSearch(e.target.value), 250)} - action={{ - icon: Search, - testId: "search-button", - onClick: () => { - const searchField = document.getElementById("search"); - if (searchField && searchField.value && searchField.value.length) { - onSearch(searchField.value); - } - } - }} - /> - ); -} -export default SearchBox; diff --git a/apps/web/src/hooks/use-search.ts b/apps/web/src/hooks/use-search.ts new file mode 100644 index 000000000..6c4cbfc4d --- /dev/null +++ b/apps/web/src/hooks/use-search.ts @@ -0,0 +1,42 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { DependencyList, useEffect, useState } from "react"; +import { useStore as useSearchStore } from "../stores/search-store"; +import { VirtualizedGrouping } from "@notesnook/core"; + +export function useSearch( + type: "notes" | "notebooks" | "reminders" | "trash" | "tags", + lookup: (query: string) => Promise> | undefined, + deps: DependencyList = [] +) { + const isSearching = useSearchStore((store) => store.isSearching); + const query = useSearchStore((store) => store.query); + const searchType = useSearchStore((store) => store.searchType); + const [filteredItems, setFilteredItems] = useState>(); + + useEffect(() => { + if (searchType !== type) return; + (async function () { + if (!query || !isSearching) return setFilteredItems(undefined); + setFilteredItems(await lookup(query)); + })(); + }, [isSearching, query, searchType, type, ...deps]); + + return filteredItems; +} diff --git a/apps/web/src/navigation/routes.tsx b/apps/web/src/navigation/routes.tsx index 1bf5efabb..c457cebdb 100644 --- a/apps/web/src/navigation/routes.tsx +++ b/apps/web/src/navigation/routes.tsx @@ -21,7 +21,6 @@ import { db } from "../common/db"; import AllNotes from "../views/all-notes"; import Notebooks from "../views/notebooks"; import Notes from "../views/notes"; -import Search from "../views/search"; import Tags from "../views/tags"; import Notebook from "../views/notebook"; import { navigate } from "."; @@ -35,7 +34,7 @@ import { CREATE_BUTTON_MAP } from "../common"; type RouteResult = { key: string; - type: "notes" | "notebooks" | "reminders" | "trash" | "tags" | "search"; + type: "notes" | "notebooks" | "reminders" | "trash" | "tags"; title?: string | (() => Promise); component: React.ReactNode; props?: any; @@ -225,20 +224,7 @@ const routes = defineRoutes({ } } }); - }, - "/search/:type": ({ type }) => - defineRoute({ - key: "general", - type: "search", - title: "Search", - component: () => , - buttons: { - back: { - title: `Go back to ${type}`, - onClick: () => window.history.back() - } - } - }) + } }); export default routes; diff --git a/apps/web/src/stores/search-store.js b/apps/web/src/stores/search-store.ts similarity index 65% rename from apps/web/src/stores/search-store.js rename to apps/web/src/stores/search-store.ts index 7b500a68b..f7c18a4f5 100644 --- a/apps/web/src/stores/search-store.js +++ b/apps/web/src/stores/search-store.ts @@ -18,20 +18,25 @@ along with this program. If not, see . */ import createStore from "../common/store"; -import { db } from "../common/db"; import BaseStore from "./index"; -/** - * @extends {BaseStore} - */ -class SearchStore extends BaseStore { - results = []; +class SearchStore extends BaseStore { + isSearching = false; + query?: string; + searchType?: string; + // startSearch = () => { + // this.set({ isSearching: true }); + // }; - search = async (items, query) => { - const { type } = this.get(); - const results = await db.lookup[type](items, query); - this.set((state) => (state.results = results)); - }; + // endSearch = () => { + // this.set({ isSearching: false }); + // }; + // results = []; + // search = async (items, query) => { + // const { type } = this.get(); + // const results = await db.lookup[type](items, query); + // this.set((state) => (state.results = results)); + // }; } const [useStore, store] = createStore(SearchStore); diff --git a/apps/web/src/views/all-notes.tsx b/apps/web/src/views/all-notes.tsx index e7e355fb2..5b7d57f00 100644 --- a/apps/web/src/views/all-notes.tsx +++ b/apps/web/src/views/all-notes.tsx @@ -23,28 +23,24 @@ import ListContainer from "../components/list-container"; import { hashNavigate } from "../navigation"; import useNavigate from "../hooks/use-navigate"; import Placeholder from "../components/placeholders"; +import { useSearch } from "../hooks/use-search"; +import { db } from "../common/db"; function Home() { const notes = useStore((store) => store.notes); const isCompact = useStore((store) => store.viewMode === "compact"); const refresh = useStore((store) => store.refresh); const setContext = useStore((store) => store.setContext); + const filteredItems = useSearch("notes", (query) => { + if (useStore.getState().context) return; + return db.lookup.notes(query); + }); useNavigate("home", setContext); useEffect(() => { - (async function () { - await refresh(); - // const note = db.notes.note("62bc3f28a1a1a10000707077").data; - // const data = await db.content.raw(note.contentId); - // const note2 = db.notes.note("62bc3f1ca1a1a10000707075").data; - // const data2 = await db.content.raw(note2.contentId); - // const data3 = { ...data, conflicted: data2 }; - // await db.content.add(data3); - // await db.notes.add({ id: note.id, conflicted: true, resolved: false }); - // console.log(data3); - })(); - }, [refresh]); + useStore.getState().refresh(); + }, []); if (!notes) return ; return ( @@ -52,7 +48,7 @@ function Home() { group="home" compact={isCompact} refresh={refresh} - items={notes} + items={filteredItems || notes} placeholder={} button={{ onClick: () => diff --git a/apps/web/src/views/notebook.tsx b/apps/web/src/views/notebook.tsx index 274335929..33c77c02d 100644 --- a/apps/web/src/views/notebook.tsx +++ b/apps/web/src/views/notebook.tsx @@ -54,6 +54,7 @@ import { NotebookContext } from "../components/list-container/types"; import { FlexScrollContainer } from "../components/scroll-container"; import { Menu } from "../hooks/use-menu"; import Config from "../utils/config"; +import { useSearch } from "../hooks/use-search"; type NotebookProps = { rootId: string; @@ -70,6 +71,14 @@ function Notebook(props: NotebookProps) { const notes = useNotesStore((store) => store.contextNotes); const refreshContext = useNotesStore((store) => store.refreshContext); const isCompact = useNotesStore((store) => store.viewMode === "compact"); + const filteredItems = useSearch( + "notes", + (query) => { + if (!context || !notes || context.type !== "notebook") return; + return db.lookup.notes(query, notes.ungrouped); + }, + [context, notes] + ); useEffect(() => { const { context, setContext } = useNotesStore.getState(); @@ -126,7 +135,7 @@ function Notebook(props: NotebookProps) { refresh={refreshContext} compact={isCompact} context={context} - items={notes} + items={filteredItems || notes} placeholder={} header={ store.refresh()); const notebooks = useStore((state) => state.notebooks); const refresh = useStore((state) => state.refresh); + const filteredItems = useSearch("notebooks", (query) => + db.lookup.notebooks(query) + ); useEffect(() => { store.get().refresh(); @@ -38,7 +42,7 @@ function Notebooks() { } button={{ onClick: () => hashNavigate("/notebooks/create") diff --git a/apps/web/src/views/notes.tsx b/apps/web/src/views/notes.tsx index b0a0b277d..9dc5a14c8 100644 --- a/apps/web/src/views/notes.tsx +++ b/apps/web/src/views/notes.tsx @@ -22,6 +22,8 @@ import ListContainer from "../components/list-container"; import { useStore as useNotesStore } from "../stores/note-store"; import { hashNavigate, navigate } from "../navigation"; import Placeholder from "../components/placeholders"; +import { useSearch } from "../hooks/use-search"; +import { db } from "../common/db"; function Notes() { const context = useNotesStore((store) => store.context); @@ -29,6 +31,14 @@ function Notes() { const refreshContext = useNotesStore((store) => store.refreshContext); const type = context?.type === "favorite" ? "favorites" : "notes"; const isCompact = useNotesStore((store) => store.viewMode === "compact"); + const filteredItems = useSearch( + "notes", + (query) => { + if (!context || !contextNotes) return; + return db.lookup.notes(query, contextNotes.ungrouped); + }, + [context, contextNotes] + ); useEffect(() => { if ( @@ -36,7 +46,7 @@ function Notes() { contextNotes && contextNotes.ids.length <= 0 ) { - navigate("/", true); + navigate("/", { replace: true }); } }, [context, contextNotes]); @@ -47,7 +57,7 @@ function Notes() { refresh={refreshContext} compact={isCompact} context={context} - items={contextNotes} + items={filteredItems || contextNotes} placeholder={ store.refresh()); const reminders = useStore((state) => state.reminders); const refresh = useStore((state) => state.refresh); + const filteredItems = useSearch("reminders", (query) => + db.lookup.reminders(query) + ); + if (!reminders) return ; return ( <> } button={{ onClick: () => hashNavigate("/reminders/create") diff --git a/apps/web/src/views/search.jsx b/apps/web/src/views/search.jsx deleted file mode 100644 index 6c8daca9a..000000000 --- a/apps/web/src/views/search.jsx +++ /dev/null @@ -1,217 +0,0 @@ -/* -This file is part of the Notesnook project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ListContainer from "../components/list-container"; -import { db } from "../common/db"; -import SearchBox from "../components/search"; -import { useStore as useNoteStore } from "../stores/note-store"; -import { Flex, Text } from "@theme-ui/components"; -import { showToast } from "../utils/toast"; -import { store as notebookstore } from "../stores/notebook-store"; -import { hardNavigate } from "../navigation"; -import Placeholder from "../components/placeholders"; - -async function typeToItems(type, context) { - switch (type) { - case "notebook": { - const selectedNotebook = notebookstore.get().selectedNotebook; - if (!selectedNotebook) return ["notes", []]; - const notes = db.relations.to(selectedNotebook, "note"); - return ["notes", notes]; - } - case "notes": { - if (!context) return ["notes", db.notes.all]; - const notes = context.notes; - let topicNotes = []; - - if (context.type === "notebook") { - topicNotes = db.notebooks - .notebook(context.value.id) - .topics.all.map((topic) => { - return db.notes.topicReferences - .get(topic.id) - .map((id) => db.notes.note(id)?.data); - }) - .flat() - .filter( - (topicNote) => - notes.findIndex((note) => note?.id !== topicNote?.id) === -1 - ); - } - - return ["notes", [...notes, ...topicNotes]]; - } - case "notebooks": - return ["notebooks", db.notebooks.all]; - case "topics": { - const selectedNotebook = notebookstore.get().selectedNotebook; - if (!selectedNotebook) return ["topics", []]; - const topics = db.notebooks.notebook(selectedNotebook.id).topics.all; - return ["topics", topics]; - } - case "tags": - return ["tags", db.tags.all]; - case "reminders": - return ["reminders", db.reminders.all]; - case "trash": - return ["trash", db.trash.all]; - default: - return []; - } -} - -function Search({ type }) { - const [searchState, setSearchState] = useState({ - isSearching: false, - totalItems: 0 - }); - const [results, setResults] = useState([]); - const context = useNoteStore((store) => store.context); - const nonce = useNoteStore((store) => store.nonce); - const cachedQuery = useRef(); - - const onSearch = useCallback( - async (query) => { - if (!query) { - setResults([]); - return; - } - cachedQuery.current = query; - - const [lookupType, items] = await typeToItems(type, context); - - if (items.length <= 0) { - showToast("error", `There are no items to search in.`); - setResults([]); - return; - } - setSearchState({ isSearching: true, totalItems: items.length }); - const results = await db.lookup[lookupType](items, query); - setResults(results); - setSearchState({ isSearching: false, totalItems: 0 }); - }, - [context, type] - ); - - const title = useMemo(() => { - switch (type) { - case "notes": - if (!context) return "all notes"; - switch (context.type) { - case "topic": { - const notebook = db.notebooks.notebook(context.value.id); - const topic = notebook.topics.topic(context.value.topic); - return `notes of ${topic._topic.title} in ${notebook.title}`; - } - case "notebook": { - const notebook = db.notebooks.notebook(context.value.id); - return `notes in ${notebook.title}`; - } - case "tag": { - const tag = db.tags.all.find((tag) => tag.id === context.value); - return `notes in #${tag.title}`; - } - case "favorite": - return "favorite notes"; - case "monographs": - return "all monographs"; - case "color": { - const color = db.colors.find(context.value); - return `notes in color ${color.title}`; - } - default: - return; - } - case "notebooks": - return "all notebooks"; - case "notebook": - case "topics": { - const selectedNotebook = notebookstore.get().selectedNotebook; - if (!selectedNotebook) return ""; - const notebook = db.notebooks.notebook(selectedNotebook.id); - return `${type === "topics" ? "topics" : "notes"} in ${ - notebook.title - } notebook`; - } - case "tags": - return "all tags"; - case "reminders": - return "all reminders"; - case "trash": - return "all trash"; - default: - return ""; - } - }, [type, context]); - - useEffect(() => { - (async function () { - const [lookupType, items] = await typeToItems(type, context); - const results = await db.lookup[lookupType](items, cachedQuery.current); - setResults(results); - })(); - }, [nonce, type, context]); - - if (!title) { - hardNavigate("/"); - return null; - } - - return ( - <> - - Searching {title} - - - {searchState.isSearching && results?.length === 0 ? ( - - - - ) : ( - ( - - )} - /> - )} - - ); -} -export default Search; diff --git a/apps/web/src/views/tags.tsx b/apps/web/src/views/tags.tsx index ff64ef3c2..5ce6fc458 100644 --- a/apps/web/src/views/tags.tsx +++ b/apps/web/src/views/tags.tsx @@ -21,18 +21,21 @@ import ListContainer from "../components/list-container"; import { useStore, store } from "../stores/tag-store"; import useNavigate from "../hooks/use-navigate"; import Placeholder from "../components/placeholders"; +import { useSearch } from "../hooks/use-search"; +import { db } from "../common/db"; function Tags() { useNavigate("tags", () => store.refresh()); const tags = useStore((store) => store.tags); const refresh = useStore((store) => store.refresh); + const filteredItems = useSearch("tags", (query) => db.lookup.tags(query)); if (!tags) return ; return ( } /> ); diff --git a/apps/web/src/views/trash.tsx b/apps/web/src/views/trash.tsx index 60e943b79..352b79e0a 100644 --- a/apps/web/src/views/trash.tsx +++ b/apps/web/src/views/trash.tsx @@ -23,12 +23,15 @@ import { useStore, store } from "../stores/trash-store"; import { showToast } from "../utils/toast"; import useNavigate from "../hooks/use-navigate"; import Placeholder from "../components/placeholders"; +import { useSearch } from "../hooks/use-search"; +import { db } from "../common/db"; function Trash() { useNavigate("trash", store.refresh); const items = useStore((store) => store.trash); const refresh = useStore((store) => store.refresh); const clearTrash = useStore((store) => store.clear); + const filteredItems = useSearch("trash", (query) => db.lookup.trash(query)); if (!items) return ; return ( @@ -36,7 +39,7 @@ function Trash() { group="trash" refresh={refresh} placeholder={} - items={items} + items={filteredItems || items} button={{ onClick: function () { confirm({ diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 00eb65f00..6d0d6a919 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -18,6 +18,7 @@ "async-mutex": "^0.3.2", "dayjs": "1.11.9", "entities": "^4.3.1", + "fuzzyjs": "^5.0.1", "html-to-text": "^9.0.5", "htmlparser2": "^8.0.1", "katex": "0.16.2", @@ -2578,6 +2579,14 @@ "dev": true, "license": "ISC" }, + "node_modules/fuzzyjs": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fuzzyjs/-/fuzzyjs-5.0.1.tgz", + "integrity": "sha512-/BixxKEZ0sIQkIogNBhuwX7EC1gznFoR9jhrEU0cb89HUTH+tb2u9s86Rj//xGzpQg04A1aD9KDyGE+ldxgLFQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-func-name": { "version": "2.0.0", "dev": true, diff --git a/packages/core/package.json b/packages/core/package.json index ddeb62902..9fa471703 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,6 +56,7 @@ "async-mutex": "^0.3.2", "dayjs": "1.11.9", "entities": "^4.3.1", + "fuzzyjs": "^5.0.1", "html-to-text": "^9.0.5", "htmlparser2": "^8.0.1", "katex": "0.16.2", diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 931c6bb33..a4273cf4d 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -153,7 +153,7 @@ class Database { ); }; - options?: Options; + options!: Options; EventSource?: EventSourceConstructor; eventSource?: EventSource | null; @@ -226,6 +226,11 @@ class Database { } async init() { + if (!this.options) + throw new Error( + "options not specified. Did you forget to call db.setup()?" + ); + EV.subscribeMulti( [EVENTS.userLoggedIn, EVENTS.userFetched, EVENTS.tokenRefreshed], this.connectSSE, @@ -241,8 +246,7 @@ class Database { this.disconnectSSE(); }); - if (this.options) - this._sql = await createDatabase(this.options.sqliteOptions); + this._sql = await createDatabase(this.options.sqliteOptions); await this._validate(); diff --git a/packages/core/src/api/lookup.ts b/packages/core/src/api/lookup.ts index c5d153875..708dd4b9a 100644 --- a/packages/core/src/api/lookup.ts +++ b/packages/core/src/api/lookup.ts @@ -17,29 +17,25 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import uFuzzy from "@leeoniya/ufuzzy"; +import { match } from "fuzzyjs"; import Database from "."; -import { - Attachment, - GroupedItems, - Note, - Notebook, - Reminder, - Tag, - TrashItem -} from "../types"; -import { DatabaseSchemaWithFTS, isFalse } from "../database"; -import { Kysely, sql } from "kysely"; +import { Item, TrashItem } from "../types"; +import { DatabaseSchema, DatabaseSchemaWithFTS, isFalse } from "../database"; +import { AnyColumnWithTable, Kysely, sql } from "kysely"; +import { FilteredSelector } from "../database/sql-collection"; +import { VirtualizedGrouping } from "../utils/virtualized-grouping"; +type FuzzySearchField = { + weight?: number; + name: keyof T; + column: AnyColumnWithTable; +}; export default class Lookup { constructor(private readonly db: Database) {} - async notes( - query: string, - ids?: string[] - ): Promise { + async notes(query: string, noteIds?: string[]) { const db = this.db.sql() as Kysely; - return (await db + const ids = await db .with("matching", (eb) => eb .selectFrom("content_fts") @@ -54,51 +50,101 @@ export default class Lookup { ) ) .selectFrom("notes") - .$if(!!ids && ids.length > 0, (eb) => eb.where("id", "in", ids!)) + .$if(!!noteIds && noteIds.length > 0, (eb) => + eb.where("id", "in", noteIds!) + ) .where(isFalse("notes.deleted")) .where(isFalse("notes.dateDeleted")) .innerJoin("matching", (eb) => eb.onRef("notes.id", "==", "matching.id")) .orderBy("matching.rank") - .selectAll() - .execute()) as unknown as Note & { rank: number }[]; + .select(["notes.id"]) + .execute(); + + return new VirtualizedGrouping( + ids.map((id) => id.id), + this.db.options.batchSize, + (ids) => this.db.notes.all.records(ids) + ); } - notebooks(array: Notebook[], query: string) { - return search(array, query, (n) => `${n.title} ${n.description}}`); + notebooks(query: string) { + return this.search(this.db.notebooks.all, query, [ + { name: "id", column: "notebooks.id", weight: -100 }, + { name: "title", column: "notebooks.title", weight: 10 }, + { name: "description", column: "notebooks.description" } + ]); } - tags(array: GroupedItems, query: string) { - return this.byTitle(array, query); + tags(query: string) { + return this.search(this.db.tags.all, query, [ + { name: "id", column: "tags.id", weight: -100 }, + { name: "title", column: "tags.title" } + ]); } - reminders(array: Reminder[], query: string) { - return search(array, query, (n) => `${n.title} ${n.description || ""}`); + reminders(query: string) { + return this.search(this.db.reminders.all, query, [ + { name: "id", column: "reminders.id", weight: -100 }, + { name: "title", column: "reminders.title", weight: 10 }, + { name: "description", column: "reminders.description" } + ]); } - trash(array: TrashItem[], query: string) { - return this.byTitle(array, query); - } + async trash(query: string) { + const items = await this.db.trash.all(); + const records: Record = {}; + for (const item of items) records[item.id] = item; - attachments(array: Attachment[], query: string) { - return search(array, query, (n) => `${n.filename} ${n.mimeType} ${n.hash}`); - } - - private byTitle(array: T[], query: string) { - return search(array, query, (n) => n.title); - } -} - -const uf = new uFuzzy(); -function search(items: T[], query: string, selector: (item: T) => string) { - try { - const [_idxs, _info, order] = uf.search(items.map(selector), query, true); - if (!order) return []; - const filtered: T[] = []; - for (const i of order) { - filtered.push(items[i]); + const results: Record = {}; + for (const item of items) { + const result = match(query, item.title); + if (result.match) results[item.id] = result.score; } - return filtered; - } catch (e) { - return []; + + const ids = Object.keys(results).sort((a, b) => results[a] - results[b]); + return new VirtualizedGrouping( + ids, + this.db.options.batchSize, + async (ids) => { + const items: Record = {}; + for (const id of ids) items[id] = records[id]; + return items; + } + ); + } + + attachments(query: string) { + return this.search(this.db.attachments.all, query, [ + { name: "id", column: "attachments.id", weight: -100 }, + { name: "filename", column: "attachments.filename", weight: 5 }, + { name: "mimeType", column: "attachments.mimeType" }, + { name: "hash", column: "attachments.hash" } + ]); + } + + private async search( + selector: FilteredSelector, + query: string, + fields: FuzzySearchField[] + ) { + const results: Record = {}; + const columns = fields.map((f) => f.column); + for await (const item of selector.fields(columns)) { + for (const field of fields) { + const result = match(query, `${item[field.name]}`); + if (result.match) { + const oldScore = results[item.id] || 0; + results[item.id] = oldScore + result.score * (field.weight || 1); + } + } + } + selector.fields([]); + + const ids = Object.keys(results).sort((a, b) => results[a] - results[b]); + return new VirtualizedGrouping( + ids, + this.db.options.batchSize, + async (ids) => selector.records(ids) + ); } } diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts index 1f7a4ebf4..1ffc6d56d 100644 --- a/packages/core/src/database/sql-collection.ts +++ b/packages/core/src/database/sql-collection.ts @@ -347,21 +347,8 @@ export class FilteredSelector { console.timeEnd("getting items"); console.log(items.length); const ids = groupArray(items, options); - return new VirtualizedGrouping( - ids, - this.batchSize, - async (ids) => { - const results = await this.filter - .where("id", "in", ids) - .selectAll() - .execute(); - const items: Record = {}; - for (const item of results) { - items[item.id] = item as T; - } - return items; - } - //(ids, items) => groupArray(ids, items, options) + return new VirtualizedGrouping(ids, this.batchSize, (ids) => + this.records(ids) ); } @@ -371,17 +358,9 @@ export class FilteredSelector { .select("id") .execute(); const ids = items.map((item) => item.id); - return new VirtualizedGrouping(ids, this.batchSize, async (ids) => { - const results = await this.filter - .where("id", "in", ids) - .selectAll() - .execute(); - const items: Record = {}; - for (const item of results) { - items[item.id] = item as T; - } - return items; - }); + return new VirtualizedGrouping(ids, this.batchSize, (ids) => + this.records(ids) + ); } private buildSortExpression(options: SortOptions) {