mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
web: add support for assigning tags to notes
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -334,10 +337,6 @@ class EditorStore extends BaseStore<EditorStore> {
|
||||
return this.saveSession(noteId, { sessionId, content });
|
||||
};
|
||||
|
||||
setTag = (tag: string) => {
|
||||
return this._setTag(tag);
|
||||
};
|
||||
|
||||
setSaveState = (saveState: SaveState) => {
|
||||
this.set((state) => {
|
||||
state.session.saveState = saveState;
|
||||
@@ -365,26 +364,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user