Compare commits

...

23 Commits

Author SHA1 Message Date
Philipinho bb83d12c8b AI module - init 2025-08-15 23:18:51 -07:00
Philipinho 0f29eb8842 WIP 2025-08-14 23:13:23 -07:00
Philipinho 08135a2fba sync 2025-08-12 11:09:26 -07:00
Philipinho d92a94244f sync 2025-08-12 10:21:17 -07:00
Philipinho 5012a68d85 sync 2025-08-06 10:19:35 -07:00
Philip Okugbe 5a3377790e feat: debug mode env variable (#1450) 2025-08-06 18:16:30 +01:00
Philip Okugbe 3b85f4b616 fix: enforce C collation for page position ordering to ensure consistent behavior in Postgres 17+ (#1446)
- Add explicit C collation to position ordering queries to fix incorrect page placement in PostgreSQL 17+
- Ensures consistent ASCII-based ordering regardless of database locale settings
- Fixes issue where new pages were incorrectly placed at random positions instead of bottom
2025-08-04 09:49:29 +01:00
Philipinho cb2a0398c7 fix: invalidate trashed page from tree state 2025-08-04 00:42:13 -07:00
Philip Okugbe 95b7be61df fix: hide trash from can view permission (#1445) 2025-08-04 08:35:28 +01:00
Philip Okugbe b0c557272d fix nested taskList in markdown export (#1443) 2025-08-04 08:01:18 +01:00
Philip Okugbe dddfd48934 feat: add attachments support for single page exports (#1440)
* feat: add attachments support for single page exports
- Add includeAttachments option to page export modal and API
- Fix internal page url in single page exports in cloud

* remove redundant line

* preserve export state
2025-08-04 08:01:11 +01:00
Philipinho aa6eec754e fix: exclude trashed pages from position generation 2025-08-04 00:00:06 -07:00
Philip Okugbe 97a7701f5d fix local storage copy function (#1442) 2025-08-04 03:20:18 +01:00
Philipinho b97eb85d05 sync 2025-08-03 03:59:08 -07:00
Philipinho 1615e0f4ad v0.22.2 2025-08-01 16:15:02 -07:00
Philip Okugbe 1cb2535de3 fix trash in search (#1439)
- delete share if page is trashed
2025-08-02 00:14:00 +01:00
Philipinho 83bc273cb0 cleanup 2025-08-01 07:05:25 -07:00
Philipinho c7beaa3742 v0.22.1 2025-08-01 06:54:28 -07:00
Philipinho 4a228e5a51 fix comment replies 2025-08-01 06:51:56 -07:00
Philipinho edff375476 sync 2025-08-01 02:54:11 -07:00
Philipinho 95016b2bfc sync 2025-08-01 02:51:55 -07:00
Philipinho ca83712364 cleanup 2025-08-01 02:26:14 -07:00
Philip Okugbe 39550fe906 fix: duplicate page position bug (#1431) 2025-07-30 18:07:06 +01:00
35 changed files with 468 additions and 232 deletions
+4 -1
View File
@@ -43,4 +43,7 @@ POSTMARK_TOKEN=
# for custom drawio server # for custom drawio server
DRAWIO_URL= DRAWIO_URL=
DISABLE_TELEMETRY=false DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
DEBUG_MODE=false
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.22.0", "version": "0.22.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
+2 -5
View File
@@ -34,7 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx"; import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-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() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,10 +50,7 @@ export default function App() {
<Route path={"/forgot-password"} element={<ForgotPassword />} /> <Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} /> <Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} /> <Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route <Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
path={"/login/mfa/setup"}
element={<MfaSetupRequiredPage />}
/>
{!isCloud() && ( {!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} /> <Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -29,19 +29,22 @@ export default function ExportModal({
}: ExportModalProps) { }: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown); const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false); const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true); const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleExport = async () => { const handleExport = async () => {
try { try {
if (type === "page") { if (type === "page") {
await exportPage({ pageId: id, format, includeChildren }); await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
} }
if (type === "space") { if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments }); await exportSpace({ spaceId: id, format, includeAttachments });
} }
setIncludeChildren(false);
setIncludeAttachments(true);
onClose(); onClose();
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
@@ -96,6 +99,18 @@ export default function ExportModal({
checked={includeChildren} checked={includeChildren}
/> />
</Group> </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 { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react"; import React from "react";
import { User } from "server/dist/database/types/entity.types"; import { IUser } from '@/features/user/types/user.types.ts';
interface UserInfoProps { interface UserInfoProps {
user: User; user: Partial<IUser>;
size?: string; size?: string;
} }
export function UserInfo({ user, size }: UserInfoProps) { export function UserInfo({ user, size }: UserInfoProps) {
+61
View File
@@ -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,
};
}
+45
View File
@@ -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;
}
+40
View File
@@ -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 spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules); const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can( const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page SpaceCaslSubject.Page
@@ -179,6 +180,17 @@ function CommentListWithTabs() {
userSpaceRole={space?.membership?.role} userSpaceRole={space?.membership?.role}
/> />
</div> </div>
{canComment && (
<>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper> </Paper>
))} ))}
</div> </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() { export function useRemovePageMutation() {
return useMutation({ return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false), mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: () => { onSuccess: (_, pageId) => {
notifications.show({ message: "Page moved to trash" }); notifications.show({ message: "Page moved to trash" });
invalidateOnDeletePage(pageId);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string), ["trash-list"].includes(item.queryKey[0] as string),
@@ -32,7 +32,7 @@ import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
export default function SpaceTrash() { export default function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { page, setPage } = usePaginateAndSearch(); const { page, setPage } = usePaginateAndSearch();
@@ -79,6 +79,7 @@ export interface IExportPageParams {
pageId: string; pageId: string;
format: ExportFormat; format: ExportFormat;
includeChildren?: boolean; includeChildren?: boolean;
includeAttachments?: boolean;
} }
export enum ExportFormat { 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 />;
}
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.22.0", "version": "0.22.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -70,6 +70,7 @@
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"openai": "^5.12.2",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.0",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
@@ -12,10 +12,14 @@ export class InternalLogFilter extends ConsoleLogger {
constructor() { constructor() {
super(); super();
this.allowedLogLevels = const isProduction = process.env.NODE_ENV === 'production';
process.env.NODE_ENV === 'production' const isDebugMode = process.env.DEBUG_MODE === 'true';
? ['log', 'error', 'fatal']
: ['log', 'debug', 'verbose', 'warn', 'error', 'fatal']; if (isProduction && !isDebugMode) {
this.allowedLogLevels = ['log', 'error', 'fatal'];
} else {
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
}
} }
private isLogLevelAllowed(level: string): boolean { private isLogLevelAllowed(level: string): boolean {
@@ -43,7 +43,7 @@ export class CommentController {
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const page = await this.pageRepo.findById(createCommentDto.pageId); const page = await this.pageRepo.findById(createCommentDto.pageId);
if (!page) { if (!page || page.deletedAt) {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
@@ -90,7 +90,10 @@ export class CommentController {
throw new NotFoundException('Comment not found'); 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)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@@ -1,7 +1,7 @@
import { IsOptional, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
export class DeletedPageDto { export class DeletedPageDto {
@IsOptional() @IsNotEmpty()
@IsString() @IsString()
spaceId: string; spaceId: string;
} }
+1 -1
View File
@@ -194,7 +194,7 @@ export class PageController {
deletedPageDto.spaceId, deletedPageDto.spaceId,
); );
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@@ -109,7 +109,8 @@ export class PageService {
.selectFrom('pages') .selectFrom('pages')
.select(['position']) .select(['position'])
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.orderBy('position', 'desc') .where('deletedAt', 'is', null)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1); .limit(1);
if (parentPageId) { if (parentPageId) {
@@ -190,7 +191,7 @@ export class PageService {
'deletedAt', 'deletedAt',
]) ])
.select((eb) => this.pageRepo.withHasChildren(eb)) .select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', 'asc') .orderBy('position', (ob) => ob.collate('C').asc())
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId); .where('spaceId', '=', spaceId);
@@ -261,35 +262,7 @@ export class PageService {
if (isDuplicateInSameSpace) { if (isDuplicateInSameSpace) {
// For duplicate in same space, position right after the original page // For duplicate in same space, position right after the original page
let siblingQuery = this.db nextPosition = generateJitteredKeyBetween(rootPage.position, null);
.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);
}
} else { } else {
// For copy to different space, position at the end // For copy to different space, position at the end
nextPosition = await this.nextPagePosition(spaceId); nextPosition = await this.nextPagePosition(spaceId);
@@ -434,25 +407,35 @@ export class PageService {
attachment.id, attachment.id,
newAttachmentId, newAttachmentId,
); );
await this.storageService.copy(attachment.filePath, newPathFile);
await this.db try {
.insertInto('attachments') await this.storageService.copy(attachment.filePath, newPathFile);
.values({
id: newAttachmentId, await this.db
type: attachment.type, .insertInto('attachments')
filePath: newPathFile, .values({
fileName: attachment.fileName, id: newAttachmentId,
fileSize: attachment.fileSize, type: attachment.type,
mimeType: attachment.mimeType, filePath: newPathFile,
fileExt: attachment.fileExt, fileName: attachment.fileName,
creatorId: attachment.creatorId, fileSize: attachment.fileSize,
workspaceId: attachment.workspaceId, mimeType: attachment.mimeType,
pageId: newPageId, fileExt: attachment.fileExt,
spaceId: spaceId, creatorId: attachment.creatorId,
}) workspaceId: attachment.workspaceId,
.execute(); 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) { } 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 @Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
async cleanupOldTrash() { async cleanupOldTrash() {
try { try {
this.logger.log('Starting trash cleanup job'); this.logger.debug('Starting trash cleanup job');
const retentionDate = new Date(); const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS); retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
@@ -59,6 +59,7 @@ export class SearchService {
.$if(Boolean(searchParams.creatorId), (qb) => .$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId), qb.where('creatorId', '=', searchParams.creatorId),
) )
.where('deletedAt', 'is', null)
.orderBy('rank', 'desc') .orderBy('rank', 'desc')
.limit(searchParams.limit | 20) .limit(searchParams.limit | 20)
.offset(searchParams.offset || 0); .offset(searchParams.offset || 0);
@@ -191,6 +192,7 @@ export class SearchService {
sql`LOWER(f_unaccent(${`%${query}%`}))`, sql`LOWER(f_unaccent(${`%${query}%`}))`,
), ),
) )
.where('deletedAt', 'is', null)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.limit(limit); .limit(limit);
+6 -4
View File
@@ -108,12 +108,12 @@ export class ShareService {
includeCreator: true, includeCreator: true,
}); });
page.content = await this.updatePublicAttachments(page); if (!page || page.deletedAt) {
if (!page) {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
} }
page.content = await this.updatePublicAttachments(page);
return { page, share }; return { page, share };
} }
@@ -132,6 +132,7 @@ export class ShareService {
sql`0`.as('level'), sql`0`.as('level'),
]) ])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId) .where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((union) => .unionAll((union) =>
union union
.selectFrom('pages as p') .selectFrom('pages as p')
@@ -144,7 +145,8 @@ export class ShareService {
// Increase the level by 1 for each ancestor. // Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'), 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') .selectFrom('page_hierarchy')
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils'; import { dbOrTx, executeTx } from '../../utils';
import { import {
InsertablePage, InsertablePage,
Page, Page,
@@ -183,14 +183,20 @@ export class PageRepo {
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
await this.db if (pageIds.length > 0) {
.updateTable('pages') await executeTx(this.db, async (trx) => {
.set({ await trx
deletedById: deletedById, .updateTable('pages')
deletedAt: currentDate, .set({
}) deletedById: deletedById,
.where('id', 'in', pageIds) deletedAt: currentDate,
.execute(); })
.where('id', 'in', pageIds)
.execute();
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
});
}
} }
async restorePage(pageId: string): Promise<void> { async restorePage(pageId: string): Promise<void> {
@@ -213,4 +213,16 @@ export class EnvironmentService {
getPostHogKey(): string { getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY'); 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() @IsOptional()
@IsBoolean() @IsBoolean()
includeChildren?: boolean; includeChildren?: boolean;
@IsOptional()
@IsBoolean()
includeAttachments?: boolean;
} }
export class ExportSpaceDto { export class ExportSpaceDto {
@@ -55,40 +55,22 @@ export class ExportController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const fileExt = getExportExtension(dto.format); const zipFileBuffer = await this.exportService.exportPages(
const fileName = sanitize(page.title || 'untitled') + fileExt; dto.pageId,
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(
dto.format, dto.format,
page, dto.includeAttachments,
true, dto.includeChildren,
); );
const fileName = sanitize(page.title || 'untitled') + '.zip';
res.headers({ res.headers({
'Content-Type': getMimeType(fileExt), 'Content-Type': 'application/zip',
'Content-Disposition': 'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"', 'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(rawContent); res.send(zipFileBuffer);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -89,10 +89,28 @@ export class ExportService {
return; return;
} }
async exportPageWithChildren(pageId: string, format: string) { async exportPages(
const pages = await this.pageRepo.getPageAndDescendants(pageId, { pageId: string,
includeContent: true, 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) { if (!pages || pages.length === 0) {
throw new BadRequestException('No pages to export'); throw new BadRequestException('No pages to export');
@@ -105,7 +123,7 @@ export class ExportService {
const tree = buildTree(pages as Page[]); const tree = buildTree(pages as Page[]);
const zip = new JSZip(); const zip = new JSZip();
await this.zipPages(tree, format, zip); await this.zipPages(tree, format, zip, includeAttachments);
const zipFile = zip.generateNodeStream({ const zipFile = zip.generateNodeStream({
type: 'nodebuffer', type: 'nodebuffer',
@@ -168,7 +186,7 @@ export class ExportService {
tree: PageExportTree, tree: PageExportTree,
format: string, format: string,
zip: JSZip, zip: JSZip,
includeAttachments = true, includeAttachments: boolean,
): Promise<void> { ): Promise<void> {
const slugIdToPath: Record<string, string> = {}; const slugIdToPath: Record<string, string> = {};
@@ -200,7 +218,8 @@ export class ExportService {
if (includeAttachments) { if (includeAttachments) {
await this.zipAttachments(updatedJsonContent, page.spaceId, folder); await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
updatedJsonContent = updateAttachmentUrlsToLocalPaths(updatedJsonContent); updatedJsonContent =
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
} }
const pageTitle = getPageTitle(page.title); const pageTitle = getPageTitle(page.title);
@@ -69,8 +69,17 @@ function taskList(turndownService: TurndownService) {
'input[type="checkbox"]', 'input[type="checkbox"]',
) as HTMLInputElement; ) as HTMLInputElement;
const isChecked = checkbox.checked; 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') .selectFrom('pages')
.select(['id', 'position']) .select(['id', 'position'])
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.orderBy('position', 'desc') .orderBy('position', (ob) => ob.collate('C').desc())
.limit(1) .limit(1)
.where('parentPageId', 'is', null) .where('parentPageId', 'is', null)
.executeTakeFirst(); .executeTakeFirst();
@@ -40,8 +40,11 @@ export class LocalDriver implements StorageDriver {
async copy(fromFilePath: string, toFilePath: string): Promise<void> { async copy(fromFilePath: string, toFilePath: string): Promise<void> {
try { try {
const fromFullPath = this._fullPath(fromFilePath);
const toFullPath = this._fullPath(toFilePath);
if (await this.exists(fromFilePath)) { if (await this.exists(fromFilePath)) {
await fs.copy(fromFilePath, toFilePath); await fs.copy(fromFullPath, toFullPath);
} }
} catch (err) { } catch (err) {
throw new Error(`Failed to copy file: ${(err as Error).message}`); throw new Error(`Failed to copy file: ${(err as Error).message}`);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.22.0", "version": "0.22.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
+20
View File
@@ -543,6 +543,9 @@ importers:
nodemailer: nodemailer:
specifier: ^7.0.3 specifier: ^7.0.3
version: 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: openid-client:
specifier: ^5.7.1 specifier: ^5.7.1
version: 5.7.1 version: 5.7.1
@@ -7655,6 +7658,18 @@ packages:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'} 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: openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
@@ -18262,6 +18277,11 @@ snapshots:
is-docker: 2.2.1 is-docker: 2.2.1
is-wsl: 2.2.0 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: openid-client@5.7.1:
dependencies: dependencies:
jose: 4.15.9 jose: 4.15.9