mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-23 19:49:56 +01:00
web: add support for setting tags in bulk (#2328)
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
@@ -88,6 +88,12 @@ export function closeOpenedDialog() {
|
||||
dialogs.forEach((elem) => elem.remove());
|
||||
}
|
||||
|
||||
export function showAddTagsDialog(noteIds: string[]) {
|
||||
return showDialog("AddTagsDialog", (Dialog, perform) => (
|
||||
<Dialog onClose={(res) => perform(res)} noteIds={noteIds} />
|
||||
));
|
||||
}
|
||||
|
||||
export function showAddNotebookDialog() {
|
||||
return showDialog("AddNotebookDialog", (Dialog, perform) => (
|
||||
<Dialog
|
||||
|
||||
220
apps/web/src/components/dialogs/add-tags-dialog.tsx
Normal file
220
apps/web/src/components/dialogs/add-tags-dialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Flex, Text } from "@theme-ui/components";
|
||||
import * as Icon from "../icons";
|
||||
import { db } from "../../common/db";
|
||||
import Dialog from "./dialog";
|
||||
import { useStore, store } from "../../stores/tag-store";
|
||||
import { store as notestore } from "../../stores/note-store";
|
||||
import { Perform } from "../../common/dialog-controller";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
|
||||
type SelectedReference = {
|
||||
id: string;
|
||||
new: boolean;
|
||||
op: "add" | "remove";
|
||||
};
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
type: "tag" | "header";
|
||||
title: string;
|
||||
};
|
||||
type Tag = Item & { noteIds: string[] };
|
||||
|
||||
export type AddTagsDialogProps = {
|
||||
onClose: Perform;
|
||||
noteIds: string[];
|
||||
};
|
||||
|
||||
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(() => {
|
||||
refreshTags();
|
||||
return (store.get().tags as Item[]).filter((a) => a.type !== "header");
|
||||
}, [refreshTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tags) return;
|
||||
|
||||
setSelected((s) => {
|
||||
const selected = s.slice();
|
||||
for (const tag of tags as Tag[]) {
|
||||
if (tag.type === "header") continue;
|
||||
if (selected.findIndex((a) => a.id === tag.id) > -1) continue;
|
||||
if (tagHasNotes(tag, noteIds)) {
|
||||
selected.push({
|
||||
id: tag.id,
|
||||
op: "add",
|
||||
new: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
});
|
||||
}, [noteIds, tags, setSelected]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Add tags"}
|
||||
description={`Add tags to multiple notes at once`}
|
||||
onClose={() => onClose(false)}
|
||||
width={450}
|
||||
positiveButton={{
|
||||
text: "Done",
|
||||
onClick: async () => {
|
||||
for (const id of noteIds) {
|
||||
for (const item of selected) {
|
||||
if (item.op === "add") await db.notes?.note(id).tag(item.id);
|
||||
else await db.notes?.note(id).untag(item.id);
|
||||
}
|
||||
}
|
||||
notestore.refresh();
|
||||
onClose(true);
|
||||
}
|
||||
}}
|
||||
negativeButton={{
|
||||
text: "Cancel",
|
||||
onClick: () => onClose(false)
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
mt={1}
|
||||
sx={{ overflowY: "hidden", flexDirection: "column" }}
|
||||
data-test-id="tag-list"
|
||||
>
|
||||
<FilteredList
|
||||
items={getAllTags}
|
||||
placeholders={{
|
||||
empty: "Add a new tag",
|
||||
filter: "Search or add a new tag"
|
||||
}}
|
||||
filter={(tags, query) => db.lookup?.tags(tags, query) || []}
|
||||
onCreateNewItem={async (title) => {
|
||||
const tag = await db.tags?.add(title);
|
||||
setSelected((selected) => [
|
||||
...selected,
|
||||
{ id: tag.id, new: true, op: "add" }
|
||||
]);
|
||||
}}
|
||||
renderItem={(tag, _index) => {
|
||||
const selectedTag = selected.find((item) => item.id === tag.id);
|
||||
return (
|
||||
<TagItem
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
selected={selectedTag ? selectedTag.op : false}
|
||||
onSelect={() => {
|
||||
setSelected((selected) => {
|
||||
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" });
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TagItem(props: {
|
||||
tag: Item;
|
||||
selected: boolean | SelectedReference["op"];
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { tag, selected, onSelect } = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="li"
|
||||
data-test-id="tag"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
bg: "bgSecondary",
|
||||
borderRadius: "default",
|
||||
p: 1,
|
||||
background: "bgSecondary"
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<SelectedCheck size={20} selected={selected} />
|
||||
<Text
|
||||
className="title"
|
||||
data-test-id="notebook-title"
|
||||
variant="subtitle"
|
||||
sx={{ fontWeight: "body", color: "text" }}
|
||||
>
|
||||
#{tag.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddTagsDialog;
|
||||
|
||||
function SelectedCheck({
|
||||
selected,
|
||||
size = 20
|
||||
}: {
|
||||
selected: SelectedReference["op"] | boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
return selected === "add" ? (
|
||||
<Icon.CheckCircleOutline size={size} sx={{ mr: 1 }} color="primary" />
|
||||
) : selected === "remove" ? (
|
||||
<Icon.CheckRemove size={size} sx={{ mr: 1 }} color="error" />
|
||||
) : (
|
||||
<Icon.CircleEmpty size={size} sx={{ mr: 1, opacity: 0.4 }} />
|
||||
);
|
||||
}
|
||||
|
||||
function tagHasNotes(tag: Tag, noteIds: string[]) {
|
||||
return tag.noteIds.some((id) => noteIds.indexOf(id) > -1);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const LanguageSelectorDialog = React.lazy(
|
||||
const BillingHistoryDialog = React.lazy(
|
||||
() => import("./billing-history-dialog")
|
||||
);
|
||||
const AddTagsDialog = React.lazy(() => import("./add-tags-dialog"));
|
||||
|
||||
export const Dialogs = {
|
||||
AddNotebookDialog,
|
||||
@@ -89,5 +90,6 @@ export const Dialogs = {
|
||||
ReminderPreviewDialog,
|
||||
EmailChangeDialog,
|
||||
LanguageSelectorDialog,
|
||||
BillingHistoryDialog
|
||||
BillingHistoryDialog,
|
||||
AddTagsDialog
|
||||
};
|
||||
|
||||
@@ -17,12 +17,11 @@ 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, useEffect, useState } from "react";
|
||||
import { Box, Button, Flex, Input, Text } from "@theme-ui/components";
|
||||
import * as Icon from "../icons";
|
||||
import { db } from "../../common/db";
|
||||
import Dialog from "./dialog";
|
||||
import Field from "../field";
|
||||
import { useStore, store } from "../../stores/notebook-store";
|
||||
import { store as notestore } from "../../stores/note-store";
|
||||
import { Perform } from "../../common/dialog-controller";
|
||||
@@ -30,6 +29,7 @@ import { showToast } from "../../utils/toast";
|
||||
import { pluralize } from "../../utils/string";
|
||||
import { isMac } from "../../utils/platform";
|
||||
import create from "zustand";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
|
||||
type MoveDialogProps = { onClose: Perform; noteIds: string[] };
|
||||
type NotebookReference = {
|
||||
@@ -193,7 +193,7 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
|
||||
sx={{ overflowY: "hidden", flexDirection: "column" }}
|
||||
data-test-id="notebook-list"
|
||||
>
|
||||
<FilteredTree
|
||||
<FilteredList
|
||||
placeholders={{
|
||||
empty: "Add a new notebook",
|
||||
filter: "Search or add a new notebook"
|
||||
@@ -423,127 +423,6 @@ function TopicItem(props: { topic: Topic }) {
|
||||
|
||||
export default MoveDialog;
|
||||
|
||||
type FilteredTreeProps<T extends Item> = {
|
||||
placeholders: { filter: string; empty: string };
|
||||
items: () => T[];
|
||||
filter: (items: T[], query: string) => T[];
|
||||
onCreateNewItem: (title: string) => Promise<void>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
refresh: () => void,
|
||||
isSearching: boolean
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
function FilteredTree<T extends Item>(props: FilteredTreeProps<T>) {
|
||||
const {
|
||||
items: _items,
|
||||
filter,
|
||||
onCreateNewItem,
|
||||
placeholders,
|
||||
renderItem
|
||||
} = props;
|
||||
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [query, setQuery] = useState<string>();
|
||||
const noItemsFound = items.length <= 0 && query && query.length > 0;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems(_items());
|
||||
}, [_items]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const _filter = useCallback(
|
||||
(query) => {
|
||||
setItems(() => {
|
||||
const items = _items();
|
||||
if (!query) {
|
||||
return items;
|
||||
}
|
||||
return filter(items, query);
|
||||
});
|
||||
setQuery(query);
|
||||
},
|
||||
[_items, filter]
|
||||
);
|
||||
|
||||
const _createNewItem = useCallback(
|
||||
async (title) => {
|
||||
await onCreateNewItem(title);
|
||||
refresh();
|
||||
setQuery(undefined);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[inputRef, refresh, onCreateNewItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
inputRef={inputRef}
|
||||
data-test-id={"filter-input"}
|
||||
autoFocus
|
||||
placeholder={
|
||||
items.length <= 0 ? placeholders.empty : placeholders.filter
|
||||
}
|
||||
onChange={(e: ChangeEvent) =>
|
||||
_filter((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onKeyUp={async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && noItemsFound) {
|
||||
await _createNewItem(query);
|
||||
}
|
||||
}}
|
||||
action={
|
||||
items.length <= 0
|
||||
? {
|
||||
icon: Icon.Plus,
|
||||
onClick: async () => await _createNewItem(query)
|
||||
}
|
||||
: { icon: 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>
|
||||
<Icon.Plus size={16} color="primary" />
|
||||
</Button>
|
||||
)}
|
||||
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedCheck({
|
||||
item,
|
||||
size = 20
|
||||
|
||||
@@ -72,7 +72,7 @@ function Header({ readonly }) {
|
||||
}
|
||||
export default Header;
|
||||
|
||||
function Autosuggest({
|
||||
export function Autosuggest({
|
||||
sessionId,
|
||||
filter,
|
||||
onRemove,
|
||||
|
||||
151
apps/web/src/components/filtered-list/index.tsx
Normal file
151
apps/web/src/components/filtered-list/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ChangeEvent, useCallback, useEffect, 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;
|
||||
};
|
||||
|
||||
type FilteredListProps<T extends FilterableItem> = {
|
||||
placeholders: { filter: string; empty: string };
|
||||
items: () => T[];
|
||||
filter: (items: T[], query: string) => T[];
|
||||
onCreateNewItem: (title: string) => Promise<void>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
refresh: () => void,
|
||||
isSearching: boolean
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
export function FilteredList<T extends FilterableItem>(
|
||||
props: FilteredListProps<T>
|
||||
) {
|
||||
const {
|
||||
items: _items,
|
||||
filter,
|
||||
onCreateNewItem,
|
||||
placeholders,
|
||||
renderItem
|
||||
} = props;
|
||||
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [query, setQuery] = useState<string>();
|
||||
const noItemsFound = items.length <= 0 && query && query.length > 0;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems(_items());
|
||||
}, [_items]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const _filter = useCallback(
|
||||
(query) => {
|
||||
setItems(() => {
|
||||
const items = _items();
|
||||
if (!query) {
|
||||
return items;
|
||||
}
|
||||
return filter(items, query);
|
||||
});
|
||||
setQuery(query);
|
||||
},
|
||||
[_items, filter]
|
||||
);
|
||||
|
||||
const _createNewItem = useCallback(
|
||||
async (title) => {
|
||||
await onCreateNewItem(title);
|
||||
refresh();
|
||||
setQuery(undefined);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[inputRef, refresh, onCreateNewItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
inputRef={inputRef}
|
||||
data-test-id={"filter-input"}
|
||||
autoFocus
|
||||
placeholder={
|
||||
items.length <= 0 ? placeholders.empty : placeholders.filter
|
||||
}
|
||||
onChange={(e: ChangeEvent) =>
|
||||
_filter((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onKeyUp={async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && noItemsFound) {
|
||||
await _createNewItem(query);
|
||||
}
|
||||
}}
|
||||
action={
|
||||
items.length <= 0
|
||||
? {
|
||||
icon: Plus,
|
||||
onClick: async () => await _createNewItem(query)
|
||||
}
|
||||
: { 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="primary" />
|
||||
</Button>
|
||||
)}
|
||||
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import ListItem from "../list-item";
|
||||
import {
|
||||
confirm,
|
||||
showAddReminderDialog,
|
||||
showAddTagsDialog,
|
||||
showMoveNoteDialog
|
||||
} from "../../common/dialog-controller";
|
||||
import { store, useStore } from "../../stores/note-store";
|
||||
@@ -372,6 +373,16 @@ const menuItems = [
|
||||
icon: Icon.Colors,
|
||||
items: colorsToMenuItems()
|
||||
},
|
||||
{
|
||||
key: "add-tags",
|
||||
title: "Tags",
|
||||
icon: Icon.Tag2,
|
||||
multiSelect: true,
|
||||
items: tagsMenuItems
|
||||
// onClick: async ({ items }) => {
|
||||
// await showAddTagsDialog(items.map((i) => i.id));
|
||||
// }
|
||||
},
|
||||
{ key: "sep2", type: "separator" },
|
||||
{
|
||||
key: "print",
|
||||
@@ -598,3 +609,60 @@ function notebooksMenuItems({ items }) {
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
function tagsMenuItems({ items }) {
|
||||
const noteIds = items.map((i) => i.id);
|
||||
|
||||
const menuItems = [];
|
||||
menuItems.push({
|
||||
key: "assign-tags",
|
||||
title: "Assign to...",
|
||||
icon: Icon.Plus,
|
||||
onClick: async () => {
|
||||
await showAddTagsDialog(noteIds);
|
||||
}
|
||||
});
|
||||
|
||||
const tags = items.map((note) => note.tags).flat();
|
||||
|
||||
if (tags?.length > 0) {
|
||||
menuItems.push(
|
||||
{
|
||||
key: "remove-from-all-tags",
|
||||
title: "Remove from all",
|
||||
icon: Icon.RemoveShortcutLink,
|
||||
onClick: async () => {
|
||||
for (const note of items) {
|
||||
for (const tag of tags) {
|
||||
if (!note.tags.includes(tag)) continue;
|
||||
await db.notes.note(note).untag(tag);
|
||||
}
|
||||
}
|
||||
store.refresh();
|
||||
}
|
||||
},
|
||||
{ key: "sep", type: "separator" }
|
||||
);
|
||||
|
||||
tags?.forEach((tag) => {
|
||||
if (menuItems.find((item) => item.key === tag)) return;
|
||||
|
||||
menuItems.push({
|
||||
key: tag,
|
||||
title: db.tags.alias(tag),
|
||||
icon: Icon.Tag,
|
||||
checked: true,
|
||||
tooltip: "Click to remove from this tag",
|
||||
onClick: async () => {
|
||||
for (const note of items) {
|
||||
if (!note.tags.includes(tag)) continue;
|
||||
await db.notes.note(note).untag(tag);
|
||||
}
|
||||
store.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
@@ -194,7 +194,8 @@ export default class Note {
|
||||
}
|
||||
|
||||
async untag(tag) {
|
||||
if (deleteItem(this._note.tags, tag)) {
|
||||
const tagItem = this._db.tags.tag(tag);
|
||||
if (tagItem && deleteItem(this._note.tags, tagItem.title)) {
|
||||
await this._db.notes.add(this._note);
|
||||
} else console.error("This note is not tagged by the specified tag.", tag);
|
||||
await this._db.tags.untag(tag, this._note.id);
|
||||
|
||||
Reference in New Issue
Block a user