mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-16 11:47:54 +01:00
web: implement virtualized search for all items
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
42
apps/web/src/hooks/use-search.ts
Normal file
42
apps/web/src/hooks/use-search.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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" />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
9
packages/core/package-lock.json
generated
9
packages/core/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user