diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e5768c7e4..14bb06d85 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1308,5 +1308,7 @@ "Toggle workspace knowledge only": "Toggle workspace knowledge only", "Read-only mode": "Read-only mode", "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", - "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode" + "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode", + "Title only": "Title only", + "you": "you" } diff --git a/apps/client/src/components/ui/checkbox-menu-item.tsx b/apps/client/src/components/ui/checkbox-menu-item.tsx new file mode 100644 index 000000000..12b1f990e --- /dev/null +++ b/apps/client/src/components/ui/checkbox-menu-item.tsx @@ -0,0 +1,12 @@ +import { UnstyledButton } from "@mantine/core"; +import { type ComponentPropsWithoutRef, forwardRef } from "react"; + +// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works. +export const CheckboxMenuItem = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<"button"> +>((props, ref) => ( + +)); + +CheckboxMenuItem.displayName = "CheckboxMenuItem"; diff --git a/apps/client/src/features/label/queries/label-query.ts b/apps/client/src/features/label/queries/label-query.ts index 6b06c4e30..119618521 100644 --- a/apps/client/src/features/label/queries/label-query.ts +++ b/apps/client/src/features/label/queries/label-query.ts @@ -39,6 +39,7 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) { queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }), enabled, staleTime: 30 * 1000, + placeholderData: keepPreviousData }); } diff --git a/apps/client/src/features/search/components/creator-filter-menu.tsx b/apps/client/src/features/search/components/creator-filter-menu.tsx new file mode 100644 index 000000000..ce4a93be1 --- /dev/null +++ b/apps/client/src/features/search/components/creator-filter-menu.tsx @@ -0,0 +1,152 @@ +import { ReactNode, useMemo, useState } from "react"; +import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; +import { RadioMenuItem } from "@/components/ui/radio-menu-item"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { IUser } from "@/features/user/types/user.types.ts"; +import { useAtomValue } from "jotai"; +import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; + +type CreatorFilterMenuProps = { + value: string | null; + onChange: (user: IUser | null) => void; + children: ReactNode; + width?: number; + position?: + | "bottom-start" + | "bottom-end" + | "bottom" + | "top-start" + | "top-end" + | "top"; + zIndex?: number; + opened?: boolean; + onOpenChange?: (opened: boolean) => void; +}; + +export function CreatorFilterMenu({ + value, + onChange, + children, + width = 280, + position = "bottom-end", + zIndex, + opened, + onOpenChange, +}: CreatorFilterMenuProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ + query: debouncedQuery, + includeUsers: true, + includeGroups: false, + includePages: false, + preload: true, + }); + + const users: IUser[] = (suggestion?.users as IUser[]) ?? []; + const currentUser = useAtomValue(userAtom); + + // pin the signed-in user on top so they never have to search themselves + const displayUsers = useMemo(() => { + if (!currentUser) return users; + const others = users.filter((user) => user.id !== currentUser.id); + const q = debouncedQuery.trim().toLowerCase(); + const matchesQuery = + !q || + currentUser.name?.toLowerCase().includes(q) || + currentUser.email?.toLowerCase().includes(q); + return matchesQuery ? [currentUser as IUser, ...others] : users; + }, [users, currentUser, debouncedQuery]); + + return ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + onChange(null)} + > + +
+ + {t("Anyone")} + +
+ {!value && } +
+
+ + + + {displayUsers.length === 0 && ( + + {isLoading ? t("Loading...") : t("No users found")} + + )} + + {displayUsers.map((user) => ( + onChange(user)} + > + + +
+ + {user.name} + {user.id === currentUser?.id && ( + + {" "} + ({t("you")}) + + )} + + {user.email && ( + + {user.email} + + )} +
+ {value === user.id && } +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/features/search/components/label-filter-menu.tsx b/apps/client/src/features/search/components/label-filter-menu.tsx new file mode 100644 index 000000000..2bc9db7a6 --- /dev/null +++ b/apps/client/src/features/search/components/label-filter-menu.tsx @@ -0,0 +1,127 @@ +import { ReactNode, useMemo, useState } from "react"; +import { + Group, + Menu, + ScrollArea, + Text, + TextInput, + useComputedColorScheme, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts"; +import { getLabelColor } from "@/features/label/utils/label-colors.ts"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; + +type LabelFilterMenuProps = { + value: string[]; + onChange: (labelIds: string[]) => void; + children: ReactNode; + width?: number; + position?: + | "bottom-start" + | "bottom-end" + | "bottom" + | "top-start" + | "top-end" + | "top"; + zIndex?: number; + opened?: boolean; + onOpenChange?: (opened: boolean) => void; +}; + +export function LabelFilterMenu({ + value, + onChange, + children, + width = 280, + position = "bottom-end", + zIndex, + opened, + onOpenChange, +}: LabelFilterMenuProps) { + const { t } = useTranslation(); + const scheme = useComputedColorScheme("light"); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true); + const labels = data?.items ?? []; + + const selectedSet = useMemo(() => new Set(value), [value]); + + const toggleLabel = (labelId: string) => { + if (selectedSet.has(labelId)) { + onChange(value.filter((id) => id !== labelId)); + } else { + onChange([...value, labelId]); + } + }; + + return ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + {labels.length === 0 && ( + + {isLoading ? t("Loading...") : t("No labels found")} + + )} + + {labels.map((label) => { + const isChecked = selectedSet.has(label.id); + const color = getLabelColor(label.name, scheme); + return ( + toggleLabel(label.id)} + > + + + + {label.name} + + {isChecked && } + + + ); + })} + + + + ); +} diff --git a/apps/client/src/features/search/components/search-result-item.tsx b/apps/client/src/features/search/components/search-result-item.tsx index 24d3472b5..4412b9a97 100644 --- a/apps/client/src/features/search/components/search-result-item.tsx +++ b/apps/client/src/features/search/components/search-result-item.tsx @@ -19,6 +19,7 @@ import { } from "@/features/search/types/search.types"; import DOMPurify from "dompurify"; import { useTranslation } from "react-i18next"; +import { timeAgo } from "@/lib/time.ts"; interface SearchResultItemProps { result: IPageSearch | IAttachmentSearch; @@ -26,6 +27,14 @@ interface SearchResultItemProps { showSpace?: boolean; } +// Spotlight hardcodes tabIndex={-1} after spreading props; a ref wins and +// React never writes -1 back because the prop value never changes +const makeActionTabbable = (el: HTMLElement | null) => { + if (el) { + el.tabIndex = 0; + } +}; + export function SearchResultItem({ result, isAttachmentResult, @@ -46,6 +55,7 @@ export function SearchResultItem({ return ( -
- {attachmentResult.fileName} +
+ + {attachmentResult.fileName} + + {timeAgo(attachmentResult.updatedAt)} + + {attachmentResult.space.name} • {attachmentResult.page.title} @@ -96,6 +111,7 @@ export function SearchResultItem({ return (
{getPageIcon(pageResult?.icon)}
-
- {pageResult.title} +
+ + {pageResult.title || t("Untitled")} + + {timeAgo(pageResult.updatedAt)} + + {showSpace && pageResult.space && ( diff --git a/apps/client/src/features/search/components/search-spotlight-filters.module.css b/apps/client/src/features/search/components/search-spotlight-filters.module.css index e8073aab6..3709f2960 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.module.css +++ b/apps/client/src/features/search/components/search-spotlight-filters.module.css @@ -17,3 +17,10 @@ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); } } + +.filterButtonActive { + color: var(--mantine-color-blue-light-color); + &:hover { + color: var(--mantine-color-blue-light-color); + } +} diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index 0b2bcc48c..96a7178ad 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import cx from "clsx"; import { Button, Menu, @@ -11,18 +12,24 @@ import { import { IconChevronDown, IconBuilding, + IconPlus, IconFileDescription, IconCheck, + IconUser, + IconTag, + IconLetterCase, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; +import { CreatorFilterMenu } from "@/features/search/components/creator-filter-menu"; import { RadioMenuItem } from "@/components/ui/radio-menu-item"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; import classes from "./search-spotlight-filters.module.css"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { LabelFilterMenu } from "./label-filter-menu"; interface SearchSpotlightFiltersProps { onFiltersChange?: (filters: any) => void; @@ -40,9 +47,17 @@ export function SearchSpotlightFilters({ const { t } = useTranslation(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const [selectedSpaceId, setSelectedSpaceId] = useState( - spaceId || null, + spaceId || null ); const [contentType, setContentType] = useState("page"); + const [selectedCreatorId, setSelectedCreatorId] = useState(null); + const [selectedCreatorName, setSelectedCreatorName] = useState( + null + ); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const [titleOnly, setTitleOnly] = useState(false); + const [openedFilter, setOpenedFilter] = useState(null); + const [visibleFilters, setVisibleFilters] = useState([]); const [workspace] = useAtom(workspaceAtom); const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); @@ -50,15 +65,6 @@ export function SearchSpotlightFilters({ ? spacesData?.items.find((space) => space.id === selectedSpaceId) : null; - useEffect(() => { - if (onFiltersChange) { - onFiltersChange({ - spaceId: selectedSpaceId, - contentType, - }); - } - }, []); - const contentTypeOptions = [ { value: "page", label: t("Pages") }, { @@ -68,38 +74,70 @@ export function SearchSpotlightFilters({ }, ]; + useEffect(() => { + onFiltersChange?.({ + spaceId: selectedSpaceId, + contentType, + creatorId: selectedCreatorId, + labelIds: selectedLabelIds, + titleOnly, + }); + }, [ + selectedSpaceId, + contentType, + selectedCreatorId, + selectedLabelIds, + titleOnly, + onFiltersChange, + ]); + const handleSpaceSelect = (spaceId: string | null) => { setSelectedSpaceId(spaceId); + }; - if (onFiltersChange) { - onFiltersChange({ - spaceId: spaceId, - contentType, - }); + const handleCreatorSelect = (user: { id: string; name: string } | null) => { + setSelectedCreatorId(user?.id ?? null); + setSelectedCreatorName(user?.name ?? null); + }; + + const handleLabelsSelect = (labelIds: string[]) => { + setSelectedLabelIds(labelIds); + }; + + const handleChangeContentType = (value: string) => { + setContentType(value); + + if (value === "attachment") { + setSelectedLabelIds([]); } }; - const handleFilterChange = (filterType: string, value: any) => { - let newSelectedSpaceId = selectedSpaceId; - let newContentType = contentType; + const onDemandFilters = [ + { key: "creator", label: t("Created by"), icon: IconUser, available: true }, + { + key: "labels", + label: t("Labels"), + icon: IconTag, + available: contentType !== "attachment", + }, + ]; - switch (filterType) { - case "spaceId": - newSelectedSpaceId = value; - setSelectedSpaceId(value); - break; - case "contentType": - newContentType = value; - setContentType(value); - break; - } + const isFilterVisible = (key: string) => { + if (openedFilter === key) return true; + if (key === "creator") return !!selectedCreatorId; + if (key === "labels") + return contentType !== "attachment" && selectedLabelIds.length > 0; + return false; + }; - if (onFiltersChange) { - onFiltersChange({ - spaceId: newSelectedSpaceId, - contentType: newContentType, - }); - } + const orderedVisibleFilters = visibleFilters.filter(isFilterVisible); + const addableFilters = onDemandFilters.filter( + (filter) => filter.available && !isFilterVisible(filter.key), + ); + + const revealFilter = (key: string) => { + setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]); + setOpenedFilter(key); }; return ( @@ -122,8 +160,17 @@ export function SearchSpotlightFilters({ color="blue" labelPosition="left" styles={{ - root: { display: "flex", alignItems: "center" }, - label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }, + root: { + display: "flex", + alignItems: "center", + flexShrink: 0, + }, + label: { + whiteSpace: "nowrap", + paddingRight: "8px", + fontSize: "13px", + fontWeight: 500, + }, }} />
@@ -181,7 +228,7 @@ export function SearchSpotlightFilters({ onClick={() => !option.disabled && contentType !== option.value && - handleFilterChange("contentType", option.value) + handleChangeContentType(option.value) } disabled={ option.disabled || (isAiMode && option.value === "attachment") @@ -195,13 +242,11 @@ export function SearchSpotlightFilters({ {t("Enterprise")} )} - {!option.disabled && - isAiMode && - option.value === "attachment" && ( - - {t("AI Answers not available for attachments")} - - )} + {!option.disabled && isAiMode && option.value === "attachment" && ( + + {t("AI Answers not available for attachments")} + + )}
{contentType === option.value && } @@ -209,6 +254,126 @@ export function SearchSpotlightFilters({ ))} + + {!isAiMode && ( + + )} + + {!isAiMode && + orderedVisibleFilters.map((filterKey) => { + if (filterKey === "creator") { + return ( + + setOpenedFilter(opened ? "creator" : null) + } + > + + + ); + } + + if (filterKey === "labels") { + return ( + + setOpenedFilter(opened ? "labels" : null) + } + > + + + ); + } + + return null; + })} + + {!isAiMode && addableFilters.length > 0 && ( + + + + + + {addableFilters.map((filter) => ( + } + onClick={() => revealFilter(filter.key)} + > + {filter.label} + + ))} + + + )}
); } diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 3ada7ab1f..4e7977650 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -1,7 +1,7 @@ import { Spotlight } from "@mantine/spotlight"; import { IconSearch, IconSparkles } from "@tabler/icons-react"; -import { Group, Button, VisuallyHidden } from "@mantine/core"; -import React, { useState, useMemo, useEffect } from "react"; +import { Group, Button, VisuallyHidden, Text } from "@mantine/core"; +import React, { useState, useMemo, useEffect, useCallback } from "react"; import { useDebouncedValue } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; @@ -31,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const [filters, setFilters] = useState<{ spaceId?: string | null; contentType?: string; + creatorId?: string | null; + labelIds?: string[]; + titleOnly?: boolean; }>({ contentType: "page", }); @@ -48,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { params.spaceId = filters.spaceId; } + if (filters.creatorId) { + params.creatorId = filters.creatorId; + } + + if (filters.labelIds?.length) { + params.labelIds = filters.labelIds; + } + + if (filters.titleOnly) { + params.titleOnly = true; + } + return params; }, [debouncedSearchQuery, filters]); - const { data: searchResults, isLoading } = useUnifiedSearch( + const { + data: searchResults, + isFetching, + } = useUnifiedSearch( searchParams, !isAiMode // Disable regular search when in AI mode ); @@ -88,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }, [aiSearchError, t]); + const isFilterBrowse = + (filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId; + // while the debounce is pending the empty list is not a settled "no results" + const isQuerySettled = query === debouncedSearchQuery; + // Determine result type for rendering const isAttachmentSearch = filters.contentType === "attachment" && hasAttachmentIndexing; @@ -110,9 +133,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }; - const handleFiltersChange = (newFilters: any) => { + const handleFiltersChange = useCallback((newFilters: any) => { setFilters(newFilters); - }; + }, [setFilters]); const handleAskClick = () => { setIsAiMode(!isAiMode); @@ -182,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ? query.length > 0 && !isAiLoading && !aiSearchResult ? t("No answer available") : "" - : query.length > 0 && !isLoading + : (query.length > 0 || isFilterBrowse) && !isFetching ? resultItems.length === 0 ? t("No results found") : t("{{count}} results found", { count: resultItems.length }) @@ -209,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ) : ( <> - {query.length === 0 && resultItems.length === 0 && ( + {query.length === 0 && !isFilterBrowse && resultItems.length === 0 && ( {t("Start typing to search...")} )} - {query.length > 0 && !isLoading && resultItems.length === 0 && ( - {t("No results found...")} - )} + {(query.length > 0 || isFilterBrowse) && + !isFetching && + isQuerySettled && + resultItems.length === 0 && ( + {t("No results found...")} + )} {resultItems.length > 0 && <>{resultItems}} + + {(query.length > 0 || isFilterBrowse) && + isFetching && + resultItems.length === 0 && ( + + + {t("Searching...")} + + + )} )} diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index 5d294f4bd..9270477b9 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -39,6 +39,20 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query && enabled, + enabled: + (!!params.query || + (params.labelIds?.length ?? 0) > 0 || + !!params.creatorId) && + enabled, + // keep previous results only within the same search type; page results + // rendered as attachments (or vice versa) crash on missing fields + placeholderData: (previousData, previousQuery) => { + if (!params.query && !params.labelIds?.length && !params.creatorId) + return undefined; + if (previousQuery && previousQuery.queryKey[1] !== searchType) { + return undefined; + } + return previousData; + }, }); } diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 9962b9ca2..60ae5d985 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -36,6 +36,9 @@ export interface IPageSearchParams { query: string; spaceId?: string; shareId?: string; + creatorId?: string; + labelIds?: string[]; + titleOnly?: boolean; } export interface IAttachmentSearch { diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css index 25c1eafe7..1fb4264d9 100644 --- a/apps/client/src/styles/a11y-overrides.css +++ b/apps/client/src/styles/a11y-overrides.css @@ -33,3 +33,31 @@ color: var(--mantine-color-dimmed); -webkit-text-fill-color: var(--mantine-color-dimmed); } + +/* Spotlight's selected action ships as primary-filled blue with white text, + * but our custom action children (gray badge, dimmed snippet, gray icons) + * keep their light-surface colors on it and drop below WCAG AA 4.5:1 + * (WCAG 1.4.3). Use a gray selection one step above the gray-0/dark-6 + * hover instead - it matches the app's selection styling (we use no blue + * fills) and keeps every child at its already-passing resting contrast. + */ +.mantine-Spotlight-action[data-selected] { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + color: var(--mantine-color-text); + --action-description-color: var(--mantine-color-dimmed); + --action-description-opacity: 1; +} + +/* Result actions are tab stops (tabIndex restored via ref); give keyboard + * focus the same gray treatment as arrow-key selection so both navigation + * modes read identically (WCAG 2.4.7 focus visible). + */ +.mantine-Spotlight-action:focus-visible { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 710f9d10b..2db13c817 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -116,8 +116,8 @@ export class AttachmentService { }); } - // Only index PDFs and DOCX files - if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) { + // Only index PDF, DOCX and TXT files + if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) { await this.attachmentQueue.add( QueueJob.ATTACHMENT_INDEX_CONTENT, { diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts index f0d63eba6..9ec0ab300 100644 --- a/apps/server/src/core/label/label.service.ts +++ b/apps/server/src/core/label/label.service.ts @@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { normalizeLabelName } from './utils'; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { EventName } from "src/common/events/event.contants"; @Injectable() export class LabelService { constructor( private readonly labelRepo: LabelRepo, private readonly pagePermissionRepo: PagePermissionRepo, + private readonly eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -34,6 +37,12 @@ export class LabelService { attached.push(label); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); + return attached; } @@ -64,6 +73,11 @@ export class LabelService { await this.labelRepo.deleteLabel(labelId, workspaceId, trx); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); } async getPageLabels(pageId: string, pagination: PaginationOptions) { diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 8be6d338d..89fb1b289 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsNotEmpty, IsNumber, @@ -8,9 +9,9 @@ import { } from 'class-validator'; export class SearchDTO { - @IsNotEmpty() + @IsOptional() @IsString() - query: string; + query?: string; @IsOptional() @IsUUID() @@ -24,6 +25,15 @@ export class SearchDTO { @IsUUID() creatorId?: string; + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + labelIds?: string[]; + + @IsOptional() + @IsBoolean() + titleOnly?: boolean; + @IsOptional() @IsNumber() limit?: number; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b2654..3db29b274 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -29,12 +29,36 @@ export class SearchService { workspaceId: string; }, ): Promise<{ items: SearchResponseDto[] }> { - const { query } = searchParams; + const query = searchParams.query?.trim() ?? ''; + const labelIds = [...new Set(searchParams.labelIds ?? [])]; + // selected filters (labels, creator) are browsable without a query + const browseByFilters = + query.length < 1 && + (labelIds.length > 0 || Boolean(searchParams.creatorId)); - if (query.length < 1) { + if (query.length < 1 && !browseByFilters) { return { items: [] }; } - const searchQuery = tsquery(query.trim() + '*'); + const searchQuery = tsquery(query + '*'); + const titleOnly = searchParams.titleOnly === true; + const titleQuery = query; + // escape LIKE wildcards; ranking keeps the raw query + const titleLikeQuery = query.replace(/[\\%_]/g, '\\$&'); + + const rankColumn = browseByFilters + ? sql`0`.as('rank') + : titleOnly + ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as( + 'rank', + ) + : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( + 'rank', + ); + const highlightColumn = browseByFilters || titleOnly + ? sql`''`.as('highlight') + : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( + 'highlight', + ); let queryResults = this.db .selectFrom('pages') @@ -47,23 +71,41 @@ export class SearchService { 'creatorId', 'createdAt', 'updatedAt', - sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( - 'rank', - ), - sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( - 'highlight', - ), + rankColumn, + highlightColumn, ]) - .where( - 'tsv', - '@@', - sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + .$if(!browseByFilters && !titleOnly, (qb) => + qb.where( + 'tsv', + '@@', + sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + ), + ) + .$if(!browseByFilters && titleOnly, (qb) => + qb.where((eb) => + eb( + sql`lower(f_unaccent(pages.title))`, + 'like', + sql`lower(f_unaccent(${`%${titleLikeQuery}%`}))`, + ), + ), ) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) + .$if(labelIds?.length > 0, (qb) => + qb.where( + 'id', + 'in', + this.db + .selectFrom('pageLabels') + .select('pageId') + .where('labelId', 'in', labelIds), + ), + ) .where('deletedAt', 'is', null) - .orderBy('rank', 'desc') + .$if(browseByFilters, (qb) => qb.orderBy('updatedAt', 'desc')) + .$if(!browseByFilters, (qb) => qb.orderBy('rank', 'desc')) .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); @@ -71,8 +113,7 @@ export class SearchService { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } - if (searchParams.spaceId) { - // search by spaceId + if (searchParams.spaceId && opts.userId) { queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); } else if (opts.userId && !searchParams.spaceId) { // only search spaces the user is a member of diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 03ee31555..e567e6caa 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -46,8 +46,9 @@ export class ShareService { throw new NotFoundException('Share not found'); } - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + share.pageId, + ); if (isRestricted) { throw new NotFoundException('Share not found'); } @@ -110,6 +111,9 @@ export class ShareService { } async getSharedPage(dto: ShareInfoDto, workspaceId: string) { + //TODO: we should resolve the page from the share id + if (!dto.pageId) throw new NotFoundException('Shared page not found'); + const share = await this.getShareForPage(dto.pageId, workspaceId); if (!share) { @@ -126,8 +130,9 @@ export class ShareService { } // Block access to restricted pages - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); if (isRestricted) { throw new NotFoundException('Shared page not found'); } diff --git a/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts new file mode 100644 index 000000000..3cadad07b --- /dev/null +++ b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts @@ -0,0 +1,17 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(f_unaccent(title)) gin_trgm_ops)`.execute( + db, + ); + + // separators normalized to spaces so space-typed queries match How_to_export.pdf + await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(f_unaccent(translate(file_name, '_.-', ' '))) gin_trgm_ops)`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS attachments_file_name_trgm_idx`.execute(db); + await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db); +} diff --git a/apps/server/src/ee b/apps/server/src/ee index 62f745608..b51a46ef1 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 62f7456089d97ceedbcbad1ceabce09a4c7e1301 +Subproject commit b51a46ef104cb407a670462a36be79ef0da94f97