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 (
+
+
+
+
+
+ );
+}
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: {}