web: add support for assigning tags to notes

This commit is contained in:
Abdullah Atta
2023-11-21 13:16:49 +05:00
parent dd42e8c443
commit c4f5024186
19 changed files with 415 additions and 397 deletions

View File

@@ -19,21 +19,56 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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) => (
<IconTag
testId={`tag`}
key={tag.id}
text={tag.title}
icon={Tag}
icon={TagIcon}
onClick={() => 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" ? (
<Autosuggest
sessionId={id}
filter={(query) =>
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}
</Flex>
)}
</>
@@ -75,19 +137,19 @@ function Header(props: HeaderProps) {
}
export default Header;
type AutosuggestProps = {
type AutosuggestProps<T> = {
sessionId: string;
filter: (query: string) => any[];
filter: (query: string) => Promise<T[]>;
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<T>(props: AutosuggestProps<T>) {
const { sessionId, filter, onRemove, onAdd, defaultItems, toMenuItems } =
props;
const inputRef = useRef<HTMLInputElement>(null);
const arrowDown = useRef<boolean>();
const filteredItems = useRef<any[]>([]);
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") {

View File

@@ -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<HTMLInputElement>;

View File

@@ -17,69 +17,41 @@ 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 { 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<T> = {
placeholders: { filter: string; empty: string };
items: () => Promise<T[]>;
filter: (items: T[], query: string) => T[];
filter: (query: string) => Promise<T[]>;
onCreateNewItem: (title: string) => Promise<void>;
renderItem: (
item: T,
index: number,
refresh: () => void,
isSearching: boolean
) => JSX.Element;
};
} & VirtualizedListProps<T>;
export function FilteredList<T>(props: FilteredListProps<T>) {
const {
items: _items,
filter,
onCreateNewItem,
placeholders,
renderItem
} = props;
const { items, filter, onCreateNewItem, placeholders, ...listProps } = props;
const [items, setItems] = useState<T[]>([]);
const [filteredItems, setFilteredItems] = useState<T[]>([]);
const [query, setQuery] = useState<string>();
const noItemsFound = items.length <= 0 && query && query.length > 0;
const noItemsFound = filteredItems.length <= 0 && query && query.length > 0;
const inputRef = useRef<HTMLInputElement>(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<T>(props: FilteredListProps<T>) {
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<T>(props: FilteredListProps<T>) {
: { icon: Search, onClick: () => _filter(query) }
}
/>
<Flex
as="ul"
mt={1}
sx={{
overflowY: "hidden",
listStyle: "none",
m: 0,
p: 0,
gap: 1,
display: "flex",
flexDirection: "column"
}}
>
{noItemsFound && (
<Button
variant={"secondary"}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 2
}}
onClick={async () => {
await _createNewItem(query);
}}
>
<Text variant={"body"}>{`Add "${query}"`}</Text>
<Plus size={16} color="accent" />
</Button>
)}
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
</Flex>
{noItemsFound ? (
<Button
variant={"secondary"}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 2
}}
onClick={async () => {
await _createNewItem(query);
}}
>
<Text variant={"body"}>{`Add "${query}"`}</Text>
<Plus size={16} color="accent" />
</Button>
) : (
<VirtualizedList {...listProps} items={query ? filteredItems : items} />
)}
</>
);
}

View File

@@ -17,7 +17,14 @@ 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 { 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<Item>;
type ResolvedItemProps<TItemType extends ItemType> = {
type: TItemType;
items: VirtualizedGrouping<ItemMap[TItemType]>;
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<TItemType extends ItemType>(
props: ResolvedItemProps<TItemType>
) {
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)}</>;
}

View File

@@ -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<T> = {
export type VirtualizedListProps<T> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | 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<T>(props: VirtualizedListProps<T>) {
const {
@@ -45,29 +46,37 @@ export function VirtualizedList<T>(props: VirtualizedListProps<T>) {
mode,
virtualizerRef,
itemWrapperProps,
itemGap,
...containerProps
} = props;
const containerRef = useRef<HTMLDivElement>(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 (
<Box {...containerProps} ref={containerRef} className="List">
<Box
{...containerProps}
ref={containerRef}
className="List"
data-top={virtualizer.options.scrollMargin}
>
<Box
sx={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative"
position: "relative",
gap: itemGap
}}
>
{virtualItems.map((row) => (

View File

@@ -17,7 +17,7 @@ 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, 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<ISelectionStore>((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<SelectedReference[]>([]);
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 (
<Dialog
@@ -93,16 +95,16 @@ function AddTagsDialog(props: AddTagsDialogProps) {
text: "Done",
onClick: async () => {
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)
}}
>
<Flex
mt={1}
sx={{ overflowY: "hidden", flexDirection: "column" }}
data-test-id="tag-list"
>
{tags && (
<FilteredList
items={getAllTags}
getItemKey={(index, items) => 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 (
<TagItem
key={tagId}
id={tagId}
resolve={(id) => 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;
});
}}
/>
<ResolvedItem key={tagId} type="tag" items={tags} id={tagId}>
{({ item }) => <TagItem tag={item} key={tagId} />}
</ResolvedItem>
);
}}
/>
</Flex>
)}
</Dialog>
);
}
function TagItem(props: {
id: string;
resolve: (id: string) => Promise<Tag | undefined> | 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<Tag>();
useEffect(() => {
(async function () {
setTag(await resolve(id));
})();
}, [id, resolve]);
if (!tag) return null;
return (
<Flex
as="li"
@@ -196,10 +160,27 @@ function TagItem(props: {
borderRadius: "default",
p: 1
}}
onClick={onSelect}
onClick={() => {
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);
}}
>
<Flex sx={{ alignItems: "center" }}>
<SelectedCheck size={20} selected={selected} />
<SelectedCheck size={20} id={tag.id} />
<Text
className="title"
data-test-id="notebook-title"
@@ -215,16 +196,13 @@ function TagItem(props: {
export default AddTagsDialog;
function SelectedCheck({
selected,
size = 20
}: {
selected: SelectedReference["op"] | boolean;
size?: number;
}) {
return selected === "add" ? (
function SelectedCheck({ id, size = 20 }: { id: string; size?: number }) {
const selected = useSelectionStore((store) => store.selected);
const selectedTag = selected.find((item) => item.id === id);
return selectedTag?.op === "add" ? (
<CheckCircleOutline size={size} sx={{ mr: 1 }} color="accent" />
) : selected === "remove" ? (
) : selectedTag?.op === "remove" ? (
<CheckRemove size={size} sx={{ mr: 1 }} color="icon-error" />
) : (
<CircleEmpty size={size} sx={{ mr: 1, opacity: 0.4 }} />

View File

@@ -19,7 +19,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { DependencyList, useEffect, useState } from "react";
export type PromiseResult<T> = PromisePendingResult | PromiseSettledResult<T>;
export type PromiseResult<T> =
| PromisePendingResult
| (PromiseSettledResult<T> & { refresh: () => void });
export interface PromisePendingResult {
status: "pending";
@@ -58,7 +60,7 @@ export default function usePromise<T>(
): PromiseResult<T> {
const [result, setResult] = useState<PromiseResult<T>>({ status: "pending" });
useEffect(() => {
useEffect(function effect() {
if (result.status !== "pending") {
setResult({ status: "pending" });
}
@@ -70,11 +72,10 @@ export default function usePromise<T>(
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();

View File

@@ -71,8 +71,8 @@ export const getDefaultSession = (sessionId?: string): EditorSession => {
class EditorStore extends BaseStore<EditorStore> {
session = getDefaultSession();
tags: Tag[] = [];
color = undefined;
tags: Tag[] = [];
arePropertiesVisible = false;
editorMargins = Config.get("editor:margins", true);
@@ -93,13 +93,17 @@ class EditorStore extends BaseStore<EditorStore> {
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<EditorStore> {
state.session.dateCreated = item.dateCreated;
state.session.locked = item.locked;
});
this.refreshTags();
};
openLockedSession = async (note: Note) => {
@@ -242,8 +245,8 @@ class EditorStore extends BaseStore<EditorStore> {
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<EditorStore> {
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<EditorStore> {
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<EditorStore> {
// ? 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);

View File

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

View File

@@ -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<void>;
};
};
export function AuthField(props: AuthFieldProps) {
export function AuthField(props: FieldProps) {
return (
<Field
type={props.type}
id={props.id}
name={props.id}
data-test-id={props.id}
autoComplete={props.autoComplete}
label={props.label}
autoFocus={props.autoFocus}
defaultValue={props.defaultValue}
helpText={props.helpText}
disabled={props.disabled}
pattern={props.pattern}
inputMode={props.inputMode}
placeholder={props.placeholder}
{...props}
required
action={props.action}
sx={{ mt: 2, width: "100%" }}
styles={{
container: { mt: 2, width: "100%" },
// label: { fontWeight: "normal" },
input: {
p: "12px",

View File

@@ -75,7 +75,7 @@ function Notebook(props: NotebookProps) {
"notes",
(query) => {
if (!context || !notes || context.type !== "notebook") return;
return db.lookup.notes(query, notes.ungrouped);
return db.lookup.notes(query, notes.ungrouped).sorted();
},
[context, notes]
);

View File

@@ -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(() => {

View File

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

View File

@@ -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 <Placeholder context="reminders" />;

View File

@@ -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 <Placeholder context="tags" />;
return (

View File

@@ -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 <Placeholder context="trash" />;
return (

View File

@@ -25,6 +25,12 @@ import { AnyColumnWithTable, Kysely, sql } from "kysely";
import { FilteredSelector } from "../database/sql-collection";
import { VirtualizedGrouping } from "../utils/virtualized-grouping";
type SearchResults<T> = {
sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>;
items: (limit?: number) => Promise<T[]>;
ids: () => Promise<string[]>;
};
type FuzzySearchField<T> = {
weight?: number;
name: keyof T;
@@ -33,38 +39,38 @@ type FuzzySearchField<T> = {
export default class Lookup {
constructor(private readonly db: Database) {}
async notes(query: string, noteIds?: string[]) {
const db = this.db.sql() as Kysely<DatabaseSchemaWithFTS>;
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<number>(`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<DatabaseSchemaWithFTS>;
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<number>(`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<string, TrashItem> = {};
for (const item of items) records[item.id] = item;
const results: Record<string, number> = {};
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<TrashItem>(
ids,
this.db.options.batchSize,
async (ids) => {
const items: Record<string, TrashItem> = {};
for (const id of ids) items[id] = records[id];
return items;
}
);
trash(query: string): SearchResults<TrashItem> {
return {
sorted: async (limit?: number) => {
const { ids, records } = await this.filterTrash(query, limit);
return new VirtualizedGrouping<TrashItem>(
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<T extends Item>(
private search<T extends Item>(
selector: FilteredSelector<T>,
query: string,
fields: FuzzySearchField<T>[]
) {
const results: Record<string, number> = {};
return this.toSearchResults(
(limit) => this.filter(selector, query, fields, limit),
selector
);
}
private async filter<T extends Item>(
selector: FilteredSelector<T>,
query: string,
fields: FuzzySearchField<T>[],
limit?: number
) {
const results: Map<string, number> = 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<T extends Item>(
ids: (limit?: number) => Promise<string[]>,
selector: FilteredSelector<T>
): SearchResults<T> {
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<string, TrashItem> = {};
const results: Map<string, number> = 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<T extends Item>(
ids: string[],
selector: FilteredSelector<T>
) {
return new VirtualizedGrouping<T>(
ids,
this.db.options.batchSize,
async (ids) => selector.records(ids)
);
}
private toItems<T extends Item>(
ids: string[],
selector: FilteredSelector<T>
) {
if (!ids.length) return [];
return selector.items(ids);
}
}

View File

@@ -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<Tag>) {
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,

View File

@@ -255,6 +255,7 @@ export class FilteredSelector<T extends Item> {
private _fields: AnyColumnWithTable<DatabaseSchema, keyof DatabaseSchema>[] =
[];
filter: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, unknown>;
private _limit = 0;
constructor(
readonly type: keyof DatabaseSchema,
filter: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, unknown>,
@@ -268,7 +269,12 @@ export class FilteredSelector<T extends Item> {
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<T extends Item> {
).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<T extends Item> {
)
.$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<string, T> = {};
for (const item of results) {
@@ -347,6 +354,7 @@ export class FilteredSelector<T extends Item> {
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<T extends Item> {
async sorted(options: SortOptions) {
const items = await this.filter
.$if(!!this._limit, (eb) => eb.limit(this._limit))
.$call(this.buildSortExpression(options))
.select("id")
.execute();