Merge branch 'main' into ai-vector

This commit is contained in:
Philipinho
2025-09-21 20:47:58 +01:00
13 changed files with 309 additions and 143 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.23.1", "version": "0.23.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",

View File

@ -10,7 +10,7 @@ import {
TextInput, TextInput,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react"; import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
useCreateShareMutation, useCreateShareMutation,
@ -18,23 +18,27 @@ import {
useShareForPageQuery, useShareForPageQuery,
useUpdateShareMutation, useUpdateShareMutation,
} from "@/features/share/queries/share-query.ts"; } from "@/features/share/queries/share-query.ts";
import { Link, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib"; import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
} }
export default function ShareModal({ readOnly }: ShareModalProps) { export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug); const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial();
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation(); const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation(); const deleteShareMutation = useDeleteShareMutation();
@ -61,7 +65,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
createShareMutation.mutateAsync({ createShareMutation.mutateAsync({
pageId: pageId, pageId: pageId,
includeSubPages: true, includeSubPages: true,
searchIndexing: true, searchIndexing: false,
}); });
setIsPagePublic(value); setIsPagePublic(value);
} else { } else {
@ -92,26 +96,29 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
}); });
}; };
const shareLink = useMemo(() => ( const shareLink = useMemo(
<Group my="sm" gap={4} wrap="nowrap"> () => (
<TextInput <Group my="sm" gap={4} wrap="nowrap">
variant="filled" <TextInput
value={publicLink} variant="filled"
readOnly value={publicLink}
rightSection={<CopyTextButton text={publicLink} />} readOnly
style={{ width: "100%" }} rightSection={<CopyTextButton text={publicLink} />}
/> style={{ width: "100%" }}
<ActionIcon />
component="a" <ActionIcon
variant="default" component="a"
target="_blank" variant="default"
href={publicLink} target="_blank"
size="sm" href={publicLink}
> size="sm"
<IconExternalLink size={16} /> >
</ActionIcon> <IconExternalLink size={16} />
</Group> </ActionIcon>
), [publicLink]); </Group>
),
[publicLink],
);
return ( return (
<Popover width={350} position="bottom" withArrow shadow="md"> <Popover width={350} position="bottom" withArrow shadow="md">
@ -135,7 +142,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
</Button> </Button>
</Popover.Target> </Popover.Target>
<Popover.Dropdown style={{ userSelect: "none" }}> <Popover.Dropdown style={{ userSelect: "none" }}>
{isDescendantShared ? ( {isCloud() && isTrial ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center" mb="sm">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button
size="xs"
onClick={() => navigate("/settings/billing")}
fullWidth
>
{t("Upgrade Plan")}
</Button>
</>
) : isDescendantShared ? (
<> <>
<Text size="sm">{t("Inherits public sharing from")}</Text> <Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor <Anchor

View File

@ -152,13 +152,36 @@ export function useDeleteSpaceMutation() {
}); });
} }
const spaces = queryClient.getQueryData(["spaces"]) as any; // Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
exact: true,
});
// Invalidate recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
queryClient.invalidateQueries({
queryKey: ["recent-changes", variables.id],
});
}
// Update spaces list cache
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) { if (spaces) {
spaces.items = spaces.items?.filter( spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id, (space: ISpace) => space.id !== variables.id,
); );
queryClient.setQueryData(["spaces"], spaces); queryClient.setQueryData(["spaces"], spaces);
} }*/
// Invalidate all spaces queries to refresh lists
queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.23.1", "version": "0.23.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@ -47,15 +47,23 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
await this.handleFailedJob(job); await this.handleFailedJob(job);
} }
@OnWorkerEvent('stalled') @OnWorkerEvent('completed')
async onStalled(job: Job) { async onCompleted(job: Job) {
this.logger.error( this.logger.log(
`Job ${job.name} stalled. . Import Task ID: ${job.data.fileTaskId}.. Job ID: ${job.id}`, `Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
); );
// Set failedReason for stalled jobs since it's not automatically set try {
job.failedReason = 'Job stalled and was marked as failed'; const fileTask = await this.fileTaskService.getFileTask(
await this.handleFailedJob(job); job.data.fileTaskId,
);
if (fileTask) {
await this.storageService.delete(fileTask.filePath);
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
}
} catch (err) {
this.logger.error(`Failed to delete imported zip file:`, err);
}
} }
private async handleFailedJob(job: Job) { private async handleFailedJob(job: Job) {
@ -78,25 +86,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
} }
} }
@OnWorkerEvent('completed')
async onCompleted(job: Job) {
this.logger.log(
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
);
try {
const fileTask = await this.fileTaskService.getFileTask(
job.data.fileTaskId,
);
if (fileTask) {
await this.storageService.delete(fileTask.filePath);
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
}
} catch (err) {
this.logger.error(`Failed to delete imported zip file:`, err);
}
}
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
if (this.worker) { if (this.worker) {
await this.worker.close(); await this.worker.close();

View File

@ -24,6 +24,7 @@ import { formatImportHtml } from '../utils/import-formatter';
import { import {
buildAttachmentCandidates, buildAttachmentCandidates,
collectMarkdownAndHtmlFiles, collectMarkdownAndHtmlFiles,
stripNotionID,
} from '../utils/import.utils'; } from '../utils/import.utils';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
@ -159,17 +160,12 @@ export class FileImportTaskService {
.split(path.sep) .split(path.sep)
.join('/'); // normalize to forward-slashes .join('/'); // normalize to forward-slashes
const ext = path.extname(relPath).toLowerCase(); const ext = path.extname(relPath).toLowerCase();
let content = await fs.readFile(absPath, 'utf-8');
if (ext.toLowerCase() === '.md') {
content = await markdownToHtml(content);
}
pagesMap.set(relPath, { pagesMap.set(relPath, {
id: v7(), id: v7(),
slugId: generateSlugId(), slugId: generateSlugId(),
name: path.basename(relPath, ext), name: stripNotionID(path.basename(relPath, ext)),
content, content: '',
parentPageId: null, parentPageId: null,
fileExtension: ext, fileExtension: ext,
filePath: relPath, filePath: relPath,
@ -254,71 +250,160 @@ export class FileImportTaskService {
}); });
}); });
const pageResults = await Promise.all( // Group pages by level (topological sort for parent-child relationships)
Array.from(pagesMap.values()).map(async (page) => { const pagesByLevel = new Map<number, Array<[string, ImportPageNode]>>();
const htmlContent = const pageLevel = new Map<string, number>();
await this.importAttachmentService.processAttachments({
html: page.content,
pageRelativePath: page.filePath,
extractDir,
pageId: page.id,
fileTask,
attachmentCandidates,
});
const { html, backlinks, pageIcon } = await formatImportHtml({ // Calculate levels using BFS
html: htmlContent, const calculateLevels = () => {
currentFilePath: page.filePath, const queue: Array<{ filePath: string; level: number }> = [];
filePathToPageMetaMap: filePathToPageMetaMap,
creatorId: fileTask.creatorId,
sourcePageId: page.id,
workspaceId: fileTask.workspaceId,
});
const pmState = getProsemirrorContent( // Start with root pages (no parent)
await this.importService.processHTML(html), for (const [filePath, page] of pagesMap.entries()) {
if (!page.parentPageId) {
queue.push({ filePath, level: 0 });
pageLevel.set(filePath, 0);
}
}
// BFS to assign levels
while (queue.length > 0) {
const { filePath, level } = queue.shift()!;
const currentPage = pagesMap.get(filePath)!;
// Find children of current page
for (const [childFilePath, childPage] of pagesMap.entries()) {
if (
childPage.parentPageId === currentPage.id &&
!pageLevel.has(childFilePath)
) {
pageLevel.set(childFilePath, level + 1);
queue.push({ filePath: childFilePath, level: level + 1 });
}
}
}
// Group pages by level
for (const [filePath, page] of pagesMap.entries()) {
const level = pageLevel.get(filePath) || 0;
if (!pagesByLevel.has(level)) {
pagesByLevel.set(level, []);
}
pagesByLevel.get(level)!.push([filePath, page]);
}
};
calculateLevels();
if (pagesMap.size < 1) return;
// Process pages level by level sequentially to respect foreign key constraints
const allBacklinks: any[] = [];
const validPageIds = new Set<string>();
let totalPagesProcessed = 0;
// Sort levels to process in order
const sortedLevels = Array.from(pagesByLevel.keys()).sort((a, b) => a - b);
try {
await executeTx(this.db, async (trx) => {
// Process pages level by level sequentially within the transaction
for (const level of sortedLevels) {
const levelPages = pagesByLevel.get(level)!;
for (const [filePath, page] of levelPages) {
const absPath = path.join(extractDir, filePath);
let content = await fs.readFile(absPath, 'utf-8');
if (page.fileExtension.toLowerCase() === '.md') {
content = await markdownToHtml(content);
}
const htmlContent =
await this.importAttachmentService.processAttachments({
html: content,
pageRelativePath: page.filePath,
extractDir,
pageId: page.id,
fileTask,
attachmentCandidates,
});
const { html, backlinks, pageIcon } = await formatImportHtml({
html: htmlContent,
currentFilePath: page.filePath,
filePathToPageMetaMap: filePathToPageMetaMap,
creatorId: fileTask.creatorId,
sourcePageId: page.id,
workspaceId: fileTask.workspaceId,
});
const pmState = getProsemirrorContent(
await this.importService.processHTML(html),
);
const { title, prosemirrorJson } =
this.importService.extractTitleAndRemoveHeading(pmState);
const insertablePage: InsertablePage = {
id: page.id,
slugId: page.slugId,
title: title || page.name,
icon: pageIcon || null,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson),
position: page.position!,
spaceId: fileTask.spaceId,
workspaceId: fileTask.workspaceId,
creatorId: fileTask.creatorId,
lastUpdatedById: fileTask.creatorId,
parentPageId: page.parentPageId,
};
await trx.insertInto('pages').values(insertablePage).execute();
// Track valid page IDs and collect backlinks
validPageIds.add(insertablePage.id);
allBacklinks.push(...backlinks);
totalPagesProcessed++;
// Log progress periodically
if (totalPagesProcessed % 50 === 0) {
this.logger.debug(`Processed ${totalPagesProcessed} pages...`);
}
}
}
const filteredBacklinks = allBacklinks.filter(
({ sourcePageId, targetPageId }) =>
validPageIds.has(sourcePageId) && validPageIds.has(targetPageId),
); );
const { title, prosemirrorJson } = // Insert backlinks in batches
this.importService.extractTitleAndRemoveHeading(pmState); if (filteredBacklinks.length > 0) {
const BACKLINK_BATCH_SIZE = 100;
for (
let i = 0;
i < filteredBacklinks.length;
i += BACKLINK_BATCH_SIZE
) {
const backlinkChunk = filteredBacklinks.slice(
i,
Math.min(i + BACKLINK_BATCH_SIZE, filteredBacklinks.length),
);
await this.backlinkRepo.insertBacklink(backlinkChunk, trx);
}
}
const insertablePage: InsertablePage = { this.logger.log(
id: page.id, `Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
slugId: page.slugId, );
title: title || page.name, });
icon: pageIcon || null, } catch (error) {
content: prosemirrorJson, this.logger.error('Failed to import files:', error);
textContent: jsonToText(prosemirrorJson), throw new Error(`File import failed: ${error?.['message']}`);
ydoc: await this.importService.createYdoc(prosemirrorJson), }
position: page.position!,
spaceId: fileTask.spaceId,
workspaceId: fileTask.workspaceId,
creatorId: fileTask.creatorId,
lastUpdatedById: fileTask.creatorId,
parentPageId: page.parentPageId,
};
return { insertablePage, backlinks };
}),
);
const insertablePages = pageResults.map((r) => r.insertablePage);
const insertableBacklinks = pageResults.flatMap((r) => r.backlinks);
if (insertablePages.length < 1) return;
const validPageIds = new Set(insertablePages.map((row) => row.id));
const filteredBacklinks = insertableBacklinks.filter(
({ sourcePageId, targetPageId }) =>
validPageIds.has(sourcePageId) && validPageIds.has(targetPageId),
);
await executeTx(this.db, async (trx) => {
await trx.insertInto('pages').values(insertablePages).execute();
if (filteredBacklinks.length > 0) {
await this.backlinkRepo.insertBacklink(filteredBacklinks, trx);
}
});
} }
async getFileTask(fileTaskId: string) { async getFileTask(fileTaskId: string) {

View File

@ -35,7 +35,7 @@ interface DrawioPair {
@Injectable() @Injectable()
export class ImportAttachmentService { export class ImportAttachmentService {
private readonly logger = new Logger(ImportAttachmentService.name); private readonly logger = new Logger(ImportAttachmentService.name);
private readonly CONCURRENT_UPLOADS = 1; private readonly CONCURRENT_UPLOADS = 3;
private readonly MAX_RETRIES = 2; private readonly MAX_RETRIES = 2;
private readonly RETRY_DELAY = 2000; private readonly RETRY_DELAY = 2000;

View File

@ -222,17 +222,40 @@ export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
} }
export function unwrapFromParagraph($: CheerioAPI, $node: Cheerio<any>) { export function unwrapFromParagraph($: CheerioAPI, $node: Cheerio<any>) {
// find the nearest <p> or <a> ancestor // Keep track of processed wrappers to avoid infinite loops
let $wrapper = $node.closest('p, a'); const processedWrappers = new Set<any>();
let $wrapper = $node.closest('p, a');
while ($wrapper.length) { while ($wrapper.length) {
// if the wrapper has only our node inside, replace it entirely const wrapperElement = $wrapper.get(0);
if ($wrapper.contents().length === 1) {
// If we've already processed this wrapper, break to avoid infinite loop
if (processedWrappers.has(wrapperElement)) {
break;
}
processedWrappers.add(wrapperElement);
// Check if the wrapper contains only whitespace and our target node
const hasOnlyTargetNode =
$wrapper.contents().filter((_, el) => {
const $el = $(el);
// Skip whitespace-only text nodes. NodeType 3 = text node
if (el.nodeType === 3 && !$el.text().trim()) {
return false;
}
// Return true if this is not our target node
return !$el.is($node) && !$node.is($el);
}).length === 0;
if (hasOnlyTargetNode) {
// Replace the wrapper entirely with our node
$wrapper.replaceWith($node); $wrapper.replaceWith($node);
} else { } else {
// otherwise just move the node to before the wrapper // Move the node to before the wrapper, preserving other content
$wrapper.before($node); $wrapper.before($node);
} }
// look again for any new wrapper around $node // look again for any new wrapper around $node
$wrapper = $node.closest('p, a'); $wrapper = $node.closest('p, a');
} }

View File

@ -64,3 +64,9 @@ export async function collectMarkdownAndHtmlFiles(
await walk(dir); await walk(dir);
return results; return results;
} }
export function stripNotionID(fileName: string): string {
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
return fileName.replace(notionIdPattern, '').trim();
}

View File

@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.23.1", "version": "0.23.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",

View File

@ -2,33 +2,39 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
export const TableCell = TiptapTableCell.extend({ export const TableCell = TiptapTableCell.extend({
name: "tableCell", name: "tableCell",
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+", content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),
backgroundColor: { backgroundColor: {
default: null, default: null,
parseHTML: (element) => element.style.backgroundColor || null, parseHTML: (element) =>
element.style.backgroundColor ||
element.getAttribute("data-background-color") ||
null,
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.backgroundColor) { if (!attributes.backgroundColor) {
return {}; return {};
} }
return { return {
style: `background-color: ${attributes.backgroundColor}`, style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor, "data-background-color": attributes.backgroundColor,
}; };
}, },
}, },
backgroundColorName: { backgroundColorName: {
default: null, default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null, parseHTML: (element) =>
element.getAttribute("data-background-color-name") || null,
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.backgroundColorName) { if (!attributes.backgroundColorName) {
return {}; return {};
} }
return { return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(), "data-background-color-name":
attributes.backgroundColorName.toLowerCase(),
}; };
}, },
}, },

View File

@ -2,36 +2,42 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header
export const TableHeader = TiptapTableHeader.extend({ export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader", name: "tableHeader",
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+", content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),
backgroundColor: { backgroundColor: {
default: null, default: null,
parseHTML: (element) => element.style.backgroundColor || null, parseHTML: (element) =>
element.style.backgroundColor ||
element.getAttribute("data-background-color") ||
null,
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.backgroundColor) { if (!attributes.backgroundColor) {
return {}; return {};
} }
return { return {
style: `background-color: ${attributes.backgroundColor}`, style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor, "data-background-color": attributes.backgroundColor,
}; };
}, },
}, },
backgroundColorName: { backgroundColorName: {
default: null, default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null, parseHTML: (element) =>
element.getAttribute("data-background-color-name") || null,
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.backgroundColorName) { if (!attributes.backgroundColorName) {
return {}; return {};
} }
return { return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(), "data-background-color-name":
attributes.backgroundColorName.toLowerCase(),
}; };
}, },
}, },
}; };
}, },
}); });