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