From ebf20c31c838fa35241d676c6e8269acfe851702 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Tue, 21 Nov 2023 13:16:49 +0500 Subject: [PATCH] web: add support for assigning tags to notes --- apps/web/src/components/editor/header.tsx | 171 ++++++++++------- apps/web/src/components/field/index.tsx | 2 +- .../src/components/filtered-list/index.tsx | 111 ++++------- .../list-container/resolved-item.tsx | 26 ++- .../src/components/virtualized-list/index.tsx | 23 ++- apps/web/src/dialogs/add-tags-dialog.tsx | 160 +++++++--------- apps/web/src/hooks/use-promise.ts | 9 +- apps/web/src/stores/editor-store.ts | 41 +--- apps/web/src/views/all-notes.tsx | 2 +- apps/web/src/views/auth.tsx | 39 +--- apps/web/src/views/notebook.tsx | 2 +- apps/web/src/views/notebooks.tsx | 2 +- apps/web/src/views/notes.tsx | 2 +- apps/web/src/views/reminders.tsx | 2 +- apps/web/src/views/tags.tsx | 4 +- apps/web/src/views/trash.tsx | 4 +- packages/core/src/api/lookup.ts | 180 ++++++++++++------ packages/core/src/collections/tags.ts | 17 +- packages/core/src/database/sql-collection.ts | 15 +- 19 files changed, 415 insertions(+), 397 deletions(-) diff --git a/apps/web/src/components/editor/header.tsx b/apps/web/src/components/editor/header.tsx index 77430e7c8..9ff804723 100644 --- a/apps/web/src/components/editor/header.tsx +++ b/apps/web/src/components/editor/header.tsx @@ -19,21 +19,56 @@ along with this program. If not, see . import { useCallback, useEffect, useRef } from "react"; import { useStore } from "../../stores/editor-store"; +import { useStore as useTagStore } from "../../stores/tag-store"; +import { useStore as useNoteStore } from "../../stores/note-store"; import { Input } from "@theme-ui/components"; -import { Tag, Plus } from "../icons"; +import { Tag as TagIcon, Plus } from "../icons"; import { Flex } from "@theme-ui/components"; import IconTag from "../icon-tag"; import { db } from "../../common/db"; import { useMenuTrigger } from "../../hooks/use-menu"; import { MenuItem } from "@notesnook/ui"; import { navigate } from "../../navigation"; +import { Tag } from "@notesnook/core"; +import usePromise from "../../hooks/use-promise"; type HeaderProps = { readonly: boolean }; function Header(props: HeaderProps) { const { readonly } = props; const id = useStore((store) => store.session.id); const tags = useStore((store) => store.tags); - const setTag = useStore((store) => store.setTag); + const refreshTags = useStore((store) => store.refreshTags); + + useEffect(() => { + if (!id) return; + refreshTags(); + }, [id, refreshTags]); + + const defaultTags = usePromise(() => + db.tags.all + .limit(10) + .items(undefined, { sortBy: "dateCreated", sortDirection: "desc" }) + ); + + const setTag = useCallback( + async function (noteId: string, tags: Tag[], value: string) { + const oldTag = tags.find((t) => t.title === value); + if (oldTag) { + await db.relations.unlink(oldTag, { type: "note", id: noteId }); + } else { + const id = await db.tags.add({ title: value }); + await db.relations.add( + { id, type: "tag" }, + { type: "note", id: noteId } + ); + await useTagStore.getState().refresh(); + if (defaultTags.status === "fulfilled") defaultTags.refresh(); + } + await refreshTags(); + await useNoteStore.getState().refresh(); + }, + [refreshTags, defaultTags] + ); return ( <> @@ -42,32 +77,59 @@ function Header(props: HeaderProps) { sx={{ lineHeight: 2.5, alignItems: "center", flexWrap: "wrap" }} data-test-id="tags" > - {tags.map((tag) => ( + {tags?.map((tag) => ( navigate(`/tags/${tag.id}`)} - onDismiss={readonly ? undefined : () => setTag(tag.title)} + onDismiss={ + readonly ? undefined : () => setTag(id, tags, tag.title) + } styles={{ container: { mr: 1 }, text: { fontSize: "body" } }} /> ))} - {!readonly && ( + {!readonly && tags && defaultTags.status === "fulfilled" ? ( - db.lookup?.tags(tags, query).slice(0, 10) || [] - } - onAdd={(value) => setTag(value)} - onSelect={(item) => setTag(item.title)} + filter={(query) => db.lookup.tags(query).items(10)} + toMenuItems={(filtered, reset, query) => { + const items: MenuItem[] = []; + const isExactMatch = + !!query && filtered.some((item) => item.title === query); + if (query && !isExactMatch) { + items.push({ + type: "button", + key: "new", + title: `Create "${query}" tag`, + icon: Plus.path, + onClick: () => setTag(id, tags, query).finally(reset) + }); + } + + if (filtered.length > 0) { + items.push( + ...filtered.map((item) => ({ + type: "button" as const, + key: item.id, + title: item.title, + icon: TagIcon.path, + onClick: () => setTag(id, tags, item.title).finally(reset) + })) + ); + } + + return items; + }} + onAdd={(value) => setTag(id, tags, value)} onRemove={() => { if (tags.length <= 0) return; - setTag(tags[tags.length - 1].title); + setTag(id, tags, tags[tags.length - 1].title); }} - defaultItems={tags.slice(0, 10) || []} + defaultItems={defaultTags.value} /> - )} + ) : null} )} @@ -75,19 +137,19 @@ function Header(props: HeaderProps) { } export default Header; -type AutosuggestProps = { +type AutosuggestProps = { sessionId: string; - filter: (query: string) => any[]; + filter: (query: string) => Promise; onRemove: () => void; - onSelect: (item: any) => void; - onAdd: (item: any) => void; - defaultItems: any[]; + onAdd: (text: string) => void; + toMenuItems: (filtered: T[], reset: () => void, query?: string) => MenuItem[]; + defaultItems: T[]; }; -export function Autosuggest(props: AutosuggestProps) { - const { sessionId, filter, onRemove, onSelect, onAdd, defaultItems } = props; +export function Autosuggest(props: AutosuggestProps) { + const { sessionId, filter, onRemove, onAdd, defaultItems, toMenuItems } = + props; const inputRef = useRef(null); const arrowDown = useRef(); - const filteredItems = useRef([]); const { openMenu, closeMenu, isOpen } = useMenuTrigger(); const clearInput = useCallback(() => { if (!inputRef.current) return; @@ -100,53 +162,22 @@ export function Autosuggest(props: AutosuggestProps) { return inputRef.current.value.trim().toLowerCase(); }, []); - const onAction = useCallback( - (type, value) => { - if (type === "select") { - onSelect(value); - } else if (type === "add") { - onAdd(value); - } - clearInput(); - closeMenu(); - }, - [clearInput, closeMenu, onSelect, onAdd] - ); + const reset = useCallback(() => { + clearInput(); + closeMenu(); + if (inputRef.current) inputRef.current.focus(); + }, [clearInput, closeMenu]); const onOpenMenu = useCallback( - (filtered: any[]) => { + async (filtered: T[]) => { const filterText = getInputValue(); - const items: MenuItem[] = []; if (!filterText && filtered.length <= 0) { closeMenu(); return; } - const isExactMatch = filtered.some((item) => item.title === filterText); - if (filterText && !isExactMatch) { - items.push({ - type: "button", - key: "new", - title: `Create "${filterText}" tag`, - icon: Plus.path, - onClick: () => onAction("add", filterText) - }); - } - - if (filtered.length > 0) { - items.push( - ...filtered.map((tag) => ({ - type: "button" as const, - key: tag.id, - title: tag.alias, - icon: Tag.path, - onClick: () => onAction("select", tag) - })) - ); - } - - openMenu(items, { + openMenu(toMenuItems(filtered, reset, filterText), { blocking: true, position: { target: inputRef.current, @@ -154,9 +185,8 @@ export function Autosuggest(props: AutosuggestProps) { location: "below" } }); - filteredItems.current = filtered; }, - [closeMenu, getInputValue, onAction, openMenu] + [closeMenu, getInputValue, openMenu, reset, toMenuItems] ); useEffect(() => { @@ -179,26 +209,27 @@ export function Autosuggest(props: AutosuggestProps) { data-test-id="editor-tag-input" onFocus={() => { const text = getInputValue(); - if (!text) onOpenMenu(defaultItems.slice()); - else onOpenMenu([]); + if (!text) onOpenMenu(defaultItems); + else closeMenu(); }} onClick={() => { const text = getInputValue(); - if (!text) onOpenMenu(defaultItems.slice()); - else onOpenMenu([]); + if (!text) onOpenMenu(defaultItems); + else closeMenu(); }} - onChange={(e) => { + onChange={async (e) => { const { value } = e.target; if (!value.length) { closeMenu(); return; } - onOpenMenu(filter(value)); + onOpenMenu(await filter(value)); }} onKeyDown={(e) => { const text = getInputValue(); if (e.key === "Enter" && !!text && isOpen && !arrowDown.current) { - onAction("add", text); + onAdd(text); + reset(); } else if (!text && e.key === "Backspace") { onRemove(); closeMenu(); @@ -208,7 +239,7 @@ export function Autosuggest(props: AutosuggestProps) { e.stopPropagation(); } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { arrowDown.current = true; - if (e.key === "ArrowDown" && !text) onOpenMenu(defaultItems.slice()); + if (e.key === "ArrowDown" && !text) onOpenMenu(defaultItems); e.preventDefault(); } else if (e.key === "Tab") { diff --git a/apps/web/src/components/field/index.tsx b/apps/web/src/components/field/index.tsx index b9e30d72c..0b1f092a0 100644 --- a/apps/web/src/components/field/index.tsx +++ b/apps/web/src/components/field/index.tsx @@ -24,7 +24,7 @@ import { ThemeUIStyleObject } from "@theme-ui/css"; import { PasswordVisible, PasswordInvisible, Icon } from "../icons"; import { useStore as useThemeStore } from "../../stores/theme-store"; -type FieldProps = InputProps & { +export type FieldProps = InputProps & { label?: string; helpText?: string; inputRef?: React.Ref; diff --git a/apps/web/src/components/filtered-list/index.tsx b/apps/web/src/components/filtered-list/index.tsx index e3591b92b..5d030fe6b 100644 --- a/apps/web/src/components/filtered-list/index.tsx +++ b/apps/web/src/components/filtered-list/index.tsx @@ -17,69 +17,41 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import Field from "../field"; import { Plus, Search } from "../icons"; -import { Button, Flex, Text } from "@theme-ui/components"; - -// type FilterableItem = { -// id: string; -// title: string; -// }; +import { Button, Text } from "@theme-ui/components"; +import { VirtualizedList, VirtualizedListProps } from "../virtualized-list"; type FilteredListProps = { placeholders: { filter: string; empty: string }; - items: () => Promise; - filter: (items: T[], query: string) => T[]; + filter: (query: string) => Promise; onCreateNewItem: (title: string) => Promise; - renderItem: ( - item: T, - index: number, - refresh: () => void, - isSearching: boolean - ) => JSX.Element; -}; +} & VirtualizedListProps; export function FilteredList(props: FilteredListProps) { - const { - items: _items, - filter, - onCreateNewItem, - placeholders, - renderItem - } = props; + const { items, filter, onCreateNewItem, placeholders, ...listProps } = props; - const [items, setItems] = useState([]); + const [filteredItems, setFilteredItems] = useState([]); const [query, setQuery] = useState(); - const noItemsFound = items.length <= 0 && query && query.length > 0; + const noItemsFound = filteredItems.length <= 0 && query && query.length > 0; const inputRef = useRef(null); - const refresh = useCallback(async () => { - setItems(await _items()); - }, [_items]); - - useEffect(() => { - refresh(); - }, [refresh]); - const _filter = useCallback( async (query) => { - const items = await _items(); - if (!query) return; - setItems(filter(items, query)); + setFilteredItems(query ? await filter(query) : []); setQuery(query); }, - [_items, filter] + [filter] ); const _createNewItem = useCallback( async (title) => { await onCreateNewItem(title); - await refresh(); setQuery(undefined); if (inputRef.current) inputRef.current.value = ""; }, - [inputRef, refresh, onCreateNewItem] + [inputRef, onCreateNewItem] ); return ( @@ -89,18 +61,16 @@ export function FilteredList(props: FilteredListProps) { data-test-id={"filter-input"} autoFocus placeholder={ - items.length <= 0 ? placeholders.empty : placeholders.filter + filteredItems.length <= 0 ? placeholders.empty : placeholders.filter } - onChange={(e: ChangeEvent) => - _filter((e.target as HTMLInputElement).value) - } - onKeyUp={async (e: KeyboardEvent) => { + onChange={(e) => _filter((e.target as HTMLInputElement).value)} + onKeyUp={async (e) => { if (e.key === "Enter" && noItemsFound) { await _createNewItem(query); } }} action={ - items.length <= 0 + filteredItems.length <= 0 && !!query ? { icon: Plus, onClick: async () => await _createNewItem(query) @@ -108,38 +78,25 @@ export function FilteredList(props: FilteredListProps) { : { icon: Search, onClick: () => _filter(query) } } /> - - {noItemsFound && ( - - )} - {items.map((item, index) => renderItem(item, index, refresh, !!query))} - + {noItemsFound ? ( + + ) : ( + + )} ); } diff --git a/apps/web/src/components/list-container/resolved-item.tsx b/apps/web/src/components/list-container/resolved-item.tsx index c59baf202..1f86ade6d 100644 --- a/apps/web/src/components/list-container/resolved-item.tsx +++ b/apps/web/src/components/list-container/resolved-item.tsx @@ -17,7 +17,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { Color, Item, Reminder, VirtualizedGrouping } from "@notesnook/core"; +import { + Color, + Item, + ItemMap, + ItemType, + Reminder, + VirtualizedGrouping +} from "@notesnook/core"; import usePromise from "../../hooks/use-promise"; import { NotebooksWithDateEdited, @@ -27,17 +34,24 @@ import { import { db } from "../../common/db"; import React from "react"; -type ResolvedItemProps = { - items: VirtualizedGrouping; +type ResolvedItemProps = { + type: TItemType; + items: VirtualizedGrouping; id: string; - children: (item: { item: Item; data: unknown }) => React.ReactNode; + children: (item: { + item: ItemMap[TItemType]; + data: unknown; + }) => React.ReactNode; }; -export function ResolvedItem(props: ResolvedItemProps) { - const { id, items, children } = props; +export function ResolvedItem( + props: ResolvedItemProps +) { + const { id, items, children, type } = props; const result = usePromise(() => items.item(id, resolveItems), [id, items]); if (result.status !== "fulfilled" || !result.value) return null; + if (result.value.item.type !== type) return null; return <>{children(result.value)}; } diff --git a/apps/web/src/components/virtualized-list/index.tsx b/apps/web/src/components/virtualized-list/index.tsx index 5999e406b..d883a56a4 100644 --- a/apps/web/src/components/virtualized-list/index.tsx +++ b/apps/web/src/components/virtualized-list/index.tsx @@ -21,18 +21,19 @@ import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; import { Box, BoxProps } from "@theme-ui/components"; import React, { useRef } from "react"; -type VirtualizedListProps = { +export type VirtualizedListProps = { virtualizerRef?: React.MutableRefObject< Virtualizer | undefined >; mode?: "fixed" | "dynamic"; items: T[]; estimatedSize: number; - getItemKey: (index: number) => string; + getItemKey: (index: number, items: T[]) => string; scrollElement?: Element | null; itemWrapperProps?: (item: T, index: number) => BoxProps; renderItem: (props: { item: T; index: number }) => JSX.Element | null; scrollMargin?: number; + itemGap?: number; } & BoxProps; export function VirtualizedList(props: VirtualizedListProps) { const { @@ -45,29 +46,37 @@ export function VirtualizedList(props: VirtualizedListProps) { mode, virtualizerRef, itemWrapperProps, + itemGap, ...containerProps } = props; const containerRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, - estimateSize: () => estimatedSize, - getItemKey, + estimateSize: () => estimatedSize + (itemGap || 0), + getItemKey: (index) => getItemKey(index, items), getScrollElement: () => scrollElement || containerRef.current?.closest(".ms-container") || null, - scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0 + scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0, + overscan: 5 }); if (virtualizerRef) virtualizerRef.current = virtualizer; const virtualItems = virtualizer.getVirtualItems(); return ( - + {virtualItems.map((row) => ( diff --git a/apps/web/src/dialogs/add-tags-dialog.tsx b/apps/web/src/dialogs/add-tags-dialog.tsx index 880853a88..f611e7564 100644 --- a/apps/web/src/dialogs/add-tags-dialog.tsx +++ b/apps/web/src/dialogs/add-tags-dialog.tsx @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { useCallback, useEffect, useState } from "react"; +import { useEffect } from "react"; import { Flex, Text } from "@theme-ui/components"; import { CheckCircleOutline, @@ -28,10 +28,12 @@ import { db } from "../common/db"; import Dialog from "../components/dialog"; import { useStore, store } from "../stores/tag-store"; import { store as notestore } from "../stores/note-store"; -import { store as editorstore } from "../stores/editor-store"; +import { store as editorStore } from "../stores/editor-store"; import { Perform } from "../common/dialog-controller"; import { FilteredList } from "../components/filtered-list"; import { ItemReference, Tag, isGroupHeader } from "@notesnook/core/dist/types"; +import { ResolvedItem } from "../components/list-container/resolved-item"; +import { create } from "zustand"; type SelectedReference = { id: string; @@ -39,6 +41,15 @@ type SelectedReference = { op: "add" | "remove"; }; +interface ISelectionStore { + selected: SelectedReference[]; + setSelected(refs: SelectedReference[]): void; +} +export const useSelectionStore = create((set) => ({ + selected: [], + setSelected: (selected) => set({ selected: selected.slice() }) +})); + export type AddTagsDialogProps = { onClose: Perform; noteIds: string[]; @@ -47,40 +58,31 @@ export type AddTagsDialogProps = { function AddTagsDialog(props: AddTagsDialogProps) { const { onClose, noteIds } = props; - const refreshTags = useStore((store) => store.refresh); const tags = useStore((store) => store.tags); useEffect(() => { - refreshTags(); - }, [refreshTags]); - - const [selected, setSelected] = useState([]); - - const getAllTags = useCallback(async () => { - await refreshTags(); - return (store.get().tags?.ids.filter((a) => !isGroupHeader(a)) || - []) as string[]; - }, [refreshTags]); - - useEffect(() => { - if (!tags) return; (async function () { - const copy = selected.slice(); + if (!tags) { + await useStore.getState().refresh(); + return; + } + + const selected: SelectedReference[] = []; for (const tag of tags.ids) { if (isGroupHeader(tag)) continue; - if (copy.findIndex((a) => a.id === tag) > -1) continue; + if (selected.findIndex((a) => a.id === tag) > -1) continue; if (await tagHasNotes(tag, noteIds)) { - copy.push({ + selected.push({ id: tag, op: "add", new: false }); } } - setSelected(copy); + useSelectionStore.getState().setSelected(selected); })(); - }, [noteIds, tags, setSelected]); + }, [tags]); return ( { for (const id of noteIds) { - for (const item of selected) { + for (const item of useSelectionStore.getState().selected) { const tagRef: ItemReference = { type: "tag", id: item.id }; const noteRef: ItemReference = { id, type: "note" }; if (item.op === "add") await db.relations.add(tagRef, noteRef); else await db.relations.unlink(tagRef, noteRef); } } - editorstore.get().refreshTags(); - store.get().refresh(); - notestore.get().refresh(); + await editorStore.get().refreshTags(); + await store.get().refresh(); + await notestore.get().refresh(); onClose(true); } }} @@ -111,79 +113,41 @@ function AddTagsDialog(props: AddTagsDialogProps) { onClick: () => onClose(false) }} > - + {tags && ( items[index]} + mode="fixed" + estimatedSize={30} + items={tags.ungrouped} + sx={{ mt: 2 }} + itemGap={5} placeholders={{ empty: "Add a new tag", filter: "Search or add a new tag" }} - filter={(tags, query) => []} - // db.lookup.tags(tags, query) || []} + filter={(query) => db.lookup.tags(query).ids()} onCreateNewItem={async (title) => { const tagId = await db.tags.add({ title }); if (!tagId) return; - setSelected((selected) => [ - ...selected, - { id: tagId, new: true, op: "add" } - ]); + const { selected, setSelected } = useSelectionStore.getState(); + setSelected([...selected, { id: tagId, new: true, op: "add" }]); }} - renderItem={(tagId, _index) => { - const selectedTag = selected.find((item) => item.id === tagId); + renderItem={({ item: tagId }) => { return ( - tags?.item(id)} - selected={selectedTag ? selectedTag.op : false} - onSelect={() => { - setSelected((selected) => { - const copy = selected.slice(); - const index = copy.findIndex((item) => item.id === tagId); - const isNew = copy[index] && copy[index].new; - if (isNew) { - copy.splice(index, 1); - } else if (index > -1) { - copy[index] = { - ...copy[index], - op: copy[index].op === "add" ? "remove" : "add" - }; - } else { - copy.push({ id: tagId, new: true, op: "add" }); - } - return copy; - }); - }} - /> + + {({ item }) => } + ); }} /> - + )} ); } -function TagItem(props: { - id: string; - resolve: (id: string) => Promise | undefined; - selected: boolean | SelectedReference["op"]; - onSelect: () => void; -}) { - const { id, resolve, selected, onSelect } = props; +function TagItem(props: { tag: Tag }) { + const { tag } = props; - const [tag, setTag] = useState(); - - useEffect(() => { - (async function () { - setTag(await resolve(id)); - })(); - }, [id, resolve]); - - if (!tag) return null; return ( { + const { selected, setSelected } = useSelectionStore.getState(); + + const copy = selected.slice(); + const index = copy.findIndex((item) => item.id === tag.id); + const isNew = copy[index] && copy[index].new; + if (isNew) { + copy.splice(index, 1); + } else if (index > -1) { + copy[index] = { + ...copy[index], + op: copy[index].op === "add" ? "remove" : "add" + }; + } else { + copy.push({ id: tag.id, new: true, op: "add" }); + } + setSelected(copy); + }} > - + store.selected); + const selectedTag = selected.find((item) => item.id === id); + + return selectedTag?.op === "add" ? ( - ) : selected === "remove" ? ( + ) : selectedTag?.op === "remove" ? ( ) : ( diff --git a/apps/web/src/hooks/use-promise.ts b/apps/web/src/hooks/use-promise.ts index 04898e804..039e3615c 100644 --- a/apps/web/src/hooks/use-promise.ts +++ b/apps/web/src/hooks/use-promise.ts @@ -19,7 +19,9 @@ along with this program. If not, see . import { DependencyList, useEffect, useState } from "react"; -export type PromiseResult = PromisePendingResult | PromiseSettledResult; +export type PromiseResult = + | PromisePendingResult + | (PromiseSettledResult & { refresh: () => void }); export interface PromisePendingResult { status: "pending"; @@ -58,7 +60,7 @@ export default function usePromise( ): PromiseResult { const [result, setResult] = useState>({ status: "pending" }); - useEffect(() => { + useEffect(function effect() { if (result.status !== "pending") { setResult({ status: "pending" }); } @@ -70,11 +72,10 @@ export default function usePromise( const [promiseResult] = await Promise.allSettled([factory(signal)]); if (!signal.aborted) { - setResult(promiseResult); + setResult({ ...promiseResult, refresh: effect }); } } - // eslint-disable-next-line @typescript-eslint/no-floating-promises handlePromise(); return () => controller.abort(); diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 979a80cac..be2a60246 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -71,8 +71,8 @@ export const getDefaultSession = (sessionId?: string): EditorSession => { class EditorStore extends BaseStore { session = getDefaultSession(); - tags: Tag[] = []; color = undefined; + tags: Tag[] = []; arePropertiesVisible = false; editorMargins = Config.get("editor:margins", true); @@ -93,13 +93,17 @@ class EditorStore extends BaseStore { this.set({ tags: await db.relations .to({ id: session.id, type: "note" }, "tag") - .resolve() + .selector.items(undefined, { + sortBy: "dateCreated", + sortDirection: "asc" + }) }); }; async refresh() { const sessionId = this.get().session.id; - if (sessionId && !db.notes.note(sessionId)) await this.clearSession(); + if (sessionId && !(await db.notes.exists(sessionId))) + await this.clearSession(); } updateSession = async (item: Note) => { @@ -112,7 +116,6 @@ class EditorStore extends BaseStore { state.session.dateCreated = item.dateCreated; state.session.locked = item.locked; }); - this.refreshTags(); }; openLockedSession = async (note: Note) => { @@ -242,8 +245,8 @@ class EditorStore extends BaseStore { note.headline !== currentSession.headline; if (shouldRefreshNotes) noteStore.refresh(); - const attachments = await db.attachments.ofNote(id, "all"); - if (attachments.length !== currentSession.attachmentsLength) { + const attachmentsLength = await db.attachments.ofNote(id, "all").count(); + if (attachmentsLength !== currentSession.attachmentsLength) { attachmentStore.refresh(); } @@ -261,7 +264,7 @@ class EditorStore extends BaseStore { state.session.id = note.id; state.session.title = note.title; state.session.dateEdited = note.dateEdited; - state.session.attachmentsLength = attachments.length; + state.session.attachmentsLength = attachmentsLength; }); setDocumentTitle( settingStore.get().hideNoteTitle ? undefined : note.title @@ -336,10 +339,6 @@ class EditorStore extends BaseStore { return this.saveSession(noteId, { sessionId, content, dateEdited }); }; - setTag = (tag: string) => { - return this._setTag(tag); - }; - setSaveState = (saveState: SaveState) => { this.set((state) => { state.session.saveState = saveState; @@ -367,26 +366,6 @@ class EditorStore extends BaseStore { // ? db.vault.save.bind(db.vault) // : db.notes.add.bind(db.notes); // }; - - async _setTag(value: string) { - // const { - // tags, - // session: { id } - // } = this.get(); - // let note = db.notes.note(id); - // if (!note) return; - // let tag = tags.find((t) => t.title === value); - // if (tag) { - // await db.relations.unlink(tag, note._note); - // appStore.refreshNavItems(); - // } else { - // const id = await db.tags.add({ title: value }); - // await db.relations.add({ id, type: "tag" }, note._note); - // } - // this.refreshTags(); - // tagStore.refresh(); - // noteStore.refresh(); - } } const [useStore, store] = createStore(EditorStore); diff --git a/apps/web/src/views/all-notes.tsx b/apps/web/src/views/all-notes.tsx index 5b7d57f00..a1418e880 100644 --- a/apps/web/src/views/all-notes.tsx +++ b/apps/web/src/views/all-notes.tsx @@ -33,7 +33,7 @@ function Home() { const setContext = useStore((store) => store.setContext); const filteredItems = useSearch("notes", (query) => { if (useStore.getState().context) return; - return db.lookup.notes(query); + return db.lookup.notes(query).sorted(); }); useNavigate("home", setContext); diff --git a/apps/web/src/views/auth.tsx b/apps/web/src/views/auth.tsx index d7b254e48..d22b7c90a 100644 --- a/apps/web/src/views/auth.tsx +++ b/apps/web/src/views/auth.tsx @@ -28,7 +28,7 @@ import { MfaRecoveryCode, Icon } from "../components/icons"; -import Field from "../components/field"; +import Field, { FieldProps } from "../components/field"; import { getQueryParams, hardNavigate, makeURL } from "../navigation"; import { store as userstore } from "../stores/user-store"; import { db } from "../common/db"; @@ -932,44 +932,13 @@ function SubtitleWithAction(props: SubtitleWithActionProps) { ); } -type AuthFieldProps = { - id: string; - type: string; - autoFocus?: boolean; - autoComplete: string; - label?: string; - placeholder?: string; - helpText?: string; - defaultValue?: string; - disabled?: boolean; - inputMode?: string; - pattern?: string; - action?: { - disabled?: boolean; - component?: JSX.Element; - onClick?: () => void | Promise; - }; -}; -export function AuthField(props: AuthFieldProps) { +export function AuthField(props: FieldProps) { return ( { if (!context || !notes || context.type !== "notebook") return; - return db.lookup.notes(query, notes.ungrouped); + return db.lookup.notes(query, notes.ungrouped).sorted(); }, [context, notes] ); diff --git a/apps/web/src/views/notebooks.tsx b/apps/web/src/views/notebooks.tsx index ae8697130..2f5c0f24c 100644 --- a/apps/web/src/views/notebooks.tsx +++ b/apps/web/src/views/notebooks.tsx @@ -29,7 +29,7 @@ function Notebooks() { const notebooks = useStore((state) => state.notebooks); const refresh = useStore((state) => state.refresh); const filteredItems = useSearch("notebooks", (query) => - db.lookup.notebooks(query) + db.lookup.notebooks(query).sorted() ); useEffect(() => { diff --git a/apps/web/src/views/notes.tsx b/apps/web/src/views/notes.tsx index 9dc5a14c8..45188409d 100644 --- a/apps/web/src/views/notes.tsx +++ b/apps/web/src/views/notes.tsx @@ -35,7 +35,7 @@ function Notes() { "notes", (query) => { if (!context || !contextNotes) return; - return db.lookup.notes(query, contextNotes.ungrouped); + return db.lookup.notes(query, contextNotes.ungrouped).sorted(); }, [context, contextNotes] ); diff --git a/apps/web/src/views/reminders.tsx b/apps/web/src/views/reminders.tsx index 6f8d8cfaa..f08dbc00f 100644 --- a/apps/web/src/views/reminders.tsx +++ b/apps/web/src/views/reminders.tsx @@ -30,7 +30,7 @@ function Reminders() { const reminders = useStore((state) => state.reminders); const refresh = useStore((state) => state.refresh); const filteredItems = useSearch("reminders", (query) => - db.lookup.reminders(query) + db.lookup.reminders(query).sorted() ); if (!reminders) return ; diff --git a/apps/web/src/views/tags.tsx b/apps/web/src/views/tags.tsx index 5ce6fc458..5d856b0bd 100644 --- a/apps/web/src/views/tags.tsx +++ b/apps/web/src/views/tags.tsx @@ -28,7 +28,9 @@ 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)); + const filteredItems = useSearch("tags", (query) => + db.lookup.tags(query).sorted() + ); if (!tags) return ; return ( diff --git a/apps/web/src/views/trash.tsx b/apps/web/src/views/trash.tsx index 352b79e0a..5c73bd68f 100644 --- a/apps/web/src/views/trash.tsx +++ b/apps/web/src/views/trash.tsx @@ -31,7 +31,9 @@ function Trash() { 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)); + const filteredItems = useSearch("trash", (query) => + db.lookup.trash(query).sorted() + ); if (!items) return ; return ( diff --git a/packages/core/src/api/lookup.ts b/packages/core/src/api/lookup.ts index 708dd4b9a..cd2d61a98 100644 --- a/packages/core/src/api/lookup.ts +++ b/packages/core/src/api/lookup.ts @@ -25,6 +25,12 @@ import { AnyColumnWithTable, Kysely, sql } from "kysely"; import { FilteredSelector } from "../database/sql-collection"; import { VirtualizedGrouping } from "../utils/virtualized-grouping"; +type SearchResults = { + sorted: (limit?: number) => Promise>; + items: (limit?: number) => Promise; + ids: () => Promise; +}; + type FuzzySearchField = { weight?: number; name: keyof T; @@ -33,38 +39,38 @@ type FuzzySearchField = { export default class Lookup { constructor(private readonly db: Database) {} - async notes(query: string, noteIds?: string[]) { - const db = this.db.sql() as Kysely; - const ids = await db - .with("matching", (eb) => - eb - .selectFrom("content_fts") - .where("data", "match", query) - .select(["noteId as id", "rank"]) - .unionAll( - eb - .selectFrom("notes_fts") - .where("title", "match", query) - // add 10 weight to title - .select(["id", sql.raw(`rank * 10`).as("rank")]) - ) - ) - .selectFrom("notes") - .$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") - .select(["notes.id"]) - .execute(); - - return new VirtualizedGrouping( - ids.map((id) => id.id), - this.db.options.batchSize, - (ids) => this.db.notes.all.records(ids) - ); + notes(query: string, noteIds?: string[]) { + return this.toSearchResults(async (limit) => { + const db = this.db.sql() as Kysely; + const result = await db + .with("matching", (eb) => + eb + .selectFrom("content_fts") + .where("data", "match", query) + .select(["noteId as id", "rank"]) + .unionAll( + eb + .selectFrom("notes_fts") + .where("title", "match", query) + // add 10 weight to title + .select(["id", sql.raw(`rank * 10`).as("rank")]) + ) + ) + .selectFrom("notes") + .$if(!!noteIds && noteIds.length > 0, (eb) => + eb.where("id", "in", noteIds!) + ) + .$if(!!limit, (eb) => eb.limit(limit!)) + .where(isFalse("notes.deleted")) + .where(isFalse("notes.dateDeleted")) + .innerJoin("matching", (eb) => + eb.onRef("notes.id", "==", "matching.id") + ) + .orderBy("matching.rank") + .select(["notes.id"]) + .execute(); + return result.map((id) => id.id); + }, this.db.notes.all); } notebooks(query: string) { @@ -90,27 +96,22 @@ export default class Lookup { ]); } - async trash(query: string) { - const items = await this.db.trash.all(); - const records: Record = {}; - for (const item of items) records[item.id] = item; - - const results: Record = {}; - for (const item of items) { - const result = match(query, item.title); - if (result.match) results[item.id] = result.score; - } - - 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; - } - ); + trash(query: string): SearchResults { + return { + sorted: async (limit?: number) => { + const { ids, records } = await this.filterTrash(query, limit); + return new VirtualizedGrouping( + ids, + this.db.options.batchSize, + async () => records + ); + }, + items: async (limit?: number) => { + const { records } = await this.filterTrash(query, limit); + return Object.values(records); + }, + ids: () => this.filterTrash(query).then(({ ids }) => ids) + }; } attachments(query: string) { @@ -122,29 +123,92 @@ export default class Lookup { ]); } - private async search( + private search( selector: FilteredSelector, query: string, fields: FuzzySearchField[] ) { - const results: Record = {}; + return this.toSearchResults( + (limit) => this.filter(selector, query, fields, limit), + selector + ); + } + + private async filter( + selector: FilteredSelector, + query: string, + fields: FuzzySearchField[], + limit?: number + ) { + const results: Map = new Map(); const columns = fields.map((f) => f.column); for await (const item of selector.fields(columns)) { + if (limit && results.size >= limit) break; + 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); + const oldScore = results.get(item.id) || 0; + results.set(item.id, oldScore + result.score * (field.weight || 1)); } } } selector.fields([]); - const ids = Object.keys(results).sort((a, b) => results[a] - results[b]); + return Array.from(results.entries()) + .sort((a, b) => a[1] - b[1]) + .map((a) => a[0]); + } + + private toSearchResults( + ids: (limit?: number) => Promise, + selector: FilteredSelector + ): SearchResults { + return { + sorted: async (limit?: number) => + this.toVirtualizedGrouping(await ids(limit), selector), + items: async (limit?: number) => this.toItems(await ids(limit), selector), + ids + }; + } + + private async filterTrash(query: string, limit?: number) { + const items = await this.db.trash.all(); + + const records: Record = {}; + const results: Map = new Map(); + for (const item of items) { + if (limit && results.size >= limit) break; + + const result = match(query, item.title); + if (result.match) { + records[item.id] = item; + results.set(item.id, result.score); + } + } + + const ids = Array.from(results.entries()) + .sort((a, b) => a[1] - b[1]) + .map((a) => a[0]); + return { ids, records }; + } + + private toVirtualizedGrouping( + ids: string[], + selector: FilteredSelector + ) { return new VirtualizedGrouping( ids, this.db.options.batchSize, async (ids) => selector.records(ids) ); } + + private toItems( + ids: string[], + selector: FilteredSelector + ) { + if (!ids.length) return []; + return selector.items(ids); + } } diff --git a/packages/core/src/collections/tags.ts b/packages/core/src/collections/tags.ts index 8093a0645..2b959f907 100644 --- a/packages/core/src/collections/tags.ts +++ b/packages/core/src/collections/tags.ts @@ -39,21 +39,24 @@ export class Tags implements ICollection { return this.collection.get(id); } - // find(idOrTitle: string) { - // return this.all.find( - // (tag) => tag.title === idOrTitle || tag.id === idOrTitle - // ); - // } + find(title: string) { + return this.all.find((eb) => eb.and([eb("title", "==", title)])); + } async add(item: Partial) { if (item.remote) throw new Error("Please use db.tags.merge to merge remote tags."); + item.title = item.title ? Tags.sanitize(item.title) : item.title; const id = item.id || getId(item.dateCreated); - const oldTag = await this.tag(id); + const oldTag = item.id + ? await this.tag(item.id) + : item.title + ? await this.find(item.title) + : undefined; - item.title = item.title ? Tags.sanitize(item.title) : item.title; if (!item.title && !oldTag?.title) throw new Error("Title is required."); + if (oldTag && item.title === oldTag.title) return oldTag.id; await this.collection.upsert({ id, diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts index 588fe6c93..fc6bb6858 100644 --- a/packages/core/src/database/sql-collection.ts +++ b/packages/core/src/database/sql-collection.ts @@ -255,6 +255,7 @@ export class FilteredSelector { private _fields: AnyColumnWithTable[] = []; filter: SelectQueryBuilder; + private _limit = 0; constructor( readonly type: keyof DatabaseSchema, filter: SelectQueryBuilder, @@ -268,7 +269,12 @@ export class FilteredSelector { return this; } - async ids(sortOptions?: GroupOptions) { + limit(limit: number) { + this._limit = limit; + return this; + } + + async ids(sortOptions?: SortOptions) { return ( await this.filter .$if(!!sortOptions, (eb) => @@ -279,7 +285,7 @@ export class FilteredSelector { ).map((i) => i.id); } - async items(ids?: string[], sortOptions?: GroupOptions) { + async items(ids?: string[], sortOptions?: SortOptions) { return (await this.filter .$if(!!ids && ids.length > 0, (eb) => eb.where("id", "in", ids!)) .$if(!!sortOptions, (eb) => @@ -287,10 +293,11 @@ export class FilteredSelector { ) .$if(this._fields.length === 0, (eb) => eb.selectAll()) .$if(this._fields.length > 0, (eb) => eb.select(this._fields)) + .$if(!!this._limit, (eb) => eb.limit(this._limit)) .execute()) as T[]; } - async records(ids?: string[], sortOptions?: GroupOptions) { + async records(ids?: string[], sortOptions?: SortOptions) { const results = await this.items(ids, sortOptions); const items: Record = {}; for (const item of results) { @@ -347,6 +354,7 @@ export class FilteredSelector { async grouped(options: GroupOptions) { console.time("getting items"); const items = await this.filter + .$if(!!this._limit, (eb) => eb.limit(this._limit)) .$call(this.buildSortExpression(options)) .select(["id", options.sortBy, "type"]) .execute(); @@ -360,6 +368,7 @@ export class FilteredSelector { async sorted(options: SortOptions) { const items = await this.filter + .$if(!!this._limit, (eb) => eb.limit(this._limit)) .$call(this.buildSortExpression(options)) .select("id") .execute();