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
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",
"private": true,
"version": "0.22.0",
"version": "0.22.2",
"scripts": {
"dev": "vite",
"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 { 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) {
+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 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),
@@ -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 />;
}
+2 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+6 -4
View File
@@ -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> {
@@ -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
View File
@@ -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",
+20
View File
@@ -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