mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 15:23:07 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb83d12c8b | |||
| 0f29eb8842 | |||
| 08135a2fba | |||
| d92a94244f | |||
| 5012a68d85 | |||
| 5a3377790e | |||
| 3b85f4b616 | |||
| cb2a0398c7 | |||
| 95b7be61df | |||
| b0c557272d | |||
| dddfd48934 | |||
| aa6eec754e | |||
| 97a7701f5d | |||
| b97eb85d05 | |||
| 1615e0f4ad | |||
| 1cb2535de3 | |||
| 83bc273cb0 | |||
| c7beaa3742 | |||
| 4a228e5a51 | |||
| edff375476 | |||
| 95016b2bfc | |||
| ca83712364 | |||
| 39550fe906 |
+4
-1
@@ -43,4 +43,7 @@ POSTMARK_TOKEN=
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/trash.tsx";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -50,10 +50,7 @@ export default function App() {
|
||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||
<Route
|
||||
path={"/login/mfa/setup"}
|
||||
element={<MfaSetupRequiredPage />}
|
||||
/>
|
||||
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
||||
|
||||
{!isCloud() && (
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
|
||||
@@ -29,19 +29,22 @@ export default function ExportModal({
|
||||
}: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({ pageId: id, format, includeChildren });
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
setIncludeChildren(false);
|
||||
setIncludeAttachments(true);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@@ -96,6 +99,18 @@ export default function ExportModal({
|
||||
checked={includeChildren}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" mt="md">
|
||||
<div>
|
||||
<Text size="md">{t("Include attachments")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeAttachments(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeAttachments}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import { User } from "server/dist/database/types/entity.types";
|
||||
import { IUser } from '@/features/user/types/user.types.ts';
|
||||
|
||||
interface UserInfoProps {
|
||||
user: User;
|
||||
user: Partial<IUser>;
|
||||
size?: string;
|
||||
}
|
||||
export function UserInfo({ user, size }: UserInfoProps) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
||||
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
|
||||
|
||||
export function useAiStream() {
|
||||
const [content, setContent] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const mutation = useAiGenerateStreamMutation();
|
||||
|
||||
const startStream = useCallback(
|
||||
async (data: AiGenerateDto) => {
|
||||
setContent("");
|
||||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
const controller = await mutation.mutateAsync({
|
||||
...data,
|
||||
onChunk: (chunk) => {
|
||||
setContent((prev) => prev + chunk.content);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("AI stream error:", error);
|
||||
setIsStreaming(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsStreaming(false);
|
||||
},
|
||||
});
|
||||
|
||||
abortControllerRef.current = controller;
|
||||
} catch (error) {
|
||||
console.error("Failed to start stream:", error);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[mutation]
|
||||
);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetContent = useCallback(() => {
|
||||
setContent("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
content,
|
||||
isStreaming,
|
||||
startStream,
|
||||
stopStream,
|
||||
resetContent,
|
||||
isLoading: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationResult,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
generateAiContent,
|
||||
generateAiContentStream,
|
||||
getAiConfig,
|
||||
} from "@/ee/ai/services/ai-service.ts";
|
||||
import {
|
||||
AiConfigResponse,
|
||||
AiContentResponse,
|
||||
AiGenerateDto,
|
||||
AiStreamChunk,
|
||||
AiStreamError,
|
||||
} from "@/ee/ai/types/ai.types.ts";
|
||||
|
||||
export function useAiGenerateMutation(): UseMutationResult<
|
||||
AiContentResponse,
|
||||
Error,
|
||||
AiGenerateDto
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
|
||||
});
|
||||
}
|
||||
|
||||
interface StreamCallbacks {
|
||||
onChunk: (chunk: AiStreamChunk) => void;
|
||||
onError?: (error: AiStreamError) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useAiGenerateStreamMutation(): UseMutationResult<
|
||||
AbortController,
|
||||
Error,
|
||||
AiGenerateDto & StreamCallbacks
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
|
||||
generateAiContentStream(data, onChunk, onError, onComplete),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import api from "@/lib/api-client.ts";
|
||||
import {
|
||||
AiGenerateDto,
|
||||
AiContentResponse,
|
||||
AiStreamChunk,
|
||||
AiStreamError,
|
||||
} from "@/ee/ai/types/ai.types.ts";
|
||||
|
||||
export async function generateAiContent(
|
||||
data: AiGenerateDto,
|
||||
): Promise<AiContentResponse> {
|
||||
const req = await api.post<AiContentResponse>("/ai/generate", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function generateAiContentStream(
|
||||
data: AiGenerateDto,
|
||||
onChunk: (chunk: AiStreamChunk) => void,
|
||||
onError?: (error: AiStreamError) => void,
|
||||
onComplete?: () => void,
|
||||
): Promise<AbortController> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const response = await fetch("/api/ai/generate/stream", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
signal: abortController.signal,
|
||||
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.error) {
|
||||
onError?.(parsed);
|
||||
} else {
|
||||
onChunk(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors for incomplete chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
onError?.({ error: error.message });
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
processStream();
|
||||
} catch (error) {
|
||||
onError?.({ error: error.message });
|
||||
}
|
||||
|
||||
return abortController;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export enum AiAction {
|
||||
IMPROVE_WRITING = "improve_writing",
|
||||
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
|
||||
MAKE_SHORTER = "make_shorter",
|
||||
MAKE_LONGER = "make_longer",
|
||||
SIMPLIFY = "simplify",
|
||||
CHANGE_TONE = "change_tone",
|
||||
SUMMARIZE = "summarize",
|
||||
CONTINUE_WRITING = "continue_writing",
|
||||
TRANSLATE = "translate",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
export interface AiGenerateDto {
|
||||
action?: AiAction;
|
||||
content: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface AiContentResponse {
|
||||
content: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AiConfigResponse {
|
||||
configured: boolean;
|
||||
availableActions: AiAction[];
|
||||
}
|
||||
|
||||
export interface AiStreamChunk {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AiStreamError {
|
||||
error: string;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ function CommentListWithTabs() {
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
|
||||
const canComment: boolean = spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page
|
||||
@@ -179,6 +180,17 @@ function CommentListWithTabs() {
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageExportModalProps {
|
||||
pageId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageExportModal({
|
||||
pageId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageExportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportPage({ pageId: pageId, format });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: t("Export failed:") + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" pt="md">
|
||||
<div>
|
||||
<Text size="md">{t("Include subpages")}</Text>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +126,9 @@ export function useUpdatePageMutation() {
|
||||
export function useRemovePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, pageId) => {
|
||||
notifications.show({ message: "Page moved to trash" });
|
||||
invalidateOnDeletePage(pageId);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["trash-list"].includes(item.queryKey[0] as string),
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||
|
||||
export default function SpaceTrash() {
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const { page, setPage } = usePaginateAndSearch();
|
||||
@@ -79,6 +79,7 @@ export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
includeChildren?: boolean;
|
||||
includeAttachments?: boolean;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Trash from "@/features/page/trash/components/trash.tsx";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import React from "react";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
export default function SpaceTrash() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <Trash />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -70,6 +70,7 @@
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openai": "^5.12.2",
|
||||
"openid-client": "^5.7.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"p-limit": "^6.2.0",
|
||||
|
||||
@@ -12,10 +12,14 @@ export class InternalLogFilter extends ConsoleLogger {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.allowedLogLevels =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? ['log', 'error', 'fatal']
|
||||
: ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||
|
||||
if (isProduction && !isDebugMode) {
|
||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
||||
} else {
|
||||
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
||||
}
|
||||
}
|
||||
|
||||
private isLogLevelAllowed(level: string): boolean {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class CommentController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
||||
if (!page) {
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
@@ -90,7 +90,10 @@ export class CommentController {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, comment.spaceId);
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class DeletedPageDto {
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ export class PageController {
|
||||
deletedPageDto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ export class PageService {
|
||||
.selectFrom('pages')
|
||||
.select(['position'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.orderBy('position', 'desc')
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('position', (ob) => ob.collate('C').desc())
|
||||
.limit(1);
|
||||
|
||||
if (parentPageId) {
|
||||
@@ -190,7 +191,7 @@ export class PageService {
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
.orderBy('position', 'asc')
|
||||
.orderBy('position', (ob) => ob.collate('C').asc())
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('spaceId', '=', spaceId);
|
||||
|
||||
@@ -261,35 +262,7 @@ export class PageService {
|
||||
|
||||
if (isDuplicateInSameSpace) {
|
||||
// For duplicate in same space, position right after the original page
|
||||
let siblingQuery = this.db
|
||||
.selectFrom('pages')
|
||||
.select(['position'])
|
||||
.where('spaceId', '=', rootPage.spaceId)
|
||||
.where('position', '>', rootPage.position);
|
||||
|
||||
if (rootPage.parentPageId) {
|
||||
siblingQuery = siblingQuery.where(
|
||||
'parentPageId',
|
||||
'=',
|
||||
rootPage.parentPageId,
|
||||
);
|
||||
} else {
|
||||
siblingQuery = siblingQuery.where('parentPageId', 'is', null);
|
||||
}
|
||||
|
||||
const nextSibling = await siblingQuery
|
||||
.orderBy('position', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (nextSibling) {
|
||||
nextPosition = generateJitteredKeyBetween(
|
||||
rootPage.position,
|
||||
nextSibling.position,
|
||||
);
|
||||
} else {
|
||||
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
|
||||
}
|
||||
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
|
||||
} else {
|
||||
// For copy to different space, position at the end
|
||||
nextPosition = await this.nextPagePosition(spaceId);
|
||||
@@ -434,25 +407,35 @@ export class PageService {
|
||||
attachment.id,
|
||||
newAttachmentId,
|
||||
);
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
try {
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Duplicate page: failed to copy attachment ${attachment.id}`,
|
||||
err,
|
||||
);
|
||||
// Continue with other attachments even if one fails
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.log(err);
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class TrashCleanupService {
|
||||
@Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
|
||||
async cleanupOldTrash() {
|
||||
try {
|
||||
this.logger.log('Starting trash cleanup job');
|
||||
this.logger.debug('Starting trash cleanup job');
|
||||
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
|
||||
|
||||
@@ -59,6 +59,7 @@ export class SearchService {
|
||||
.$if(Boolean(searchParams.creatorId), (qb) =>
|
||||
qb.where('creatorId', '=', searchParams.creatorId),
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('rank', 'desc')
|
||||
.limit(searchParams.limit | 20)
|
||||
.offset(searchParams.offset || 0);
|
||||
@@ -191,6 +192,7 @@ export class SearchService {
|
||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
||||
),
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.limit(limit);
|
||||
|
||||
|
||||
@@ -108,12 +108,12 @@ export class ShareService {
|
||||
includeCreator: true,
|
||||
});
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
if (!page) {
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
return { page, share };
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export class ShareService {
|
||||
sql`0`.as('level'),
|
||||
])
|
||||
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
@@ -144,7 +145,8 @@ export class ShareService {
|
||||
// Increase the level by 1 for each ancestor.
|
||||
sql`ph.level + 1`.as('level'),
|
||||
])
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'),
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import { dbOrTx, executeTx } from '../../utils';
|
||||
import {
|
||||
InsertablePage,
|
||||
Page,
|
||||
@@ -183,14 +183,20 @@ export class PageRepo {
|
||||
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
await this.db
|
||||
.updateTable('pages')
|
||||
.set({
|
||||
deletedById: deletedById,
|
||||
deletedAt: currentDate,
|
||||
})
|
||||
.where('id', 'in', pageIds)
|
||||
.execute();
|
||||
if (pageIds.length > 0) {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await trx
|
||||
.updateTable('pages')
|
||||
.set({
|
||||
deletedById: deletedById,
|
||||
deletedAt: currentDate,
|
||||
})
|
||||
.where('id', 'in', pageIds)
|
||||
.execute();
|
||||
|
||||
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async restorePage(pageId: string): Promise<void> {
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 576cf4fa42...4100345c18
@@ -213,4 +213,16 @@ export class EnvironmentService {
|
||||
getPostHogKey(): string {
|
||||
return this.configService.get<string>('POSTHOG_KEY');
|
||||
}
|
||||
|
||||
getOpenAiApiKey(): string {
|
||||
return this.configService.get<string>('OPENAI_API_KEY');
|
||||
}
|
||||
|
||||
getOpenAiApiUrl(): string {
|
||||
return this.configService.get<string>('OPENAI_API_URL');
|
||||
}
|
||||
|
||||
getOpenAiModel(): string {
|
||||
return this.configService.get<string>('OPENAI_MODEL');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ export class ExportPageDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeChildren?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeAttachments?: boolean;
|
||||
}
|
||||
|
||||
export class ExportSpaceDto {
|
||||
|
||||
@@ -55,40 +55,22 @@ export class ExportController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const fileExt = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'untitled') + fileExt;
|
||||
|
||||
if (dto.includeChildren) {
|
||||
const zipFileBuffer = await this.exportService.exportPageWithChildren(
|
||||
dto.pageId,
|
||||
dto.format,
|
||||
);
|
||||
|
||||
const newName = path.parse(fileName).name + '.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(newName) + '"',
|
||||
});
|
||||
|
||||
res.send(zipFileBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawContent = await this.exportService.exportPage(
|
||||
const zipFileBuffer = await this.exportService.exportPages(
|
||||
dto.pageId,
|
||||
dto.format,
|
||||
page,
|
||||
true,
|
||||
dto.includeAttachments,
|
||||
dto.includeChildren,
|
||||
);
|
||||
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(fileExt),
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
|
||||
res.send(rawContent);
|
||||
res.send(zipFileBuffer);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@@ -89,10 +89,28 @@ export class ExportService {
|
||||
return;
|
||||
}
|
||||
|
||||
async exportPageWithChildren(pageId: string, format: string) {
|
||||
const pages = await this.pageRepo.getPageAndDescendants(pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
async exportPages(
|
||||
pageId: string,
|
||||
format: string,
|
||||
includeAttachments: boolean,
|
||||
includeChildren: boolean,
|
||||
) {
|
||||
let pages: Page[];
|
||||
|
||||
if (includeChildren) {
|
||||
//@ts-ignore
|
||||
pages = await this.pageRepo.getPageAndDescendants(pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
} else {
|
||||
// Only fetch the single page when includeChildren is false
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
if (page){
|
||||
pages = [page];
|
||||
}
|
||||
}
|
||||
|
||||
if (!pages || pages.length === 0) {
|
||||
throw new BadRequestException('No pages to export');
|
||||
@@ -105,7 +123,7 @@ export class ExportService {
|
||||
const tree = buildTree(pages as Page[]);
|
||||
|
||||
const zip = new JSZip();
|
||||
await this.zipPages(tree, format, zip);
|
||||
await this.zipPages(tree, format, zip, includeAttachments);
|
||||
|
||||
const zipFile = zip.generateNodeStream({
|
||||
type: 'nodebuffer',
|
||||
@@ -168,7 +186,7 @@ export class ExportService {
|
||||
tree: PageExportTree,
|
||||
format: string,
|
||||
zip: JSZip,
|
||||
includeAttachments = true,
|
||||
includeAttachments: boolean,
|
||||
): Promise<void> {
|
||||
const slugIdToPath: Record<string, string> = {};
|
||||
|
||||
@@ -200,7 +218,8 @@ export class ExportService {
|
||||
|
||||
if (includeAttachments) {
|
||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||
updatedJsonContent = updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||
updatedJsonContent =
|
||||
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||
}
|
||||
|
||||
const pageTitle = getPageTitle(page.title);
|
||||
|
||||
@@ -69,8 +69,17 @@ function taskList(turndownService: TurndownService) {
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
const isChecked = checkbox.checked;
|
||||
|
||||
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
|
||||
|
||||
// Process content like regular list items
|
||||
content = content
|
||||
.replace(/^\n+/, '') // remove leading newlines
|
||||
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
|
||||
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces
|
||||
|
||||
// Create the checkbox prefix
|
||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
||||
|
||||
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class ImportService {
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'position'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.orderBy('position', 'desc')
|
||||
.orderBy('position', (ob) => ob.collate('C').desc())
|
||||
.limit(1)
|
||||
.where('parentPageId', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -40,8 +40,11 @@ export class LocalDriver implements StorageDriver {
|
||||
|
||||
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
|
||||
try {
|
||||
const fromFullPath = this._fullPath(fromFilePath);
|
||||
const toFullPath = this._fullPath(toFilePath);
|
||||
|
||||
if (await this.exists(fromFilePath)) {
|
||||
await fs.copy(fromFilePath, toFilePath);
|
||||
await fs.copy(fromFullPath, toFullPath);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to copy file: ${(err as Error).message}`);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
|
||||
Generated
+20
@@ -543,6 +543,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
openai:
|
||||
specifier: ^5.12.2
|
||||
version: 5.12.2(ws@8.18.2)(zod@3.25.56)
|
||||
openid-client:
|
||||
specifier: ^5.7.1
|
||||
version: 5.7.1
|
||||
@@ -7655,6 +7658,18 @@ packages:
|
||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
openai@5.12.2:
|
||||
resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openid-client@5.7.1:
|
||||
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||
|
||||
@@ -18262,6 +18277,11 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openai@5.12.2(ws@8.18.2)(zod@3.25.56):
|
||||
optionalDependencies:
|
||||
ws: 8.18.2
|
||||
zod: 3.25.56
|
||||
|
||||
openid-client@5.7.1:
|
||||
dependencies:
|
||||
jose: 4.15.9
|
||||
|
||||
Reference in New Issue
Block a user