diff options
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 "{trimmedInputValue}"</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'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 |
