From f12866cf422f27a906f43bad796bed9ac82c5c15 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 2 Sep 2025 05:27:01 +0100 Subject: [PATCH] feat(EE): full-text search in attachments (#1502) * feat(EE): fulltext search in attachments * feat: global search - search filters - attachments search ui - and more * fix import * fix import * rename migration * add GIN index * fix table name * sanitize --- .../components/layouts/global/app-header.tsx | 17 ++ .../src/components/layouts/global/layout.tsx | 9 +- .../src/features/editor/page-editor.tsx | 5 + .../src/features/editor/title-editor.tsx | 17 +- .../search/components/search-result-item.tsx | 124 +++++++++ .../search-spotlight-filters.module.css | 19 ++ .../components/search-spotlight-filters.tsx | 252 ++++++++++++++++++ .../search/components/search-spotlight.tsx | 104 ++++++++ .../share-search-spotlight.tsx | 8 +- .../search/hooks/use-unified-search.ts | 42 +++ .../features/search/queries/search-query.ts | 16 +- .../src/features/search/search-spotlight.tsx | 83 ------ .../search/services/search-service.ts | 10 +- .../src/features/search/types/search.types.ts | 22 ++ .../features/share/components/share-shell.tsx | 2 +- .../components/sidebar/space-sidebar.tsx | 3 - apps/client/src/lib/app-route.ts | 1 + apps/server/package.json | 2 + apps/server/src/common/helpers/utils.ts | 9 + .../processors/attachment.processor.ts | 34 ++- .../attachment/services/attachment.service.ts | 22 ++ apps/server/src/core/search/dto/search.dto.ts | 4 +- .../server/src/core/share/share.controller.ts | 17 +- .../20250901T184612-attachments-search.ts | 29 ++ .../repos/attachment/attachment.repo.ts | 26 +- apps/server/src/database/types/db.d.ts | 2 + apps/server/src/ee | 2 +- .../queue/constants/queue.constants.ts | 2 + pnpm-lock.yaml | 182 +++++++++++++ 29 files changed, 956 insertions(+), 109 deletions(-) create mode 100644 apps/client/src/features/search/components/search-result-item.tsx create mode 100644 apps/client/src/features/search/components/search-spotlight-filters.module.css create mode 100644 apps/client/src/features/search/components/search-spotlight-filters.tsx create mode 100644 apps/client/src/features/search/components/search-spotlight.tsx rename apps/client/src/features/search/{ => components}/share-search-spotlight.tsx (90%) create mode 100644 apps/client/src/features/search/hooks/use-unified-search.ts delete mode 100644 apps/client/src/features/search/search-spotlight.tsx create mode 100644 apps/server/src/database/migrations/20250901T184612-attachments-search.ts diff --git a/apps/client/src/components/layouts/global/app-header.tsx b/apps/client/src/components/layouts/global/app-header.tsx index 09b95539..eb1ca74f 100644 --- a/apps/client/src/components/layouts/global/app-header.tsx +++ b/apps/client/src/components/layouts/global/app-header.tsx @@ -14,6 +14,14 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import { useTranslation } from "react-i18next"; import useTrial from "@/ee/hooks/use-trial.tsx"; import { isCloud } from "@/lib/config.ts"; +import { + SearchControl, + SearchMobileControl, +} from "@/features/search/components/search-control.tsx"; +import { + searchSpotlight, + shareSearchSpotlight, +} from "@/features/search/constants.ts"; const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; @@ -79,6 +87,15 @@ export function AppHeader() { +
+ + + + + + +
+ {isCloud() && isTrial && trialDaysLeft !== 0 && ( {isCloud() && } + ); } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index f68f50de..e97a783f 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -50,6 +50,7 @@ import { extractPageSlugId } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; +import { searchSpotlight } from '@/features/search/constants.ts'; interface PageEditorProps { pageId: string; @@ -222,6 +223,10 @@ export default function PageEditor({ event.preventDefault(); return true; } + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') { + searchSpotlight.open(); + return true; + } if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 937ae374..f7665ecb 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -26,6 +26,7 @@ import { UpdateEvent } from "@/features/websocket/types"; import localEmitter from "@/lib/local-emitter.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; +import { searchSpotlight } from "@/features/search/constants.ts"; export interface TitleEditorProps { pageId: string; @@ -86,6 +87,20 @@ export function TitleEditor({ content: title, immediatelyRender: true, shouldRerenderOnTransaction: false, + editorProps: { + handleDOMEvents: { + keydown: (_view, event) => { + if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { + event.preventDefault(); + return true; + } + if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { + searchSpotlight.open(); + return true; + } + }, + }, + }, }); useEffect(() => { @@ -193,7 +208,7 @@ export function TitleEditor({ onKeyDown={(event) => { // First handle the search hotkey getHotkeyHandler([["mod+F", openSearchDialog]])(event); - + // Then handle other key events handleTitleKeyDown(event); }} diff --git a/apps/client/src/features/search/components/search-result-item.tsx b/apps/client/src/features/search/components/search-result-item.tsx new file mode 100644 index 00000000..76b74f29 --- /dev/null +++ b/apps/client/src/features/search/components/search-result-item.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { Group, Center, Text, Badge, ActionIcon } from "@mantine/core"; +import { Spotlight } from "@mantine/spotlight"; +import { Link } from "react-router-dom"; +import { IconFile, IconDownload } from "@tabler/icons-react"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { getPageIcon } from "@/lib"; +import { + IAttachmentSearch, + IPageSearch, +} from "@/features/search/types/search.types"; +import DOMPurify from "dompurify"; + +interface SearchResultItemProps { + result: IPageSearch | IAttachmentSearch; + isAttachmentResult: boolean; + showSpace?: boolean; +} + +export function SearchResultItem({ + result, + isAttachmentResult, + showSpace, +}: SearchResultItemProps) { + if (isAttachmentResult) { + const attachmentResult = result as IAttachmentSearch; + + const handleDownload = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const downloadUrl = `/api/files/${attachmentResult.id}/${attachmentResult.fileName}`; + window.open(downloadUrl, "_blank"); + }; + + return ( + + +
+ +
+ +
+ {attachmentResult.fileName} + + {attachmentResult.space.name} • {attachmentResult.page.title} + + + {attachmentResult?.highlight && ( + + )} +
+ + + + +
+
+ ); + } else { + const pageResult = result as IPageSearch; + return ( + + +
{getPageIcon(pageResult?.icon)}
+ +
+ {pageResult.title} + + {showSpace && pageResult.space && ( + + {pageResult.space.name} + + )} + + {pageResult?.highlight && ( + + )} +
+
+
+ ); + } +} 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 new file mode 100644 index 00000000..e8073aab --- /dev/null +++ b/apps/client/src/features/search/components/search-spotlight-filters.module.css @@ -0,0 +1,19 @@ +.filtersContainer { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 8px 0; + scrollbar-width: thin; +} + +.filterButton { + white-space: nowrap; + flex-shrink: 0; + font-size: 13px; + height: 32px; + padding: 0 12px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); + &:hover { + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); + } +} diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx new file mode 100644 index 00000000..d8f770e7 --- /dev/null +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -0,0 +1,252 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { + Button, + Menu, + Text, + TextInput, + Divider, + Badge, + ScrollArea, + Avatar, + Group, + getDefaultZIndex, +} from "@mantine/core"; +import { + IconChevronDown, + IconBuilding, + IconFileDescription, + IconSearch, + IconCheck, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useGetSpacesQuery } from "@/features/space/queries/space-query"; +import { useLicense } from "@/ee/hooks/use-license"; +import classes from "./search-spotlight-filters.module.css"; + +interface SearchSpotlightFiltersProps { + onFiltersChange?: (filters: any) => void; + spaceId?: string; +} + +export function SearchSpotlightFilters({ + onFiltersChange, + spaceId, +}: SearchSpotlightFiltersProps) { + const { t } = useTranslation(); + const { hasLicenseKey } = useLicense(); + const [selectedSpaceId, setSelectedSpaceId] = useState( + spaceId || null, + ); + const [spaceSearchQuery, setSpaceSearchQuery] = useState(""); + const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300); + const [contentType, setContentType] = useState("page"); + + const { data: spacesData } = useGetSpacesQuery({ + page: 1, + limit: 100, + query: debouncedSpaceQuery, + }); + + const selectedSpaceData = useMemo(() => { + if (!spacesData?.items || !selectedSpaceId) return null; + return spacesData.items.find((space) => space.id === selectedSpaceId); + }, [spacesData?.items, selectedSpaceId]); + + const availableSpaces = useMemo(() => { + const spaces = spacesData?.items || []; + if (!selectedSpaceId) return spaces; + + // Sort to put selected space first + return [...spaces].sort((a, b) => { + if (a.id === selectedSpaceId) return -1; + if (b.id === selectedSpaceId) return 1; + return 0; + }); + }, [spacesData?.items, selectedSpaceId]); + + useEffect(() => { + if (onFiltersChange) { + onFiltersChange({ + spaceId: selectedSpaceId, + contentType, + }); + } + }, []); + + const contentTypeOptions = [ + { value: "page", label: "Pages" }, + { + value: "attachment", + label: "Attachments", + disabled: !hasLicenseKey, + }, + ]; + + const handleSpaceSelect = (spaceId: string | null) => { + setSelectedSpaceId(spaceId); + + if (onFiltersChange) { + onFiltersChange({ + spaceId: spaceId, + contentType, + }); + } + }; + + const handleFilterChange = (filterType: string, value: any) => { + let newSelectedSpaceId = selectedSpaceId; + let newContentType = contentType; + + switch (filterType) { + case "spaceId": + newSelectedSpaceId = value; + setSelectedSpaceId(value); + break; + case "contentType": + newContentType = value; + setContentType(value); + break; + } + + if (onFiltersChange) { + onFiltersChange({ + spaceId: newSelectedSpaceId, + contentType: newContentType, + }); + } + }; + + return ( +
+ + + + + + } + value={spaceSearchQuery} + onChange={(e) => setSpaceSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + handleSpaceSelect(null)}> + + +
+ + All spaces + + + Search in all your spaces + +
+ {!selectedSpaceId && } +
+
+ + + + {availableSpaces.map((space) => ( + handleSpaceSelect(space.id)} + > + + + + {space.name} + + {selectedSpaceId === space.id && } + + + ))} +
+
+
+ + + + + + + {contentTypeOptions.map((option) => ( + + !option.disabled && + contentType !== option.value && + handleFilterChange("contentType", option.value) + } + disabled={option.disabled} + > + +
+ {option.label} + {option.disabled && ( + + Enterprise + + )} +
+ {contentType === option.value && } +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx new file mode 100644 index 00000000..e7e3c9f7 --- /dev/null +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -0,0 +1,104 @@ +import { Spotlight } from "@mantine/spotlight"; +import { IconSearch } from "@tabler/icons-react"; +import React, { useState, useMemo } from "react"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useTranslation } from "react-i18next"; +import { searchSpotlightStore } from "../constants.ts"; +import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx"; +import { useUnifiedSearch } from "../hooks/use-unified-search.ts"; +import { SearchResultItem } from "./search-result-item.tsx"; +import { useLicense } from "@/ee/hooks/use-license.tsx"; + +interface SearchSpotlightProps { + spaceId?: string; +} +export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { + const { t } = useTranslation(); + const { hasLicenseKey } = useLicense(); + const [query, setQuery] = useState(""); + const [debouncedSearchQuery] = useDebouncedValue(query, 300); + const [filters, setFilters] = useState<{ + spaceId?: string | null; + contentType?: string; + }>({ + contentType: "page", + }); + + // Build unified search params + const searchParams = useMemo(() => { + const params: any = { + query: debouncedSearchQuery, + contentType: filters.contentType || "page", // Only used for frontend routing + }; + + // Handle space filtering - only pass spaceId if a specific space is selected + if (filters.spaceId) { + params.spaceId = filters.spaceId; + } + + return params; + }, [debouncedSearchQuery, filters]); + + const { data: searchResults, isLoading } = useUnifiedSearch(searchParams); + + // Determine result type for rendering + const isAttachmentSearch = + filters.contentType === "attachment" && hasLicenseKey; + + const resultItems = (searchResults || []).map((result) => ( + + )); + + const handleFiltersChange = (newFilters: any) => { + setFilters(newFilters); + }; + + return ( + <> + + } + /> + +
+ +
+ + + {query.length === 0 && resultItems.length === 0 && ( + {t("Start typing to search...")} + )} + + {query.length > 0 && !isLoading && resultItems.length === 0 && ( + {t("No results found...")} + )} + + {resultItems.length > 0 && <>{resultItems}} + +
+ + ); +} diff --git a/apps/client/src/features/search/share-search-spotlight.tsx b/apps/client/src/features/search/components/share-search-spotlight.tsx similarity index 90% rename from apps/client/src/features/search/share-search-spotlight.tsx rename to apps/client/src/features/search/components/share-search-spotlight.tsx index bfbced6e..dd0d5181 100644 --- a/apps/client/src/features/search/share-search-spotlight.tsx +++ b/apps/client/src/features/search/components/share-search-spotlight.tsx @@ -9,6 +9,7 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import { getPageIcon } from "@/lib"; import { useTranslation } from "react-i18next"; import { shareSearchSpotlightStore } from "@/features/search/constants.ts"; +import DOMPurify from "dompurify"; interface ShareSearchSpotlightProps { shareId?: string; @@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) { )} diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts new file mode 100644 index 00000000..2adccc25 --- /dev/null +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { + searchPage, + searchAttachments, +} from "@/features/search/services/search-service"; +import { + IAttachmentSearch, + IPageSearch, + IPageSearchParams, +} from "@/features/search/types/search.types"; +import { useLicense } from "@/ee/hooks/use-license"; + +export type UnifiedSearchResult = IPageSearch | IAttachmentSearch; + +export interface UseUnifiedSearchParams extends IPageSearchParams { + contentType?: string; +} + +export function useUnifiedSearch( + params: UseUnifiedSearchParams, +): UseQueryResult { + const { hasLicenseKey } = useLicense(); + + const isAttachmentSearch = + params.contentType === "attachment" && hasLicenseKey; + const searchType = isAttachmentSearch ? "attachment" : "page"; + + return useQuery({ + queryKey: ["unified-search", searchType, params], + queryFn: async () => { + // Remove contentType from backend params since it's only used for frontend routing + const { contentType, ...backendParams } = params; + + if (isAttachmentSearch) { + return await searchAttachments(backendParams); + } else { + return await searchPage(backendParams); + } + }, + enabled: !!params.query, + }); +} diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index 6b8c0296..bc91dfac 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -1,15 +1,17 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { + searchAttachments, searchPage, searchShare, searchSuggestions, -} from "@/features/search/services/search-service"; +} from '@/features/search/services/search-service'; import { + IAttachmentSearch, IPageSearch, IPageSearchParams, ISuggestionResult, SearchSuggestionParams, -} from "@/features/search/types/search.types"; +} from '@/features/search/types/search.types'; export function usePageSearchQuery( params: IPageSearchParams, @@ -41,3 +43,13 @@ export function useShareSearchQuery( enabled: !!params.query, }); } + +export function useAttachmentSearchQuery( + params: IPageSearchParams, +): UseQueryResult { + return useQuery({ + queryKey: ["attachment-search", params], + queryFn: () => searchAttachments(params), + enabled: !!params.query, + }); +} diff --git a/apps/client/src/features/search/search-spotlight.tsx b/apps/client/src/features/search/search-spotlight.tsx deleted file mode 100644 index 581524fc..00000000 --- a/apps/client/src/features/search/search-spotlight.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Group, Center, Text } from "@mantine/core"; -import { Spotlight } from "@mantine/spotlight"; -import { IconSearch } from "@tabler/icons-react"; -import React, { useState } from "react"; -import { Link } from "react-router-dom"; -import { useDebouncedValue } from "@mantine/hooks"; -import { usePageSearchQuery } from "@/features/search/queries/search-query"; -import { buildPageUrl } from "@/features/page/page.utils.ts"; -import { getPageIcon } from "@/lib"; -import { useTranslation } from "react-i18next"; -import { searchSpotlightStore } from "./constants"; - -interface SearchSpotlightProps { - spaceId?: string; -} -export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { - const { t } = useTranslation(); - const [query, setQuery] = useState(""); - const [debouncedSearchQuery] = useDebouncedValue(query, 300); - - const { data: searchResults } = usePageSearchQuery({ - query: debouncedSearchQuery, - spaceId, - }); - - const pages = ( - searchResults && searchResults.length > 0 ? searchResults : [] - ).map((page) => ( - - -
{getPageIcon(page?.icon)}
- -
- {page.title} - - {page?.highlight && ( - - )} -
-
-
- )); - - return ( - <> - - } - /> - - {query.length === 0 && pages.length === 0 && ( - {t("Start typing to search...")} - )} - - {query.length > 0 && pages.length === 0 && ( - {t("No results found...")} - )} - - {pages.length > 0 && pages} - - - - ); -} diff --git a/apps/client/src/features/search/services/search-service.ts b/apps/client/src/features/search/services/search-service.ts index 8ea4e6a6..417e3f11 100644 --- a/apps/client/src/features/search/services/search-service.ts +++ b/apps/client/src/features/search/services/search-service.ts @@ -1,10 +1,11 @@ import api from "@/lib/api-client"; import { + IAttachmentSearch, IPageSearch, IPageSearchParams, ISuggestionResult, SearchSuggestionParams, -} from "@/features/search/types/search.types"; +} from '@/features/search/types/search.types'; export async function searchPage( params: IPageSearchParams, @@ -26,3 +27,10 @@ export async function searchShare( const req = await api.post("/search/share-search", params); return req.data; } + +export async function searchAttachments( + params: IPageSearchParams, +): Promise { + const req = await api.post("/search-attachments", params); + return req.data; +} diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 1338e121..9962b9ca 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -37,3 +37,25 @@ export interface IPageSearchParams { spaceId?: string; shareId?: string; } + +export interface IAttachmentSearch { + id: string; + fileName: string; + pageId: string; + creatorId: string; + createdAt: Date; + updatedAt: Date; + rank: string; + highlight: string; + space: { + id: string; + name: string; + slug: string; + icon: string; + }; + page: { + id: string; + title: string; + slugId: string; + }; +} diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx index 10b2a74d..a300172e 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -34,7 +34,7 @@ import { SearchControl, SearchMobileControl, } from "@/features/search/components/search-control.tsx"; -import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight"; +import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx"; import { shareSearchSpotlight } from "@/features/search/constants"; import ShareBranding from '@/features/share/components/share-branding.tsx'; diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index f0235b3e..d650b178 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -19,7 +19,6 @@ import { import classes from "./space-sidebar.module.css"; import React from "react"; import { useAtom } from "jotai"; -import { SearchSpotlight } from "@/features/search/search-spotlight.tsx"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { Link, useLocation, useParams } from "react-router-dom"; import clsx from "clsx"; @@ -195,8 +194,6 @@ export function SpaceSidebar() { onClose={closeSettings} spaceId={space?.slug} /> - - ); } diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index dc42dad5..0151c856 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -1,6 +1,7 @@ const APP_ROUTE = { HOME: "/home", SPACES: "/spaces", + SEARCH: "/search", AUTH: { LOGIN: "/login", SIGNUP: "/signup", diff --git a/apps/server/package.json b/apps/server/package.json index 267abe00..35f17339 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -67,6 +67,7 @@ "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", "ldapts": "^7.4.0", + "mammoth": "^1.10.0", "mime-types": "^2.1.35", "nanoid": "3.3.11", "nestjs-kysely": "^1.2.0", @@ -76,6 +77,7 @@ "p-limit": "^6.2.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "pdfjs-dist": "^5.4.54", "pg": "^8.16.0", "pg-tsquery": "^8.4.2", "postmark": "^4.0.5", diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 0bc7d708..1b672610 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -87,3 +87,12 @@ export function extractBearerTokenFromHeader( const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; } + +export function hasLicenseOrEE(opts: { + licenseKey: string; + plan: string; + isCloud: boolean; +}): boolean { + const { licenseKey, plan, isCloud } = opts; + return Boolean(licenseKey) || (isCloud && plan === 'business'); +} diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts index 935c20a2..83323de5 100644 --- a/apps/server/src/core/attachment/processors/attachment.processor.ts +++ b/apps/server/src/core/attachment/processors/attachment.processor.ts @@ -3,12 +3,15 @@ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; import { AttachmentService } from '../services/attachment.service'; import { QueueJob, QueueName } from 'src/integrations/queue/constants'; -import { Space } from '@docmost/db/types/entity.types'; +import { ModuleRef } from '@nestjs/core'; @Processor(QueueName.ATTACHMENT_QUEUE) export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy { private readonly logger = new Logger(AttachmentProcessor.name); - constructor(private readonly attachmentService: AttachmentService) { + constructor( + private readonly attachmentService: AttachmentService, + private moduleRef: ModuleRef, + ) { super(); } @@ -25,6 +28,33 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy { job.data.pageId, ); } + if ( + job.name === QueueJob.ATTACHMENT_INDEX_CONTENT || + job.name === QueueJob.ATTACHMENT_INDEXING + ) { + let AttachmentEeModule: any; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AttachmentEeModule = require('./../../../ee/attachments-ee/attachment-ee.service'); + } catch (err) { + this.logger.error( + 'Attachment enterprise module requested but EE module not bundled in this build', + ); + return; + } + const attachmentEeService = this.moduleRef.get( + AttachmentEeModule.AttachmentEeService, + { strict: false }, + ); + + if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) { + await attachmentEeService.indexAttachment(job.data.attachmentId); + } else if (job.name === QueueJob.ATTACHMENT_INDEXING) { + await attachmentEeService.indexAttachments( + job.data.workspaceId, + ); + } + } } catch (err) { throw err; } diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 6472c671..299ddb60 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -22,6 +22,9 @@ import { executeTx } from '@docmost/db/utils'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { InjectQueue } from '@nestjs/bullmq'; +import { QueueJob, QueueName } from '../../../integrations/queue/constants'; +import { Queue } from 'bullmq'; @Injectable() export class AttachmentService { @@ -33,6 +36,7 @@ export class AttachmentService { private readonly workspaceRepo: WorkspaceRepo, private readonly spaceRepo: SpaceRepo, @InjectKysely() private readonly db: KyselyDB, + @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, ) {} async uploadFile(opts: { @@ -99,6 +103,23 @@ export class AttachmentService { pageId, }); } + + // Only index PDFs and DOCX files + if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) { + await this.attachmentQueue.add( + QueueJob.ATTACHMENT_INDEX_CONTENT, + { + attachmentId: attachmentId, + }, + { + attempts: 2, + backoff: { + type: 'exponential', + delay: 10000, + }, + }, + ); + } } catch (err) { // delete uploaded file on error this.logger.error(err); @@ -367,4 +388,5 @@ export class AttachmentService { throw err; } } + } diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 8dffef02..40486a52 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -5,15 +5,13 @@ import { IsOptional, IsString, } from 'class-validator'; -import { PartialType } from '@nestjs/mapped-types'; -import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto'; export class SearchDTO { @IsNotEmpty() @IsString() query: string; - @IsNotEmpty() + @IsOptional() @IsString() spaceId: string; diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index b9a9fcbf..ef6e9b2a 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -31,6 +31,7 @@ import { Public } from '../../common/decorators/public.decorator'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { hasLicenseOrEE } from '../../common/helpers'; @UseGuards(JwtAuthGuard) @Controller('shares') @@ -65,9 +66,11 @@ export class ShareController { return { ...(await this.shareService.getSharedPage(dto, workspace.id)), - hasLicenseKey: - Boolean(workspace.licenseKey) || - (this.environmentService.isCloud() && workspace.plan === 'business'), + hasLicenseKey: hasLicenseOrEE({ + licenseKey: workspace.licenseKey, + isCloud: this.environmentService.isCloud(), + plan: workspace.plan, + }), }; } @@ -175,9 +178,11 @@ export class ShareController { ) { return { ...(await this.shareService.getShareTree(dto.shareId, workspace.id)), - hasLicenseKey: - Boolean(workspace.licenseKey) || - (this.environmentService.isCloud() && workspace.plan === 'business'), + hasLicenseKey: hasLicenseOrEE({ + licenseKey: workspace.licenseKey, + isCloud: this.environmentService.isCloud(), + plan: workspace.plan, + }), }; } } diff --git a/apps/server/src/database/migrations/20250901T184612-attachments-search.ts b/apps/server/src/database/migrations/20250901T184612-attachments-search.ts new file mode 100644 index 00000000..a543adf6 --- /dev/null +++ b/apps/server/src/database/migrations/20250901T184612-attachments-search.ts @@ -0,0 +1,29 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('attachments') + .addColumn('text_content', 'text', (col) => col) + .addColumn('tsv', sql`tsvector`, (col) => col) + .execute(); + + await db.schema + .createIndex('attachments_tsv_idx') + .on('attachments') + .using('GIN') + .column('tsv') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('attachments') + .dropIndex('attachments_tsv_idx') + .execute(); + + await db.schema + .alterTable('attachments') + .dropColumn('text_content') + .dropColumn('tsv') + .execute(); +} diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index 784e6c84..5824ce5f 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -12,6 +12,23 @@ import { export class AttachmentRepo { constructor(@InjectKysely() private readonly db: KyselyDB) {} + private baseFields: Array = [ + 'id', + 'fileName', + 'filePath', + 'fileSize', + 'fileExt', + 'mimeType', + 'type', + 'creatorId', + 'pageId', + 'spaceId', + 'workspaceId', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]; + async findById( attachmentId: string, opts?: { @@ -22,7 +39,7 @@ export class AttachmentRepo { return db .selectFrom('attachments') - .selectAll() + .select(this.baseFields) .where('id', '=', attachmentId) .executeTakeFirst(); } @@ -36,7 +53,7 @@ export class AttachmentRepo { return db .insertInto('attachments') .values(insertableAttachment) - .returningAll() + .returning(this.baseFields) .executeTakeFirst(); } @@ -50,7 +67,7 @@ export class AttachmentRepo { return db .selectFrom('attachments') - .selectAll() + .select(this.baseFields) .where('spaceId', '=', spaceId) .execute(); } @@ -64,6 +81,7 @@ export class AttachmentRepo { .updateTable('attachments') .set(updatableAttachment) .where('pageId', 'in', pageIds) + .returning(this.baseFields) .executeTakeFirst(); } @@ -75,7 +93,7 @@ export class AttachmentRepo { .updateTable('attachments') .set(updatableAttachment) .where('id', '=', attachmentId) - .returningAll() + .returning(this.baseFields) .executeTakeFirst(); } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 0d0922c8..2f8baaf7 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -37,6 +37,8 @@ export interface Attachments { mimeType: string | null; pageId: string | null; spaceId: string | null; + textContent: string | null; + tsv: string | null; type: string | null; updatedAt: Generated; workspaceId: string; diff --git a/apps/server/src/ee b/apps/server/src/ee index 505081bb..3775df60 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 505081bb85d283a0d620165fa9f210f7c8e14232 +Subproject commit 3775df60137366b6953d80037f90547fe8ee4ac7 diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 150c098e..4a1b1d1c 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -9,6 +9,8 @@ export enum QueueName { export enum QueueJob { SEND_EMAIL = 'send-email', DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments', + ATTACHMENT_INDEX_CONTENT = 'attachment-index-content', + ATTACHMENT_INDEXING = 'attachment-indexing', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', PAGE_CONTENT_UPDATE = 'page-content-update', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e22bafa..58479826 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: ldapts: specifier: ^7.4.0 version: 7.4.0 + mammoth: + specifier: ^1.10.0 + version: 1.10.0 mime-types: specifier: ^2.1.35 version: 2.1.35 @@ -564,6 +567,9 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 + pdfjs-dist: + specifier: ^5.4.54 + version: 5.4.54 pg: specifier: ^8.16.0 version: 8.16.0 @@ -2619,6 +2625,70 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/canvas-android-arm64@0.1.77': + resolution: {integrity: sha512-jC8YX0rbAnu9YrLK1A52KM2HX9EDjrJSCLVuBf9Dsov4IC6GgwMLS2pwL9GFLJnSZBFgdwnA84efBehHT9eshA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.77': + resolution: {integrity: sha512-VFaCaCgAV0+hPwXajDIiHaaGx4fVCuUVYp/CxCGXmTGz699ngIEBx3Sa2oDp0uk3X+6RCRLueb7vD44BKBiPIg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.77': + resolution: {integrity: sha512-uD2NSkf6I4S3o0POJDwweK85FE4rfLNA2N714MgiEEMMw5AmupfSJGgpYzcyEXtPzdaca6rBfKcqNvzR1+EyLQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.77': + resolution: {integrity: sha512-03GxMMZGhHRQxiA4gyoKT6iQSz8xnA6T9PAfg/WNJnbkVMFZG782DwUJUb39QIZ1uE1euMCPnDgWAJ092MmgJQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.77': + resolution: {integrity: sha512-ZO+d2gRU9JU1Bb7SgJcJ1k9wtRMCpSWjJAJ+2phhu0Lw5As8jYXXXmLKmMTGs1bOya2dBMYDLzwp7KS/S/+aCA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.77': + resolution: {integrity: sha512-S1KtnP1+nWs2RApzNkdNf8X4trTLrHaY7FivV61ZRaL8NvuGOkSkKa+gWN2iedIGFEDz6gecpl/JAUSewwFXYg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.77': + resolution: {integrity: sha512-A4YIKFYUwDtrSzCtdCAO5DYmRqlhCVKHdpq0+dBGPnIEhOQDFkPBTfoTAjO3pjlEnorlfKmNMOH21sKQg2esGA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.77': + resolution: {integrity: sha512-Lt6Sef5l0+5O1cSZ8ysO0JI+x+rSrqZyXs5f7+kVkCAOVq8X5WTcDVbvWvEs2aRhrWTp5y25Jf2Bn+3IcNHOuQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.77': + resolution: {integrity: sha512-NiNFvC+D+omVeJ3IjYlIbyt/igONSABVe9z0ZZph29epHgZYu4eHwV9osfpRt1BGGOAM8LkFrHk4LBdn2EDymA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-x64-msvc@0.1.77': + resolution: {integrity: sha512-fP6l0hZiWykyjvpZTS3sI46iib8QEflbPakNoUijtwyxRuOPTTBfzAWZUz5z2vKpJJ/8r305wnZeZ8lhsBHY5A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.77': + resolution: {integrity: sha512-N9w2DkEKE1AXGp3q55GBOP6BEoFrqChDiFqJtKViTpQCWNOSVuMz7LkoGehbnpxtidppbsC36P0kCZNqJKs29w==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} @@ -4948,6 +5018,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -5679,6 +5752,9 @@ packages: dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dnd-core@14.0.1: resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} @@ -5727,6 +5803,9 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -7181,6 +7260,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -7227,6 +7309,11 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + mammoth@1.10.0: + resolution: {integrity: sha512-9HOmqt8uJ5rz7q8XrECU5gRjNftCq4GNG0YIrA6f9iQPCeLgpvgcmRBHi9NQWJQIpT/MAXeg1oKliAK1xoB3eg==} + engines: {node: '>=12.0.0'} + hasBin: true + mantine-form-zod-resolver@1.3.0: resolution: {integrity: sha512-XlXXkJCYuUuOllW0zedYW+m/lbdFQ/bso1Vz+pJOYkxgjhoGvzN2EXWCS2+0iTOT9Q7WnOwWvHmvpTJN3PxSXw==} engines: {node: '>=16.6.0'} @@ -7686,6 +7773,9 @@ packages: optics-ts@2.4.1: resolution: {integrity: sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -7834,6 +7924,10 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + pdfjs-dist@5.4.54: + resolution: {integrity: sha512-TBAiTfQw89gU/Z4LW98Vahzd2/LoCFprVGvGbTgFt+QCB1F+woyOPmNNVgLa6djX9Z9GGTnj7qE1UzpOVJiINw==} + engines: {node: '>=20.16.0 || >=22.3.0'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -9212,6 +9306,9 @@ packages: unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} @@ -9580,6 +9677,10 @@ packages: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} @@ -12452,6 +12553,50 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.2': optional: true + '@napi-rs/canvas-android-arm64@0.1.77': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.77': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.77': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.77': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.77': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.77': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.77': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.77': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.77': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.77': + optional: true + + '@napi-rs/canvas@0.1.77': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.77 + '@napi-rs/canvas-darwin-arm64': 0.1.77 + '@napi-rs/canvas-darwin-x64': 0.1.77 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.77 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.77 + '@napi-rs/canvas-linux-arm64-musl': 0.1.77 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.77 + '@napi-rs/canvas-linux-x64-gnu': 0.1.77 + '@napi-rs/canvas-linux-x64-musl': 0.1.77 + '@napi-rs/canvas-win32-x64-msvc': 0.1.77 + optional: true + '@napi-rs/wasm-runtime@0.2.4': dependencies: '@emnapi/core': 1.2.0 @@ -15049,6 +15194,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + bluebird@3.7.2: {} boolbase@1.0.0: {} @@ -15805,6 +15952,8 @@ snapshots: dijkstrajs@1.0.3: {} + dingbat-to-unicode@1.0.1: {} + dnd-core@14.0.1: dependencies: '@react-dnd/asap': 4.0.1 @@ -15862,6 +16011,10 @@ snapshots: dotenv@16.4.7: {} + duck@0.1.12: + dependencies: + underscore: 1.13.7 + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -17707,6 +17860,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.7 + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -17753,6 +17912,19 @@ snapshots: dependencies: tmpl: 1.0.5 + mammoth@1.10.0: + dependencies: + '@xmldom/xmldom': 0.8.10 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.7 + xmlbuilder: 10.1.1 + mantine-form-zod-resolver@1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56): dependencies: '@mantine/form': 8.1.3(react@18.3.1) @@ -18337,6 +18509,8 @@ snapshots: optics-ts@2.4.1: {} + option@0.2.4: {} + optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -18498,6 +18672,10 @@ snapshots: pause@0.0.1: {} + pdfjs-dist@5.4.54: + optionalDependencies: + '@napi-rs/canvas': 0.1.77 + peberminta@0.9.0: {} peek-readable@7.0.0: {} @@ -20005,6 +20183,8 @@ snapshots: has-symbols: 1.0.3 which-boxed-primitive: 1.1.0 + underscore@1.13.7: {} + undici-types@6.20.0: {} undici@7.10.0: {} @@ -20332,6 +20512,8 @@ snapshots: sax: 1.4.1 xmlbuilder: 11.0.1 + xmlbuilder@10.1.1: {} + xmlbuilder@11.0.1: {} xmlbuilder@15.1.1: {}