From a47967dd53aae75e972c32505721c0542f545a91 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:48:44 +0500 Subject: [PATCH] web: add command palette (#7314) Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- apps/web/src/app.css | 23 + apps/web/src/common/key-map.ts | 13 +- apps/web/src/components/editor/types.ts | 2 +- apps/web/src/components/icons/index.tsx | 4 +- .../src/components/list-container/types.ts | 7 +- apps/web/src/components/trash-item/index.tsx | 2 +- .../command-palette-dialog.tsx | 623 +++++++++++++ .../src/dialogs/command-palette/commands.ts | 816 ++++++++++++++++++ apps/web/src/dialogs/command-palette/index.ts | 20 + apps/web/src/navigation/routes.tsx | 5 + apps/web/src/views/notebook.tsx | 9 +- packages/core/__tests__/lookup.test.js | 61 +- packages/core/src/api/lookup.ts | 79 +- .../core/src/utils/__tests__/fuzzy.test.ts | 113 +++ packages/core/src/utils/fuzzy.ts | 64 ++ packages/core/src/utils/index.ts | 1 + .../editor/src/extensions/key-map/key-map.ts | 2 +- packages/intl/locale/en.po | 100 +++ packages/intl/locale/pseudo-LOCALE.po | 100 +++ packages/intl/src/strings.ts | 28 +- 20 files changed, 2035 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/dialogs/command-palette/command-palette-dialog.tsx create mode 100644 apps/web/src/dialogs/command-palette/commands.ts create mode 100644 apps/web/src/dialogs/command-palette/index.ts create mode 100644 packages/core/src/utils/__tests__/fuzzy.test.ts create mode 100644 packages/core/src/utils/fuzzy.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index e9a7aeb96..42c64aa36 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -249,3 +249,26 @@ textarea, background-color: color-mix(in srgb, var(--accent) 5%, transparent); } } + +kbd { + background: var(--background); + border-radius: 3px; + padding: 2px 5px; + color: var(--paragraph-secondary); +} + +.ping { + animation: ping 1s infinite; +} + +@keyframes ping { + 0% { + opacity: 1; + } + 50% { + opacity: 0.7; + } + 100% { + opacity: 1; + } +} diff --git a/apps/web/src/common/key-map.ts b/apps/web/src/common/key-map.ts index ad4e7574d..9f4df1314 100644 --- a/apps/web/src/common/key-map.ts +++ b/apps/web/src/common/key-map.ts @@ -21,6 +21,7 @@ import hotkeys from "hotkeys-js"; import { useEditorStore } from "../stores/editor-store"; import { useStore as useSearchStore } from "../stores/search-store"; import { useEditorManager } from "../components/editor/manager"; +import { CommandPaletteDialog } from "../dialogs/command-palette"; function isInEditor(e: KeyboardEvent) { return ( @@ -123,7 +124,7 @@ const KEYMAP = [ useSearchStore.setState({ isSearching: true, searchType: "notes" }); } - } + }, // { // keys: ["alt+n"], // description: "Go to Notes", @@ -187,6 +188,16 @@ const KEYMAP = [ // themestore.get().toggleNightMode(); // }, // }, + { + keys: ["ctrl+k", "cmd+k", "ctrl+p", "cmd+p"], + description: "Open command palette", + action: (e: KeyboardEvent) => { + e.preventDefault(); + CommandPaletteDialog.show({ + isCommandMode: e.key === "k" + }); + } + } ]; export function registerKeyMap() { diff --git a/apps/web/src/components/editor/types.ts b/apps/web/src/components/editor/types.ts index b1fff4983..a6508f823 100644 --- a/apps/web/src/components/editor/types.ts +++ b/apps/web/src/components/editor/types.ts @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { Attachment } from "@notesnook/editor"; +import { Attachment, Editor } from "@notesnook/editor"; export const MAX_AUTO_SAVEABLE_WORDS = IS_TESTING ? 100 : 100_000; diff --git a/apps/web/src/components/icons/index.tsx b/apps/web/src/components/icons/index.tsx index 2ce356863..e20e2ede6 100644 --- a/apps/web/src/components/icons/index.tsx +++ b/apps/web/src/components/icons/index.tsx @@ -220,7 +220,8 @@ import { mdiTagOutline, mdiChatQuestionOutline, mdiNoteRemoveOutline, - mdiTabPlus + mdiTabPlus, + mdiRadar } from "@mdi/js"; import { useTheme } from "@emotion/react"; import { Theme } from "@notesnook/theme"; @@ -563,3 +564,4 @@ export const OpenInNew = createIcon(mdiOpenInNew); export const Coupon = createIcon(mdiTagOutline); export const Support = createIcon(mdiChatQuestionOutline); export const NewTab = createIcon(mdiTabPlus); +export const Radar = createIcon(mdiRadar); diff --git a/apps/web/src/components/list-container/types.ts b/apps/web/src/components/list-container/types.ts index 88b8ab21c..312ccef37 100644 --- a/apps/web/src/components/list-container/types.ts +++ b/apps/web/src/components/list-container/types.ts @@ -26,7 +26,12 @@ export type NotebookContext = { }; export type Context = | { - type: "tag" | "color"; + type: "tag"; + id: string; + item?: Tag; + } + | { + type: "color"; id: string; } | NotebookContext diff --git a/apps/web/src/components/trash-item/index.tsx b/apps/web/src/components/trash-item/index.tsx index d0f5f0505..46b3602b7 100644 --- a/apps/web/src/components/trash-item/index.tsx +++ b/apps/web/src/components/trash-item/index.tsx @@ -100,7 +100,7 @@ const menuItems: (item: TrashItemType, ids?: string[]) => MenuItem[] = ( ]; }; -async function deleteTrash(ids: string[]) { +export async function deleteTrash(ids: string[]) { if (!(await showMultiPermanentDeleteConfirmation(ids.length))) return; await store.delete(...ids); showToast("success", `${pluralize(ids.length, "item")} permanently deleted`); diff --git a/apps/web/src/dialogs/command-palette/command-palette-dialog.tsx b/apps/web/src/dialogs/command-palette/command-palette-dialog.tsx new file mode 100644 index 000000000..96747e68d --- /dev/null +++ b/apps/web/src/dialogs/command-palette/command-palette-dialog.tsx @@ -0,0 +1,623 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { debounce, toTitleCase } from "@notesnook/common"; +import { fuzzy } from "@notesnook/core"; +import { Box, Button, Flex, Text } from "@theme-ui/components"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { GroupedVirtuoso, GroupedVirtuosoHandle } from "react-virtuoso"; +import { db } from "../../common/db"; +import { BaseDialogProps, DialogManager } from "../../common/dialog-manager"; +import Dialog from "../../components/dialog"; +import Field from "../../components/field"; +import { + Cross, + Icon, + Notebook as NotebookIcon, + Note as NoteIcon, + Reminder as ReminderIcon, + Tag as TagIcon +} from "../../components/icons"; +import { CustomScrollbarsVirtualList } from "../../components/list-container"; +import { hashNavigate, navigate } from "../../navigation"; +import { useEditorStore } from "../../stores/editor-store"; +import Config from "../../utils/config"; +import { commands as COMMANDS } from "./commands"; +import { strings } from "@notesnook/intl"; + +interface Command { + id: string; + title: string; + highlightedTitle?: string; + type: + | "command" + | "command-dynamic" + | "note" + | "notebook" + | "tag" + | "reminder"; + group: string; +} + +type GroupedCommands = { group: string; count: number }[]; + +type CommandPaletteDialogProps = BaseDialogProps & { + isCommandMode: boolean; +}; + +type Coords = Record<"x" | "y", number>; + +export const CommandPaletteDialog = DialogManager.register( + function CommandPaletteDialog(props: CommandPaletteDialogProps) { + const [commands, setCommands] = useState( + props.isCommandMode ? getDefaultCommands() : getSessionsAsCommands() + ); + const [selected, setSelected] = useState({ x: 0, y: 0 }); + const [query, setQuery] = useState(props.isCommandMode ? ">" : ""); + const [loading, setLoading] = useState(false); + const virtuosoRef = useRef(null); + + useEffect(() => { + virtuosoRef.current?.scrollToIndex({ + index: selected.y, + align: "end", + behavior: "auto" + }); + }, [selected]); + + const onChange = useCallback(async function onChange( + e: React.ChangeEvent + ) { + try { + setSelected({ x: 0, y: 0 }); + const query = e.target.value; + setQuery(query); + if (!isCommandMode(query)) { + setLoading(true); + } + const res = await search(query); + const highlighted = fuzzy( + prepareQuery(query), + res.map((r) => ({ + ...r, + highlightedTitle: r.title + })) ?? [], + /** + * we use a separate key for highlighted title + * so that when we save recent commands to local storage + * we can save the original title instead of the highlighted one + */ + "highlightedTitle", + { + prefix: "", + suffix: "" + } + ); + setCommands(sortCommands(highlighted)); + } finally { + setLoading(false); + } + }, + []); + + const grouped = useMemo( + () => + commands.reduce((acc, command) => { + const item = acc.find((c) => c.group === command.group); + if (item) { + item.count++; + } else { + acc.push({ group: command.group, count: 1 }); + } + return acc; + }, [] as GroupedCommands), + [commands] + ); + + return ( + { + props.onClose(false); + }} + noScroll + sx={{ + fontFamily: "body" + }} + > + + { + if (e.key == "Enter") { + e.preventDefault(); + const command = commands[selected.y]; + if (!command) return; + if (selected.x === 1) { + setSelected({ x: 0, y: 0 }); + removeRecentCommand(command.id); + setCommands((commands) => + commands.filter((c) => c.id !== command.id) + ); + return; + } + const action = getCommandAction({ + id: command.id, + type: command.type + }); + action?.(command.id); + addRecentCommand(command); + props.onClose(false); + setSelected({ x: 0, y: 0 }); + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelected(moveSelectionDown(selected, commands)); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelected(moveSelectionUp(selected, commands)); + } + if (e.key === "ArrowRight") { + e.preventDefault(); + setSelected(moveSelectionRight(selected, commands)); + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + setSelected(moveSelectionLeft(selected, commands)); + } + }} + > + + {query && commands.length === 0 && ( + + + {strings.noResultsFound(prepareQuery(query))} + + + )} + + g.count)} + groupContent={(groupIndex) => { + const label = + grouped[groupIndex].group === "recent" + ? strings.recent() + : grouped[groupIndex].group; + return ( + + + {toTitleCase(label)} + + + ); + }} + itemContent={(index) => { + const command = commands[index]; + if (!command) return null; + + const Icon = getCommandIcon({ + id: command.id, + type: command.type + }); + + return ( + + + {command.group === "recent" && ( + + )} + + ); + }} + /> + + + + + + + ); + } +); + +function moveSelectionDown(selected: Coords, commands: Command[]) { + const currentCommand = commands[selected.y]; + const nextIndex = (selected.y + 1) % commands.length; + const nextCommand = commands[nextIndex]; + if (currentCommand.group === "recent" && nextCommand.group === "recent") { + return { x: selected.x, y: nextIndex }; + } + return { x: 0, y: nextIndex }; +} + +function moveSelectionUp(selected: Coords, commands: Command[]) { + const currentCommand = commands[selected.y]; + const nextIndex = (selected.y - 1 + commands.length) % commands.length; + const nextCommand = commands[nextIndex]; + if (currentCommand.group === "recent" && nextCommand.group === "recent") { + return { x: selected.x, y: nextIndex }; + } + return { x: 0, y: nextIndex }; +} + +function moveSelectionRight(selected: Coords, commands: Command[]) { + const currentCommand = commands[selected.y]; + if (currentCommand.group !== "recent") return selected; + const nextIndex = (selected.x + 1) % 2; + return { x: nextIndex, y: selected.y }; +} + +function moveSelectionLeft(selected: Coords, commands: Command[]) { + const currentCommand = commands[selected.y]; + if (currentCommand.group !== "recent") return selected; + const nextIndex = (selected.x - 1 + 2) % 2; + return { x: nextIndex, y: selected.y }; +} + +const CommandIconMap = COMMANDS.reduce((acc, command) => { + acc.set(command.id, command.icon); + return acc; +}, new Map()); + +const CommandActionMap = COMMANDS.reduce((acc, command) => { + acc.set(command.id, command.action); + return acc; +}, new Map void>()); + +function resolveCommands() { + return COMMANDS.reduce((acc, command) => { + if (acc.find((c) => c.id === command.id)) return acc; + + const hidden = command.hidden ? command.hidden() : false; + const group = + typeof command.group === "function" ? command.group() : command.group; + const title = + typeof command.title === "function" ? command.title() : command.title; + if (hidden || group === undefined || title === undefined) return acc; + return acc.concat({ + id: command.id, + title: title, + type: command.dynamic + ? ("command-dynamic" as const) + : ("command" as const), + group: group + }); + }, [] as Command[]); +} + +function getDefaultCommands() { + return getRecentCommands().concat(resolveCommands()); +} + +function getRecentCommands() { + return Config.get("commandPalette:recent", []); +} + +function addRecentCommand(command: Command) { + if (command.type === "command-dynamic") return; + let commands = getRecentCommands(); + const index = commands.findIndex((c) => c.id === command.id); + if (index > -1) { + commands.splice(index, 1); + } + commands.unshift({ + ...command, + highlightedTitle: undefined, + group: "recent" + }); + if (commands.length > 3) { + commands = commands.slice(0, 3); + } + Config.set("commandPalette:recent", commands); +} + +function removeRecentCommand(id: Command["id"]) { + let commands = getRecentCommands(); + const index = commands.findIndex((c) => c.id === id); + if (index > -1) { + commands.splice(index, 1); + Config.set("commandPalette:recent", commands); + } +} + +function getCommandAction({ + id, + type +}: { + id: Command["id"]; + type: Command["type"]; +}) { + switch (type) { + case "command": + case "command-dynamic": + return CommandActionMap.get(id); + case "note": + return (noteId: string) => useEditorStore.getState().openSession(noteId); + case "notebook": + return (notebookId: string) => navigate(`/notebooks/${notebookId}`); + case "tag": + return (tagId: string) => navigate(`/tags/${tagId}`); + case "reminder": + return (reminderId: string) => + hashNavigate(`/reminders/${reminderId}/edit`); + } +} + +function getCommandIcon({ + id, + type +}: { + id: Command["id"]; + type: Command["type"]; +}) { + switch (type) { + case "command": + case "command-dynamic": + return CommandIconMap.get(id); + case "note": + return NoteIcon; + case "notebook": + return NotebookIcon; + case "tag": + return TagIcon; + case "reminder": + return ReminderIcon; + default: + return undefined; + } +} + +function getSessionsAsCommands() { + const sessions = useEditorStore.getState().get().sessions; + return sessions + .filter((s) => s.type !== "new") + .map((session) => { + return { + id: session.id, + title: session.note.title, + group: strings.dataTypesCamelCase.note(), + type: "note" as const + }; + }); +} + +/** + * commands need to be sorted wrt groups, + * meaning commands of same group should be next to each other, + * and recent commands should be at the top + */ +function sortCommands(commands: Command[]) { + const recent: Command[] = []; + const sortedWrtGroups: Command[][] = []; + for (const command of commands) { + const group = command.group; + if (group === "recent") { + recent.push(command); + continue; + } + const index = sortedWrtGroups.findIndex((c) => c[0].group === group); + if (index === -1) { + sortedWrtGroups.push([command]); + } else { + sortedWrtGroups[index].push(command); + } + } + return recent.concat(sortedWrtGroups.flat()); +} + +function search(query: string) { + const prepared = prepareQuery(query); + if (isCommandMode(query)) { + return commandSearch(prepared); + } + if (prepared.length < 1) { + return getSessionsAsCommands(); + } + return dbSearch(prepared); +} + +function commandSearch(query: string) { + const commands = getDefaultCommands(); + const result = fuzzy(query, commands, "title", { + matchOnly: true + }); + return result; +} + +async function dbSearch(query: string) { + const notes = db.lookup.notes(query, undefined, { + titleOnly: true + }); + const notebooks = db.lookup.notebooks(query, { + titleOnly: true + }); + const tags = db.lookup.tags(query); + const reminders = db.lookup.reminders(query, { + titleOnly: true + }); + const list = ( + await Promise.all([ + notes.items(), + notebooks.items(), + tags.items(), + reminders.items() + ]) + ).flat(); + const commands = list.map((item) => { + return { + id: item.id, + title: item.title, + group: strings.dataTypesCamelCase[item.type](), + type: item.type + }; + }); + return commands; +} + +function isCommandMode(query: string) { + return query.startsWith(">"); +} + +function prepareQuery(query: string) { + return isCommandMode(query) ? query.substring(1).trim() : query.trim(); +} diff --git a/apps/web/src/dialogs/command-palette/commands.ts b/apps/web/src/dialogs/command-palette/commands.ts new file mode 100644 index 000000000..2ae92dd16 --- /dev/null +++ b/apps/web/src/dialogs/command-palette/commands.ts @@ -0,0 +1,816 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { createInternalLink, hosts } from "@notesnook/core"; +import { strings } from "@notesnook/intl"; +import { db } from "../../common/db"; +import { Multiselect } from "../../common/multi-select"; +import { useEditorManager } from "../../components/editor/manager"; +import { + ArrowLeft, + ArrowRight, + ArrowTopRight, + Copy, + DeleteForver, + Duplicate, + Edit, + Editor, + InternalLink, + Notebook, + NotebookEdit, + OpenInNew, + Pin, + Plus, + Publish, + Radar, + Readonly, + Reminder, + Restore, + Shortcut, + Star, + Sync, + Tag, + Trash +} from "../../components/icons"; +import { showPublishView } from "../../components/publish-view"; +import { deleteTrash } from "../../components/trash-item"; +import { hashNavigate, navigate } from "../../navigation"; +import { store as appStore } from "../../stores/app-store"; +import { useEditorStore } from "../../stores/editor-store"; +import { store as monographStore } from "../../stores/monograph-store"; +import { store as noteStore } from "../../stores/note-store"; +import { store as notebookStore } from "../../stores/notebook-store"; +import { useStore as useThemeStore } from "../../stores/theme-store"; +import { store as trashStore } from "../../stores/trash-store"; +import { writeToClipboard } from "../../utils/clipboard"; +import { AddNotebookDialog } from "../add-notebook-dialog"; +import { AddReminderDialog } from "../add-reminder-dialog"; +import { AddTagsDialog } from "../add-tags-dialog"; +import { AttachmentsDialog } from "../attachments-dialog"; +import { ConfirmDialog } from "../confirm"; +import { CreateColorDialog } from "../create-color-dialog"; +import { EditTagDialog } from "../item-dialog"; +import { MoveNoteDialog } from "../move-note-dialog"; + +function getLabelForActiveNoteGroup() { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.actionsForNote(note.title) : undefined; +} + +function getLabelForActiveNotebookGroup() { + const context = noteStore.get().context; + return context?.type === "notebook" && context.item?.title + ? strings.actionsForNotebook(context.item.title) + : undefined; +} + +function getLabelForActiveTagGroup() { + const context = noteStore.get().context; + return context?.type === "tag" && context.item?.title + ? strings.actionsForTag(context.item.title) + : undefined; +} + +export const commands = [ + { + id: "pin-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? (note.pinned ? strings.unpin() : strings.pin()) : undefined; + }, + icon: Pin, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + noteStore.get().pin(!note.pinned, note.id); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "readonly-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.toggleReadonly() : undefined; + }, + icon: Readonly, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + noteStore.get().readonly(!note.readonly, note.id); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "favorite-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note + ? note.favorite + ? strings.unfavorite() + : strings.favorite() + : undefined; + }, + icon: Star, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + noteStore.get().favorite(!note.favorite, note.id); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "remind-me-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.remindMe() : undefined; + }, + icon: Reminder, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + if (note.type === "trash") return; + AddReminderDialog.show({ note: note }); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "link-notebooks-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.linkNotebooks() : undefined; + }, + icon: Notebook, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + MoveNoteDialog.show({ noteIds: [note.id] }); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "add-tags-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.addTags() : undefined; + }, + icon: Tag, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + AddTagsDialog.show({ noteIds: [note.id] }); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "publish-on-monograph-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.publishOnMonograph() : undefined; + }, + icon: Publish, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note || note.type === "trash") return; + const isPublished = db.monographs.isPublished(note.id); + if (isPublished) return; + showPublishView(note); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return ( + !note || note.type === "trash" || db.monographs.isPublished(note.id) + ); + }, + dynamic: true + }, + { + id: "open-in-monograph-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.openInMonograph() : undefined; + }, + icon: OpenInNew, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note || note.type === "trash") return; + const isPublished = db.monographs.isPublished(note.id); + if (!isPublished) return; + const url = `${hosts.MONOGRAPH_HOST}/${note.id}`; + window.open(url, "_blank"); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return ( + !note || note.type === "trash" || !db.monographs.isPublished(note.id) + ); + }, + dynamic: true + }, + { + id: "copy-monograph-link-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.copyMonographLink() : undefined; + }, + icon: Copy, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note || note.type === "trash") return; + const isPublished = db.monographs.isPublished(note.id); + if (!isPublished) return; + const url = `${hosts.MONOGRAPH_HOST}/${note.id}`; + writeToClipboard({ + "text/plain": url, + "text/html": `${note.title}`, + "text/markdown": `[${note.title}](${url})` + }); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return ( + !note || note.type === "trash" || !db.monographs.isPublished(note.id) + ); + }, + dynamic: true + }, + { + id: "toggle-sync-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note + ? note?.localOnly + ? strings.turnSyncOn() + : strings.turnSyncOff() + : undefined; + }, + icon: Sync, + action: async () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note || note.type === "trash") return; + if ( + note.localOnly || + (await ConfirmDialog.show({ + title: strings.syncOffConfirm(1), + message: strings.syncOffDesc(1), + positiveButtonText: strings.yes(), + negativeButtonText: strings.no() + })) + ) { + await noteStore.localOnly(!note.localOnly, note.id); + } + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "unpublish-on-monograph-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.unpublishOnMonograph() : undefined; + }, + icon: Publish, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note || note.type === "trash") return; + monographStore.get().unpublish(note.id); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return ( + !note || note.type === "trash" || !db.monographs.isPublished(note.id) + ); + }, + dynamic: true + }, + { + id: "copy-link-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.copyLink() : undefined; + }, + icon: InternalLink, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (note) { + const link = createInternalLink("note", note.id); + writeToClipboard({ + "text/plain": link, + "text/html": `${note.title}`, + "text/markdown": `[${note.title}](${link})` + }); + } + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "duplicate-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.duplicate() : undefined; + }, + icon: Duplicate, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + noteStore.get().duplicate(note.id); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type === "trash"; + }, + dynamic: true + }, + { + id: "move-to-trash-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.moveToTrash() : undefined; + }, + icon: Trash, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note || db.monographs.isPublished(note.id)) return; + Multiselect.moveNotesToTrash([note.id], false); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return ( + !note || note.type === "trash" || db.monographs.isPublished(note.id) + ); + }, + dynamic: true + }, + { + id: "restore-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.restore() : undefined; + }, + icon: Restore, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + trashStore.restore(note.id); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type !== "trash"; + }, + dynamic: true + }, + { + id: "delete-active-note", + title: () => { + const note = useEditorStore.getState().getActiveNote(); + return note ? strings.delete() : undefined; + }, + icon: DeleteForver, + action: () => { + const note = useEditorStore.getState().getActiveNote(); + if (!note) return; + deleteTrash([note.id]); + }, + group: getLabelForActiveNoteGroup, + hidden: () => { + const note = useEditorStore.getState().getActiveNote(); + return !note || note.type !== "trash"; + }, + dynamic: true + }, + { + id: "add-subnotebook-active-notebook", + title: () => { + const context = noteStore.get().context; + return context?.type === "notebook" + ? strings.addSubnotebook() + : undefined; + }, + icon: Plus, + action: () => { + const context = noteStore.get().context; + if (context?.type !== "notebook") return; + AddNotebookDialog.show({ parentId: context.id }); + }, + group: getLabelForActiveNotebookGroup, + hidden: () => { + const context = noteStore.get().context; + return ( + context?.type !== "notebook" || !context.item || context.item.deleted + ); + }, + dynamic: true + }, + { + id: "edit-active-notebook", + title: () => { + const context = noteStore.get().context; + return context?.type === "notebook" ? strings.edit() : undefined; + }, + icon: NotebookEdit, + action: () => { + const context = noteStore.get().context; + if (context?.type !== "notebook") return; + hashNavigate(`/notebooks/${context.id}/edit`); + }, + group: getLabelForActiveNotebookGroup, + hidden: () => { + const context = noteStore.get().context; + return ( + context?.type !== "notebook" || !context.item || context.item.deleted + ); + }, + dynamic: true + }, + { + id: "pin-active-notebook", + title: () => { + const context = noteStore.get().context; + return context?.type === "notebook" + ? context.item?.pinned + ? strings.unpin() + : strings.pin() + : undefined; + }, + icon: Pin, + action: () => { + const context = noteStore.get().context; + if (context?.type !== "notebook") return; + notebookStore.pin(!context.item?.pinned, context.id); + }, + group: getLabelForActiveNotebookGroup, + hidden: () => { + const context = noteStore.get().context; + return context?.type !== "notebook"; + }, + dynamic: true + }, + { + id: "add-shortcut-active-notebook", + title: () => { + const context = noteStore.get().context; + return context?.type === "notebook" && context.item + ? db.shortcuts.exists(context.item.id) + ? strings.removeShortcut() + : strings.addShortcut() + : undefined; + }, + icon: Shortcut, + action: () => { + const context = noteStore.get().context; + if (context?.type !== "notebook" || !context.item) return; + appStore.addToShortcuts(context.item); + }, + group: getLabelForActiveNotebookGroup, + hidden: () => { + const context = noteStore.get().context; + return context?.type !== "notebook"; + }, + dynamic: true + }, + { + id: "move-to-trash-active-notebook", + title: () => { + const context = noteStore.get().context; + return context?.type === "notebook" ? strings.moveToTrash() : undefined; + }, + icon: Trash, + action: () => { + const context = noteStore.get().context; + if (context?.type !== "notebook") return; + Multiselect.moveNotebooksToTrash([context.id]).then(() => { + navigate("/notebooks"); + }); + }, + group: getLabelForActiveNotebookGroup, + hidden: () => { + const context = noteStore.get().context; + return context?.type !== "notebook"; + }, + dynamic: true + }, + { + id: "rename-active-tag", + title: () => { + const context = noteStore.get().context; + return context?.type === "tag" ? strings.rename() : undefined; + }, + icon: Edit, + action: () => { + const context = noteStore.get().context; + if (context?.type === "tag" && context.item) { + EditTagDialog.show(context.item); + } + }, + group: getLabelForActiveTagGroup, + hidden: () => { + const context = noteStore.get().context; + return context?.type !== "tag"; + }, + dynamic: true + }, + { + id: "add-shortcut-active-tag", + title: () => { + const context = noteStore.get().context; + return context?.type === "tag" && context.item + ? db.shortcuts.exists(context.item.id) + ? strings.removeShortcut() + : strings.addShortcut() + : undefined; + }, + icon: Shortcut, + action: () => { + const context = noteStore.get().context; + if (context?.type !== "tag" || !context.item) return; + appStore.addToShortcuts(context.item); + }, + group: getLabelForActiveTagGroup, + hidden: () => { + const context = noteStore.get().context; + return context?.type !== "tag"; + }, + dynamic: true + }, + { + id: "delete-active-tag", + title: () => { + const context = noteStore.get().context; + return context?.type === "tag" ? strings.delete() : undefined; + }, + icon: DeleteForver, + action: () => { + const context = noteStore.get().context; + if (!context || context.type !== "tag" || !context.item) return; + Multiselect.deleteTags([context.item.id]); + }, + group: getLabelForActiveTagGroup, + hidden: () => { + const context = noteStore.get().context; + return context?.type !== "tag"; + }, + dynamic: true + }, + { + id: "undo", + title: strings.undo(), + icon: Editor, + action: () => { + const session = useEditorStore.getState().getActiveSession(); + if (!session) return; + useEditorManager.getState().editors[session.id].editor?.undo(); + }, + group: strings.editor(), + hidden: () => { + const session = useEditorStore.getState().getActiveSession(); + return ( + !session || + !useEditorManager.getState().editors[session.id].canUndo || + session.type === "readonly" + ); + }, + dynamic: true + }, + { + id: "redo", + title: strings.redo(), + icon: Editor, + action: () => { + const session = useEditorStore.getState().getActiveSession(); + if (!session) return; + useEditorManager.getState().editors[session.id].editor?.redo(); + }, + group: strings.editor(), + hidden: () => { + const session = useEditorStore.getState().getActiveSession(); + return ( + !session || + !useEditorManager.getState().editors[session.id].canRedo || + session.type === "readonly" + ); + }, + dynamic: true + }, + { + id: "next-tab", + title: strings.nextTab(), + icon: ArrowTopRight, + action: () => useEditorStore.getState().focusNextTab(), + group: strings.navigate() + }, + { + id: "previous-tab", + title: strings.previousTab(), + icon: ArrowTopRight, + action: () => useEditorStore.getState().focusPreviousTab(), + group: strings.navigate() + }, + { + id: "go-forward-in-tab", + title: strings.goForwardInTab(), + icon: ArrowRight, + action: () => useEditorStore.getState().goForward(), + group: strings.navigate() + }, + { + id: "go-back-in-tab", + title: strings.goBackInTab(), + icon: ArrowLeft, + action: () => useEditorStore.getState().goBack(), + group: strings.navigate() + }, + { + id: "notes", + title: strings.dataTypesPluralCamelCase.note(), + icon: ArrowTopRight, + action: () => navigate("/"), + group: strings.navigate() + }, + { + id: "notebooks", + title: strings.dataTypesPluralCamelCase.notebook(), + icon: ArrowTopRight, + action: () => navigate("/notebooks"), + group: strings.navigate() + }, + { + id: "tags", + title: strings.dataTypesPluralCamelCase.tag(), + icon: ArrowTopRight, + action: () => navigate("/tags"), + group: strings.navigate() + }, + { + id: "favorites", + title: strings.dataTypesPluralCamelCase.favorite(), + icon: ArrowTopRight, + action: () => navigate("/favorites"), + group: strings.navigate() + }, + { + id: "reminders", + title: strings.dataTypesPluralCamelCase.reminder(), + icon: ArrowTopRight, + action: () => navigate("/reminders"), + group: strings.navigate() + }, + { + id: "monographs", + title: strings.dataTypesPluralCamelCase.monograph(), + icon: ArrowTopRight, + action: () => navigate("/monographs"), + group: strings.navigate() + }, + { + id: "trash", + title: strings.trash(), + icon: ArrowTopRight, + action: () => navigate("/trash"), + group: strings.navigate() + }, + { + id: "settings", + title: strings.settings(), + icon: ArrowTopRight, + action: () => hashNavigate("/settings", { replace: true }), + group: strings.navigate() + }, + { + id: "help", + title: strings.helpAndSupport(), + icon: ArrowTopRight, + action: () => (window.location.href = "https://help.notesnook.com"), + group: strings.navigate() + }, + { + id: "attachment-manager", + title: strings.attachmentManager(), + icon: ArrowTopRight, + action: () => AttachmentsDialog.show({}), + group: strings.navigate() + }, + { + id: "new-tab", + title: strings.newTab(), + icon: Plus, + action: () => useEditorStore.getState().addTab(), + group: strings.create() + }, + { + id: "new-note", + title: strings.newNote(), + icon: Plus, + action: () => useEditorStore.getState().newSession(), + group: strings.create() + }, + { + id: "new-notebook", + title: strings.newNotebook(), + icon: Plus, + action: () => hashNavigate("/notebooks/create", { replace: true }), + group: strings.create() + }, + { + id: "new-tag", + title: strings.newTag(), + icon: Plus, + action: () => hashNavigate("/tags/create", { replace: true }), + group: strings.create() + }, + { + id: "new-reminder", + title: strings.newReminder(), + icon: Plus, + action: () => hashNavigate(`/reminders/create`, { replace: true }), + group: strings.create() + }, + { + id: "new-color", + title: strings.newColor(), + icon: Plus, + action: () => CreateColorDialog.show(true), + group: strings.create() + }, + { + id: "close-tab", + title: strings.closeCurrentTab(), + icon: Radar, + action: () => useEditorStore.getState().closeActiveTab(), + group: strings.general() + }, + { + id: "close-all-tabs", + title: strings.closeAllTabs(), + icon: Radar, + action: () => useEditorStore.getState().closeAllTabs(), + group: strings.general() + }, + { + id: "toggle-theme", + title: strings.toggleTheme(), + icon: Radar, + action: () => useThemeStore.getState().toggleColorScheme(), + group: strings.general() + } +]; diff --git a/apps/web/src/dialogs/command-palette/index.ts b/apps/web/src/dialogs/command-palette/index.ts new file mode 100644 index 000000000..ec54b01a4 --- /dev/null +++ b/apps/web/src/dialogs/command-palette/index.ts @@ -0,0 +1,20 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +export * from "./command-palette-dialog"; diff --git a/apps/web/src/navigation/routes.tsx b/apps/web/src/navigation/routes.tsx index e58d95b17..3927e5961 100644 --- a/apps/web/src/navigation/routes.tsx +++ b/apps/web/src/navigation/routes.tsx @@ -168,6 +168,11 @@ const routes = defineRoutes({ title: async () => { const tag = await db.tags.tag(tagId); if (!tag) return; + notestore.setContext({ + type: "tag", + id: tagId, + item: tag + }); return `#${tag.title}`; }, component: Notes, diff --git a/apps/web/src/views/notebook.tsx b/apps/web/src/views/notebook.tsx index 654c75a9b..f9311a8ae 100644 --- a/apps/web/src/views/notebook.tsx +++ b/apps/web/src/views/notebook.tsx @@ -81,12 +81,17 @@ function Notebook(props: NotebookProps) { Promise.all([ !!notebookId && db.notebooks.exists(notebookId), db.notebooks.exists(rootId) - ]).then((exists) => { + ]).then(async (exists) => { if (exists.every((e) => !e)) { navigate(`/notebooks`, { replace: true }); return; } - setContext({ type: "notebook", id: notebookId || rootId }); + const notebook = await db.notebooks.notebook(notebookId || rootId); + setContext({ + type: "notebook", + id: notebookId || rootId, + item: notebook + }); }); }, [rootId, notebookId]); diff --git a/packages/core/__tests__/lookup.test.js b/packages/core/__tests__/lookup.test.js index 50b4658e7..138ce8929 100644 --- a/packages/core/__tests__/lookup.test.js +++ b/packages/core/__tests__/lookup.test.js @@ -24,7 +24,7 @@ import { TEST_NOTEBOOK2, databaseTest } from "./utils/index.ts"; -import { test, expect } from "vitest"; +import { test, expect, describe } from "vitest"; const content = { ...TEST_NOTE.content, @@ -81,6 +81,22 @@ test("search notes with an empty note", () => expect(filtered).toHaveLength(1); })); +test("search notes with opts.titleOnly should not search in descriptions", () => + noteTest({ + content: content + }).then(async ({ db }) => { + await db.notes.add({ + title: "note of the world", + content: { type: "tiptap", data: "

hello

" } + }); + let filtered = await db.lookup + .notes("note of the world", undefined, { + titleOnly: true + }) + .ids(); + expect(filtered).toHaveLength(1); + })); + test("search notebooks", () => notebookTest().then(async ({ db }) => { await db.notebooks.add(TEST_NOTEBOOK2); @@ -88,6 +104,16 @@ test("search notebooks", () => expect(filtered.length).toBeGreaterThan(0); })); +test("search notebook with titleOnly option should not search in descriptions", () => + notebookTest().then(async ({ db }) => { + await db.notebooks.add({ title: "Description" }); + await db.notebooks.add(TEST_NOTEBOOK2); + let filtered = await db.lookup + .notebooks("Description", { titleOnly: true }) + .ids(); + expect(filtered).toHaveLength(1); + })); + test("search should not return trashed notes", () => databaseTest().then(async (db) => { const id = await db.notes.add({ @@ -112,3 +138,36 @@ test("search should return restored notes", () => expect(filtered).toHaveLength(1); })); + +test("search reminders", () => + databaseTest().then(async (db) => { + await db.reminders.add({ + title: "remind me", + description: "please do", + date: Date.now() + }); + + const titleSearch = await db.lookup.reminders("remind me").ids(); + expect(titleSearch).toHaveLength(1); + const descriptionSearch = await db.lookup.reminders("please do").ids(); + expect(descriptionSearch).toHaveLength(1); + })); + +test("search reminders with titleOnly option should not search in descriptions", () => + databaseTest().then(async (db) => { + await db.reminders.add({ + title: "idc", + description: "desc", + date: Date.now() + }); + await db.reminders.add({ + title: "remind me", + description: "idc", + date: Date.now() + }); + + const filtered = await db.lookup + .reminders("idc", { titleOnly: true }) + .ids(); + expect(filtered).toHaveLength(1); + })); diff --git a/packages/core/src/api/lookup.ts b/packages/core/src/api/lookup.ts index de46f93a0..6c12c915a 100644 --- a/packages/core/src/api/lookup.ts +++ b/packages/core/src/api/lookup.ts @@ -19,7 +19,14 @@ along with this program. If not, see . import { match } from "fuzzyjs"; import Database from "./index.js"; -import { Item, Note, SortOptions, TrashItem } from "../types.js"; +import { + Item, + Note, + Notebook, + Reminder, + SortOptions, + TrashItem +} from "../types.js"; import { DatabaseSchema, RawDatabaseSchema } from "../database/index.js"; import { AnyColumnWithTable, Kysely, sql } from "@streetwriters/kysely"; import { FilteredSelector } from "../database/sql-collection.js"; @@ -43,7 +50,11 @@ type FuzzySearchField = { export default class Lookup { constructor(private readonly db: Database) {} - notes(query: string, notes?: FilteredSelector): SearchResults { + notes( + query: string, + notes?: FilteredSelector, + opts?: { titleOnly?: boolean } + ): SearchResults { return this.toSearchResults(async (limit, sortOptions) => { const db = this.db.sql() as unknown as Kysely; const excludedIds = this.db.trash.cache.notes; @@ -61,21 +72,23 @@ export default class Lookup { ) .where("title", "match", query) .select(["id", sql`rank * 10`.as("rank")]) - .unionAll((eb) => - eb - .selectFrom("content_fts") - .$if(!!notes, (eb) => - eb.where("noteId", "in", notes!.filter.select("id")) - ) - .$if(excludedIds.length > 0, (eb) => - eb.where("id", "not in", excludedIds) - ) - .where("data", "match", query) - .select(["noteId as id", "rank"]) - .$castTo<{ - id: string; - rank: number; - }>() + .$if(!opts?.titleOnly, (eb) => + eb.unionAll((eb) => + eb + .selectFrom("content_fts") + .$if(!!notes, (eb) => + eb.where("noteId", "in", notes!.filter.select("id")) + ) + .$if(excludedIds.length > 0, (eb) => + eb.where("id", "not in", excludedIds) + ) + .where("data", "match", query) + .select(["noteId as id", "rank"]) + .$castTo<{ + id: string; + rank: number; + }>() + ) ) .as("results") ) @@ -99,12 +112,18 @@ export default class Lookup { }, notes || this.db.notes.all); } - notebooks(query: string) { - return this.search(this.db.notebooks.all, query, [ + notebooks(query: string, opts: { titleOnly?: boolean } = {}) { + const fields: FuzzySearchField[] = [ { name: "id", column: "notebooks.id", weight: -100 }, - { name: "title", column: "notebooks.title", weight: 10 }, - { name: "description", column: "notebooks.description" } - ]); + { name: "title", column: "notebooks.title", weight: 10 } + ]; + if (!opts.titleOnly) { + fields.push({ + name: "description", + column: "notebooks.description" + }); + } + return this.search(this.db.notebooks.all, query, fields); } tags(query: string) { @@ -114,12 +133,18 @@ export default class Lookup { ]); } - reminders(query: string) { - return this.search(this.db.reminders.all, query, [ + reminders(query: string, opts: { titleOnly?: boolean } = {}) { + const fields: FuzzySearchField[] = [ { name: "id", column: "reminders.id", weight: -100 }, - { name: "title", column: "reminders.title", weight: 10 }, - { name: "description", column: "reminders.description" } - ]); + { name: "title", column: "reminders.title", weight: 10 } + ]; + if (!opts.titleOnly) { + fields.push({ + name: "description", + column: "reminders.description" + }); + } + return this.search(this.db.reminders.all, query, fields); } trash(query: string): SearchResults { diff --git a/packages/core/src/utils/__tests__/fuzzy.test.ts b/packages/core/src/utils/__tests__/fuzzy.test.ts new file mode 100644 index 000000000..5bf9cec3d --- /dev/null +++ b/packages/core/src/utils/__tests__/fuzzy.test.ts @@ -0,0 +1,113 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { fuzzy } from "../fuzzy"; +import { test, expect, describe } from "vitest"; + +describe("lookup.fuzzy", () => { + test("should sort items by score when sort", () => { + const items = [ + { + title: "system" + }, + { + title: "hello" + }, + { + title: "items" + } + ]; + const query = "ems"; + expect(fuzzy(query, items, "title")).toStrictEqual([ + items[2], + items[0], + items[1] + ]); + }); + describe("opts.matchOnly", () => { + test("should return all items when matchOnly is false", () => { + const items = [ + { + title: "hello" + }, + { + title: "world" + } + ]; + const successQuery = "o"; + const failureQuery = "i"; + expect(fuzzy(successQuery, items, "title")).toStrictEqual(items); + expect(fuzzy(failureQuery, items, "title")).toStrictEqual(items); + }); + test("should return only matching items when matchOnly is true", () => { + const items = [ + { + title: "hello" + }, + { + title: "world" + } + ]; + const successQuery = "or"; + const failureQuery = "i"; + expect( + fuzzy(successQuery, items, "title", { matchOnly: true }) + ).toStrictEqual([items[1]]); + expect( + fuzzy(failureQuery, items, "title", { matchOnly: true }) + ).toStrictEqual([]); + }); + }); + describe("opts.prefix", () => { + test("should prefix matched field with provided value when given", () => { + const items = [ + { + title: "hello" + }, + { + title: "world" + } + ]; + const query = "d"; + expect( + fuzzy(query, items, "title", { + prefix: "prefix-" + }) + ).toStrictEqual([items[0], { title: "worlprefix-d" }]); + }); + }); + describe("opt.suffix", () => { + test("should suffix matched field with provided value when given", () => { + const items = [ + { + title: "hello" + }, + { + title: "world" + } + ]; + const query = "llo"; + expect( + fuzzy(query, items, "title", { + suffix: "-suffix" + }) + ).toStrictEqual([{ title: "hello-suffix" }, items[1]]); + }); + }); +}); diff --git a/packages/core/src/utils/fuzzy.ts b/packages/core/src/utils/fuzzy.ts new file mode 100644 index 000000000..3424b24c6 --- /dev/null +++ b/packages/core/src/utils/fuzzy.ts @@ -0,0 +1,64 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { match, surround } from "fuzzyjs"; + +export function fuzzy( + query: string, + items: T[], + key: keyof T, + opts?: { + prefix?: string; + suffix?: string; + /** + * If true, only items that match the query will be returned + */ + matchOnly?: boolean; + } +): T[] { + if (query === "") return items; + + const fuzzied: [T, number][] = []; + + for (const item of items) { + const result = match(query, `${item[key]}`); + if (!result.match) { + if (opts?.matchOnly) continue; + fuzzied.push([item, result.score]); + continue; + } + if (opts?.prefix || opts?.suffix) { + fuzzied.push([ + { + ...item, + [key]: surround(`${item[key]}`, { + result: result, + prefix: opts?.prefix, + suffix: opts?.suffix + }) + }, + result.score + ]); + continue; + } + fuzzied.push([item, result.score]); + } + + return fuzzied.sort((a, b) => b[1] - a[1]).map((f) => f[0]); +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index f88eb4ebb..d1f00aeac 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -41,3 +41,4 @@ export * from "./set.js"; export * from "./title-format.js"; export * from "./virtualized-grouping.js"; export * from "./crypto.js"; +export * from "./fuzzy"; diff --git a/packages/editor/src/extensions/key-map/key-map.ts b/packages/editor/src/extensions/key-map/key-map.ts index 148005836..5f8689d6a 100644 --- a/packages/editor/src/extensions/key-map/key-map.ts +++ b/packages/editor/src/extensions/key-map/key-map.ts @@ -67,7 +67,7 @@ export const KeyMap = Extension.create({ }); return true; }, - "Mod-k": ({ editor }) => { + "Shift-Mod-k": ({ editor }) => { showLinkPopup(editor); return true; } diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po index d8832a975..3328512a0 100644 --- a/packages/intl/locale/en.po +++ b/packages/intl/locale/en.po @@ -580,6 +580,10 @@ msgstr "{words, plural, other {# selected}}" msgid "#notesnook" msgstr "#notesnook" +#: src/strings.ts:2417 +msgid "{\">\"} for command mode · remove {\">\"} for search mode · to select · to navigate" +msgstr "{\">\"} for command mode · remove {\">\"} for search mode · to select · to navigate" + #: src/strings.ts:1564 msgid "12-hour" msgstr "12-hour" @@ -628,6 +632,18 @@ msgstr "Account" msgid "Account password" msgstr "Account password" +#: src/strings.ts:2437 +msgid "Actions for note: {title}" +msgstr "Actions for note: {title}" + +#: src/strings.ts:2438 +msgid "Actions for notebook: {title}" +msgstr "Actions for notebook: {title}" + +#: src/strings.ts:2439 +msgid "Actions for tag: {title}" +msgstr "Actions for tag: {title}" + #: src/strings.ts:2018 msgid "Activate" msgstr "Activate" @@ -672,6 +688,10 @@ msgstr "Add shortcut" msgid "Add shortcuts for notebooks and tags here." msgstr "Add shortcuts for notebooks and tags here." +#: src/strings.ts:2426 +msgid "Add subnotebook" +msgstr "Add subnotebook" + #: src/strings.ts:564 msgid "Add tag" msgstr "Add tag" @@ -907,6 +927,10 @@ msgstr "attachment" msgid "Attachment" msgstr "Attachment" +#: src/strings.ts:2432 +msgid "Attachment manager" +msgstr "Attachment manager" + #: src/strings.ts:1916 msgid "Attachment preview failed" msgstr "Attachment preview failed" @@ -1480,6 +1504,14 @@ msgstr "Close" msgid "Close all" msgstr "Close all" +#: src/strings.ts:2435 +msgid "Close all tabs" +msgstr "Close all tabs" + +#: src/strings.ts:2434 +msgid "Close current tab" +msgstr "Close current tab" + #: src/strings.ts:1997 msgid "Close others" msgstr "Close others" @@ -1650,6 +1682,10 @@ msgstr "Copy link" msgid "Copy link text" msgstr "Copy link text" +#: src/strings.ts:2422 +msgid "Copy monograph link" +msgstr "Copy monograph link" + #: src/strings.ts:447 msgid "Copy note" msgstr "Copy note" @@ -2812,6 +2848,10 @@ msgstr "Getting recovery codes" msgid "GNU GENERAL PUBLIC LICENSE Version 3" msgstr "GNU GENERAL PUBLIC LICENSE Version 3" +#: src/strings.ts:2431 +msgid "Go back in tab" +msgstr "Go back in tab" + #: src/strings.ts:2206 msgid "Go back to notebooks" msgstr "Go back to notebooks" @@ -2820,6 +2860,10 @@ msgstr "Go back to notebooks" msgid "Go back to tags" msgstr "Go back to tags" +#: src/strings.ts:2430 +msgid "Go forward in tab" +msgstr "Go forward in tab" + #: src/strings.ts:1794 msgid "Go to" msgstr "Go to" @@ -3672,6 +3716,10 @@ msgstr "Name" msgid "Native high-performance encryption" msgstr "Native high-performance encryption" +#: src/strings.ts:2427 +msgid "Navigate" +msgstr "Navigate" + #: src/strings.ts:390 msgid "Never" msgstr "Never" @@ -3728,6 +3776,10 @@ msgstr "New reminder" msgid "New tab" msgstr "New tab" +#: src/strings.ts:2433 +msgid "New tag" +msgstr "New tag" + #: src/strings.ts:1351 msgid "New update available" msgstr "New update available" @@ -3752,6 +3804,10 @@ msgstr "Next" msgid "Next match" msgstr "Next match" +#: src/strings.ts:2428 +msgid "Next tab" +msgstr "Next tab" + #: src/strings.ts:539 msgid "No" msgstr "No" @@ -4033,6 +4089,10 @@ msgstr "Open in browser" msgid "Open in browser to manage subscription" msgstr "Open in browser to manage subscription" +#: src/strings.ts:2421 +msgid "Open in monograph" +msgstr "Open in monograph" + #: src/strings.ts:2304 msgid "Open in new tab" msgstr "Open in new tab" @@ -4430,6 +4490,10 @@ msgstr "Preview not available, content is encrypted." msgid "Previous match" msgstr "Previous match" +#: src/strings.ts:2429 +msgid "Previous tab" +msgstr "Previous tab" + #: src/strings.ts:2015 msgid "Print" msgstr "Print" @@ -4518,6 +4582,10 @@ msgstr "Publish" msgid "Publish note" msgstr "Publish note" +#: src/strings.ts:2420 +msgid "Publish on monograph" +msgstr "Publish on monograph" + #: src/strings.ts:481 msgid "Publish your note to share it with others. You can set a password to protect it." msgstr "Publish your note to share it with others. You can set a password to protect it." @@ -4598,6 +4666,10 @@ msgstr "Reading backup file..." msgid "Receipt" msgstr "Receipt" +#: src/strings.ts:2440 +msgid "Recent" +msgstr "Recent" + #: src/strings.ts:1592 msgid "RECENT BACKUPS" msgstr "RECENT BACKUPS" @@ -4788,6 +4860,10 @@ msgstr "Remove from all" msgid "Remove from notebook" msgstr "Remove from notebook" +#: src/strings.ts:2441 +msgid "Remove from recent" +msgstr "Remove from recent" + #: src/strings.ts:1030 msgid "Remove full name" msgstr "Remove full name" @@ -5146,6 +5222,10 @@ msgstr "Search in Notebooks" msgid "Search in Notes" msgstr "Search in Notes" +#: src/strings.ts:2418 +msgid "Search in notes, notebooks, and tags" +msgstr "Search in notes, notebooks, and tags" + #: src/strings.ts:31 msgid "Search in Reminders" msgstr "Search in Reminders" @@ -6085,10 +6165,18 @@ msgstr "Toggle dark/light mode" msgid "Toggle indentation mode" msgstr "Toggle indentation mode" +#: src/strings.ts:2419 +msgid "Toggle readonly" +msgstr "Toggle readonly" + #: src/strings.ts:2360 msgid "Toggle replace" msgstr "Toggle replace" +#: src/strings.ts:2436 +msgid "Toggle theme" +msgstr "Toggle theme" + #: src/strings.ts:2113 msgid "Toolbar" msgstr "Toolbar" @@ -6142,6 +6230,14 @@ msgstr "Turn off reminder" msgid "Turn on reminder" msgstr "Turn on reminder" +#: src/strings.ts:2424 +msgid "Turn sync off" +msgstr "Turn sync off" + +#: src/strings.ts:2423 +msgid "Turn sync on" +msgstr "Turn sync on" + #: src/strings.ts:1078 msgid "Turns off syncing completely on this device. Any changes made will remain local only and new changes from your other devices won't sync to this device." msgstr "Turns off syncing completely on this device. Any changes made will remain local only and new changes from your other devices won't sync to this device." @@ -6255,6 +6351,10 @@ msgstr "Unpublish" msgid "Unpublish notes to delete them" msgstr "Unpublish notes to delete them" +#: src/strings.ts:2425 +msgid "Unpublish on monograph" +msgstr "Unpublish on monograph" + #: src/strings.ts:2067 msgid "Unregister" msgstr "Unregister" diff --git a/packages/intl/locale/pseudo-LOCALE.po b/packages/intl/locale/pseudo-LOCALE.po index dc0fd52b6..f6a817c29 100644 --- a/packages/intl/locale/pseudo-LOCALE.po +++ b/packages/intl/locale/pseudo-LOCALE.po @@ -580,6 +580,10 @@ msgstr "" msgid "#notesnook" msgstr "" +#: src/strings.ts:2417 +msgid "{\">\"} for command mode · remove {\">\"} for search mode · to select · to navigate" +msgstr "" + #: src/strings.ts:1564 msgid "12-hour" msgstr "" @@ -628,6 +632,18 @@ msgstr "" msgid "Account password" msgstr "" +#: src/strings.ts:2437 +msgid "Actions for note: {title}" +msgstr "" + +#: src/strings.ts:2438 +msgid "Actions for notebook: {title}" +msgstr "" + +#: src/strings.ts:2439 +msgid "Actions for tag: {title}" +msgstr "" + #: src/strings.ts:2018 msgid "Activate" msgstr "" @@ -672,6 +688,10 @@ msgstr "" msgid "Add shortcuts for notebooks and tags here." msgstr "" +#: src/strings.ts:2426 +msgid "Add subnotebook" +msgstr "" + #: src/strings.ts:564 msgid "Add tag" msgstr "" @@ -907,6 +927,10 @@ msgstr "" msgid "Attachment" msgstr "" +#: src/strings.ts:2432 +msgid "Attachment manager" +msgstr "" + #: src/strings.ts:1916 msgid "Attachment preview failed" msgstr "" @@ -1469,6 +1493,14 @@ msgstr "" msgid "Close all" msgstr "" +#: src/strings.ts:2435 +msgid "Close all tabs" +msgstr "" + +#: src/strings.ts:2434 +msgid "Close current tab" +msgstr "" + #: src/strings.ts:1997 msgid "Close others" msgstr "" @@ -1639,6 +1671,10 @@ msgstr "" msgid "Copy link text" msgstr "" +#: src/strings.ts:2422 +msgid "Copy monograph link" +msgstr "" + #: src/strings.ts:447 msgid "Copy note" msgstr "" @@ -2794,6 +2830,10 @@ msgstr "" msgid "GNU GENERAL PUBLIC LICENSE Version 3" msgstr "" +#: src/strings.ts:2431 +msgid "Go back in tab" +msgstr "" + #: src/strings.ts:2206 msgid "Go back to notebooks" msgstr "" @@ -2802,6 +2842,10 @@ msgstr "" msgid "Go back to tags" msgstr "" +#: src/strings.ts:2430 +msgid "Go forward in tab" +msgstr "" + #: src/strings.ts:1794 msgid "Go to" msgstr "" @@ -3652,6 +3696,10 @@ msgstr "" msgid "Native high-performance encryption" msgstr "" +#: src/strings.ts:2427 +msgid "Navigate" +msgstr "" + #: src/strings.ts:390 msgid "Never" msgstr "" @@ -3708,6 +3756,10 @@ msgstr "" msgid "New tab" msgstr "" +#: src/strings.ts:2433 +msgid "New tag" +msgstr "" + #: src/strings.ts:1351 msgid "New update available" msgstr "" @@ -3732,6 +3784,10 @@ msgstr "" msgid "Next match" msgstr "" +#: src/strings.ts:2428 +msgid "Next tab" +msgstr "" + #: src/strings.ts:539 msgid "No" msgstr "" @@ -4007,6 +4063,10 @@ msgstr "" msgid "Open in browser to manage subscription" msgstr "" +#: src/strings.ts:2421 +msgid "Open in monograph" +msgstr "" + #: src/strings.ts:2304 msgid "Open in new tab" msgstr "" @@ -4404,6 +4464,10 @@ msgstr "" msgid "Previous match" msgstr "" +#: src/strings.ts:2429 +msgid "Previous tab" +msgstr "" + #: src/strings.ts:2015 msgid "Print" msgstr "" @@ -4492,6 +4556,10 @@ msgstr "" msgid "Publish note" msgstr "" +#: src/strings.ts:2420 +msgid "Publish on monograph" +msgstr "" + #: src/strings.ts:481 msgid "Publish your note to share it with others. You can set a password to protect it." msgstr "" @@ -4572,6 +4640,10 @@ msgstr "" msgid "Receipt" msgstr "" +#: src/strings.ts:2440 +msgid "Recent" +msgstr "" + #: src/strings.ts:1592 msgid "RECENT BACKUPS" msgstr "" @@ -4762,6 +4834,10 @@ msgstr "" msgid "Remove from notebook" msgstr "" +#: src/strings.ts:2441 +msgid "Remove from recent" +msgstr "" + #: src/strings.ts:1030 msgid "Remove full name" msgstr "" @@ -5120,6 +5196,10 @@ msgstr "" msgid "Search in Notes" msgstr "" +#: src/strings.ts:2418 +msgid "Search in notes, notebooks, and tags" +msgstr "" + #: src/strings.ts:31 msgid "Search in Reminders" msgstr "" @@ -6044,10 +6124,18 @@ msgstr "" msgid "Toggle indentation mode" msgstr "" +#: src/strings.ts:2419 +msgid "Toggle readonly" +msgstr "" + #: src/strings.ts:2360 msgid "Toggle replace" msgstr "" +#: src/strings.ts:2436 +msgid "Toggle theme" +msgstr "" + #: src/strings.ts:2113 msgid "Toolbar" msgstr "" @@ -6101,6 +6189,14 @@ msgstr "" msgid "Turn on reminder" msgstr "" +#: src/strings.ts:2424 +msgid "Turn sync off" +msgstr "" + +#: src/strings.ts:2423 +msgid "Turn sync on" +msgstr "" + #: src/strings.ts:1078 msgid "Turns off syncing completely on this device. Any changes made will remain local only and new changes from your other devices won't sync to this device." msgstr "" @@ -6214,6 +6310,10 @@ msgstr "" msgid "Unpublish notes to delete them" msgstr "" +#: src/strings.ts:2425 +msgid "Unpublish on monograph" +msgstr "" + #: src/strings.ts:2067 msgid "Unregister" msgstr "" diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts index b76db3ee3..e2cc248b4 100644 --- a/packages/intl/src/strings.ts +++ b/packages/intl/src/strings.ts @@ -2412,5 +2412,31 @@ Use this if changes from other devices are not appearing on this device. This wi redeemGiftCode: () => t`Redeem gift code`, redeemGiftCodeDesc: () => t`Enter the gift code to redeem your subscription.`, redeemingGiftCode: () => t`Redeeming gift code`, - redeem: () => t`Redeem` + redeem: () => t`Redeem`, + commandPaletteDescription: () => + t`{">"} for command mode · remove {">"} for search mode · to select · to navigate`, + searchInNotesNotebooksAndTags: () => t`Search in notes, notebooks, and tags`, + toggleReadonly: () => t`Toggle readonly`, + publishOnMonograph: () => t`Publish on monograph`, + openInMonograph: () => t`Open in monograph`, + copyMonographLink: () => t`Copy monograph link`, + turnSyncOn: () => t`Turn sync on`, + turnSyncOff: () => t`Turn sync off`, + unpublishOnMonograph: () => t`Unpublish on monograph`, + addSubnotebook: () => t`Add subnotebook`, + navigate: () => t`Navigate`, + nextTab: () => t`Next tab`, + previousTab: () => t`Previous tab`, + goForwardInTab: () => t`Go forward in tab`, + goBackInTab: () => t`Go back in tab`, + attachmentManager: () => t`Attachment manager`, + newTag: () => t`New tag`, + closeCurrentTab: () => t`Close current tab`, + closeAllTabs: () => t`Close all tabs`, + toggleTheme: () => t`Toggle theme`, + actionsForNote: (title: string) => t`Actions for note: ${title}`, + actionsForNotebook: (title: string) => t`Actions for notebook: ${title}`, + actionsForTag: (title: string) => t`Actions for tag: ${title}`, + recent: () => t`Recent`, + removeFromRecent: () => t`Remove from recent` };