web: implement virtualized search for all items

This commit is contained in:
Abdullah Atta
2023-11-11 13:05:03 +05:00
parent 4556e4d230
commit ae023bdf5b
19 changed files with 325 additions and 452 deletions

View File

@@ -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}
>

View File

@@ -19,11 +19,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 (
<Flex
sx={{ alignItems: "center", justifyContent: "center", mx: 1, my: 1 }}
>
<Field
data-test-id="search-input"
autoFocus
id="search"
name="search"
type="text"
sx={{ m: 0, flex: 1 }}
styles={{ input: { p: "7px" } }}
placeholder="Type your query here"
onChange={debounce(
(e) => useSearchStore.setState({ query: e.target.value }),
250
)}
action={{
icon: Close,
testId: "search-button",
onClick: () =>
useSearchStore.setState({
isSearching: false,
searchType: undefined
})
}}
/>
</Flex>
);
return (
<Flex mx={2} sx={{ flexDirection: "column", justifyContent: "center" }}>
<Flex sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Flex py={1} sx={{ alignItems: "center", justifyContent: "center" }}>
{buttons?.back ? (
<ArrowLeft
size={24}
{...buttons.back}
sx={{ flexShrink: 0, mr: 2, cursor: "pointer" }}
data-test-id="go-back"
/>
) : (
<Menu
onClick={() => toggleSideMenu(true)}
sx={{
flexShrink: 0,
ml: 0,
mr: 4,
mt: 1,
display: ["block", "none", "none"]
}}
size={30}
/>
)}
{titlePromise.status === "fulfilled" && titlePromise.value && (
<Text variant="heading" data-test-id="routeHeader" color="heading">
{titlePromise.value}
</Text>
)}
</Flex>
<Flex sx={{ flexShrink: 0 }}>
{buttons?.search && (
<Search
data-test-id={"open-search"}
size={24}
title={buttons.search.title}
onClick={() => navigate(`/search/${type}`)}
/>
)}
{!isMobile && buttons?.create && (
<Plus
data-test-id={`${type}-action-button`}
color="accentForeground"
size={18}
sx={{
bg: "accent",
ml: 2,
borderRadius: 100,
size: 28,
cursor: "pointer",
":hover": { boxShadow: "0px 0px 5px 0px var(--accent)" }
}}
{...buttons.create}
/>
)}
</Flex>
<Flex mx={2} sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Flex py={1} sx={{ alignItems: "center", justifyContent: "center" }}>
{buttons?.back ? (
<ArrowLeft
size={24}
{...buttons.back}
sx={{ flexShrink: 0, mr: 2, cursor: "pointer" }}
data-test-id="go-back"
/>
) : (
<Menu
onClick={() => toggleSideMenu(true)}
sx={{
flexShrink: 0,
ml: 0,
mr: 4,
mt: 1,
display: ["block", "none", "none"]
}}
size={30}
/>
)}
{titlePromise.status === "fulfilled" && titlePromise.value && (
<Text variant="heading" data-test-id="routeHeader" color="heading">
{titlePromise.value}
</Text>
)}
</Flex>
<Flex sx={{ flexShrink: 0 }}>
{buttons?.search && (
<Search
data-test-id={"open-search"}
size={24}
title={buttons.search.title}
onClick={() =>
useSearchStore.setState({ isSearching: true, searchType: type })
}
sx={{
size: 24,
cursor: "pointer"
}}
/>
)}
{!isMobile && buttons?.create && (
<Plus
data-test-id={`${type}-action-button`}
color="accentForeground"
size={18}
sx={{
bg: "accent",
ml: 2,
borderRadius: 100,
size: 28,
cursor: "pointer",
":hover": { boxShadow: "0px 0px 5px 0px var(--accent)" }
}}
{...buttons.create}
/>
)}
</Flex>
</Flex>
);

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { Search } from "../icons";
import Field from "../field";
import { debounce } from "@notesnook/common";
function SearchBox({ onSearch }) {
return (
<Field
data-test-id="search-input"
autoFocus
id="search"
name="search"
type="text"
sx={{ m: 0, mx: 2, mt: 1, mb: 1 }}
placeholder="Type your query here"
onChange={debounce((e) => 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;

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { DependencyList, useEffect, useState } from "react";
import { useStore as useSearchStore } from "../stores/search-store";
import { VirtualizedGrouping } from "@notesnook/core";
export function useSearch<T>(
type: "notes" | "notebooks" | "reminders" | "trash" | "tags",
lookup: (query: string) => Promise<VirtualizedGrouping<T>> | 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<VirtualizedGrouping<T>>();
useEffect(() => {
if (searchType !== type) return;
(async function () {
if (!query || !isSearching) return setFilteredItems(undefined);
setFilteredItems(await lookup(query));
})();
}, [isSearching, query, searchType, type, ...deps]);
return filteredItems;
}

View File

@@ -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<string | undefined>);
component: React.ReactNode;
props?: any;
@@ -225,20 +224,7 @@ const routes = defineRoutes({
}
}
});
},
"/search/:type": ({ type }) =>
defineRoute({
key: "general",
type: "search",
title: "Search",
component: () => <Search type={type} />,
buttons: {
back: {
title: `Go back to ${type}`,
onClick: () => window.history.back()
}
}
})
}
});
export default routes;

View File

@@ -18,20 +18,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import createStore from "../common/store";
import { db } from "../common/db";
import BaseStore from "./index";
/**
* @extends {BaseStore<SearchStore>}
*/
class SearchStore extends BaseStore {
results = [];
class SearchStore extends BaseStore<SearchStore> {
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);

View File

@@ -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 <Placeholder context="notes" />;
return (
@@ -52,7 +48,7 @@ function Home() {
group="home"
compact={isCompact}
refresh={refresh}
items={notes}
items={filteredItems || notes}
placeholder={<Placeholder context="notes" />}
button={{
onClick: () =>

View File

@@ -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={<Placeholder context="notes" />}
header={
<NotebookHeader

View File

@@ -22,11 +22,15 @@ import { useStore, store } from "../stores/notebook-store";
import { hashNavigate } from "../navigation";
import Placeholder from "../components/placeholders";
import { useEffect } from "react";
import { db } from "../common/db";
import { useSearch } from "../hooks/use-search";
function Notebooks() {
// useNavigate("notebooks", () => 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() {
<ListContainer
group="notebooks"
refresh={refresh}
items={notebooks}
items={filteredItems || notebooks}
placeholder={<Placeholder context="notebooks" />}
button={{
onClick: () => hashNavigate("/notebooks/create")

View File

@@ -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={
<Placeholder
context={

View File

@@ -22,18 +22,24 @@ import { useStore, store } from "../stores/reminder-store";
import { hashNavigate } from "../navigation";
import useNavigate from "../hooks/use-navigate";
import Placeholder from "../components/placeholders";
import { db } from "../common/db";
import { useSearch } from "../hooks/use-search";
function Reminders() {
useNavigate("reminders", () => 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 <Placeholder context="reminders" />;
return (
<>
<ListContainer
group="reminders"
refresh={refresh}
items={reminders}
items={filteredItems || reminders}
placeholder={<Placeholder context="reminders" />}
button={{
onClick: () => hashNavigate("/reminders/create")

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<>
<Text variant="subtitle" mx={2}>
Searching {title}
</Text>
<SearchBox onSearch={onSearch} />
{searchState.isSearching && results?.length === 0 ? (
<Flex
sx={{
flex: "1",
flexDirection: "column",
alignItems: "center",
justifyContent: "center"
}}
>
<Placeholder
context="search"
text={`Searching in ${searchState.totalItems} ${type}...`}
/>
</Flex>
) : (
<ListContainer
context={context}
group={type}
items={results}
placeholder={() => (
<Placeholder
context="search"
text={
cachedQuery.current
? `Nothing found for "${cachedQuery.current}"`
: undefined
}
/>
)}
/>
)}
</>
);
}
export default Search;

View File

@@ -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 <Placeholder context="tags" />;
return (
<ListContainer
group="tags"
refresh={refresh}
items={tags}
items={filteredItems || tags}
placeholder={<Placeholder context="tags" />}
/>
);

View File

@@ -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 <Placeholder context="trash" />;
return (
@@ -36,7 +39,7 @@ function Trash() {
group="trash"
refresh={refresh}
placeholder={<Placeholder context="trash" />}
items={items}
items={filteredItems || items}
button={{
onClick: function () {
confirm({

View File

@@ -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",
@@ -3036,6 +3037,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.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",

View File

@@ -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",

View File

@@ -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();

View File

@@ -17,29 +17,25 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<T> = {
weight?: number;
name: keyof T;
column: AnyColumnWithTable<DatabaseSchema, keyof DatabaseSchema>;
};
export default class Lookup {
constructor(private readonly db: Database) {}
async notes(
query: string,
ids?: string[]
): Promise<Note & { rank: number }[]> {
async notes(query: string, noteIds?: string[]) {
const db = this.db.sql() as Kysely<DatabaseSchemaWithFTS>;
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<Tag>, 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<string, TrashItem> = {};
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<T extends { title: string }>(array: T[], query: string) {
return search(array, query, (n) => n.title);
}
}
const uf = new uFuzzy();
function search<T>(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<string, number> = {};
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<TrashItem>(
ids,
this.db.options.batchSize,
async (ids) => {
const items: Record<string, TrashItem> = {};
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<T extends Item>(
selector: FilteredSelector<T>,
query: string,
fields: FuzzySearchField<T>[]
) {
const results: Record<string, number> = {};
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<T>(
ids,
this.db.options.batchSize,
async (ids) => selector.records(ids)
);
}
}

View File

@@ -347,21 +347,8 @@ export class FilteredSelector<T extends Item> {
console.timeEnd("getting items");
console.log(items.length);
const ids = groupArray(items, options);
return new VirtualizedGrouping<T>(
ids,
this.batchSize,
async (ids) => {
const results = await this.filter
.where("id", "in", ids)
.selectAll()
.execute();
const items: Record<string, T> = {};
for (const item of results) {
items[item.id] = item as T;
}
return items;
}
//(ids, items) => groupArray(ids, items, options)
return new VirtualizedGrouping<T>(ids, this.batchSize, (ids) =>
this.records(ids)
);
}
@@ -371,17 +358,9 @@ export class FilteredSelector<T extends Item> {
.select("id")
.execute();
const ids = items.map((item) => item.id);
return new VirtualizedGrouping<T>(ids, this.batchSize, async (ids) => {
const results = await this.filter
.where("id", "in", ids)
.selectAll()
.execute();
const items: Record<string, T> = {};
for (const item of results) {
items[item.id] = item as T;
}
return items;
});
return new VirtualizedGrouping<T>(ids, this.batchSize, (ids) =>
this.records(ids)
);
}
private buildSortExpression(options: SortOptions) {