mirror of
https://github.com/docmost/docmost.git
synced 2026-08-25 15:57:11 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17369411d | ||
|
|
0a5206f19e |
+1
-1
@@ -42,7 +42,7 @@ RUN chown -R node:node /app
|
|||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
RUN mkdir -p /app/data/storage
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
"mantine-form-zod-resolver": "1.3.0",
|
"mantine-form-zod-resolver": "1.3.0",
|
||||||
"mermaid": "11.16.1",
|
"mermaid": "11.16.1",
|
||||||
"mitt": "3.0.1",
|
"mitt": "3.0.1",
|
||||||
"nanoid": "3.3.18",
|
"nanoid": "3.3.17",
|
||||||
"posthog-js": "1.391.2",
|
"posthog-js": "1.391.2",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
|
|||||||
@@ -1291,16 +1291,5 @@
|
|||||||
"{{count}} rows deleted_one": "1 row deleted",
|
"{{count}} rows deleted_one": "1 row deleted",
|
||||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||||
"{{count}} selected_one": "1 selected",
|
"{{count}} selected_one": "1 selected",
|
||||||
"{{count}} selected_other": "{{count}} selected",
|
"{{count}} selected_other": "{{count}} selected"
|
||||||
"Compare": "Compare",
|
|
||||||
"Compare versions": "Compare versions",
|
|
||||||
"Select version from {{date}}": "Select version from {{date}}",
|
|
||||||
"Version actions for {{date}}": "Version actions for {{date}}",
|
|
||||||
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
|
|
||||||
"Exit compare": "Exit compare",
|
|
||||||
"Search attachments...": "Search attachments...",
|
|
||||||
"Error loading attachments.": "Error loading attachments.",
|
|
||||||
"No attachments on this page yet.": "No attachments on this page yet.",
|
|
||||||
"Uploaded by {{name}}": "Uploaded by {{name}}",
|
|
||||||
"Download {{name}}": "Download {{name}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ export default function ChatInput({
|
|||||||
},
|
},
|
||||||
content: "",
|
content: "",
|
||||||
editable: true,
|
editable: true,
|
||||||
textDirection: "auto",
|
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
autofocus: autofocus ? "end" : false,
|
autofocus: autofocus ? "end" : false,
|
||||||
|
|||||||
@@ -15,14 +15,6 @@ export interface IAiSearchResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hintVectorCache(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await api.post("/ai/vector-cache-hint");
|
|
||||||
} catch {
|
|
||||||
// best-effort cache hint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function aiAnswers(
|
export async function aiAnswers(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default function ReadonlyTemplateEditor({
|
|||||||
<EditorProvider
|
<EditorProvider
|
||||||
editable={false}
|
editable={false}
|
||||||
immediatelyRender={true}
|
immediatelyRender={true}
|
||||||
textDirection="auto"
|
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
content={template.content}
|
content={template.content}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export default function TemplateEditor() {
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: templateExtensions,
|
extensions: templateExtensions,
|
||||||
content: "",
|
content: "",
|
||||||
textDirection: "auto",
|
|
||||||
editorProps: {
|
editorProps: {
|
||||||
scrollThreshold: 80,
|
scrollThreshold: 80,
|
||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { ThemeIcon } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconFile,
|
|
||||||
IconFileTypeCsv,
|
|
||||||
IconFileTypeDocx,
|
|
||||||
IconFileTypePdf,
|
|
||||||
IconFileTypePpt,
|
|
||||||
IconFileTypeXls,
|
|
||||||
IconFileZip,
|
|
||||||
IconMovie,
|
|
||||||
IconMusic,
|
|
||||||
IconPhoto,
|
|
||||||
type Icon,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
|
|
||||||
".pdf": { icon: IconFileTypePdf, color: "red" },
|
|
||||||
".doc": { icon: IconFileTypeDocx, color: "blue" },
|
|
||||||
".docx": { icon: IconFileTypeDocx, color: "blue" },
|
|
||||||
".xls": { icon: IconFileTypeXls, color: "teal" },
|
|
||||||
".xlsx": { icon: IconFileTypeXls, color: "teal" },
|
|
||||||
".csv": { icon: IconFileTypeCsv, color: "teal" },
|
|
||||||
".ppt": { icon: IconFileTypePpt, color: "orange" },
|
|
||||||
".pptx": { icon: IconFileTypePpt, color: "orange" },
|
|
||||||
".zip": { icon: IconFileZip, color: "gray" },
|
|
||||||
".rar": { icon: IconFileZip, color: "gray" },
|
|
||||||
".7z": { icon: IconFileZip, color: "gray" },
|
|
||||||
".tar": { icon: IconFileZip, color: "gray" },
|
|
||||||
".gz": { icon: IconFileZip, color: "gray" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
|
|
||||||
{ prefix: "image/", icon: IconPhoto, color: "grape" },
|
|
||||||
{ prefix: "video/", icon: IconMovie, color: "violet" },
|
|
||||||
{ prefix: "audio/", icon: IconMusic, color: "pink" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface AttachmentFileIconProps {
|
|
||||||
fileExt?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttachmentFileIcon({
|
|
||||||
fileExt,
|
|
||||||
mimeType,
|
|
||||||
}: AttachmentFileIconProps) {
|
|
||||||
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
|
|
||||||
const byMime = mimeType
|
|
||||||
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
|
|
||||||
: undefined;
|
|
||||||
const { icon: FileIcon, color } = byExt ??
|
|
||||||
byMime ?? { icon: IconFile, color: "gray" };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeIcon variant="light" color={color} size={40} radius="md">
|
|
||||||
<FileIcon size={22} stroke={1.5} />
|
|
||||||
</ThemeIcon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Anchor,
|
|
||||||
Center,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
ScrollArea,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconDownload } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
|
|
||||||
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
|
|
||||||
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
|
|
||||||
import { formatBytes } from "@/lib";
|
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
|
||||||
|
|
||||||
interface PageAttachmentsModalProps {
|
|
||||||
pageId: string;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageAttachmentsModal({
|
|
||||||
pageId,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
}: PageAttachmentsModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Attachments")}
|
|
||||||
size={800}
|
|
||||||
closeButtonProps={{ "aria-label": t("Close") }}
|
|
||||||
>
|
|
||||||
<PageAttachmentsList pageId={pageId} />
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageAttachmentsList({ pageId }: { pageId: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
isFetching,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
} = usePageAttachmentsQuery(pageId, search);
|
|
||||||
|
|
||||||
const attachments = useMemo(
|
|
||||||
() => data?.pages.flatMap((page) => page.items) ?? [],
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = loadMoreRef.current;
|
|
||||||
if (!sentinel || !hasNextPage) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0].isIntersecting && !isFetching) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [fetchNextPage, hasNextPage, isFetching]);
|
|
||||||
|
|
||||||
const handleSearch = useCallback((value: string) => setSearch(value), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SearchInput
|
|
||||||
onSearch={handleSearch}
|
|
||||||
placeholder={t("Search attachments...")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
) : isError ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Error loading attachments.")}
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
) : attachments.length === 0 ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{search
|
|
||||||
? t("No results found")
|
|
||||||
: t("No attachments on this page yet.")}
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
|
|
||||||
{attachments.map((attachment) => (
|
|
||||||
<AttachmentRow key={attachment.id} attachment={attachment} />
|
|
||||||
))}
|
|
||||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
|
||||||
{isFetchingNextPage && (
|
|
||||||
<Center py="sm">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const fileUrl = getFileUrl(attachment.url);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
|
|
||||||
<AttachmentFileIcon
|
|
||||||
fileExt={attachment.fileExt}
|
|
||||||
mimeType={attachment.mimeType}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Anchor
|
|
||||||
href={fileUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
size="sm"
|
|
||||||
fw={500}
|
|
||||||
c="inherit"
|
|
||||||
truncate="end"
|
|
||||||
style={{ display: "block" }}
|
|
||||||
>
|
|
||||||
{attachment.fileName}
|
|
||||||
</Anchor>
|
|
||||||
<Text size="xs" c="dimmed" mt={2} truncate="end">
|
|
||||||
{formatBytes(Number(attachment.fileSize))}
|
|
||||||
{" · "}
|
|
||||||
{formattedDate(new Date(attachment.createdAt))}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attachment.creator && (
|
|
||||||
<Tooltip
|
|
||||||
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={attachment.creator.avatarUrl}
|
|
||||||
name={attachment.creator.name}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip label={t("Download attachment")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
component="a"
|
|
||||||
href={fileUrl}
|
|
||||||
download={attachment.fileName}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label={t("Download {{name}}", { name: attachment.fileName })}
|
|
||||||
>
|
|
||||||
<IconDownload size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import {
|
|
||||||
InfiniteData,
|
|
||||||
keepPreviousData,
|
|
||||||
useInfiniteQuery,
|
|
||||||
UseInfiniteQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
|
|
||||||
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export function usePageAttachmentsQuery(
|
|
||||||
pageId: string,
|
|
||||||
search?: string,
|
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: ["page-attachments", pageId, search],
|
|
||||||
queryFn: ({ pageParam }) =>
|
|
||||||
getPageAttachments(pageId, { cursor: pageParam, query: search }),
|
|
||||||
enabled: !!pageId,
|
|
||||||
gcTime: 0,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
initialPageParam: undefined,
|
|
||||||
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -3,17 +3,7 @@ import loadImage from "blueimp-load-image";
|
|||||||
import {
|
import {
|
||||||
AvatarIconType,
|
AvatarIconType,
|
||||||
IAttachment,
|
IAttachment,
|
||||||
IPageAttachment,
|
|
||||||
} from "@/features/attachments/types/attachment.types.ts";
|
} from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export async function getPageAttachments(
|
|
||||||
pageId: string,
|
|
||||||
params?: QueryParams,
|
|
||||||
): Promise<IPagination<IPageAttachment>> {
|
|
||||||
const req = await api.post("/pages/attachments", { pageId, ...params });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressAndResizeIcon(
|
async function compressAndResizeIcon(
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export {
|
export {
|
||||||
getPageAttachments,
|
|
||||||
uploadIcon,
|
uploadIcon,
|
||||||
uploadUserAvatar,
|
uploadUserAvatar,
|
||||||
uploadSpaceIcon,
|
uploadSpaceIcon,
|
||||||
|
|||||||
@@ -15,15 +15,6 @@ export interface IAttachment {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageAttachment extends IAttachment {
|
|
||||||
url: string;
|
|
||||||
creator: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AvatarIconType {
|
export enum AvatarIconType {
|
||||||
AVATAR = "avatar",
|
AVATAR = "avatar",
|
||||||
SPACE_ICON = "space-icon",
|
SPACE_ICON = "space-icon",
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ const CommentEditor = forwardRef(
|
|||||||
},
|
},
|
||||||
content: defaultContent,
|
content: defaultContent,
|
||||||
editable,
|
editable,
|
||||||
textDirection: "auto",
|
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
autofocus: (autofocus && "end") || false,
|
autofocus: (autofocus && "end") || false,
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export default function TransclusionContent({ content }: Props) {
|
|||||||
<EditorProvider
|
<EditorProvider
|
||||||
editable={false}
|
editable={false}
|
||||||
immediatelyRender={true}
|
immediatelyRender={true}
|
||||||
textDirection="auto"
|
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
content={content as any}
|
content={content as any}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ function CollabPageEditor({
|
|||||||
{
|
{
|
||||||
extensions,
|
extensions,
|
||||||
editable,
|
editable,
|
||||||
textDirection: "auto",
|
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -485,7 +484,6 @@ function StaticPageEditor({
|
|||||||
<EditorProvider
|
<EditorProvider
|
||||||
editable={false}
|
editable={false}
|
||||||
immediatelyRender={true}
|
immediatelyRender={true}
|
||||||
textDirection="auto"
|
|
||||||
extensions={mainExtensions}
|
extensions={mainExtensions}
|
||||||
content={content}
|
content={content}
|
||||||
editorProps={{
|
editorProps={{
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export default function ReadonlyPageEditor({
|
|||||||
<EditorProvider
|
<EditorProvider
|
||||||
editable={false}
|
editable={false}
|
||||||
immediatelyRender={true}
|
immediatelyRender={true}
|
||||||
textDirection="auto"
|
|
||||||
extensions={titleExtensions}
|
extensions={titleExtensions}
|
||||||
content={title}
|
content={title}
|
||||||
></EditorProvider>
|
></EditorProvider>
|
||||||
@@ -94,7 +93,6 @@ export default function ReadonlyPageEditor({
|
|||||||
<EditorProvider
|
<EditorProvider
|
||||||
editable={false}
|
editable={false}
|
||||||
immediatelyRender={true}
|
immediatelyRender={true}
|
||||||
textDirection="auto"
|
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
content={content}
|
content={content}
|
||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
var(--mantine-color-dark-5)
|
var(--mantine-color-dark-5)
|
||||||
);
|
);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: start;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-resize-handle {
|
.column-resize-handle {
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export function TitleEditor({
|
|||||||
},
|
},
|
||||||
editable: editable,
|
editable: editable,
|
||||||
content: title,
|
content: title,
|
||||||
textDirection: "auto",
|
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
|
|||||||
@@ -6,13 +6,4 @@ export const activeHistoryPrevIdAtom = atom<string>("");
|
|||||||
export const highlightChangesAtom = atom<boolean>(true);
|
export const highlightChangesAtom = atom<boolean>(true);
|
||||||
|
|
||||||
export type DiffCounts = { added: number; deleted: number; total: number };
|
export type DiffCounts = { added: number; deleted: number; total: number };
|
||||||
export const diffCountsAtom = atom<DiffCounts | null>(
|
export const diffCountsAtom = atom<DiffCounts | null>(null);
|
||||||
null as DiffCounts | null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ComparePair = { newerId: string; olderId: string };
|
|
||||||
export const compareModeAtom = atom<boolean>(false);
|
|
||||||
export const compareSelectionAtom = atom<string[]>([]);
|
|
||||||
export const comparePairAtom = atom<ComparePair | null>(
|
|
||||||
null as ComparePair | null,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.history {
|
.history {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
@mixin hover {
|
@mixin hover {
|
||||||
@@ -12,28 +12,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyButton {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compareCheckbox {
|
|
||||||
padding-left: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemMenu {
|
|
||||||
opacity: 0;
|
|
||||||
margin-right: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history:hover .itemMenu,
|
|
||||||
.history:focus-within .itemMenu,
|
|
||||||
.history.active .itemMenu,
|
|
||||||
.itemMenu[aria-expanded="true"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.historyEditor {
|
.historyEditor {
|
||||||
:global(.ProseMirror) {
|
:global(.ProseMirror) {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
@@ -99,8 +77,3 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: rem(16px) rem(40px);
|
padding: rem(16px) rem(40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compareBanner {
|
|
||||||
border-bottom: rem(1px) solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function HistoryEditor({
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: mainExtensions,
|
extensions: mainExtensions,
|
||||||
editable: false,
|
editable: false,
|
||||||
textDirection: "auto",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,6 +170,7 @@ export function HistoryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = addedCount + deletedCount;
|
const total = addedCount + deletedCount;
|
||||||
|
// @ts-ignore
|
||||||
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
||||||
|
|
||||||
editor.setOptions({
|
editor.setOptions({
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import {
|
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
UnstyledButton,
|
|
||||||
Avatar,
|
|
||||||
Tooltip,
|
|
||||||
ActionIcon,
|
|
||||||
Checkbox,
|
|
||||||
Menu,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconDots } from "@tabler/icons-react";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./css/history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
|
|
||||||
@@ -26,13 +15,6 @@ interface HistoryItemProps {
|
|||||||
onHover?: (id: string, index: number) => void;
|
onHover?: (id: string, index: number) => void;
|
||||||
onHoverEnd?: () => void;
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
compareMode: boolean;
|
|
||||||
isChecked: boolean;
|
|
||||||
isCheckboxDisabled: boolean;
|
|
||||||
canCompare: boolean;
|
|
||||||
onToggleCompare: (id: string) => void;
|
|
||||||
onStartCompare: (id: string) => void;
|
|
||||||
onRestore?: (id: string, index: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
const HistoryItem = memo(function HistoryItem({
|
||||||
@@ -42,24 +24,10 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onHover,
|
onHover,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
isActive,
|
isActive,
|
||||||
compareMode,
|
|
||||||
isChecked,
|
|
||||||
isCheckboxDisabled,
|
|
||||||
canCompare,
|
|
||||||
onToggleCompare,
|
|
||||||
onStartCompare,
|
|
||||||
onRestore,
|
|
||||||
}: HistoryItemProps) {
|
}: HistoryItemProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const date = formattedDate(new Date(historyItem.createdAt));
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (compareMode) {
|
|
||||||
onToggleCompare(historyItem.id);
|
|
||||||
} else {
|
|
||||||
onSelect(historyItem.id, index);
|
onSelect(historyItem.id, index);
|
||||||
}
|
}, [onSelect, historyItem.id, index]);
|
||||||
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
onHover?.(historyItem.id, index);
|
onHover?.(historyItem.id, index);
|
||||||
@@ -69,42 +37,22 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
const hasContributors = contributors && contributors.length > 0;
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={onHoverEnd}
|
|
||||||
>
|
|
||||||
{compareMode && (
|
|
||||||
<Checkbox
|
|
||||||
size="xs"
|
|
||||||
className={classes.compareCheckbox}
|
|
||||||
checked={isChecked}
|
|
||||||
disabled={isCheckboxDisabled}
|
|
||||||
onChange={() => onToggleCompare(historyItem.id)}
|
|
||||||
aria-label={t("Select version from {{date}}", { date })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
p="xs"
|
p="xs"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={classes.historyButton}
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={onHoverEnd}
|
||||||
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
>
|
>
|
||||||
<Text size="sm">{date}</Text>
|
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={4}>
|
<Group gap={6} wrap="nowrap" mt={4}>
|
||||||
{hasContributors ? (
|
{hasContributors ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||||
<Avatar.Group spacing={8}>
|
<Avatar.Group spacing={8}>
|
||||||
{contributors
|
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
|
||||||
.slice(0, MAX_VISIBLE_AVATARS)
|
<Tooltip key={contributor.id} label={contributor.name} withArrow>
|
||||||
.map((contributor) => (
|
|
||||||
<Tooltip
|
|
||||||
key={contributor.id}
|
|
||||||
label={contributor.name}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size="sm"
|
size="sm"
|
||||||
avatarUrl={contributor.avatarUrl}
|
avatarUrl={contributor.avatarUrl}
|
||||||
@@ -115,9 +63,7 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
{contributors.length > MAX_VISIBLE_AVATARS && (
|
{contributors.length > MAX_VISIBLE_AVATARS && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
withArrow
|
withArrow
|
||||||
label={contributors
|
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
|
||||||
.slice(MAX_VISIBLE_AVATARS)
|
|
||||||
.map((c) => (
|
|
||||||
<div key={c.id}>{c.name}</div>
|
<div key={c.id}>{c.name}</div>
|
||||||
))}
|
))}
|
||||||
>
|
>
|
||||||
@@ -148,36 +94,6 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
{!compareMode && (
|
|
||||||
<Menu shadow="md" width={180} position="bottom-end">
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
className={classes.itemMenu}
|
|
||||||
aria-label={t("Version actions for {{date}}", { date })}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<IconDots size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
disabled={!canCompare}
|
|
||||||
onClick={() => onStartCompare(historyItem.id)}
|
|
||||||
>
|
|
||||||
{t("Compare")}
|
|
||||||
</Menu.Item>
|
|
||||||
{onRestore && (
|
|
||||||
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
|
|
||||||
{t("Restore")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ import HistoryItem from "@/features/page-history/components/history-item";
|
|||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
compareModeAtom,
|
|
||||||
comparePairAtom,
|
|
||||||
compareSelectionAtom,
|
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
|
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -36,9 +32,6 @@ function HistoryList({ pageId }: Props) {
|
|||||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
|
|
||||||
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
|
|
||||||
const setComparePair = useSetAtom(comparePairAtom);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pageHistoryData,
|
data: pageHistoryData,
|
||||||
@@ -86,58 +79,10 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string, index: number) => {
|
(id: string, index: number) => {
|
||||||
setComparePair(null);
|
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryId(id);
|
||||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
},
|
},
|
||||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleCompare = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
setCompareSelection((prev) => {
|
|
||||||
if (prev.includes(id)) return prev.filter((item) => item !== id);
|
|
||||||
if (prev.length >= 2) return prev;
|
|
||||||
return [...prev, id];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setCompareSelection],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStartCompare = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
setComparePair(null);
|
|
||||||
setCompareMode(true);
|
|
||||||
setCompareSelection([id]);
|
|
||||||
},
|
|
||||||
[setComparePair, setCompareMode, setCompareSelection],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCancelCompare = useCallback(() => {
|
|
||||||
setCompareMode(false);
|
|
||||||
setCompareSelection([]);
|
|
||||||
}, [setCompareMode, setCompareSelection]);
|
|
||||||
|
|
||||||
const handleConfirmCompare = useCallback(() => {
|
|
||||||
const pair = resolveComparePair(historyItems, compareSelection);
|
|
||||||
if (!pair) return;
|
|
||||||
setComparePair(pair);
|
|
||||||
setCompareMode(false);
|
|
||||||
setCompareSelection([]);
|
|
||||||
}, [
|
|
||||||
historyItems,
|
|
||||||
compareSelection,
|
|
||||||
setComparePair,
|
|
||||||
setCompareMode,
|
|
||||||
setCompareSelection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRestoreItem = useCallback(
|
|
||||||
(id: string, index: number) => {
|
|
||||||
handleSelect(id, index);
|
|
||||||
confirmRestore(id);
|
|
||||||
},
|
|
||||||
[handleSelect, confirmRestore],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -193,16 +138,6 @@ function HistoryList({ pageId }: Props) {
|
|||||||
onHover={handleHover}
|
onHover={handleHover}
|
||||||
onHoverEnd={clearPrefetchTimeout}
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
isActive={historyItem.id === activeHistoryId}
|
isActive={historyItem.id === activeHistoryId}
|
||||||
compareMode={compareMode}
|
|
||||||
isChecked={compareSelection.includes(historyItem.id)}
|
|
||||||
isCheckboxDisabled={
|
|
||||||
!compareSelection.includes(historyItem.id) &&
|
|
||||||
compareSelection.length >= 2
|
|
||||||
}
|
|
||||||
canCompare={historyItems.length >= 2}
|
|
||||||
onToggleCompare={handleToggleCompare}
|
|
||||||
onStartCompare={handleStartCompare}
|
|
||||||
onRestore={canRestore ? handleRestoreItem : undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||||
@@ -213,28 +148,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{compareMode ? (
|
{canRestore && (
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Group p="xs" wrap="nowrap">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="compact-md"
|
|
||||||
onClick={handleCancelCompare}
|
|
||||||
>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact-md"
|
|
||||||
disabled={compareSelection.length !== 2}
|
|
||||||
onClick={handleConfirmCompare}
|
|
||||||
>
|
|
||||||
{t("Compare")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
canRestore && (
|
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group p="xs" wrap="nowrap">
|
<Group p="xs" wrap="nowrap">
|
||||||
@@ -245,12 +159,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
>
|
>
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="compact-md" onClick={() => confirmRestore()}>
|
<Button size="compact-md" onClick={confirmRestore}>
|
||||||
{t("Restore")}
|
{t("Restore")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
CloseButton,
|
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -13,20 +12,17 @@ import { useAtom, useAtomValue } from "jotai";
|
|||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
comparePairAtom,
|
|
||||||
diffCountsAtom,
|
diffCountsAtom,
|
||||||
highlightChangesAtom,
|
highlightChangesAtom,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryView from "@/features/page-history/components/history-view";
|
import HistoryView from "@/features/page-history/components/history-view";
|
||||||
import { useMemo, useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
useDiffNavigation,
|
useDiffNavigation,
|
||||||
useHistoryReset,
|
useHistoryReset,
|
||||||
} from "@/features/page-history/hooks";
|
} from "@/features/page-history/hooks";
|
||||||
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -40,28 +36,6 @@ export default function HistoryModalBody({ pageId }: Props) {
|
|||||||
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
const diffCounts = useAtomValue(diffCountsAtom);
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
const [comparePair, setComparePair] = useAtom(comparePairAtom);
|
|
||||||
|
|
||||||
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
|
|
||||||
const historyItems = useMemo(
|
|
||||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
|
||||||
[pageHistoryData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const compareLabel = useMemo(() => {
|
|
||||||
if (!comparePair) return null;
|
|
||||||
const newerItem = historyItems.find(
|
|
||||||
(item) => item.id === comparePair.newerId,
|
|
||||||
);
|
|
||||||
const olderItem = historyItems.find(
|
|
||||||
(item) => item.id === comparePair.olderId,
|
|
||||||
);
|
|
||||||
if (!newerItem || !olderItem) return null;
|
|
||||||
return t("Comparing {{newer}} and {{older}}", {
|
|
||||||
newer: formattedDate(new Date(newerItem.createdAt)),
|
|
||||||
older: formattedDate(new Date(olderItem.createdAt)),
|
|
||||||
});
|
|
||||||
}, [comparePair, historyItems, t]);
|
|
||||||
|
|
||||||
useHistoryReset(pageId);
|
useHistoryReset(pageId);
|
||||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
@@ -76,25 +50,6 @@ export default function HistoryModalBody({ pageId }: Props) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ position: "relative", flex: 1 }}>
|
<div style={{ position: "relative", flex: 1 }}>
|
||||||
{comparePair && (
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
wrap="nowrap"
|
|
||||||
px="md"
|
|
||||||
py={4}
|
|
||||||
className={classes.compareBanner}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
|
||||||
{compareLabel ?? t("Compare versions")}
|
|
||||||
</Text>
|
|
||||||
<CloseButton
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("Exit compare")}
|
|
||||||
onClick={() => setComparePair(null)}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
h={650}
|
h={650}
|
||||||
w="100%"
|
w="100%"
|
||||||
@@ -102,18 +57,11 @@ export default function HistoryModalBody({ pageId }: Props) {
|
|||||||
viewportRef={scrollViewportRef}
|
viewportRef={scrollViewportRef}
|
||||||
>
|
>
|
||||||
<div className={classes.sidebarRightSection}>
|
<div className={classes.sidebarRightSection}>
|
||||||
{comparePair ? (
|
{activeHistoryId && <HistoryView />}
|
||||||
<HistoryView
|
|
||||||
historyId={comparePair.newerId}
|
|
||||||
prevHistoryId={comparePair.olderId}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
activeHistoryId && <HistoryView />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
|
{activeHistoryId && activeHistoryPrevId && (
|
||||||
<Paper
|
<Paper
|
||||||
shadow="md"
|
shadow="md"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
|
<Button onClick={confirmRestore}>{t("Restore")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,21 @@ import {
|
|||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
interface Props {
|
function HistoryView() {
|
||||||
historyId?: string;
|
|
||||||
prevHistoryId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryView({ historyId, prevHistoryId }: Props) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const activeId = useAtomValue(activeHistoryIdAtom);
|
const historyId = useAtomValue(activeHistoryIdAtom);
|
||||||
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
|
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
|
||||||
|
|
||||||
const resolvedId = historyId ?? activeId;
|
|
||||||
const resolvedPrevId = prevHistoryId ?? activePrevId;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading: isLoadingCurrent,
|
isLoading: isLoadingCurrent,
|
||||||
isError: isErrorCurrent,
|
isError: isErrorCurrent,
|
||||||
} = usePageHistoryQuery(resolvedId);
|
} = usePageHistoryQuery(historyId);
|
||||||
const {
|
const {
|
||||||
data: prevData,
|
data: prevData,
|
||||||
isLoading: isLoadingPrev,
|
isLoading: isLoadingPrev,
|
||||||
isError: isErrorPrev,
|
isError: isErrorPrev,
|
||||||
} = usePageHistoryQuery(resolvedPrevId);
|
} = usePageHistoryQuery(prevHistoryId);
|
||||||
|
|
||||||
if (isLoadingCurrent || isLoadingPrev) {
|
if (isLoadingCurrent || isLoadingPrev) {
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -3,45 +3,22 @@ import { useEffect } from "react";
|
|||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
compareModeAtom,
|
|
||||||
comparePairAtom,
|
|
||||||
compareSelectionAtom,
|
|
||||||
diffCountsAtom,
|
diffCountsAtom,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets history state when pageId changes.
|
* Resets history state when pageId changes.
|
||||||
* Clears active selection, diff counts, and compare state.
|
* Clears active selection and diff counts.
|
||||||
* Compare state also resets on unmount so reopening the modal starts clean.
|
|
||||||
*/
|
*/
|
||||||
export function useHistoryReset(pageId: string) {
|
export function useHistoryReset(pageId: string) {
|
||||||
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
||||||
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
||||||
const [, setCompareMode] = useAtom(compareModeAtom);
|
|
||||||
const [, setCompareSelection] = useAtom(compareSelectionAtom);
|
|
||||||
const [, setComparePair] = useAtom(comparePairAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resetCompare = () => {
|
|
||||||
setCompareMode(false);
|
|
||||||
setCompareSelection([]);
|
|
||||||
setComparePair(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
setActiveHistoryId("");
|
setActiveHistoryId("");
|
||||||
setActiveHistoryPrevId("");
|
setActiveHistoryPrevId("");
|
||||||
|
// @ts-ignore
|
||||||
setDiffCounts(null);
|
setDiffCounts(null);
|
||||||
resetCompare();
|
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
|
||||||
|
|
||||||
return resetCompare;
|
|
||||||
}, [
|
|
||||||
pageId,
|
|
||||||
setActiveHistoryId,
|
|
||||||
setActiveHistoryPrevId,
|
|
||||||
setDiffCounts,
|
|
||||||
setCompareMode,
|
|
||||||
setCompareSelection,
|
|
||||||
setComparePair,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "@mantine/core";
|
import { Text } from "@mantine/core";
|
||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
|
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
@@ -26,6 +25,8 @@ export function useHistoryRestore() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||||
|
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||||
|
|
||||||
const mainEditor = useAtomValue(pageEditorAtom);
|
const mainEditor = useAtomValue(pageEditorAtom);
|
||||||
const mainEditorTitle = useAtomValue(titleEditorAtom);
|
const mainEditorTitle = useAtomValue(titleEditorAtom);
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
@@ -39,19 +40,8 @@ export function useHistoryRestore() {
|
|||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRestore = useCallback(
|
const handleRestore = useCallback(() => {
|
||||||
async (historyId: string) => {
|
if (!activeHistoryData) return;
|
||||||
let historyData: IPageHistory;
|
|
||||||
try {
|
|
||||||
historyData = await fetchPageHistory(historyId);
|
|
||||||
} catch {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Error fetching page data."),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!mainEditor ||
|
!mainEditor ||
|
||||||
mainEditor.isDestroyed ||
|
mainEditor.isDestroyed ||
|
||||||
@@ -64,26 +54,20 @@ export function useHistoryRestore() {
|
|||||||
mainEditorTitle
|
mainEditorTitle
|
||||||
.chain()
|
.chain()
|
||||||
.clearContent()
|
.clearContent()
|
||||||
.setContent(historyData.title, { emitUpdate: true })
|
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
mainEditor
|
mainEditor
|
||||||
.chain()
|
.chain()
|
||||||
.clearContent()
|
.clearContent()
|
||||||
.setContent(historyData.content)
|
.setContent(activeHistoryData.content)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
setHistoryModalOpen(false);
|
setHistoryModalOpen(false);
|
||||||
notifications.show({ message: t("Successfully restored") });
|
notifications.show({ message: t("Successfully restored") });
|
||||||
},
|
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
|
||||||
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const confirmRestore = useCallback(
|
|
||||||
(historyId?: string) => {
|
|
||||||
const targetId = historyId ?? activeHistoryId;
|
|
||||||
if (!targetId) return;
|
|
||||||
|
|
||||||
|
const confirmRestore = useCallback(() => {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: t("Please confirm your action"),
|
title: t("Please confirm your action"),
|
||||||
children: (
|
children: (
|
||||||
@@ -94,11 +78,9 @@ export function useHistoryRestore() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||||
onConfirm: () => handleRestore(targetId),
|
onConfirm: handleRestore,
|
||||||
});
|
});
|
||||||
},
|
}, [t, handleRestore]);
|
||||||
[t, handleRestore, activeHistoryId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { canRestore, confirmRestore };
|
return { canRestore, confirmRestore };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,6 @@ export function prefetchPageHistory(historyId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
|
|
||||||
return queryClient.fetchQuery({
|
|
||||||
queryKey: ["page-history", historyId],
|
|
||||||
queryFn: () => getPageHistoryById(historyId),
|
|
||||||
staleTime: HISTORY_STALE_TIME,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePageHistoryListQuery(
|
export function usePageHistoryListQuery(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { resolveComparePair } from "./resolve-compare-pair";
|
|
||||||
|
|
||||||
// list is newest-first, matching usePageHistoryListQuery order
|
|
||||||
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
|
|
||||||
|
|
||||||
describe("resolveComparePair", () => {
|
|
||||||
it("orders newer before older regardless of selection order", () => {
|
|
||||||
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
|
|
||||||
newerId: "v3",
|
|
||||||
olderId: "v1",
|
|
||||||
});
|
|
||||||
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
|
|
||||||
newerId: "v3",
|
|
||||||
olderId: "v1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null unless exactly two versions are selected", () => {
|
|
||||||
expect(resolveComparePair(items, [])).toBeNull();
|
|
||||||
expect(resolveComparePair(items, ["v1"])).toBeNull();
|
|
||||||
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when a selected id is not in the list", () => {
|
|
||||||
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the same id is selected twice", () => {
|
|
||||||
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves which of the two selected versions is newer using their position
|
|
||||||
* in the history list (list is newest-first: lower index = newer).
|
|
||||||
*/
|
|
||||||
export function resolveComparePair(
|
|
||||||
historyItems: { id: string }[],
|
|
||||||
selection: string[],
|
|
||||||
): ComparePair | null {
|
|
||||||
if (selection.length !== 2) return null;
|
|
||||||
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
|
|
||||||
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
|
|
||||||
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
|
|
||||||
return indexA < indexB
|
|
||||||
? { newerId: selection[0], olderId: selection[1] }
|
|
||||||
: { newerId: selection[1], olderId: selection[0] };
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPaperclip,
|
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
@@ -43,7 +42,6 @@ import {
|
|||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
|
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
import {
|
import {
|
||||||
@@ -159,10 +157,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
verificationOpened,
|
verificationOpened,
|
||||||
{ open: openVerificationModal, close: closeVerificationModal },
|
{ open: openVerificationModal, close: closeVerificationModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
const [
|
|
||||||
attachmentsOpened,
|
|
||||||
{ open: openAttachmentsModal, close: closeAttachmentsModal },
|
|
||||||
] = useDisclosure(false);
|
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||||
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
||||||
@@ -299,15 +293,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!page?.isBase && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconPaperclip size={16} />}
|
|
||||||
onClick={openAttachmentsModal}
|
|
||||||
>
|
|
||||||
{t("Attachments")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!readOnly && !page?.isBase && (
|
{!readOnly && !page?.isBase && (
|
||||||
<PageVerificationMenuItem
|
<PageVerificationMenuItem
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
@@ -410,12 +395,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
opened={verificationOpened}
|
opened={verificationOpened}
|
||||||
onClose={closeVerificationModal}
|
onClose={closeVerificationModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageAttachmentsModal
|
|
||||||
pageId={page.id}
|
|
||||||
open={attachmentsOpened}
|
|
||||||
onClose={closeAttachmentsModal}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,11 @@ import { SearchResultItem } from "./search-result-item.tsx";
|
|||||||
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
|
|
||||||
import { getAiVectorDriver } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasAiFeature = useHasFeature(Feature.AI);
|
const hasAiFeature = useHasFeature(Feature.AI);
|
||||||
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
|
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
|
||||||
@@ -101,15 +96,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
const handleSpotlightOpen = () => {
|
|
||||||
if (
|
|
||||||
workspace?.settings?.ai?.search === true &&
|
|
||||||
getAiVectorDriver() === "turbopuffer"
|
|
||||||
) {
|
|
||||||
hintVectorCache();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFiltersChange = (newFilters: any) => {
|
const handleFiltersChange = (newFilters: any) => {
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
};
|
};
|
||||||
@@ -129,7 +115,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
<Spotlight.Root
|
<Spotlight.Root
|
||||||
size="xl"
|
size="xl"
|
||||||
maxHeight={600}
|
maxHeight={600}
|
||||||
onSpotlightOpen={handleSpotlightOpen}
|
|
||||||
store={searchSpotlightStore}
|
store={searchSpotlightStore}
|
||||||
query={query}
|
query={query}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -14,7 +14,6 @@ interface SpaceSelectProps {
|
|||||||
width?: number;
|
width?: number;
|
||||||
opened?: boolean;
|
opened?: boolean;
|
||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
withinPortal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||||
@@ -42,7 +41,6 @@ export function SpaceSelect({
|
|||||||
width,
|
width,
|
||||||
opened,
|
opened,
|
||||||
clearable,
|
clearable,
|
||||||
withinPortal = true,
|
|
||||||
}: SpaceSelectProps) {
|
}: SpaceSelectProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
@@ -52,13 +50,9 @@ export function SpaceSelect({
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const fetchedSpaces = useRef(new Map<string, ISpace>());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (spaces) {
|
if (spaces) {
|
||||||
spaces.items.forEach((space: ISpace) =>
|
|
||||||
fetchedSpaces.current.set(space.slug, space),
|
|
||||||
);
|
|
||||||
const spaceData = spaces?.items
|
const spaceData = spaces?.items
|
||||||
.filter((space: ISpace) => space.slug !== value)
|
.filter((space: ISpace) => space.slug !== value)
|
||||||
.map((space: ISpace) => {
|
.map((space: ISpace) => {
|
||||||
@@ -89,19 +83,14 @@ export function SpaceSelect({
|
|||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
clearable={clearable}
|
clearable={clearable}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onChange={(slug) => {
|
onChange={(slug) =>
|
||||||
// options accumulate across fetches; resolve against everything
|
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||||
// fetched, not just the latest query result
|
|
||||||
const space = slug && fetchedSpaces.current.get(slug);
|
|
||||||
if (space) {
|
|
||||||
onChange(space);
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
nothingFoundMessage={t("No space found")}
|
nothingFoundMessage={t("No space found")}
|
||||||
limit={50}
|
limit={50}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||||
dropdownOpened={opened}
|
dropdownOpened={opened}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export function SwitchSpace({
|
|||||||
onChange={(space) => handleSelect(space.slug)}
|
onChange={(space) => handleSelect(space.slug)}
|
||||||
width={300}
|
width={300}
|
||||||
opened={true}
|
opened={true}
|
||||||
withinPortal={false}
|
|
||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ export function isCloud(): boolean {
|
|||||||
return castToBoolean(getConfigValue("CLOUD"));
|
return castToBoolean(getConfigValue("CLOUD"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAiVectorDriver(): string {
|
|
||||||
return getConfigValue("AI_VECTOR_DRIVER");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
avatarUrl: string,
|
avatarUrl: string,
|
||||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
BILLING_TRIAL_DAYS,
|
BILLING_TRIAL_DAYS,
|
||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
AI_VECTOR_DRIVER,
|
|
||||||
} = loadEnv(mode, envPath, "");
|
} = loadEnv(mode, envPath, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -32,7 +31,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
BILLING_TRIAL_DAYS,
|
BILLING_TRIAL_DAYS,
|
||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
AI_VECTOR_DRIVER,
|
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-11
@@ -40,33 +40,32 @@
|
|||||||
"@clickhouse/client": "1.18.2",
|
"@clickhouse/client": "1.18.2",
|
||||||
"@docmost/base-formula": "workspace:*",
|
"@docmost/base-formula": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@fastify/cookie": "11.1.2",
|
"@fastify/cookie": "11.0.2",
|
||||||
"@fastify/multipart": "10.1.1",
|
"@fastify/multipart": "10.0.0",
|
||||||
"@fastify/static": "10.1.3",
|
"@fastify/static": "10.1.2",
|
||||||
"@keyv/redis": "5.1.6",
|
"@keyv/redis": "5.1.6",
|
||||||
"@langchain/core": "1.1.46",
|
"@langchain/core": "1.1.46",
|
||||||
"@langchain/textsplitters": "1.0.1",
|
"@langchain/textsplitters": "1.0.1",
|
||||||
"@modelcontextprotocol/sdk": "1.30.0",
|
"@modelcontextprotocol/sdk": "1.30.0",
|
||||||
"@nest-lab/throttler-storage-redis": "1.2.0",
|
"@nest-lab/throttler-storage-redis": "1.2.0",
|
||||||
"@nestjs-labs/nestjs-ioredis": "11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "11.0.4",
|
||||||
"@nestjs/bullmq": "11.0.5",
|
"@nestjs/bullmq": "11.0.4",
|
||||||
"@nestjs/cache-manager": "3.1.3",
|
"@nestjs/cache-manager": "3.1.3",
|
||||||
"@nestjs/common": "11.2.1",
|
"@nestjs/common": "11.1.28",
|
||||||
"@nestjs/config": "4.0.4",
|
"@nestjs/config": "4.0.4",
|
||||||
"@nestjs/core": "11.2.1",
|
"@nestjs/core": "11.1.27",
|
||||||
"@nestjs/event-emitter": "3.1.0",
|
"@nestjs/event-emitter": "3.1.0",
|
||||||
"@nestjs/jwt": "11.0.2",
|
"@nestjs/jwt": "11.0.2",
|
||||||
"@nestjs/mapped-types": "2.1.1",
|
"@nestjs/mapped-types": "2.1.1",
|
||||||
"@nestjs/passport": "11.0.5",
|
"@nestjs/passport": "11.0.5",
|
||||||
"@nestjs/platform-fastify": "11.2.1",
|
"@nestjs/platform-fastify": "11.1.28",
|
||||||
"@nestjs/platform-socket.io": "11.2.1",
|
"@nestjs/platform-socket.io": "11.1.28",
|
||||||
"@nestjs/schedule": "6.1.3",
|
"@nestjs/schedule": "6.1.3",
|
||||||
"@nestjs/terminus": "11.1.1",
|
"@nestjs/terminus": "11.1.1",
|
||||||
"@nestjs/throttler": "6.5.0",
|
"@nestjs/throttler": "6.5.0",
|
||||||
"@nestjs/websockets": "11.2.1",
|
"@nestjs/websockets": "11.1.28",
|
||||||
"@node-saml/passport-saml": "5.1.0",
|
"@node-saml/passport-saml": "5.1.0",
|
||||||
"@socket.io/redis-adapter": "8.3.0",
|
"@socket.io/redis-adapter": "8.3.0",
|
||||||
"@turbopuffer/turbopuffer": "^2.8.0",
|
|
||||||
"ai": "6.0.134",
|
"ai": "6.0.134",
|
||||||
"ai-sdk-ollama": "3.8.1",
|
"ai-sdk-ollama": "3.8.1",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -120,7 +119,7 @@
|
|||||||
"tmp-promise": "3.0.3",
|
"tmp-promise": "3.0.3",
|
||||||
"typesense": "3.0.5",
|
"typesense": "3.0.5",
|
||||||
"undici": "7.29.0",
|
"undici": "7.29.0",
|
||||||
"ws": "8.21.3",
|
"ws": "8.21.0",
|
||||||
"yauzl": "3.4.0",
|
"yauzl": "3.4.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,13 +22,11 @@ import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
|||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
import { parseRedisUrl } from './common/helpers';
|
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +53,6 @@ try {
|
|||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
EncryptionModule,
|
|
||||||
RedisModule.forRootAsync({
|
RedisModule.forRootAsync({
|
||||||
useClass: RedisConfigService,
|
useClass: RedisConfigService,
|
||||||
}),
|
}),
|
||||||
@@ -63,20 +60,10 @@ try {
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
useFactory: async (environmentService: EnvironmentService) => {
|
useFactory: async (environmentService: EnvironmentService) => {
|
||||||
const redisUrl = environmentService.getRedisUrl();
|
const redisUrl = environmentService.getRedisUrl();
|
||||||
const { family, tls } = parseRedisUrl(redisUrl);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ttl: 5 * 1000,
|
ttl: 5 * 1000,
|
||||||
stores: [
|
stores: [new KeyvRedis(redisUrl)],
|
||||||
new KeyvRedis({
|
|
||||||
url: redisUrl,
|
|
||||||
socket: {
|
|
||||||
family,
|
|
||||||
reconnectStrategy: defaultReconnectStrategy,
|
|
||||||
...tls,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
inject: [EnvironmentService],
|
inject: [EnvironmentService],
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export class CollaborationGateway {
|
|||||||
password: this.redisConfig.password,
|
password: this.redisConfig.password,
|
||||||
db: this.redisConfig.db,
|
db: this.redisConfig.db,
|
||||||
family: this.redisConfig.family,
|
family: this.redisConfig.family,
|
||||||
tls: this.redisConfig.tls,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
retryStrategy: createRetryStrategy(),
|
||||||
}),
|
}),
|
||||||
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
|
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
|
||||||
|
|||||||
@@ -50,14 +50,8 @@ import {
|
|||||||
Footnote,
|
Footnote,
|
||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import {
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
extensions as coreExtensions,
|
|
||||||
generateText,
|
|
||||||
getSchema,
|
|
||||||
JSONContent,
|
|
||||||
} from '@tiptap/core';
|
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
import { collapseBlankLines } from '../common/helpers';
|
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
@@ -67,7 +61,6 @@ import * as Y from 'yjs';
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
coreExtensions.TextDirection.configure({ direction: 'auto' }),
|
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
document: false,
|
document: false,
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
@@ -147,7 +140,7 @@ export function htmlToJson(html: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToText(tiptapJson: JSONContent) {
|
export function jsonToText(tiptapJson: JSONContent) {
|
||||||
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
|
return generateText(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToNode(tiptapJson: JSONContent) {
|
export function jsonToNode(tiptapJson: JSONContent) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './text.utils';
|
|
||||||
export * from './nanoid.utils';
|
export * from './nanoid.utils';
|
||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { collapseBlankLines } from './text.utils';
|
|
||||||
|
|
||||||
describe('collapseBlankLines', () => {
|
|
||||||
it.each([
|
|
||||||
['a\n\n\n\nb', 'a\n\nb'],
|
|
||||||
['a\n\nb', 'a\n\nb'],
|
|
||||||
['a\nb', 'a\nb'],
|
|
||||||
['\n\n\n\na\n\n\n', '\n\na\n\n'],
|
|
||||||
['no newlines', 'no newlines'],
|
|
||||||
['', ''],
|
|
||||||
])('collapses %j to %j', (input, expected) => {
|
|
||||||
expect(collapseBlankLines(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function collapseBlankLines(text: string): string {
|
|
||||||
return text.replace(/\n{2,}/g, '\n\n');
|
|
||||||
}
|
|
||||||
@@ -30,14 +30,13 @@ export type RedisConfig = {
|
|||||||
db: number;
|
db: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
family?: number;
|
family?: number;
|
||||||
tls?: { rejectUnauthorized?: boolean };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseRedisUrl(redisUrl: string): RedisConfig {
|
export function parseRedisUrl(redisUrl: string): RedisConfig {
|
||||||
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6][&rejectUnauthorized=false]
|
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6]
|
||||||
const url = new URL(redisUrl);
|
const url = new URL(redisUrl);
|
||||||
const { hostname, port, password, pathname, protocol, searchParams } = url;
|
const { hostname, port, password, pathname, searchParams } = url;
|
||||||
const portInt = port ? parseInt(port, 10) : 6379;
|
const portInt = parseInt(port, 10);
|
||||||
|
|
||||||
let db: number = 0;
|
let db: number = 0;
|
||||||
// extract db value if present
|
// extract db value if present
|
||||||
@@ -55,14 +54,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
|
|||||||
family = parseInt(familyParam, 10);
|
family = parseInt(familyParam, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tls =
|
return { host: hostname, port: portInt, password, db, family };
|
||||||
protocol === 'rediss:'
|
|
||||||
? searchParams.get('rejectUnauthorized') === 'false'
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: {}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { host: hostname, port: portInt, password: password || undefined, db, family, tls };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRetryStrategy() {
|
export function createRetryStrategy() {
|
||||||
|
|||||||
@@ -53,14 +53,8 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
|||||||
import { TokenService } from '../auth/services/token.service';
|
import { TokenService } from '../auth/services/token.service';
|
||||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
|
||||||
AttachmentInfoDto,
|
|
||||||
PageIdDto,
|
|
||||||
RemoveIconDto,
|
|
||||||
} from './dto/attachment.dto';
|
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|
||||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||||
import { DomainService } from '../../integrations/environment/domain.service';
|
|
||||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||||
import {
|
import {
|
||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
@@ -81,7 +75,6 @@ export class AttachmentController {
|
|||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly pageAccessService: PageAccessService,
|
private readonly pageAccessService: PageAccessService,
|
||||||
private readonly domainService: DomainService,
|
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -158,10 +151,7 @@ export class AttachmentController {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.send({
|
return res.send(fileResponse);
|
||||||
...fileResponse,
|
|
||||||
url: this.buildFileUrl(workspace, fileResponse),
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.statusCode === 413) {
|
if (err?.statusCode === 413) {
|
||||||
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
||||||
@@ -421,37 +411,7 @@ export class AttachmentController {
|
|||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
|
return attachment;
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('pages/attachments')
|
|
||||||
async getPageAttachments(
|
|
||||||
@Body() dto: PageIdDto,
|
|
||||||
@Body() pagination: PaginationOptions,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const page = await this.pageRepo.findById(dto.pageId);
|
|
||||||
if (!page || page.workspaceId !== workspace.id) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pageAccessService.validateCanView(page, user);
|
|
||||||
|
|
||||||
const result = await this.attachmentRepo.findPageAttachments(
|
|
||||||
page.id,
|
|
||||||
pagination,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
items: result.items.map((attachment) => ({
|
|
||||||
...attachment,
|
|
||||||
url: this.buildFileUrl(workspace, attachment),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -505,10 +465,6 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
|
|
||||||
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendFileResponse(
|
private async sendFileResponse(
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
res: FastifyReply,
|
res: FastifyReply,
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||||
IsEnum,
|
|
||||||
IsIn,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { AttachmentType } from '../attachment.constants';
|
import { AttachmentType } from '../attachment.constants';
|
||||||
|
|
||||||
export class AttachmentInfoDto {
|
export class AttachmentInfoDto {
|
||||||
@@ -14,12 +7,6 @@ export class AttachmentInfoDto {
|
|||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageIdDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RemoveIconDto {
|
export class RemoveIconDto {
|
||||||
@IsEnum(AttachmentType)
|
@IsEnum(AttachmentType)
|
||||||
@IsIn([
|
@IsIn([
|
||||||
|
|||||||
@@ -496,21 +496,10 @@ export class PageService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.aiQueue.add(
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
QueueJob.PAGE_MOVED_TO_SPACE,
|
|
||||||
{
|
|
||||||
pageIds: pageIdsToMove,
|
pageIds: pageIdsToMove,
|
||||||
spaceId,
|
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: rootPage.workspaceId,
|
||||||
},
|
});
|
||||||
{
|
|
||||||
attempts: 2,
|
|
||||||
backoff: {
|
|
||||||
type: 'fixed',
|
|
||||||
delay: 2 * 60 * 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -339,25 +339,15 @@ export class SpaceMemberService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
await trx
|
|
||||||
.selectFrom('spaces')
|
|
||||||
.select('id')
|
|
||||||
.where('id', '=', dto.spaceId)
|
|
||||||
.forUpdate()
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||||
await this.validateLastAdmin(dto.spaceId, trx);
|
await this.validateLastAdmin(dto.spaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.spaceMemberRepo.updateSpaceMember(
|
await this.spaceMemberRepo.updateSpaceMember(
|
||||||
{ role: dto.role },
|
{ role: dto.role },
|
||||||
spaceMember.id,
|
spaceMember.id,
|
||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
trx,
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
||||||
@@ -378,14 +368,10 @@ export class SpaceMemberService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateLastAdmin(
|
async validateLastAdmin(spaceId: string): Promise<void> {
|
||||||
spaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
|
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
|
||||||
SpaceRole.ADMIN,
|
SpaceRole.ADMIN,
|
||||||
spaceId,
|
spaceId,
|
||||||
trx,
|
|
||||||
);
|
);
|
||||||
if (spaceOwnerCount === 1) {
|
if (spaceOwnerCount === 1) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -396,10 +396,7 @@ export class WorkspaceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (updateWorkspaceDto.aiSearch) {
|
||||||
updateWorkspaceDto.aiSearch &&
|
|
||||||
this.environmentService.getAiVectorDriver() !== 'turbopuffer'
|
|
||||||
) {
|
|
||||||
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
|||||||
|
|
||||||
export class SpaceEvent {
|
export class SpaceEvent {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
workspaceId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -23,12 +22,12 @@ export class SpaceListener {
|
|||||||
|
|
||||||
@OnEvent(EventName.SPACE_DELETED)
|
@OnEvent(EventName.SPACE_DELETED)
|
||||||
async handleSpaceDeleted(event: SpaceEvent) {
|
async handleSpaceDeleted(event: SpaceEvent) {
|
||||||
const { spaceId, workspaceId } = event;
|
const { spaceId } = event;
|
||||||
if (this.isTypesense()) {
|
if (this.isTypesense()) {
|
||||||
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId, workspaceId });
|
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
isTypesense(): boolean {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
||||||
import { DB } from '@docmost/db/types/db';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +8,6 @@ import {
|
|||||||
UpdatableAttachment,
|
UpdatableAttachment,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentRepo {
|
export class AttachmentRepo {
|
||||||
@@ -94,41 +89,6 @@ export class AttachmentRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPageAttachments(pageId: string, pagination: PaginationOptions) {
|
|
||||||
let query = this.db
|
|
||||||
.selectFrom('attachments')
|
|
||||||
.select(this.baseFields)
|
|
||||||
.select((eb) => this.withCreator(eb))
|
|
||||||
.where('pageId', '=', pageId)
|
|
||||||
.where('type', '=', AttachmentType.File)
|
|
||||||
.where('deletedAt', 'is', null);
|
|
||||||
|
|
||||||
if (pagination.query) {
|
|
||||||
query = query.where(
|
|
||||||
sql`f_unaccent(file_name)`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
|
||||||
perPage: pagination.limit,
|
|
||||||
cursor: pagination.cursor,
|
|
||||||
beforeCursor: pagination.beforeCursor,
|
|
||||||
fields: [{ expression: 'id', direction: 'desc' }],
|
|
||||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
|
|
||||||
return jsonObjectFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('users')
|
|
||||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
|
||||||
.whereRef('users.id', '=', 'attachments.creatorId'),
|
|
||||||
).as('creator');
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIds(
|
async findByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
opts?: {
|
opts?: {
|
||||||
|
|||||||
@@ -46,10 +46,8 @@ export class SpaceMemberRepo {
|
|||||||
updatableSpaceMember: UpdatableSpaceMember,
|
updatableSpaceMember: UpdatableSpaceMember,
|
||||||
spaceMemberId: string,
|
spaceMemberId: string,
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = dbOrTx(this.db, trx);
|
await this.db
|
||||||
await db
|
|
||||||
.updateTable('spaceMembers')
|
.updateTable('spaceMembers')
|
||||||
.set(updatableSpaceMember)
|
.set(updatableSpaceMember)
|
||||||
.where('id', '=', spaceMemberId)
|
.where('id', '=', spaceMemberId)
|
||||||
@@ -94,13 +92,8 @@ export class SpaceMemberRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async roleCountBySpaceId(
|
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
|
||||||
role: string,
|
const { count } = await this.db
|
||||||
spaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<number> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
const { count } = await db
|
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.select((eb) => eb.fn.count('role').as('count'))
|
.select((eb) => eb.fn.count('role').as('count'))
|
||||||
.where('role', '=', role)
|
.where('role', '=', role)
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ export class SpaceRepo {
|
|||||||
|
|
||||||
this.eventEmitter.emit(EventName.SPACE_DELETED, {
|
this.eventEmitter.emit(EventName.SPACE_DELETED, {
|
||||||
spaceId,
|
spaceId,
|
||||||
workspaceId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,24 +211,6 @@ export class WorkspaceRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAiEmbeddingFingerprint(
|
|
||||||
workspaceId: string,
|
|
||||||
fingerprint: { driver: string; model: string; dimensions: number },
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('workspaces')
|
|
||||||
.set({
|
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
|
||||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
|
||||||
|| jsonb_build_object('embedding', ${JSON.stringify(fingerprint)}::text::jsonb))`,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where('id', '=', workspaceId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSharingSettings(
|
async updateSharingSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
prefKey: string,
|
prefKey: string,
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 6dfbcb9241...c7b77ffb9e
@@ -1,13 +0,0 @@
|
|||||||
export class UnableToInitialize extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(`Unable to initialize the encryption service: ${message}`);
|
|
||||||
this.name = 'UnableToInitialize';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnableToDecrypt extends Error {
|
|
||||||
constructor(reason: string) {
|
|
||||||
super(`Unable to decrypt the ciphertext: ${reason}`);
|
|
||||||
this.name = 'UnableToDecrypt';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { EncryptionService } from './encryption.service';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [EncryptionService],
|
|
||||||
exports: [EncryptionService],
|
|
||||||
})
|
|
||||||
export class EncryptionModule {}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { EncryptionService } from './encryption.service';
|
|
||||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
|
||||||
|
|
||||||
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
|
||||||
|
|
||||||
const buildService = (appSecret: string | undefined) => {
|
|
||||||
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
|
||||||
return new EncryptionService(env);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeEnvelope = (encrypted: string) =>
|
|
||||||
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
|
||||||
iv: string;
|
|
||||||
authTag: string;
|
|
||||||
cipherText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodeEnvelope = (envelope: {
|
|
||||||
iv: string;
|
|
||||||
authTag: string;
|
|
||||||
cipherText: string;
|
|
||||||
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
|
||||||
|
|
||||||
describe('EncryptionService', () => {
|
|
||||||
let service: EncryptionService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
EncryptionService,
|
|
||||||
{
|
|
||||||
provide: EnvironmentService,
|
|
||||||
useValue: { getAppSecret: () => APP_SECRET },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<EncryptionService>(EncryptionService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initialization', () => {
|
|
||||||
it('compiles via Nest DI', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
|
||||||
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
|
||||||
expect(() => buildService('')).toThrow(UnableToInitialize);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('encrypt + decrypt round-trip', () => {
|
|
||||||
it('decrypts back to the original plaintext', () => {
|
|
||||||
const plaintext = 'hello world';
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty string', () => {
|
|
||||||
const encrypted = service.encrypt('');
|
|
||||||
expect(service.decrypt(encrypted)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles unicode (multi-byte UTF-8)', () => {
|
|
||||||
const plaintext = 'héllo 🔐 世界';
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles long plaintext (>1 block)', () => {
|
|
||||||
const plaintext = 'a'.repeat(10_000);
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
|
||||||
const plaintext = 'same input';
|
|
||||||
const a = service.encrypt(plaintext);
|
|
||||||
const b = service.encrypt(plaintext);
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
expect(service.decrypt(a)).toBe(plaintext);
|
|
||||||
expect(service.decrypt(b)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cross-key isolation', () => {
|
|
||||||
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
|
||||||
const other = buildService('totally-different-secret-value-9876543210');
|
|
||||||
const encrypted = service.encrypt('secret');
|
|
||||||
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('tamper detection', () => {
|
|
||||||
it('rejects modified ciphertext', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
|
||||||
tamperedCipher[0] ^= 0x01;
|
|
||||||
const tampered = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
cipherText: tamperedCipher.toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects modified auth tag', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
|
||||||
tamperedTag[0] ^= 0x01;
|
|
||||||
const tampered = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
authTag: tamperedTag.toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects modified IV', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const tamperedIV = Buffer.from(env.iv, 'base64');
|
|
||||||
tamperedIV[0] ^= 0x01;
|
|
||||||
const tampered = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
iv: tamperedIV.toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('malformed payloads', () => {
|
|
||||||
it('rejects non-base64 garbage', () => {
|
|
||||||
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
|
||||||
UnableToDecrypt,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects base64 of non-JSON', () => {
|
|
||||||
const garbage = Buffer.from('not json at all').toString('base64');
|
|
||||||
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects JSON missing required fields', () => {
|
|
||||||
const partial = encodeEnvelope({
|
|
||||||
iv: Buffer.alloc(12).toString('base64'),
|
|
||||||
authTag: Buffer.alloc(16).toString('base64'),
|
|
||||||
} as never);
|
|
||||||
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects wrong-length IV', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const bad = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
iv: Buffer.alloc(8).toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects wrong-length auth tag', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
const bad = encodeEnvelope({
|
|
||||||
...env,
|
|
||||||
authTag: Buffer.alloc(8).toString('base64'),
|
|
||||||
});
|
|
||||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('envelope format', () => {
|
|
||||||
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
|
||||||
const encrypted = service.encrypt('hello');
|
|
||||||
const env = decodeEnvelope(encrypted);
|
|
||||||
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
|
||||||
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
|
||||||
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// https://github.com/nhedger/nestjs-encryption - MIT
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
createCipheriv,
|
|
||||||
createDecipheriv,
|
|
||||||
createHash,
|
|
||||||
randomBytes,
|
|
||||||
} from 'node:crypto';
|
|
||||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
|
||||||
const KEY_DOMAIN = 'docmost:encryption:v1';
|
|
||||||
const IV_LENGTH = 12;
|
|
||||||
const AUTH_TAG_LENGTH = 16;
|
|
||||||
|
|
||||||
type AEADPayload<TFormat = string | Buffer> = {
|
|
||||||
iv: TFormat;
|
|
||||||
authTag: TFormat;
|
|
||||||
cipherText: TFormat;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EncryptionService {
|
|
||||||
private readonly key: Buffer;
|
|
||||||
|
|
||||||
constructor(environmentService: EnvironmentService) {
|
|
||||||
const appSecret = environmentService.getAppSecret();
|
|
||||||
if (!appSecret) {
|
|
||||||
throw new UnableToInitialize('APP_SECRET is not set.');
|
|
||||||
}
|
|
||||||
this.key = createHash('sha256')
|
|
||||||
.update(KEY_DOMAIN)
|
|
||||||
.update(appSecret)
|
|
||||||
.digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
public encrypt(plaintext: string): string {
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
|
||||||
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
|
||||||
const cipherText = Buffer.concat([
|
|
||||||
cipher.update(plaintext, 'utf8'),
|
|
||||||
cipher.final(),
|
|
||||||
]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
const aead: AEADPayload<string> = {
|
|
||||||
iv: iv.toString('base64'),
|
|
||||||
authTag: authTag.toString('base64'),
|
|
||||||
cipherText: cipherText.toString('base64'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
public decrypt(encrypted: string): string {
|
|
||||||
try {
|
|
||||||
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
const decrypted = Buffer.concat([
|
|
||||||
decipher.update(cipherText),
|
|
||||||
decipher.final(),
|
|
||||||
]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
} catch (e: unknown) {
|
|
||||||
throw new UnableToDecrypt((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
|
||||||
const payload = Buffer.from(encodedPayload, 'base64');
|
|
||||||
|
|
||||||
let deserializedPkg: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
deserializedPkg = JSON.parse(payload.toString());
|
|
||||||
} catch {
|
|
||||||
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of ['iv', 'authTag', 'cipherText']) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
|
||||||
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
|
||||||
if (iv.length !== IV_LENGTH) {
|
|
||||||
throw new Error(
|
|
||||||
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
|
||||||
if (authTag.length !== AUTH_TAG_LENGTH) {
|
|
||||||
throw new Error(
|
|
||||||
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cipherText = Buffer.from(
|
|
||||||
deserializedPkg.cipherText as string,
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
|
|
||||||
return { iv, authTag, cipherText };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -310,31 +310,6 @@ export class EnvironmentService {
|
|||||||
return val === 'true';
|
return val === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
getAiVectorDriver(): string {
|
|
||||||
return this.configService
|
|
||||||
.get<string>('AI_VECTOR_DRIVER', 'pgvector')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
getTurbopufferApiKey(): string {
|
|
||||||
return this.configService.get<string>('TURBOPUFFER_API_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
getTurbopufferRegion(): string {
|
|
||||||
return this.configService.get<string>('TURBOPUFFER_REGION');
|
|
||||||
}
|
|
||||||
|
|
||||||
getTurbopufferBaseUrl(): string {
|
|
||||||
return this.configService.get<string>('TURBOPUFFER_BASE_URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
getTurbopufferNamespacePrefix(): string {
|
|
||||||
return this.configService.get<string>(
|
|
||||||
'TURBOPUFFER_NAMESPACE_PREFIX',
|
|
||||||
'docmost',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getOpenAiApiKey(): string {
|
getOpenAiApiKey(): string {
|
||||||
return this.configService.get<string>('OPENAI_API_KEY');
|
return this.configService.get<string>('OPENAI_API_KEY');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
Matches,
|
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
validateSync,
|
validateSync,
|
||||||
@@ -109,41 +108,6 @@ export class EnvironmentVariables {
|
|||||||
@IsString()
|
@IsString()
|
||||||
AI_DRIVER: string;
|
AI_DRIVER: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER)
|
|
||||||
@IsIn(['pgvector', 'turbopuffer'])
|
|
||||||
@IsString()
|
|
||||||
AI_VECTOR_DRIVER: string;
|
|
||||||
|
|
||||||
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER === 'turbopuffer')
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
TURBOPUFFER_API_KEY: string;
|
|
||||||
|
|
||||||
@ValidateIf(
|
|
||||||
(obj) =>
|
|
||||||
obj.AI_VECTOR_DRIVER === 'turbopuffer' && !obj.TURBOPUFFER_BASE_URL,
|
|
||||||
)
|
|
||||||
@IsNotEmpty({
|
|
||||||
message:
|
|
||||||
'TURBOPUFFER_REGION is required when AI_VECTOR_DRIVER is turbopuffer, unless TURBOPUFFER_BASE_URL is set',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
TURBOPUFFER_REGION: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@ValidateIf((obj) => obj.TURBOPUFFER_BASE_URL != '' && obj.TURBOPUFFER_BASE_URL != null)
|
|
||||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
|
||||||
TURBOPUFFER_BASE_URL: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^[A-Za-z0-9\-_.]{1,90}$/, {
|
|
||||||
message:
|
|
||||||
'TURBOPUFFER_NAMESPACE_PREFIX may only contain letters, digits, dot, dash, underscore (max 90 chars)',
|
|
||||||
})
|
|
||||||
TURBOPUFFER_NAMESPACE_PREFIX: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
AI_EMBEDDING_MODEL: string;
|
AI_EMBEDDING_MODEL: string;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { parseRedisUrl } from '../../common/helpers';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisHealthIndicator {
|
export class RedisHealthIndicator {
|
||||||
@@ -20,10 +19,8 @@ export class RedisHealthIndicator {
|
|||||||
const indicator = this.healthIndicatorService.check(key);
|
const indicator = this.healthIndicatorService.check(key);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const redisUrl = this.environmentService.getRedisUrl();
|
const redis = new Redis(this.environmentService.getRedisUrl(), {
|
||||||
const redis = new Redis(redisUrl, {
|
|
||||||
maxRetriesPerRequest: 15,
|
maxRetriesPerRequest: 15,
|
||||||
tls: parseRedisUrl(redisUrl).tls,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await redis.ping();
|
await redis.ping();
|
||||||
|
|||||||
@@ -97,15 +97,6 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBareLink($el: Cheerio<any>): boolean {
|
|
||||||
const href = $el.attr("href")?.trim();
|
|
||||||
const text = $el.text().trim();
|
|
||||||
|
|
||||||
if(!text || !href) return false
|
|
||||||
|
|
||||||
return text === href;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
normalizeTableColumnWidths($, $root);
|
normalizeTableColumnWidths($, $root);
|
||||||
|
|
||||||
@@ -113,9 +104,7 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
const url = $el.attr('href')!;
|
const url = $el.attr('href')!;
|
||||||
const { provider } = getEmbedUrlAndProvider(url);
|
const { provider } = getEmbedUrlAndProvider(url);
|
||||||
if (provider === 'iframe' || !isBareLink($el)) {
|
if (provider === 'iframe') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
||||||
$el.replaceWith(embed);
|
$el.replaceWith(embed);
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export enum QueueJob {
|
|||||||
WORKSPACE_DELETED = 'workspace-deleted',
|
WORKSPACE_DELETED = 'workspace-deleted',
|
||||||
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
|
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
|
||||||
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
|
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
|
||||||
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
|
|
||||||
|
|
||||||
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
||||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
|||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db,
|
db: redisConfig.db,
|
||||||
family: redisConfig.family,
|
family: redisConfig.family,
|
||||||
tls: redisConfig.tls,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
retryStrategy: createRetryStrategy(),
|
||||||
},
|
},
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export class RedisConfigService implements RedisOptionsFactory {
|
|||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db,
|
db: redisConfig.db,
|
||||||
family: redisConfig.family,
|
family: redisConfig.family,
|
||||||
tls: redisConfig.tls,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
retryStrategy: createRetryStrategy(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ export class StaticModule implements OnModuleInit {
|
|||||||
: undefined,
|
: undefined,
|
||||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||||
AI_VECTOR_DRIVER:
|
|
||||||
this.environmentService.getAiVectorDriver() === 'turbopuffer'
|
|
||||||
? 'turbopuffer'
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
|||||||
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
import { EnvironmentModule } from '../environment/environment.module';
|
import { EnvironmentModule } from '../environment/environment.module';
|
||||||
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
import { parseRedisUrl } from '../../common/helpers';
|
||||||
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
|
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
@@ -27,8 +27,6 @@ import Redis from 'ioredis';
|
|||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db,
|
db: redisConfig.db,
|
||||||
family: redisConfig.family,
|
family: redisConfig.family,
|
||||||
tls: redisConfig.tls,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
|
||||||
keyPrefix: 'throttle:',
|
keyPrefix: 'throttle:',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export class WsRedisIoAdapter extends IoAdapter {
|
|||||||
|
|
||||||
const options: RedisOptions = {
|
const options: RedisOptions = {
|
||||||
family: this.redisConfig.family,
|
family: this.redisConfig.family,
|
||||||
tls: this.redisConfig.tls,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
retryStrategy: createRetryStrategy(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,13 +73,9 @@ export const embedProviders: IEmbedProvider[] = [
|
|||||||
id: "vimeo",
|
id: "vimeo",
|
||||||
name: "Vimeo",
|
name: "Vimeo",
|
||||||
regex:
|
regex:
|
||||||
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)(?:\/([\da-zA-Z]+))?/,
|
/^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
||||||
getEmbedUrl: (match, url: string) => {
|
getEmbedUrl: (match) => {
|
||||||
// preserve ?h= hash for unlisted videos
|
return `https://player.vimeo.com/video/${match[4]}`;
|
||||||
const hash =
|
|
||||||
match[5] ?? new URL(url, "https://vimeo.com").searchParams.get("h");
|
|
||||||
const base = `https://player.vimeo.com/video/${match[4]}`;
|
|
||||||
return hash ? `${base}?h=${hash}` : base;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
|
|||||||
// No usable static export representation: skip without failing.
|
// No usable static export representation: skip without failing.
|
||||||
subpages() {},
|
subpages() {},
|
||||||
transclusionReference() {},
|
transclusionReference() {},
|
||||||
base() {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultMarks: MarkSerializer = {
|
export const defaultMarks: MarkSerializer = {
|
||||||
|
|||||||
Generated
+285
-200
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -6,12 +6,12 @@ patchedDependencies:
|
|||||||
overrides:
|
overrides:
|
||||||
prosemirror-changeset: 2.4.0
|
prosemirror-changeset: 2.4.0
|
||||||
glob: 13.0.6
|
glob: 13.0.6
|
||||||
ws: 8.21.3
|
ws: 8.21.0
|
||||||
dompurify: 3.4.13
|
dompurify: 3.4.13
|
||||||
mermaid: 11.16.1
|
mermaid: 11.16.1
|
||||||
undici: 7.29.0
|
undici: 7.29.0
|
||||||
tmp: 0.2.7
|
tmp: 0.2.7
|
||||||
nanoid@^3: 3.3.18
|
nanoid@^3: 3.3.17
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
express-rate-limit: 8.2.2
|
express-rate-limit: 8.2.2
|
||||||
flatted: 3.4.2
|
flatted: 3.4.2
|
||||||
|
|||||||
Reference in New Issue
Block a user