aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/browser-extension/src/components/TagsSelector.tsx2
-rw-r--r--apps/cli/src/commands/dump.ts17
-rw-r--r--apps/cli/src/commands/migrate.ts4
-rw-r--r--apps/cli/src/commands/tags.ts2
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx2
-rw-r--r--apps/mobile/app/dashboard/search.tsx2
-rw-r--r--apps/mobile/package.json1
-rw-r--r--apps/web/app/dashboard/tags/page.tsx5
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx483
-rw-r--r--apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx11
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx434
-rw-r--r--apps/web/components/dashboard/tags/MergeTagModal.tsx8
-rw-r--r--apps/web/components/dashboard/tags/MultiTagSelector.tsx4
-rw-r--r--apps/web/components/dashboard/tags/TagAutocomplete.tsx46
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx4
-rw-r--r--apps/web/components/dashboard/tags/TagSelector.tsx57
-rw-r--r--apps/web/lib/bulkTagActions.ts7
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json12
-rw-r--r--apps/workers/workers/inference/tagging.ts2
-rw-r--r--packages/api/routes/tags.ts23
-rw-r--r--packages/e2e_tests/tests/api/tags.test.ts101
-rw-r--r--packages/open-api/karakeep-openapi-spec.json61
-rw-r--r--packages/open-api/lib/tags.ts6
-rw-r--r--packages/sdk/src/karakeep-api.d.ts9
-rw-r--r--packages/shared-react/hooks/tags.ts35
-rw-r--r--packages/shared-react/hooks/use-debounce.ts17
-rw-r--r--packages/shared/types/tags.ts58
-rw-r--r--packages/shared/utils/switch.ts6
-rw-r--r--packages/trpc/models/tags.ts130
-rw-r--r--packages/trpc/routers/tags.test.ts635
-rw-r--r--packages/trpc/routers/tags.ts27
-rw-r--r--pnpm-lock.yaml13
32 files changed, 1731 insertions, 493 deletions
diff --git a/apps/browser-extension/src/components/TagsSelector.tsx b/apps/browser-extension/src/components/TagsSelector.tsx
index 91864603..ce404ac8 100644
--- a/apps/browser-extension/src/components/TagsSelector.tsx
+++ b/apps/browser-extension/src/components/TagsSelector.tsx
@@ -22,7 +22,7 @@ import { DynamicPopoverContent } from "./ui/dynamic-popover";
import { Popover, PopoverTrigger } from "./ui/popover";
export function TagsSelector({ bookmarkId }: { bookmarkId: string }) {
- const { data: allTags } = api.tags.list.useQuery();
+ const { data: allTags } = api.tags.list.useQuery({});
const { data: bookmark } = useAutoRefreshingBookmarkQuery({ bookmarkId });
const existingTagIds = new Set(bookmark?.tags.map((t) => t.id) ?? []);
diff --git a/apps/cli/src/commands/dump.ts b/apps/cli/src/commands/dump.ts
index 5f3f8f5e..6f473182 100644
--- a/apps/cli/src/commands/dump.ts
+++ b/apps/cli/src/commands/dump.ts
@@ -12,6 +12,7 @@ import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
import type { ZBookmarkList } from "@karakeep/shared/types/lists";
import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
import { ZCursor } from "@karakeep/shared/types/pagination";
+import { MAX_NUM_TAGS_PER_PAGE } from "@karakeep/shared/types/tags";
const OK = chalk.green("✓");
const FAIL = chalk.red("✗");
@@ -191,9 +192,19 @@ export const dumpCmd = new Command()
// 3) Tags
if (!opts.excludeTags) {
stepStart("Exporting tags");
- const { tags } = await api.tags.list.query();
- await writeJson(path.join(workRoot, "tags", "index.json"), tags);
- manifest.counts.tags = tags.length;
+
+ let cursor = null;
+ let allTags = [];
+ do {
+ const { tags, nextCursor } = await api.tags.list.query({
+ limit: MAX_NUM_TAGS_PER_PAGE,
+ cursor,
+ });
+ allTags.push(...tags);
+ cursor = nextCursor;
+ } while (cursor);
+ await writeJson(path.join(workRoot, "tags", "index.json"), allTags);
+ manifest.counts.tags = allTags.length;
stepEndSuccess();
}
diff --git a/apps/cli/src/commands/migrate.ts b/apps/cli/src/commands/migrate.ts
index 750daf61..ee0d85c8 100644
--- a/apps/cli/src/commands/migrate.ts
+++ b/apps/cli/src/commands/migrate.ts
@@ -490,7 +490,7 @@ async function migrateTags(
onProgress?: (ensured: number, total: number) => void,
) {
try {
- const { tags: srcTags } = await src.tags.list.query();
+ const { tags: srcTags } = await src.tags.list.query({});
// Create tags by name; ignore if exist
let ensured = 0;
for (const t of srcTags) {
@@ -503,7 +503,7 @@ async function migrateTags(
onProgress?.(ensured, srcTags.length);
}
// Build id map using destination's current tags
- const { tags: destTags } = await dest.tags.list.query();
+ const { tags: destTags } = await dest.tags.list.query({});
const nameToDestId = destTags.reduce<Record<string, string>>((acc, t) => {
acc[t.name] = t.id;
return acc;
diff --git a/apps/cli/src/commands/tags.ts b/apps/cli/src/commands/tags.ts
index 14cb7f10..b15fef90 100644
--- a/apps/cli/src/commands/tags.ts
+++ b/apps/cli/src/commands/tags.ts
@@ -20,7 +20,7 @@ tagsCmd
const api = getAPIClient();
try {
- const tags = (await api.tags.list.query()).tags;
+ const tags = (await api.tags.list.query({})).tags;
tags.sort((a, b) => b.numBookmarks - a.numBookmarks);
if (getGlobalOptions().json) {
printObject(tags);
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
index ea6c2f4d..a4575b27 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
@@ -35,7 +35,7 @@ const ListPickerPage = () => {
};
const { data: allTags, isPending: isAllTagsPending } = api.tags.list.useQuery(
- undefined,
+ {},
{
select: React.useCallback(
(data: { tags: { id: string; name: string }[] }) => {
diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx
index 66423870..5fababc3 100644
--- a/apps/mobile/app/dashboard/search.tsx
+++ b/apps/mobile/app/dashboard/search.tsx
@@ -10,9 +10,9 @@ import { Text } from "@/components/ui/Text";
import { api } from "@/lib/trpc";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { keepPreviousData } from "@tanstack/react-query";
-import { useDebounce } from "use-debounce";
import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
const MAX_DISPLAY_SUGGESTIONS = 5;
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 8a942349..73f9f3ab 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -62,7 +62,6 @@
"react-native-svg": "^15.11.2",
"react-native-webview": "^13.13.5",
"tailwind-merge": "^2.2.1",
- "use-debounce": "^10.0.0",
"zod": "^3.24.2",
"zustand": "^5.0.5"
},
diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx
index f8e4d8ea..b2acd45b 100644
--- a/apps/web/app/dashboard/tags/page.tsx
+++ b/apps/web/app/dashboard/tags/page.tsx
@@ -1,8 +1,5 @@
import AllTagsView from "@/components/dashboard/tags/AllTagsView";
-import { api } from "@/server/api/client";
export default async function TagsPage() {
- const allTags = (await api.tags.list()).tags;
-
- return <AllTagsView initialData={allTags} />;
+ return <AllTagsView />;
}
diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
index f80ba963..7c6393c3 100644
--- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
@@ -1,21 +1,25 @@
-import type { ActionMeta } from "react-select";
-import { useState } from "react";
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
-import { Sparkles } from "lucide-react";
-import CreateableSelect from "react-select/creatable";
-
-import type {
- ZAttachedByEnum,
- ZBookmarkTags,
-} from "@karakeep/shared/types/tags";
-
-interface EditableTag {
- attachedBy: ZAttachedByEnum;
- value?: string;
- label: string;
-}
+import { keepPreviousData } from "@tanstack/react-query";
+import { Command as CommandPrimitive } from "cmdk";
+import { Check, Loader2, Plus, Sparkles, X } from "lucide-react";
+
+import type { ZBookmarkTags } from "@karakeep/shared/types/tags";
export function TagsEditor({
tags: _tags,
@@ -27,167 +31,354 @@ export function TagsEditor({
onDetach: (tag: { tagName: string; tagId: string }) => void;
}) {
const demoMode = !!useClientConfig().demoMode;
-
+ const isDisabled = demoMode;
+ const inputRef = React.useRef<HTMLInputElement>(null);
+ const containerRef = React.useRef<HTMLDivElement>(null);
+ const [open, setOpen] = React.useState(false);
+ const [inputValue, setInputValue] = React.useState("");
const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags);
+ const tempIdCounter = React.useRef(0);
+
+ const generateTempId = React.useCallback(() => {
+ tempIdCounter.current += 1;
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return `temp-${crypto.randomUUID()}`;
+ }
+
+ return `temp-${Date.now()}-${tempIdCounter.current}`;
+ }, []);
- const { data: existingTags, isLoading: isExistingTagsLoading } =
- api.tags.list.useQuery(undefined, {
- select: (data) => ({
- tags: data.tags.sort((a, b) => a.name.localeCompare(b.name)),
- }),
+ React.useEffect(() => {
+ setOptimisticTags((prev) => {
+ let results = prev;
+ for (const tag of _tags) {
+ const idx = results.findIndex((t) => t.name === tag.name);
+ if (idx == -1) {
+ results.push(tag);
+ continue;
+ }
+ if (results[idx].id.startsWith("temp-")) {
+ results[idx] = tag;
+ continue;
+ }
+ }
+ return results;
});
+ }, [_tags]);
+
+ const { data: filteredOptions, isLoading: isExistingTagsLoading } =
+ api.tags.list.useQuery(
+ {
+ nameContains: inputValue,
+ limit: 50,
+ sortBy: inputValue.length > 0 ? "relevance" : "usage",
+ },
+ {
+ select: (data) =>
+ data.tags.map((t) => ({
+ id: t.id,
+ name: t.name,
+ attachedBy:
+ (t.numBookmarksByAttachedType.human ?? 0) > 0
+ ? ("human" as const)
+ : ("ai" as const),
+ })),
+ placeholderData: keepPreviousData,
+ gcTime: inputValue.length > 0 ? 60_000 : 3_600_000,
+ },
+ );
+
+ const selectedValues = optimisticTags.map((tag) => tag.id);
+
+ // Add "create new" option if input doesn't match any existing option
+ const trimmedInputValue = inputValue.trim();
+
+ interface DisplayOption {
+ id: string;
+ name: string;
+ label: string;
+ attachedBy: "human" | "ai";
+ isCreateOption?: boolean;
+ }
+
+ const displayedOptions = React.useMemo<DisplayOption[]>(() => {
+ if (!filteredOptions) return [];
+
+ const baseOptions = filteredOptions.map((option) => ({
+ ...option,
+ label: option.name,
+ }));
+
+ if (!trimmedInputValue) {
+ return baseOptions;
+ }
+
+ const exactMatch = baseOptions.some(
+ (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(),
+ );
+
+ if (!exactMatch) {
+ return [
+ {
+ id: "create-new",
+ name: trimmedInputValue,
+ label: `Create "${trimmedInputValue}"`,
+ attachedBy: "human" as const,
+ isCreateOption: true,
+ },
+ ...baseOptions,
+ ];
+ }
+
+ return baseOptions;
+ }, [filteredOptions, trimmedInputValue]);
const onChange = (
- _option: readonly EditableTag[],
- actionMeta: ActionMeta<EditableTag>,
+ actionMeta:
+ | { action: "create-option"; name: string }
+ | { action: "select-option"; id: string; name: string }
+ | {
+ action: "remove-value";
+ id: string;
+ name: string;
+ },
) => {
switch (actionMeta.action) {
- case "pop-value":
case "remove-value": {
- if (actionMeta.removedValue.value) {
- setOptimisticTags((prev) =>
- prev.filter((t) => t.id != actionMeta.removedValue.value),
- );
- onDetach({
- tagId: actionMeta.removedValue.value,
- tagName: actionMeta.removedValue.label,
- });
- }
+ setOptimisticTags((prev) => prev.filter((t) => t.id != actionMeta.id));
+ onDetach({
+ tagId: actionMeta.id,
+ tagName: actionMeta.name,
+ });
break;
}
case "create-option": {
+ const tempId = generateTempId();
setOptimisticTags((prev) => [
...prev,
{
- id: "",
- name: actionMeta.option.label,
+ id: tempId,
+ name: actionMeta.name,
attachedBy: "human" as const,
},
]);
- onAttach({ tagName: actionMeta.option.label });
+ onAttach({ tagName: actionMeta.name });
break;
}
case "select-option": {
- if (actionMeta.option) {
- setOptimisticTags((prev) => [
+ setOptimisticTags((prev) => {
+ if (prev.some((tag) => tag.id === actionMeta.id)) {
+ return prev;
+ }
+
+ return [
...prev,
{
- id: actionMeta.option?.value ?? "",
- name: actionMeta.option!.label,
+ id: actionMeta.id,
+ name: actionMeta.name,
attachedBy: "human" as const,
},
- ]);
- onAttach({
- tagName: actionMeta.option.label,
- tagId: actionMeta.option?.value,
- });
- }
+ ];
+ });
+ onAttach({
+ tagName: actionMeta.name,
+ tagId: actionMeta.id,
+ });
break;
}
}
};
+ const createTag = () => {
+ if (!inputValue.trim()) return;
+ onChange({ action: "create-option", name: inputValue.trim() });
+ setInputValue("");
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ } else if (
+ e.key === "Backspace" &&
+ !inputValue &&
+ optimisticTags.length > 0
+ ) {
+ const lastTag = optimisticTags.slice(-1)[0];
+ onChange({
+ action: "remove-value",
+ id: lastTag.id,
+ name: lastTag.name,
+ });
+ }
+ };
+
+ const handleSelect = (option: DisplayOption) => {
+ if (option.isCreateOption) {
+ onChange({ action: "create-option", name: option.name });
+ setInputValue("");
+ inputRef.current?.focus();
+ return;
+ }
+
+ // If already selected, remove it
+ if (selectedValues.includes(option.id)) {
+ onChange({
+ action: "remove-value",
+ id: option.id,
+ name: option.name,
+ });
+ } else {
+ // Add the new tag
+ onChange({
+ action: "select-option",
+ id: option.id,
+ name: option.name,
+ });
+ }
+
+ // Reset input and keep focus
+ setInputValue("");
+ inputRef.current?.focus();
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ setOpen(open);
+ if (open) {
+ // Focus the input
+ setTimeout(() => {
+ inputRef.current?.focus();
+ }, 0);
+ }
+ };
+
return (
- <CreateableSelect
- isDisabled={demoMode}
- onChange={onChange}
- options={
- existingTags?.tags.map((t) => ({
- label: t.name,
- value: t.id,
- attachedBy: "human" as const,
- })) ?? []
- }
- value={optimisticTags.slice().map((t) => ({
- label: t.name,
- value: t.id,
- attachedBy: t.attachedBy,
- }))}
- isMulti
- closeMenuOnSelect={false}
- isClearable={false}
- isLoading={isExistingTagsLoading}
- theme={(theme) => ({
- ...theme,
- // This color scheme doesn't support disabled options.
- colors: {
- ...theme.colors,
- primary: "hsl(var(--accent))",
- primary50: "hsl(var(--accent))",
- primary75: "hsl(var(--accent))",
- primary25: "hsl(var(--accent))",
- },
- })}
- styles={{
- multiValueRemove: () => ({
- backgroundColor: "transparent",
- }),
- valueContainer: (styles) => ({
- ...styles,
- padding: "0.5rem",
- maxHeight: "14rem",
- overflowY: "auto",
- scrollbarWidth: "thin",
- }),
- container: (styles) => ({
- ...styles,
- width: "100%",
- }),
- control: (styles) => ({
- ...styles,
- overflow: "hidden",
- backgroundColor: "hsl(var(--background))",
- borderColor: "hsl(var(--border))",
- ":hover": {
- borderColor: "hsl(var(--border))",
- },
- }),
- input: (styles) => ({
- ...styles,
- color: "rgb(156 163 175)",
- }),
- menu: (styles) => ({
- ...styles,
- overflow: "hidden",
- color: "rgb(156 163 175)",
- }),
- placeholder: (styles) => ({
- ...styles,
- color: "hsl(var(--muted-foreground))",
- }),
- }}
- components={{
- MultiValueContainer: ({ children, data }) => (
- <div
- className={cn(
- "flex min-h-8 space-x-1 rounded px-2",
- (data as { attachedBy: string }).attachedBy == "ai"
- ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
- : "bg-accent",
- )}
+ <div ref={containerRef}>
+ <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}>
+ <Command shouldFilter={false}>
+ <PopoverTrigger asChild>
+ <div
+ className={cn(
+ "relative flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
+ isDisabled && "cursor-not-allowed opacity-50",
+ )}
+ >
+ {optimisticTags.length > 0 && (
+ <>
+ {optimisticTags.map((tag) => (
+ <div
+ key={tag.id}
+ className={cn(
+ "flex min-h-8 space-x-1 rounded px-2",
+ tag.attachedBy == "ai"
+ ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
+ : "bg-accent",
+ )}
+ >
+ <div className="m-auto flex gap-2">
+ {tag.attachedBy === "ai" && (
+ <Sparkles className="m-auto size-4" />
+ )}
+ {tag.name}
+ {!isDisabled && (
+ <button
+ type="button"
+ className="rounded-full outline-none ring-offset-background focus:ring-1 focus:ring-ring focus:ring-offset-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ onChange({
+ action: "remove-value",
+ id: tag.id,
+ name: tag.name,
+ });
+ }}
+ >
+ <X className="h-3 w-3" />
+ <span className="sr-only">Remove {tag.name}</span>
+ </button>
+ )}
+ </div>
+ </div>
+ ))}
+ </>
+ )}
+ <CommandPrimitive.Input
+ ref={inputRef}
+ value={inputValue}
+ onKeyDown={handleKeyDown}
+ onValueChange={(v) => setInputValue(v)}
+ className="bg-transparent outline-none placeholder:text-muted-foreground"
+ style={{ width: `${Math.max(inputValue.length, 1)}ch` }}
+ disabled={isDisabled}
+ />
+ {isExistingTagsLoading && (
+ <div className="absolute bottom-2 right-2">
+ <Loader2 className="h-4 w-4 animate-spin opacity-50" />
+ </div>
+ )}
+ </div>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-[--radix-popover-trigger-width] p-0"
+ align="start"
>
- {children}
- </div>
- ),
- MultiValueLabel: ({ children, data }) => (
- <div className="m-auto flex gap-2">
- {(data as { attachedBy: string }).attachedBy == "ai" && (
- <Sparkles className="m-auto size-4" />
- )}
- {children}
- </div>
- ),
- DropdownIndicator: () => <span />,
- IndicatorSeparator: () => <span />,
- }}
- classNames={{
- multiValueRemove: () => "my-auto",
- valueContainer: () => "gap-2 bg-background text-sm",
- menu: () => "dark:text-gray-300",
- menuList: () => "bg-background text-sm",
- option: () => "text-red-500",
- input: () => "dark:text-gray-300",
- }}
- />
+ <CommandList className="max-h-64">
+ {displayedOptions.length === 0 ? (
+ <CommandEmpty>
+ {trimmedInputValue ? (
+ <div className="flex items-center justify-between px-2 py-1.5">
+ <span>Create &quot;{trimmedInputValue}&quot;</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={createTag}
+ className="h-auto p-1"
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ </div>
+ ) : (
+ "No tags found."
+ )}
+ </CommandEmpty>
+ ) : (
+ <CommandGroup>
+ {displayedOptions.map((option) => {
+ const isSelected = selectedValues.includes(option.id);
+ return (
+ <CommandItem
+ key={
+ option.isCreateOption
+ ? `create-${option.name}`
+ : option.id
+ }
+ value={option.label}
+ onSelect={() => handleSelect(option)}
+ >
+ <div className="flex w-full items-center gap-2">
+ {option.isCreateOption ? (
+ <Plus className="h-4 w-4" />
+ ) : (
+ <Check
+ className={cn(
+ "h-4 w-4",
+ isSelected ? "opacity-100" : "opacity-0",
+ )}
+ />
+ )}
+ <span>{option.name}</span>
+ </div>
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </PopoverContent>
+ </Command>
+ </Popover>
+ </div>
);
}
diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx
index afc70f24..52a9ab0c 100644
--- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx
+++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx
@@ -200,15 +200,18 @@ function SuggestionRow({
export function TagDuplicationDetection() {
const [expanded, setExpanded] = useState(false);
- let { data: allTags } = api.tags.list.useQuery(undefined, {
- refetchOnWindowFocus: false,
- });
+ let { data: allTags } = api.tags.list.useQuery(
+ {},
+ {
+ refetchOnWindowFocus: false,
+ },
+ );
const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } =
useSuggestions();
useEffect(() => {
- allTags = allTags ?? { tags: [] };
+ allTags = allTags ?? { tags: [], nextCursor: null };
const sortedTags = allTags.tags.sort((a, b) =>
normalizeTag(a.name).localeCompare(normalizeTag(b.name)),
);
diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx
index 73d9a595..c21f9aac 100644
--- a/apps/web/components/dashboard/tags/AllTagsView.tsx
+++ b/apps/web/components/dashboard/tags/AllTagsView.tsx
@@ -13,20 +13,29 @@ import {
CardTitle,
} from "@/components/ui/card";
import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import InfoTooltip from "@/components/ui/info-tooltip";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import Spinner from "@/components/ui/spinner";
import { Toggle } from "@/components/ui/toggle";
import { toast } from "@/components/ui/use-toast";
import useBulkTagActionsStore from "@/lib/bulkTagActions";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
-import { ArrowDownAZ, Combine, Tag } from "lucide-react";
+import { ArrowDownAZ, ChevronDown, Combine, Search, Tag } from "lucide-react";
+import { parseAsStringEnum, useQueryState } from "nuqs";
import type { ZGetTagResponse, ZTagBasic } from "@karakeep/shared/types/tags";
-import { useDeleteUnusedTags } from "@karakeep/shared-react/hooks/tags";
+import {
+ useDeleteUnusedTags,
+ usePaginatedSearchTags,
+} from "@karakeep/shared-react/hooks/tags";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
import BulkTagAction from "./BulkTagAction";
import { CreateTagModal } from "./CreateTagModal";
@@ -70,104 +79,169 @@ function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) {
);
}
-const byUsageSorter = (a: ZGetTagResponse, b: ZGetTagResponse) => {
- // Sort by name if the usage is the same to get a stable result
- if (b.numBookmarks == a.numBookmarks) {
- return byNameSorter(a, b);
- }
- return b.numBookmarks - a.numBookmarks;
-};
-const byNameSorter = (a: ZGetTagResponse, b: ZGetTagResponse) =>
- a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
-
-export default function AllTagsView({
- initialData,
-}: {
- initialData: ZGetTagResponse[];
-}) {
+export default function AllTagsView() {
const { t } = useTranslation();
+
+ const [searchQueryRaw, setSearchQuery] = useQueryState("q", {
+ defaultValue: "",
+ });
+ const searchQuery = useDebounce(searchQueryRaw, 100);
+ const [sortBy, setSortBy] = useQueryState<"name" | "usage" | "relevance">(
+ "sort",
+ parseAsStringEnum(["name", "usage", "relevance"])
+ .withOptions({
+ clearOnDefault: true,
+ })
+ .withDefault("usage"),
+ );
+ const hasActiveSearch = searchQuery.length > 0;
const [draggingEnabled, setDraggingEnabled] = React.useState(false);
- const [sortByName, setSortByName] = React.useState(false);
- const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const [selectedTag, setSelectedTag] = React.useState<ZTagBasic | null>(null);
+ const isDialogOpen = !!selectedTag;
const { setVisibleTagIds, isBulkEditEnabled } = useBulkTagActionsStore();
- const handleOpenDialog = (tag: ZTagBasic) => {
+ const handleOpenDialog = React.useCallback((tag: ZTagBasic) => {
setSelectedTag(tag);
- setIsDialogOpen(true);
- };
-
- function toggleSortByName(): void {
- setSortByName(!sortByName);
- }
+ }, []);
function toggleDraggingEnabled(): void {
setDraggingEnabled(!draggingEnabled);
}
- const { data } = api.tags.list.useQuery(undefined, {
- initialData: { tags: initialData },
+ const {
+ data: allHumanTagsRaw,
+ isFetching: isHumanTagsFetching,
+ isLoading: isHumanTagsLoading,
+ hasNextPage: hasNextPageHumanTags,
+ fetchNextPage: fetchNextPageHumanTags,
+ isFetchingNextPage: isFetchingNextPageHumanTags,
+ } = usePaginatedSearchTags({
+ nameContains: searchQuery,
+ sortBy,
+ attachedBy: "human",
+ limit: 50,
});
+ const {
+ data: allAiTagsRaw,
+ isFetching: isAiTagsFetching,
+ isLoading: isAiTagsLoading,
+ hasNextPage: hasNextPageAiTags,
+ fetchNextPage: fetchNextPageAiTags,
+ isFetchingNextPage: isFetchingNextPageAiTags,
+ } = usePaginatedSearchTags({
+ nameContains: searchQuery,
+ sortBy,
+ attachedBy: "ai",
+ limit: 50,
+ });
+
+ const {
+ data: allEmptyTagsRaw,
+ isFetching: isEmptyTagsFetching,
+ isLoading: isEmptyTagsLoading,
+ hasNextPage: hasNextPageEmptyTags,
+ fetchNextPage: fetchNextPageEmptyTags,
+ isFetchingNextPage: isFetchingNextPageEmptyTags,
+ } = usePaginatedSearchTags({
+ nameContains: searchQuery,
+ sortBy,
+ attachedBy: "none",
+ limit: 50,
+ });
+
+ const isFetching =
+ isHumanTagsFetching || isAiTagsFetching || isEmptyTagsFetching;
+
+ const { allHumanTags, allAiTags, allEmptyTags } = React.useMemo(() => {
+ return {
+ allHumanTags: allHumanTagsRaw?.tags ?? [],
+ allAiTags: allAiTagsRaw?.tags ?? [],
+ allEmptyTags: allEmptyTagsRaw?.tags ?? [],
+ };
+ }, [allHumanTagsRaw, allAiTagsRaw, allEmptyTagsRaw]);
+
useEffect(() => {
- const visibleTagIds = data.tags.map((tag) => tag.id);
- setVisibleTagIds(visibleTagIds);
+ const allTags = [...allHumanTags, ...allAiTags, ...allEmptyTags];
+ setVisibleTagIds(allTags.map((tag) => tag.id) ?? []);
return () => {
setVisibleTagIds([]);
};
- }, [data.tags]);
+ }, [allHumanTags, allAiTags, allEmptyTags, setVisibleTagIds]);
- // Sort tags by usage desc
- const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter);
+ const sortLabels: Record<typeof sortBy, string> = {
+ name: t("tags.sort_by_name"),
+ usage: t("tags.sort_by_usage"),
+ relevance: t("tags.sort_by_relevance"),
+ };
- const humanTags = allTags.filter(
- (t) => (t.numBookmarksByAttachedType.human ?? 0) > 0,
- );
- const aiTags = allTags.filter(
- (t) =>
- (t.numBookmarksByAttachedType.human ?? 0) == 0 &&
- (t.numBookmarksByAttachedType.ai ?? 0) > 0,
- );
- const emptyTags = allTags.filter((t) => t.numBookmarks === 0);
+ const tagsToPill = React.useMemo(
+ () =>
+ (
+ tags: ZGetTagResponse[],
+ bulkEditEnabled: boolean,
+ {
+ emptyMessage,
+ searchEmptyMessage,
+ }: { emptyMessage: string; searchEmptyMessage: string },
+ isLoading: boolean,
+ ) => {
+ if (isLoading && tags.length === 0) {
+ return (
+ <div className="flex flex-wrap gap-3">
+ {Array.from({ length: 15 }).map((_, index) => (
+ <Skeleton key={`tag-skeleton-${index}`} className="h-9 w-24" />
+ ))}
+ </div>
+ );
+ }
- const tagsToPill = (tags: typeof allTags, bulkEditEnabled: boolean) => {
- let tagPill;
- if (tags.length) {
- tagPill = (
- <div className="flex flex-wrap gap-3">
- {tags.map((t) =>
- bulkEditEnabled ? (
- <MultiTagSelector
- key={t.id}
- id={t.id}
- name={t.name}
- count={t.numBookmarks}
- />
- ) : (
- <TagPill
- key={t.id}
- id={t.id}
- name={t.name}
- count={t.numBookmarks}
- isDraggable={draggingEnabled}
- onOpenDialog={handleOpenDialog}
- />
- ),
- )}
- </div>
- );
- } else {
- tagPill = (
- <div className="py-8 text-center">
- <Tag className="mx-auto mb-4 h-12 w-12 text-gray-300" />
- <p className="mb-4 text-gray-500">No custom tags yet</p>
- </div>
- );
- }
- return tagPill;
- };
+ if (tags.length === 0) {
+ return (
+ <div className="py-8 text-center">
+ <Tag className="mx-auto mb-4 h-12 w-12 text-gray-300" />
+ <p className="mb-4 text-gray-500">
+ {hasActiveSearch ? searchEmptyMessage : emptyMessage}
+ </p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-wrap gap-3">
+ {tags.map((t) =>
+ bulkEditEnabled ? (
+ <MultiTagSelector
+ key={t.id}
+ id={t.id}
+ name={t.name}
+ count={t.numBookmarks}
+ />
+ ) : (
+ <TagPill
+ key={t.id}
+ id={t.id}
+ name={t.name}
+ count={t.numBookmarks}
+ isDraggable={draggingEnabled}
+ onOpenDialog={handleOpenDialog}
+ />
+ ),
+ )}
+ {isLoading &&
+ Array.from({ length: 3 }).map((_, index) => (
+ <Skeleton
+ key={`tag-skeleton-loading-${index}`}
+ className="h-9 w-24"
+ />
+ ))}
+ </div>
+ );
+ },
+ [draggingEnabled, handleOpenDialog, hasActiveSearch],
+ );
return (
<div className="flex flex-col gap-4">
{selectedTag && (
@@ -178,83 +252,173 @@ export default function AllTagsView({
if (!o) {
setSelectedTag(null);
}
- setIsDialogOpen(o);
}}
/>
)}
- <div className="flex justify-between gap-x-2">
- <span className="text-2xl">{t("tags.all_tags")}</span>
- <div className="flex gap-x-2">
- <CreateTagModal />
- <BulkTagAction />
- <Toggle
- variant="outline"
- className="bg-background"
- aria-label="Toggle bold"
- pressed={draggingEnabled}
- onPressedChange={toggleDraggingEnabled}
- disabled={isBulkEditEnabled}
- >
- <Combine className="mr-2 size-4" />
- {t("tags.drag_and_drop_merging")}
- <InfoTooltip size={15} className="my-auto ml-2" variant="explain">
- <p>{t("tags.drag_and_drop_merging_info")}</p>
- </InfoTooltip>
- </Toggle>
- <Toggle
- variant="outline"
- className="bg-background"
- aria-label="Toggle bold"
- pressed={sortByName}
- onPressedChange={toggleSortByName}
- >
- <ArrowDownAZ className="mr-2 size-4" /> {t("tags.sort_by_name")}
- </Toggle>
+ <div className="flex flex-col gap-4">
+ <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-3">
+ <span className="text-2xl">{t("tags.all_tags")}</span>
+ <div className="flex flex-wrap items-center justify-end gap-2">
+ <CreateTagModal />
+ <BulkTagAction />
+ <Toggle
+ variant="outline"
+ className="bg-background"
+ aria-label={t("tags.drag_and_drop_merging")}
+ pressed={draggingEnabled}
+ onPressedChange={toggleDraggingEnabled}
+ disabled={isBulkEditEnabled}
+ >
+ <Combine className="mr-2 size-4" />
+ {t("tags.drag_and_drop_merging")}
+ <InfoTooltip size={15} className="my-auto ml-2" variant="explain">
+ <p>{t("tags.drag_and_drop_merging_info")}</p>
+ </InfoTooltip>
+ </Toggle>
+ </div>
+ </div>
+ <div className="flex flex-col gap-3">
+ <div className="flex w-full items-center gap-2">
+ <div className="flex-1">
+ <Input
+ type="search"
+ value={searchQueryRaw}
+ onChange={(event) => setSearchQuery(event.target.value)}
+ placeholder={t("common.search")}
+ aria-label={t("common.search")}
+ startIcon={<Search className="h-4 w-4 text-muted-foreground" />}
+ endIcon={isFetching && <Spinner className="h-4 w-4" />}
+ autoComplete="off"
+ className="h-10"
+ />
+ </div>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="flex-shrink-0 bg-background"
+ >
+ <ArrowDownAZ className="mr-2 size-4" />
+ <span className="mr-1 text-sm">
+ {t("actions.sort.title")}
+ </span>
+ <span className="hidden text-sm font-medium sm:inline">
+ {sortLabels[sortBy]}
+ </span>
+ <ChevronDown className="ml-2 size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuRadioGroup
+ value={sortBy}
+ onValueChange={(value) => setSortBy(value as typeof sortBy)}
+ >
+ <DropdownMenuRadioItem value="usage">
+ {sortLabels["usage"]}
+ </DropdownMenuRadioItem>
+ <DropdownMenuRadioItem value="name">
+ {sortLabels["name"]}
+ </DropdownMenuRadioItem>
+ <DropdownMenuRadioItem value="relevance">
+ {sortLabels["relevance"]}
+ </DropdownMenuRadioItem>
+ </DropdownMenuRadioGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{t("tags.your_tags")}</span>
- <Badge variant="secondary">{humanTags.length}</Badge>
+ <Badge variant="secondary">{allHumanTags.length}</Badge>
</CardTitle>
<CardDescription>{t("tags.your_tags_info")}</CardDescription>
</CardHeader>
- <CardContent>{tagsToPill(humanTags, isBulkEditEnabled)}</CardContent>
+ <CardContent className="flex flex-col gap-4">
+ {tagsToPill(
+ allHumanTags,
+ isBulkEditEnabled,
+ {
+ emptyMessage: t("tags.no_custom_tags"),
+ searchEmptyMessage: t("tags.no_tags_match_your_search"),
+ },
+ isHumanTagsLoading,
+ )}
+ {hasNextPageHumanTags && (
+ <ActionButton
+ variant="secondary"
+ onClick={() => fetchNextPageHumanTags()}
+ loading={isFetchingNextPageHumanTags}
+ ignoreDemoMode
+ >
+ {t("actions.load_more")}
+ </ActionButton>
+ )}
+ </CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{t("tags.ai_tags")}</span>
- <Badge variant="secondary">{aiTags.length}</Badge>
+ <Badge variant="secondary">{allAiTags.length}</Badge>
</CardTitle>
<CardDescription>{t("tags.ai_tags_info")}</CardDescription>
</CardHeader>
- <CardContent>{tagsToPill(aiTags, isBulkEditEnabled)}</CardContent>
+ <CardContent className="flex flex-col gap-4">
+ {tagsToPill(
+ allAiTags,
+ isBulkEditEnabled,
+ {
+ emptyMessage: t("tags.no_ai_tags"),
+ searchEmptyMessage: t("tags.no_tags_match_your_search"),
+ },
+ isAiTagsLoading,
+ )}
+ {hasNextPageAiTags && (
+ <ActionButton
+ variant="secondary"
+ onClick={() => fetchNextPageAiTags()}
+ loading={isFetchingNextPageAiTags}
+ ignoreDemoMode
+ >
+ {t("actions.load_more")}
+ </ActionButton>
+ )}
+ </CardContent>
</Card>
<Card>
<CardHeader>
- <CardTitle>{t("tags.unused_tags")}</CardTitle>
+ <CardTitle className="flex items-center gap-2">
+ <span>{t("tags.unused_tags")}</span>
+ <Badge variant="secondary">{allEmptyTags.length}</Badge>
+ </CardTitle>
<CardDescription>{t("tags.unused_tags_info")}</CardDescription>
</CardHeader>
- <CardContent>
- <Collapsible>
- <div className="space-x-1 pb-2">
- <CollapsibleTrigger asChild>
- <Button variant="secondary" disabled={emptyTags.length == 0}>
- {emptyTags.length > 0
- ? `Show ${emptyTags.length} unused tags`
- : "You don't have any unused tags"}
- </Button>
- </CollapsibleTrigger>
- {emptyTags.length > 0 && (
- <DeleteAllUnusedTags numUnusedTags={emptyTags.length} />
- )}
- </div>
- <CollapsibleContent>
- {tagsToPill(emptyTags, isBulkEditEnabled)}
- </CollapsibleContent>
- </Collapsible>
+ <CardContent className="flex flex-col gap-4">
+ {tagsToPill(
+ allEmptyTags,
+ isBulkEditEnabled,
+ {
+ emptyMessage: t("tags.no_unused_tags"),
+ searchEmptyMessage: t("tags.no_unused_tags_match_your_search"),
+ },
+ isEmptyTagsLoading,
+ )}
+ {hasNextPageEmptyTags && (
+ <ActionButton
+ variant="secondary"
+ onClick={() => fetchNextPageEmptyTags()}
+ loading={isFetchingNextPageEmptyTags}
+ ignoreDemoMode
+ >
+ {t("actions.load_more")}
+ </ActionButton>
+ )}
+ {allEmptyTags.length > 0 && (
+ <DeleteAllUnusedTags numUnusedTags={allEmptyTags.length} />
+ )}
</CardContent>
</Card>
</div>
diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx
index c3ae1e57..84dcd478 100644
--- a/apps/web/components/dashboard/tags/MergeTagModal.tsx
+++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx
@@ -25,7 +25,7 @@ import { z } from "zod";
import { useMergeTag } from "@karakeep/shared-react/hooks/tags";
-import { TagSelector } from "./TagSelector";
+import { TagAutocomplete } from "./TagAutocomplete";
export function MergeTagModal({
open,
@@ -119,10 +119,10 @@ export function MergeTagModal({
return (
<FormItem className="grow py-4">
<FormControl>
- <TagSelector
- value={field.value}
+ <TagAutocomplete
+ tagId={field.value}
onChange={field.onChange}
- placeholder="Select a tag to merge into"
+ className="w-full"
/>
</FormControl>
<FormMessage />
diff --git a/apps/web/components/dashboard/tags/MultiTagSelector.tsx b/apps/web/components/dashboard/tags/MultiTagSelector.tsx
index 096c4566..c7511eec 100644
--- a/apps/web/components/dashboard/tags/MultiTagSelector.tsx
+++ b/apps/web/components/dashboard/tags/MultiTagSelector.tsx
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { useTheme } from "next-themes";
-export function MultiTagSelector({
+export const MultiTagSelector = React.memo(function MultiTagSelector({
id,
name,
count,
@@ -58,4 +58,4 @@ export function MultiTagSelector({
);
return pill;
-}
+});
diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx
index 23054bc7..8164dc81 100644
--- a/apps/web/components/dashboard/tags/TagAutocomplete.tsx
+++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx
@@ -14,10 +14,13 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import LoadingSpinner from "@/components/ui/spinner";
-import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, X } from "lucide-react";
+import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
+import { api } from "@karakeep/shared-react/trpc";
+
interface TagAutocompleteProps {
tagId: string;
onChange?: (value: string) => void;
@@ -29,17 +32,28 @@ export function TagAutocomplete({
onChange,
className,
}: TagAutocompleteProps) {
- const { data: tags, isPending } = api.tags.list.useQuery(undefined, {
- select: (data) => data.tags,
- });
-
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
+ const searchQueryDebounced = useDebounce(searchQuery, 500);
- // Filter tags based on search query
- const filteredTags = (tags ?? [])
- .filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase()))
- .slice(0, 10); // Only show first 10 matches for performance
+ const { data: tags, isLoading } = useTagAutocomplete({
+ nameContains: searchQueryDebounced,
+ select: (data) => data.tags,
+ });
+
+ const { data: selectedTag, isLoading: isSelectedTagLoading } =
+ api.tags.get.useQuery(
+ {
+ tagId,
+ },
+ {
+ select: ({ id, name }) => ({
+ id,
+ name,
+ }),
+ enabled: !!tagId,
+ },
+ );
const handleSelect = (currentValue: string) => {
setOpen(false);
@@ -50,12 +64,7 @@ export function TagAutocomplete({
onChange?.("");
};
- const selectedTag = React.useMemo(() => {
- if (!tagId) return null;
- return tags?.find((t) => t.id === tagId) ?? null;
- }, [tags, tagId]);
-
- if (isPending) {
+ if (!tags || isLoading || isSelectedTagLoading) {
return <LoadingSpinner />;
}
@@ -96,7 +105,7 @@ export function TagAutocomplete({
<CommandList>
<CommandEmpty>No tags found.</CommandEmpty>
<CommandGroup className="max-h-60 overflow-y-auto">
- {filteredTags.map((tag) => (
+ {tags.map((tag) => (
<CommandItem
key={tag.id}
value={tag.id}
@@ -112,11 +121,6 @@ export function TagAutocomplete({
{tag.name}
</CommandItem>
))}
- {searchQuery && filteredTags.length >= 10 && (
- <div className="px-2 py-2 text-center text-xs text-muted-foreground">
- Showing first 10 results. Keep typing to refine your search.
- </div>
- )}
</CommandGroup>
</CommandList>
</Command>
diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx
index 2da97b2e..65a42e08 100644
--- a/apps/web/components/dashboard/tags/TagPill.tsx
+++ b/apps/web/components/dashboard/tags/TagPill.tsx
@@ -9,7 +9,7 @@ import Draggable from "react-draggable";
import { useMergeTag } from "@karakeep/shared-react/hooks/tags";
-export function TagPill({
+export const TagPill = React.memo(function TagPill({
id,
name,
count,
@@ -118,4 +118,4 @@ export function TagPill({
{pill}
</Draggable>
);
-}
+});
diff --git a/apps/web/components/dashboard/tags/TagSelector.tsx b/apps/web/components/dashboard/tags/TagSelector.tsx
deleted file mode 100644
index 27213b8a..00000000
--- a/apps/web/components/dashboard/tags/TagSelector.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import LoadingSpinner from "@/components/ui/spinner";
-import { api } from "@/lib/trpc";
-import { cn } from "@/lib/utils";
-
-export function TagSelector({
- value,
- onChange,
- placeholder = "Select a tag",
- className,
-}: {
- value?: string | null;
- onChange: (value: string) => void;
- placeholder?: string;
- className?: string;
-}) {
- const { data: allTags, isPending } = api.tags.list.useQuery(undefined, {
- select: (data) => ({
- tags: data.tags.sort((a, b) => a.name.localeCompare(b.name)),
- }),
- });
-
- if (isPending || !allTags) {
- return <LoadingSpinner />;
- }
-
- return (
- <Select onValueChange={onChange} value={value ?? ""}>
- <SelectTrigger className={cn("w-full", className)}>
- <SelectValue placeholder={placeholder} />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {allTags?.tags.map((tag) => {
- return (
- <SelectItem key={tag.id} value={tag.id}>
- {tag.name}
- </SelectItem>
- );
- })}
- {allTags && allTags.tags.length == 0 && (
- <SelectItem value="notag" disabled>
- You don&apos;t currently have any tags.
- </SelectItem>
- )}
- </SelectGroup>
- </SelectContent>
- </Select>
- );
-}
diff --git a/apps/web/lib/bulkTagActions.ts b/apps/web/lib/bulkTagActions.ts
index aa49f4f1..4904e257 100644
--- a/apps/web/lib/bulkTagActions.ts
+++ b/apps/web/lib/bulkTagActions.ts
@@ -46,7 +46,12 @@ const useBulkTagActionsStore = create<TagState>((set, get) => ({
},
setVisibleTagIds: (visibleTagIds: string[]) => {
- set({ visibleTagIds });
+ set({
+ visibleTagIds,
+ selectedTagIds: get().selectedTagIds.filter((id) =>
+ visibleTagIds.includes(id),
+ ),
+ });
},
isTagSelected: (tagId: string) => {
return get().selectedTagIds.includes(tagId);
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 561c4e5a..ce8d2839 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -88,7 +88,8 @@
"relevant_first": "Most Relevant First",
"newest_first": "Newest First",
"oldest_first": "Oldest First"
- }
+ },
+ "load_more": "Load More"
},
"highlights": {
"no_highlights": "You don't have any highlights yet."
@@ -450,10 +451,17 @@
"drag_and_drop_merging": "Drag & Drop Merging",
"drag_and_drop_merging_info": "Drag and drop tags on each other to merge them",
"sort_by_name": "Sort by Name",
+ "sort_by_usage": "Sort by Usage",
+ "sort_by_relevance": "Sort by Relevance",
"create_tag": "Create Tag",
"create_tag_description": "Create a new tag without attaching it to any bookmark",
"tag_name": "Tag Name",
- "enter_tag_name": "Enter tag name"
+ "enter_tag_name": "Enter tag name",
+ "no_custom_tags": "No custom tags yet",
+ "no_ai_tags": "No AI tags yet",
+ "no_unused_tags": "You don't have any unused tags",
+ "no_unused_tags_match_your_search": "No unused tags match your search",
+ "no_tags_match_your_search": "No tags match your search"
},
"search": {
"is_favorited": "Is Favorited",
diff --git a/apps/workers/workers/inference/tagging.ts b/apps/workers/workers/inference/tagging.ts
index 0d245644..789a30b4 100644
--- a/apps/workers/workers/inference/tagging.ts
+++ b/apps/workers/workers/inference/tagging.ts
@@ -179,7 +179,7 @@ async function replaceTagsPlaceholders(
userId: string,
): Promise<string[]> {
const api = await buildImpersonatingTRPCClient(userId);
- const tags = (await api.tags.list()).tags;
+ const tags = (await api.tags.list({})).tags;
const tagsString = `[${tags.map((tag) => tag.name).join(", ")}]`;
const aiTagsString = `[${tags
.filter((tag) => tag.numBookmarksByAttachedType.human ?? true)
diff --git a/packages/api/routes/tags.ts b/packages/api/routes/tags.ts
index 816e58b4..79e36e99 100644
--- a/packages/api/routes/tags.ts
+++ b/packages/api/routes/tags.ts
@@ -1,8 +1,11 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
+import { z } from "zod";
import {
zCreateTagRequestSchema,
+ zTagListApiResultSchema,
+ zTagListQueryParamsSchema,
zUpdateTagRequestSchema,
} from "@karakeep/shared/types/tags";
@@ -14,9 +17,23 @@ const app = new Hono()
.use(authMiddleware)
// GET /tags
- .get("/", async (c) => {
- const tags = await c.var.api.tags.list();
- return c.json(tags, 200);
+ .get("/", zValidator("query", zTagListQueryParamsSchema), async (c) => {
+ const searchParams = c.req.valid("query");
+ const tags = await c.var.api.tags.list({
+ nameContains: searchParams.nameContains,
+ attachedBy: searchParams.attachedBy,
+ sortBy: searchParams.sort,
+ cursor: searchParams.cursor,
+ limit: searchParams.limit,
+ });
+
+ const resp: z.infer<typeof zTagListApiResultSchema> = {
+ tags: tags.tags,
+ nextCursor: tags.nextCursor
+ ? Buffer.from(JSON.stringify(tags.nextCursor)).toString("base64url")
+ : null,
+ };
+ return c.json(resp, 200);
})
// POST /tags
diff --git a/packages/e2e_tests/tests/api/tags.test.ts b/packages/e2e_tests/tests/api/tags.test.ts
index 6c387628..bfedb307 100644
--- a/packages/e2e_tests/tests/api/tags.test.ts
+++ b/packages/e2e_tests/tests/api/tags.test.ts
@@ -198,4 +198,105 @@ describe("Tags API", () => {
expect(updatedTaggedBookmarks!.bookmarks.length).toBe(1);
expect(updatedTaggedBookmarks!.bookmarks[0].id).toBe(secondBookmark!.id);
});
+
+ it("should paginate through tags", async () => {
+ // Create multiple tags
+ const tagNames = ["Tag A", "Tag B", "Tag C", "Tag D", "Tag E"];
+ const createdTags = [];
+
+ for (const name of tagNames) {
+ const { data: tag } = await client.POST("/tags", {
+ body: { name },
+ });
+ createdTags.push(tag!);
+ }
+
+ // Test pagination with limit of 2
+ const { data: firstPage, response: firstResponse } = await client.GET(
+ "/tags",
+ {
+ params: {
+ query: {
+ limit: 2,
+ },
+ },
+ },
+ );
+
+ expect(firstResponse.status).toBe(200);
+ expect(firstPage!.tags.length).toBe(2);
+ expect(firstPage!.nextCursor).toBeDefined();
+
+ // Get second page using cursor
+ const { data: secondPage, response: secondResponse } = await client.GET(
+ "/tags",
+ {
+ params: {
+ query: {
+ limit: 2,
+ cursor: firstPage!.nextCursor!,
+ },
+ },
+ },
+ );
+
+ expect(secondResponse.status).toBe(200);
+ expect(secondPage!.tags.length).toBe(2);
+ expect(secondPage!.nextCursor).toBeDefined();
+
+ // Get third page
+ const { data: thirdPage, response: thirdResponse } = await client.GET(
+ "/tags",
+ {
+ params: {
+ query: {
+ limit: 2,
+ cursor: secondPage!.nextCursor!,
+ },
+ },
+ },
+ );
+
+ expect(thirdResponse.status).toBe(200);
+ expect(thirdPage!.tags.length).toBe(1); // Only one tag remaining
+ expect(thirdPage!.nextCursor).toBeNull(); // No more pages
+
+ // Verify all tags are accounted for across pages
+ const allPagedTags = [
+ ...firstPage!.tags,
+ ...secondPage!.tags,
+ ...thirdPage!.tags,
+ ];
+ expect(allPagedTags.length).toBe(5);
+
+ // Verify all created tags are included
+ const allPagedTagIds = allPagedTags.map((tag) => tag.id);
+ const createdTagIds = createdTags.map((tag) => tag.id);
+ expect(allPagedTagIds.sort()).toEqual(createdTagIds.sort());
+ });
+
+ it("Invalid cursor should return 400", async () => {
+ const { response } = await client.GET("/tags", {
+ params: {
+ query: {
+ limit: 2,
+ cursor: "{}",
+ },
+ },
+ });
+ expect(response.status).toBe(400);
+ });
+
+ it("Listing without args returns all tags", async () => {
+ const tagNames = ["Tag A", "Tag B", "Tag C", "Tag D", "Tag E"];
+
+ for (const name of tagNames) {
+ await client.POST("/tags", {
+ body: { name },
+ });
+ }
+
+ const { data } = await client.GET("/tags");
+ expect(data?.tags).toHaveLength(tagNames.length);
+ });
});
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index 83a5b811..ffa9c357 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -2292,6 +2292,60 @@
"bearerAuth": []
}
],
+ "parameters": [
+ {
+ "schema": {
+ "type": "string"
+ },
+ "required": false,
+ "name": "nameContains",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "name",
+ "usage",
+ "relevance"
+ ],
+ "default": "usage"
+ },
+ "required": false,
+ "name": "sort",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "ai",
+ "human",
+ "none"
+ ]
+ },
+ "required": false,
+ "name": "attachedBy",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string"
+ },
+ "required": false,
+ "name": "cursor",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "number",
+ "nullable": true
+ },
+ "required": false,
+ "name": "limit",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"description": "Object with all tags data.",
@@ -2305,10 +2359,15 @@
"items": {
"$ref": "#/components/schemas/Tag"
}
+ },
+ "nextCursor": {
+ "type": "string",
+ "nullable": true
}
},
"required": [
- "tags"
+ "tags",
+ "nextCursor"
]
}
}
diff --git a/packages/open-api/lib/tags.ts b/packages/open-api/lib/tags.ts
index 0a4f62cb..84af39b1 100644
--- a/packages/open-api/lib/tags.ts
+++ b/packages/open-api/lib/tags.ts
@@ -9,6 +9,7 @@ import {
zCreateTagRequestSchema,
zGetTagResponseSchema,
zTagBasicSchema,
+ zTagListQueryParamsSchema,
zUpdateTagRequestSchema,
} from "@karakeep/shared/types/tags";
@@ -43,7 +44,9 @@ registry.registerPath({
summary: "Get all tags",
tags: ["Tags"],
security: [{ [BearerAuth.name]: [] }],
- request: {},
+ request: {
+ query: zTagListQueryParamsSchema,
+ },
responses: {
200: {
description: "Object with all tags data.",
@@ -51,6 +54,7 @@ registry.registerPath({
"application/json": {
schema: z.object({
tags: z.array(TagSchema),
+ nextCursor: z.string().nullable(),
}),
},
},
diff --git a/packages/sdk/src/karakeep-api.d.ts b/packages/sdk/src/karakeep-api.d.ts
index a50fec82..1ac35e04 100644
--- a/packages/sdk/src/karakeep-api.d.ts
+++ b/packages/sdk/src/karakeep-api.d.ts
@@ -1146,7 +1146,13 @@ export interface paths {
*/
get: {
parameters: {
- query?: never;
+ query?: {
+ nameContains?: string;
+ sort?: "name" | "usage" | "relevance";
+ attachedBy?: "ai" | "human" | "none";
+ cursor?: string;
+ limit?: number | null;
+ };
header?: never;
path?: never;
cookie?: never;
@@ -1161,6 +1167,7 @@ export interface paths {
content: {
"application/json": {
tags: components["schemas"]["Tag"][];
+ nextCursor: string | null;
};
};
};
diff --git a/packages/shared-react/hooks/tags.ts b/packages/shared-react/hooks/tags.ts
index bbbe3d0e..f02ebc8f 100644
--- a/packages/shared-react/hooks/tags.ts
+++ b/packages/shared-react/hooks/tags.ts
@@ -1,5 +1,40 @@
+import { keepPreviousData } from "@tanstack/react-query";
+
+import { ZTagListResponse } from "@karakeep/shared/types/tags";
+
import { api } from "../trpc";
+export function usePaginatedSearchTags(
+ input: Parameters<typeof api.tags.list.useInfiniteQuery>[0],
+) {
+ return api.tags.list.useInfiniteQuery(input, {
+ placeholderData: keepPreviousData,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ select: (data) => ({
+ tags: data.pages.flatMap((page) => page.tags),
+ }),
+ gcTime: 60_000,
+ });
+}
+
+export function useTagAutocomplete<T>(opts: {
+ nameContains: string;
+ select?: (tags: ZTagListResponse) => T;
+}) {
+ return api.tags.list.useQuery(
+ {
+ nameContains: opts.nameContains,
+ limit: 50,
+ sortBy: opts.nameContains ? "relevance" : "usage",
+ },
+ {
+ select: opts.select,
+ placeholderData: keepPreviousData,
+ gcTime: opts.nameContains?.length > 0 ? 60_000 : 3_600_000,
+ },
+ );
+}
+
export function useCreateTag(
...opts: Parameters<typeof api.tags.create.useMutation>
) {
diff --git a/packages/shared-react/hooks/use-debounce.ts b/packages/shared-react/hooks/use-debounce.ts
new file mode 100644
index 00000000..a973d774
--- /dev/null
+++ b/packages/shared-react/hooks/use-debounce.ts
@@ -0,0 +1,17 @@
+import React from "react";
+
+export function useDebounce<T>(value: T, delayMs: number): T {
+ const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
+
+ React.useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delayMs);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delayMs]);
+
+ return debouncedValue;
+}
diff --git a/packages/shared/types/tags.ts b/packages/shared/types/tags.ts
index efb26bfa..91ad1d96 100644
--- a/packages/shared/types/tags.ts
+++ b/packages/shared/types/tags.ts
@@ -2,6 +2,8 @@ import { z } from "zod";
import { normalizeTagName } from "../utils/tag";
+export const MAX_NUM_TAGS_PER_PAGE = 1000;
+
const zTagNameSchemaWithValidation = z
.string()
.transform((s) => normalizeTagName(s).trim())
@@ -38,3 +40,59 @@ export const zTagBasicSchema = z.object({
name: z.string(),
});
export type ZTagBasic = z.infer<typeof zTagBasicSchema>;
+
+export const zTagCursorSchema = z.object({
+ page: z.number().int().min(0),
+});
+
+export const zTagListRequestSchema = z.object({
+ nameContains: z.string().optional(),
+ attachedBy: z.enum([...zAttachedByEnumSchema.options, "none"]).optional(),
+ sortBy: z.enum(["name", "usage", "relevance"]).optional().default("usage"),
+ cursor: zTagCursorSchema.nullish().default({ page: 0 }),
+ // TODO: Remove the optional to enforce a limit after the next release
+ limit: z.number().int().min(1).max(MAX_NUM_TAGS_PER_PAGE).optional(),
+});
+
+export const zTagListValidatedRequestSchema = zTagListRequestSchema.refine(
+ (val) => val.sortBy != "relevance" || val.nameContains !== undefined,
+ {
+ message: "Relevance sorting requires a nameContains filter",
+ path: ["sortBy"],
+ },
+);
+
+export const zTagListResponseSchema = z.object({
+ tags: z.array(zGetTagResponseSchema),
+ nextCursor: zTagCursorSchema.nullish(),
+});
+export type ZTagListResponse = z.infer<typeof zTagListResponseSchema>;
+
+// API Types
+
+export const zTagListQueryParamsSchema = z.object({
+ nameContains: zTagListRequestSchema.shape.nameContains,
+ sort: zTagListRequestSchema.shape.sortBy,
+ attachedBy: zTagListRequestSchema.shape.attachedBy,
+ cursor: z
+ .string()
+ .transform((val, ctx) => {
+ try {
+ return JSON.parse(Buffer.from(val, "base64url").toString("utf8"));
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ message: "Invalid cursor",
+ });
+ return z.NEVER;
+ }
+ })
+ .optional()
+ .pipe(zTagListRequestSchema.shape.cursor),
+ limit: z.coerce.number().optional(),
+});
+
+export const zTagListApiResultSchema = z.object({
+ tags: zTagListResponseSchema.shape.tags,
+ nextCursor: z.string().nullish(),
+});
diff --git a/packages/shared/utils/switch.ts b/packages/shared/utils/switch.ts
new file mode 100644
index 00000000..9123c060
--- /dev/null
+++ b/packages/shared/utils/switch.ts
@@ -0,0 +1,6 @@
+export function switchCase<T extends string | number, R>(
+ value: T,
+ cases: Record<T, R>,
+) {
+ return cases[value];
+}
diff --git a/packages/trpc/models/tags.ts b/packages/trpc/models/tags.ts
index dadb20f7..33b032c1 100644
--- a/packages/trpc/models/tags.ts
+++ b/packages/trpc/models/tags.ts
@@ -1,5 +1,16 @@
import { TRPCError } from "@trpc/server";
-import { and, count, eq, inArray, notExists } from "drizzle-orm";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ gt,
+ inArray,
+ like,
+ notExists,
+ sql,
+} from "drizzle-orm";
import { z } from "zod";
import type { ZAttachedByEnum } from "@karakeep/shared/types/tags";
@@ -12,6 +23,7 @@ import {
zTagBasicSchema,
zUpdateTagRequestSchema,
} from "@karakeep/shared/types/tags";
+import { switchCase } from "@karakeep/shared/utils/switch";
import { AuthedContext } from "..";
import { PrivacyAware } from "./privacy";
@@ -70,46 +82,100 @@ export class Tag implements PrivacyAware {
}
}
- static async getAllWithStats(ctx: AuthedContext) {
- const tags = await ctx.db
+ static async getAll(
+ ctx: AuthedContext,
+ opts: {
+ nameContains?: string;
+ attachedBy?: "ai" | "human" | "none";
+ sortBy?: "name" | "usage" | "relevance";
+ pagination?: {
+ page: number;
+ limit: number;
+ };
+ } = {},
+ ) {
+ const sortBy = opts.sortBy ?? "usage";
+
+ const countAi = sql<number>`
+ SUM(CASE WHEN ${tagsOnBookmarks.attachedBy} = 'ai' THEN 1 ELSE 0 END)
+ `;
+ const countHuman = sql<number>`
+ SUM(CASE WHEN ${tagsOnBookmarks.attachedBy} = 'human' THEN 1 ELSE 0 END)
+ `;
+ // Count only matched right rows; will be 0 when there are none
+ const countAny = sql<number>`COUNT(${tagsOnBookmarks.tagId})`;
+ let qSql = ctx.db
.select({
id: bookmarkTags.id,
name: bookmarkTags.name,
- attachedBy: tagsOnBookmarks.attachedBy,
- count: count(),
+ countAttachedByAi: countAi.as("countAttachedByAi"),
+ countAttachedByHuman: countHuman.as("countAttachedByHuman"),
+ count: countAny.as("count"),
})
.from(bookmarkTags)
.leftJoin(tagsOnBookmarks, eq(bookmarkTags.id, tagsOnBookmarks.tagId))
- .where(and(eq(bookmarkTags.userId, ctx.user.id)))
- .groupBy(bookmarkTags.id, tagsOnBookmarks.attachedBy);
+ .where(
+ and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ opts.nameContains
+ ? like(bookmarkTags.name, `%${opts.nameContains}%`)
+ : undefined,
+ ),
+ )
+ .groupBy(bookmarkTags.id, bookmarkTags.name)
+ .orderBy(
+ ...switchCase(sortBy, {
+ name: [asc(bookmarkTags.name)],
+ usage: [desc(sql`count`)],
+ relevance: [
+ desc(sql<number>`
+ CASE
+ WHEN lower(${opts.nameContains ?? ""}) = lower(${bookmarkTags.name}) THEN 2
+ WHEN ${bookmarkTags.name} LIKE ${opts.nameContains ? opts.nameContains + "%" : ""} THEN 1
+ ELSE 0
+ END`),
+ asc(sql<number>`length(${bookmarkTags.name})`),
+ ],
+ }),
+ )
+ .having(
+ opts.attachedBy
+ ? switchCase(opts.attachedBy, {
+ ai: and(eq(countHuman, 0), gt(countAi, 0)),
+ human: gt(countHuman, 0),
+ none: eq(countAny, 0),
+ })
+ : undefined,
+ );
- if (tags.length === 0) {
- return [];
+ if (opts.pagination) {
+ qSql.offset(opts.pagination.page * opts.pagination.limit);
+ qSql.limit(opts.pagination.limit + 1);
}
-
- const tagsById = tags.reduce<
- Record<
- string,
- {
- id: string;
- name: string;
- attachedBy: "ai" | "human" | null;
- count: number;
- }[]
- >
- >((acc, curr) => {
- if (!acc[curr.id]) {
- acc[curr.id] = [];
+ const tags = await qSql;
+
+ let nextCursor = null;
+ if (opts.pagination) {
+ if (tags.length > opts.pagination.limit) {
+ tags.pop();
+ nextCursor = {
+ page: opts.pagination.page + 1,
+ };
}
- acc[curr.id].push(curr);
- return acc;
- }, {});
-
- return Object.entries(tagsById).map(([k, t]) => ({
- id: k,
- name: t[0].name,
- ...Tag._aggregateStats(t),
- }));
+ }
+
+ return {
+ tags: tags.map((t) => ({
+ id: t.id,
+ name: t.name,
+ numBookmarks: t.count,
+ numBookmarksByAttachedType: {
+ ai: t.countAttachedByAi,
+ human: t.countAttachedByHuman,
+ },
+ })),
+ nextCursor,
+ };
}
static async deleteUnused(ctx: AuthedContext): Promise<number> {
diff --git a/packages/trpc/routers/tags.test.ts b/packages/trpc/routers/tags.test.ts
index 4004cc2c..8e557064 100644
--- a/packages/trpc/routers/tags.test.ts
+++ b/packages/trpc/routers/tags.test.ts
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, test } from "vitest";
-import { bookmarkTags } from "@karakeep/db/schema";
+import { bookmarkTags, tagsOnBookmarks } from "@karakeep/db/schema";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import type { CustomTestContext } from "../testUtils";
@@ -160,62 +160,605 @@ describe("Tags Routes", () => {
).rejects.toThrow(/Cannot merge tag into itself/);
});
- test<CustomTestContext>("list tags", async ({ apiCallers }) => {
- const api = apiCallers[0].tags;
- await api.create({ name: "tag1" });
- await api.create({ name: "tag2" });
-
- const res = await api.list();
- expect(res.tags.length).toBeGreaterThanOrEqual(2);
- expect(res.tags.some((tag) => tag.name === "tag1")).toBeTruthy();
- expect(res.tags.some((tag) => tag.name === "tag2")).toBeTruthy();
- });
-
- test<CustomTestContext>("list tags includes bookmark stats", async ({
- apiCallers,
- }) => {
- const tagsApi = apiCallers[0].tags;
- const bookmarksApi = apiCallers[0].bookmarks;
+ describe("list tags", () => {
+ test<CustomTestContext>("basic list", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
- const firstBookmark = await bookmarksApi.createBookmark({
- url: "https://example.com/list-first",
- type: BookmarkTypes.LINK,
- });
- const secondBookmark = await bookmarksApi.createBookmark({
- url: "https://example.com/list-second",
- type: BookmarkTypes.LINK,
+ const res = await api.list();
+ expect(res.tags.length).toBeGreaterThanOrEqual(2);
+ expect(res.tags.some((tag) => tag.name === "tag1")).toBeTruthy();
+ expect(res.tags.some((tag) => tag.name === "tag2")).toBeTruthy();
});
- const firstAttachment = await bookmarksApi.updateTags({
- bookmarkId: firstBookmark.id,
- attach: [{ tagName: "list-stats-tag" }],
- detach: [],
+ test<CustomTestContext>("includes bookmark stats", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ const firstBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/list-first",
+ type: BookmarkTypes.LINK,
+ });
+ const secondBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/list-second",
+ type: BookmarkTypes.LINK,
+ });
+
+ const firstAttachment = await bookmarksApi.updateTags({
+ bookmarkId: firstBookmark.id,
+ attach: [{ tagName: "list-stats-tag" }],
+ detach: [],
+ });
+
+ const tagId = firstAttachment.attached[0];
+
+ await bookmarksApi.updateTags({
+ bookmarkId: secondBookmark.id,
+ attach: [{ tagId }],
+ detach: [],
+ });
+
+ const list = await tagsApi.list();
+ const tagStats = list.tags.find((tag) => tag.id === tagId);
+
+ expect(tagStats).toBeDefined();
+ expect(tagStats!.numBookmarks).toBe(2);
+ expect(tagStats!.numBookmarksByAttachedType.human).toBe(2);
+ expect(tagStats!.numBookmarksByAttachedType.ai).toBe(0);
});
- const tagId = firstAttachment.attached[0];
+ test<CustomTestContext>("privacy", async ({ apiCallers }) => {
+ const apiUser1 = apiCallers[0].tags;
+ await apiUser1.create({ name: "user1Tag" });
- await bookmarksApi.updateTags({
- bookmarkId: secondBookmark.id,
- attach: [{ tagId }],
- detach: [],
+ const apiUser2 = apiCallers[1].tags; // Different user
+ const resUser2 = await apiUser2.list();
+ expect(resUser2.tags.some((tag) => tag.name === "user1Tag")).toBeFalsy(); // Should not see other user's tags
});
- const list = await tagsApi.list();
- const tagStats = list.tags.find((tag) => tag.id === tagId);
+ test<CustomTestContext>("search by name", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+
+ await api.create({ name: "alpha" });
+ await api.create({ name: "beta" });
+ await api.create({ name: "alph2" });
+
+ {
+ const res = await api.list({ nameContains: "al" });
+ expect(res.tags.length).toBe(2);
+ expect(res.tags.some((tag) => tag.name === "alpha")).toBeTruthy();
+ expect(res.tags.some((tag) => tag.name === "beta")).not.toBeTruthy();
+ expect(res.tags.some((tag) => tag.name === "alph2")).toBeTruthy();
+ }
+
+ {
+ const res = await api.list({ nameContains: "beta" });
+ expect(res.tags.length).toBe(1);
+ expect(res.tags.some((tag) => tag.name === "beta")).toBeTruthy();
+ }
+
+ {
+ const res = await api.list({});
+ expect(res.tags.length).toBe(3);
+ }
+ });
- expect(tagStats).toBeDefined();
- expect(tagStats!.numBookmarks).toBe(2);
- expect(tagStats!.numBookmarksByAttachedType.human).toBe(2);
- expect(tagStats!.numBookmarksByAttachedType.ai).toBe(0);
- });
+ describe("pagination", () => {
+ test<CustomTestContext>("basic limit and cursor", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].tags;
+
+ // Create several tags
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
+ await api.create({ name: "tag3" });
+ await api.create({ name: "tag4" });
+ await api.create({ name: "tag5" });
+
+ // Test first page with limit
+ const firstPage = await api.list({
+ limit: 2,
+ cursor: { page: 0 },
+ });
+ expect(firstPage.tags.length).toBe(2);
+ expect(firstPage.nextCursor).not.toBeNull();
+
+ // Test second page
+ const secondPage = await api.list({
+ limit: 2,
+ cursor: firstPage.nextCursor!,
+ });
+ expect(secondPage.tags.length).toBe(2);
+ expect(secondPage.nextCursor).not.toBeNull();
+
+ // Test third page (last page)
+ const thirdPage = await api.list({
+ limit: 2,
+ cursor: { page: 2 },
+ });
+ expect(thirdPage.tags.length).toBe(1);
+ expect(thirdPage.nextCursor).toBeNull();
+ });
+
+ test<CustomTestContext>("no limit returns all tags", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].tags;
+
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
+ await api.create({ name: "tag3" });
+
+ const res = await api.list({});
+ expect(res.tags.length).toBe(3);
+ expect(res.nextCursor).toBeNull();
+ });
+
+ test<CustomTestContext>("empty page", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+
+ await api.create({ name: "tag1" });
+
+ const emptyPage = await api.list({
+ limit: 2,
+ cursor: { page: 5 }, // Way beyond available data
+ });
+ expect(emptyPage.tags.length).toBe(0);
+ expect(emptyPage.nextCursor).toBeNull();
+ });
+
+ test<CustomTestContext>("edge cases", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+
+ // Test pagination with no tags
+ const emptyResult = await api.list({
+ limit: 10,
+ cursor: { page: 0 },
+ });
+ expect(emptyResult.tags.length).toBe(0);
+ expect(emptyResult.nextCursor).toBeNull();
+
+ // Create exactly one page worth of tags
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
+
+ const exactPage = await api.list({
+ limit: 2,
+ cursor: { page: 0 },
+ });
+ expect(exactPage.tags.length).toBe(2);
+ expect(exactPage.nextCursor).toBeNull();
+
+ // Test with limit larger than available tags
+ const oversizedLimit = await api.list({
+ limit: 100,
+ cursor: { page: 0 },
+ });
+ expect(oversizedLimit.tags.length).toBe(2);
+ expect(oversizedLimit.nextCursor).toBeNull();
+ });
+ });
- test<CustomTestContext>("list tags - privacy", async ({ apiCallers }) => {
- const apiUser1 = apiCallers[0].tags;
- await apiUser1.create({ name: "user1Tag" });
+ describe("attachedBy filtering", () => {
+ test<CustomTestContext>("human tags", async ({ apiCallers, db }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create tags attached by humans
+ const bookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/human",
+ type: BookmarkTypes.LINK,
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagName: "human-tag" }],
+ detach: [],
+ });
+
+ // Create an unused tag (no attachments)
+ await tagsApi.create({ name: "unused-tag" });
+
+ const aiTag = await tagsApi.create({ name: "ai-tag" });
+ await db.insert(tagsOnBookmarks).values([
+ {
+ bookmarkId: bookmark.id,
+ tagId: aiTag.id,
+ attachedBy: "ai",
+ },
+ ]);
+
+ const humanTags = await tagsApi.list({ attachedBy: "human" });
+ expect(humanTags.tags.length).toBe(1);
+ expect(humanTags.tags[0].name).toBe("human-tag");
+ });
+
+ test<CustomTestContext>("none (unused tags)", async ({ apiCallers }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create a used tag
+ const bookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/used",
+ type: BookmarkTypes.LINK,
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagName: "used-tag" }],
+ detach: [],
+ });
+
+ // Create unused tags
+ await tagsApi.create({ name: "unused-tag-1" });
+ await tagsApi.create({ name: "unused-tag-2" });
+
+ const unusedTags = await tagsApi.list({ attachedBy: "none" });
+ expect(unusedTags.tags.length).toBe(2);
+
+ const tagNames = unusedTags.tags.map((tag) => tag.name);
+ expect(tagNames).toContain("unused-tag-1");
+ expect(tagNames).toContain("unused-tag-2");
+ expect(tagNames).not.toContain("used-tag");
+ });
+
+ test<CustomTestContext>("ai tags", async ({ apiCallers, db }) => {
+ const bookmarksApi = apiCallers[0].bookmarks;
+ const tagsApi = apiCallers[0].tags;
+
+ const tag1 = await tagsApi.create({ name: "ai-tag" });
+ const tag2 = await tagsApi.create({ name: "human-tag" });
+
+ // Create bookmarks and attach tags to give them usage
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/z",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Manually attach some tags
+ await db.insert(tagsOnBookmarks).values([
+ {
+ bookmarkId: bookmark1.id,
+ tagId: tag1.id,
+ attachedBy: "ai",
+ },
+ {
+ bookmarkId: bookmark1.id,
+ tagId: tag2.id,
+ attachedBy: "human",
+ },
+ ]);
+
+ const aiTags = await tagsApi.list({ attachedBy: "ai" });
+ expect(aiTags.tags.length).toBe(1);
+ expect(aiTags.tags[0].name).toBe("ai-tag");
+ });
+ });
- const apiUser2 = apiCallers[1].tags; // Different user
- const resUser2 = await apiUser2.list();
- expect(resUser2.tags.some((tag) => tag.name === "user1Tag")).toBeFalsy(); // Should not see other user's tags
+ describe("sortBy", () => {
+ test<CustomTestContext>("name sorting", async ({ apiCallers }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks and attach tags to give them usage
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/z",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/a",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark3 = await bookmarksApi.createBookmark({
+ url: "https://example.com/m",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Attach tags in order: zebra (1 use), apple (2 uses), middle (1 use)
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "zebra" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "apple" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark3.id,
+ attach: [{ tagName: "apple" }, { tagName: "middle" }],
+ detach: [],
+ });
+
+ // Test sorting by name (alphabetical)
+ const nameSort = await tagsApi.list({ sortBy: "name" });
+ expect(nameSort.tags.length).toBe(3);
+ expect(nameSort.tags[0].name).toBe("apple");
+ expect(nameSort.tags[1].name).toBe("middle");
+ expect(nameSort.tags[2].name).toBe("zebra");
+ });
+
+ test<CustomTestContext>("usage sorting (default)", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks and attach tags with different usage counts
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/usage1",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/usage2",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark3 = await bookmarksApi.createBookmark({
+ url: "https://example.com/usage3",
+ type: BookmarkTypes.LINK,
+ });
+
+ // single-use: 1 bookmark, high-use: 3 bookmarks, medium-use: 2 bookmarks
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "high-use" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "high-use" }, { tagName: "medium-use" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark3.id,
+ attach: [
+ { tagName: "high-use" },
+ { tagName: "medium-use" },
+ { tagName: "single-use" },
+ ],
+ detach: [],
+ });
+
+ // Test default sorting (usage) and explicit usage sorting
+ const defaultSort = await tagsApi.list({});
+ expect(defaultSort.tags.length).toBe(3);
+ expect(defaultSort.tags[0].name).toBe("high-use");
+ expect(defaultSort.tags[0].numBookmarks).toBe(3);
+ expect(defaultSort.tags[1].name).toBe("medium-use");
+ expect(defaultSort.tags[1].numBookmarks).toBe(2);
+ expect(defaultSort.tags[2].name).toBe("single-use");
+ expect(defaultSort.tags[2].numBookmarks).toBe(1);
+
+ const usageSort = await tagsApi.list({ sortBy: "usage" });
+ expect(usageSort.tags[0].name).toBe("high-use");
+ expect(usageSort.tags[1].name).toBe("medium-use");
+ expect(usageSort.tags[2].name).toBe("single-use");
+ });
+
+ test<CustomTestContext>("relevance sorting", async ({ apiCallers }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks to give tags usage
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/rel1",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create tags with different relevance to search term "java"
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [
+ { tagName: "java" }, // Exact match - highest relevance
+ { tagName: "javascript" }, // Prefix match
+ { tagName: "java-script" }, // Prefix match (shorter)
+ { tagName: "advanced-java" }, // Substring match
+ ],
+ detach: [],
+ });
+
+ // Test relevance sorting
+ const relevanceSort = await tagsApi.list({
+ nameContains: "java",
+ sortBy: "relevance",
+ });
+
+ expect(relevanceSort.tags.length).toBe(4);
+
+ // Exact match should be first
+ expect(relevanceSort.tags[0].name).toBe("java");
+
+ // Prefix matches should come next, shorter first (by length)
+ expect(relevanceSort.tags[1].name).toBe("javascript"); // length 10
+ expect(relevanceSort.tags[2].name).toBe("java-script"); // length 11
+
+ // Substring matches should be last
+ expect(relevanceSort.tags[3].name).toBe("advanced-java");
+ });
+
+ test<CustomTestContext>("relevance sorting case insensitive", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/case",
+ type: BookmarkTypes.LINK,
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [
+ { tagName: "React" }, // Exact match (different case)
+ { tagName: "ReactJS" }, // Prefix match
+ { tagName: "my-react" }, // Substring match
+ ],
+ detach: [],
+ });
+
+ const relevanceSort = await tagsApi.list({
+ nameContains: "react",
+ sortBy: "relevance",
+ });
+
+ expect(relevanceSort.tags.length).toBe(3);
+ expect(relevanceSort.tags[0].name).toBe("React"); // Exact match first
+ expect(relevanceSort.tags[1].name).toBe("ReactJS"); // Prefix match second
+ expect(relevanceSort.tags[2].name).toBe("my-react"); // Substring match last
+ });
+
+ test<CustomTestContext>("relevance sorting without search term is prevented by validation", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+
+ // Without nameContains, relevance sorting should throw validation error
+ await expect(() =>
+ tagsApi.list({ sortBy: "relevance" }),
+ ).rejects.toThrow(/Relevance sorting requires a nameContains filter/);
+ });
+ });
+
+ describe("combination filtering", () => {
+ test<CustomTestContext>("nameContains with attachedBy", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks with tags
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/combo1",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/combo2",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Attach human tags with "test" in name
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "test-human" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "test-used" }],
+ detach: [],
+ });
+
+ // Create unused tag with "test" in name
+ await tagsApi.create({ name: "test-unused" });
+
+ // Create used tag without "test" in name
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "other-human" }],
+ detach: [],
+ });
+
+ // Test combination: nameContains + attachedBy human
+ const humanTestTags = await tagsApi.list({
+ nameContains: "test",
+ attachedBy: "human",
+ });
+ expect(humanTestTags.tags.length).toBe(2);
+
+ const humanTestNames = humanTestTags.tags.map((tag) => tag.name);
+ expect(humanTestNames).toContain("test-human");
+ expect(humanTestNames).toContain("test-used");
+ expect(humanTestNames).not.toContain("test-unused");
+ expect(humanTestNames).not.toContain("other-human");
+
+ // Test combination: nameContains + attachedBy none
+ const unusedTestTags = await tagsApi.list({
+ nameContains: "test",
+ attachedBy: "none",
+ });
+ expect(unusedTestTags.tags.length).toBe(1);
+ expect(unusedTestTags.tags[0].name).toBe("test-unused");
+ });
+
+ test<CustomTestContext>("all parameters together", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create multiple bookmarks with various tags
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/all1",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/all2",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark3 = await bookmarksApi.createBookmark({
+ url: "https://example.com/all3",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create tags with different usage patterns
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "filter-high" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "filter-high" }, { tagName: "filter-low" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark3.id,
+ attach: [{ tagName: "filter-high" }],
+ detach: [],
+ });
+
+ // Test all parameters: nameContains + attachedBy + sortBy + pagination
+ const result = await tagsApi.list({
+ nameContains: "filter",
+ attachedBy: "human",
+ sortBy: "usage",
+ limit: 1,
+ cursor: { page: 0 },
+ });
+
+ expect(result.tags.length).toBe(1);
+ expect(result.tags[0].name).toBe("filter-high"); // Highest usage
+ expect(result.tags[0].numBookmarks).toBe(3);
+ expect(result.nextCursor).not.toBeNull();
+
+ // Get second page
+ const secondPage = await tagsApi.list({
+ nameContains: "filter",
+ attachedBy: "human",
+ sortBy: "usage",
+ limit: 1,
+ cursor: result.nextCursor!,
+ });
+
+ expect(secondPage.tags.length).toBe(1);
+ expect(secondPage.tags[0].name).toBe("filter-low"); // Lower usage
+ expect(secondPage.tags[0].numBookmarks).toBe(1);
+ expect(secondPage.nextCursor).toBeNull();
+ });
+ });
});
test<CustomTestContext>("create strips extra leading hashes", async ({
diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts
index c1217cf9..d4cfbe8c 100644
--- a/packages/trpc/routers/tags.ts
+++ b/packages/trpc/routers/tags.ts
@@ -5,6 +5,8 @@ import {
zCreateTagRequestSchema,
zGetTagResponseSchema,
zTagBasicSchema,
+ zTagListResponseSchema,
+ zTagListValidatedRequestSchema,
zUpdateTagRequestSchema,
} from "@karakeep/shared/types/tags";
@@ -90,13 +92,24 @@ export const tagsAppRouter = router({
return await Tag.merge(ctx, input);
}),
list: authedProcedure
- .output(
- z.object({
- tags: z.array(zGetTagResponseSchema),
- }),
+ .input(
+ // TODO: Remove the optional and default once the next release is out.
+ zTagListValidatedRequestSchema
+ .optional()
+ .default(zTagListValidatedRequestSchema.parse({})),
)
- .query(async ({ ctx }) => {
- const tags = await Tag.getAllWithStats(ctx);
- return { tags };
+ .output(zTagListResponseSchema)
+ .query(async ({ ctx, input }) => {
+ return await Tag.getAll(ctx, {
+ nameContains: input.nameContains,
+ attachedBy: input.attachedBy,
+ sortBy: input.sortBy,
+ pagination: input.limit
+ ? {
+ page: input.cursor?.page ?? 0,
+ limit: input.limit,
+ }
+ : undefined,
+ });
}),
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 14bb788e..f04745e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -449,9 +449,6 @@ importers:
tailwind-merge:
specifier: ^2.2.1
version: 2.2.1
- use-debounce:
- specifier: ^10.0.0
- version: 10.0.0(react@19.1.0)
zod:
specifier: ^3.24.2
version: 3.24.2
@@ -13990,12 +13987,6 @@ packages:
'@types/react':
optional: true
- use-debounce@10.0.0:
- resolution: {integrity: sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==}
- engines: {node: '>= 16.0.0'}
- peerDependencies:
- react: '>=16.8.0'
-
use-editable@2.3.3:
resolution: {integrity: sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA==}
peerDependencies:
@@ -30679,10 +30670,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
- use-debounce@10.0.0(react@19.1.0):
- dependencies:
- react: 19.1.0
-
use-editable@2.3.3(react@19.1.0):
dependencies:
react: 19.1.0