feat(export): add metadata file to preserve page icons and ordering on import (#1877)

* feat(export): add metadata file to preserve page icons and ordering on import
- Export includes `docmost-metadata.json`
- Import reads metadata to restore icons and sort siblings by original position

* cleanup

* bonus fixes

* handle unknown prosemirror nodes

* add docmost app  version
This commit is contained in:
Philip Okugbe
2026-01-27 16:39:39 +00:00
committed by GitHub
parent 0245a183e1
commit 6ccb2bb872
8 changed files with 178 additions and 9 deletions
@@ -172,6 +172,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
queryKey: ["root-sidebar-pages", fileTask.spaceId], queryKey: ["root-sidebar-pages", fileTask.spaceId],
}); });
await queryClient.invalidateQueries({
queryKey: ["recent-changes", fileTask.spaceId],
});
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "refetchRootTreeNodeEvent", operation: "refetchRootTreeNodeEvent",
@@ -6,6 +6,7 @@ import { ISpace } from "../types/space.types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route"; import APP_ROUTE from "@/lib/app-route";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useState } from "react";
interface DeleteSpaceModalProps { interface DeleteSpaceModalProps {
space: ISpace; space: ISpace;
@@ -14,6 +15,7 @@ interface DeleteSpaceModalProps {
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) { export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteSpaceMutation = useDeleteSpaceMutation(); const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,12 +37,15 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
return; return;
} }
setIsDeleting(true);
try { try {
// pass slug too so we can clear the local cache // pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug }); await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME); navigate(APP_ROUTE.HOME);
} catch (error) { } catch (error) {
console.error("Failed to delete space", error); console.error("Failed to delete space", error);
} finally {
setIsDeleting(false);
} }
}; };
@@ -79,7 +84,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
<Button onClick={close} variant="default"> <Button onClick={close} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleDelete} color="red"> <Button onClick={handleDelete} color="red" loading={isDeleting}>
{t("Confirm")} {t("Confirm")}
</Button> </Button>
</Group> </Group>
@@ -41,7 +41,8 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html'; //import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model'; import { Node, Schema } from '@tiptap/pm/model';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@@ -110,9 +111,53 @@ export function jsonToText(tiptapJson: JSONContent) {
} }
export function jsonToNode(tiptapJson: JSONContent) { export function jsonToNode(tiptapJson: JSONContent) {
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson); const schema = getSchema(tiptapExtensions);
try {
return Node.fromJSON(schema, tiptapJson);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes('Unknown node type')
) {
Logger.warn('Stripping unknown node types from document:', error.message);
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
return Node.fromJSON(schema, cleanedJson);
}
throw error;
}
} }
export function getPageId(documentName: string) { export function getPageId(documentName: string) {
return documentName.split('.')[1]; return documentName.split('.')[1];
} }
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
): JSONContent | null {
if (!json || typeof json !== 'object') return json;
// Recursively clean children first, flattening any unwrapped content
if (json.content && Array.isArray(json.content)) {
const newContent: JSONContent[] = [];
for (const child of json.content) {
const cleaned = stripUnknownNodes(child, schema);
if (Array.isArray(cleaned)) {
newContent.push(...cleaned);
} else if (cleaned) {
newContent.push(cleaned);
}
}
json.content = newContent;
}
// Check if this node is unknown AFTER processing children
if (json.type && !schema.nodes[json.type]) {
// Unwrap: return cleaned children directly instead of wrapping
return (
json.content && json.content.length > 0 ? json.content : null
) as any;
}
return json;
}
@@ -0,0 +1,14 @@
export type ExportPageMetadata = {
pageId: string;
slugId: string;
icon: string | null;
position: string;
parentPath: string | null;
};
export type ExportMetadata = {
exportedAt: string;
source: 'docmost';
version: string;
pages: Record<string, ExportPageMetadata>;
};
@@ -20,11 +20,17 @@ import {
replaceInternalLinks, replaceInternalLinks,
updateAttachmentUrlsToLocalPaths, updateAttachmentUrlsToLocalPaths,
} from './utils'; } from './utils';
import {
ExportMetadata,
ExportPageMetadata,
} from '../../common/helpers/types/export-metadata.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state'; import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify'); import slugify = require('@sindresorhus/slugify');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const packageJson = require('../../../package.json');
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
import { import {
getAttachmentIds, getAttachmentIds,
@@ -155,12 +161,15 @@ export class ExportService {
'pages.id', 'pages.id',
'pages.slugId', 'pages.slugId',
'pages.title', 'pages.title',
'pages.icon',
'pages.position',
'pages.content', 'pages.content',
'pages.parentPageId', 'pages.parentPageId',
'pages.spaceId', 'pages.spaceId',
'pages.workspaceId', 'pages.workspaceId',
]) ])
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.execute(); .execute();
const tree = buildTree(pages as Page[]); const tree = buildTree(pages as Page[]);
@@ -189,10 +198,12 @@ export class ExportService {
includeAttachments: boolean, includeAttachments: boolean,
): Promise<void> { ): Promise<void> {
const slugIdToPath: Record<string, string> = {}; const slugIdToPath: Record<string, string> = {};
const pageIdToFilePath: Record<string, string> = {};
const pagesMetadata: Record<string, ExportPageMetadata> = {};
computeLocalPath(tree, format, null, '', slugIdToPath); computeLocalPath(tree, format, null, '', slugIdToPath);
const stack: { folder: JSZip; parentPageId: string }[] = [ const stack: { folder: JSZip; parentPageId: string | null }[] = [
{ folder: zip, parentPageId: null }, { folder: zip, parentPageId: null },
]; ];
@@ -232,12 +243,33 @@ export class ExportService {
`${pageTitle}${getExportExtension(format)}`, `${pageTitle}${getExportExtension(format)}`,
pageExportContent, pageExportContent,
); );
pageIdToFilePath[page.id] = currentPagePath;
const parentPath = parentPageId ? pageIdToFilePath[parentPageId] : null;
pagesMetadata[currentPagePath] = {
pageId: page.id,
slugId: page.slugId,
icon: page.icon ?? null,
position: page.position,
parentPath,
};
if (childPages.length > 0) { if (childPages.length > 0) {
const pageFolder = folder.folder(pageTitle); const pageFolder = folder.folder(pageTitle);
stack.push({ folder: pageFolder, parentPageId: page.id }); stack.push({ folder: pageFolder, parentPageId: page.id });
} }
} }
} }
const metadata: ExportMetadata = {
exportedAt: new Date().toISOString(),
source: 'docmost',
version: packageJson.version,
pages: pagesMetadata,
};
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
} }
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) { async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
@@ -15,4 +15,5 @@ export type ImportPageNode = {
parentPageId: string | null; parentPageId: string | null;
fileExtension: string; fileExtension: string;
filePath: string; filePath: string;
icon?: string | null;
}; };
@@ -24,6 +24,8 @@ import { formatImportHtml } from '../utils/import-formatter';
import { import {
buildAttachmentCandidates, buildAttachmentCandidates,
collectMarkdownAndHtmlFiles, collectMarkdownAndHtmlFiles,
encodeFilePath,
readDocmostMetadata,
stripNotionID, stripNotionID,
} from '../utils/import.utils'; } from '../utils/import.utils';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
@@ -154,6 +156,7 @@ export class FileImportTaskService {
const { extractDir, fileTask } = opts; const { extractDir, fileTask } = opts;
const allFiles = await collectMarkdownAndHtmlFiles(extractDir); const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
const attachmentCandidates = await buildAttachmentCandidates(extractDir); const attachmentCandidates = await buildAttachmentCandidates(extractDir);
const docmostMetadata = await readDocmostMetadata(extractDir);
const pagesMap = new Map<string, ImportPageNode>(); const pagesMap = new Map<string, ImportPageNode>();
@@ -164,6 +167,9 @@ export class FileImportTaskService {
.join('/'); // normalize to forward-slashes .join('/'); // normalize to forward-slashes
const ext = path.extname(relPath).toLowerCase(); const ext = path.extname(relPath).toLowerCase();
const encodedPath = encodeFilePath(relPath);
const pageMetadata = docmostMetadata?.pages[encodedPath];
pagesMap.set(relPath, { pagesMap.set(relPath, {
id: v7(), id: v7(),
slugId: generateSlugId(), slugId: generateSlugId(),
@@ -172,6 +178,7 @@ export class FileImportTaskService {
parentPageId: null, parentPageId: null,
fileExtension: ext, fileExtension: ext,
filePath: relPath, filePath: relPath,
icon: pageMetadata?.icon ?? null,
}); });
} }
@@ -224,6 +231,8 @@ export class FileImportTaskService {
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) { if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath); const folderName = path.basename(folderPath);
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, { pagesMap.set(mdPath, {
id: v7(), id: v7(),
slugId: generateSlugId(), slugId: generateSlugId(),
@@ -232,6 +241,7 @@ export class FileImportTaskService {
parentPageId: null, parentPageId: null,
fileExtension: '.md', fileExtension: '.md',
filePath: mdPath, filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
}); });
} }
}); });
@@ -266,11 +276,39 @@ export class FileImportTaskService {
siblingsMap.set(page.parentPageId, group); siblingsMap.set(page.parentPageId, group);
}); });
const encodedPathsMap = new Map<string, string>();
if (docmostMetadata) {
pagesMap.forEach((_, filePath) => {
encodedPathsMap.set(filePath, encodeFilePath(filePath));
});
}
// Sort siblings by metadata position if available, otherwise alphabetically
const sortSiblings = (siblings: ImportPageNode[]) => {
if (docmostMetadata) {
siblings.sort((a, b) => {
const posA =
docmostMetadata.pages[encodedPathsMap.get(a.filePath)]?.position;
const posB =
docmostMetadata.pages[encodedPathsMap.get(b.filePath)]?.position;
if (posA && posB) {
// Use direct comparison to match PostgreSQL collation 'C' (byte order)
if (posA < posB) return -1;
if (posA > posB) return 1;
return 0;
}
return a.name.localeCompare(b.name);
});
} else {
siblings.sort((a, b) => a.name.localeCompare(b.name));
}
};
// get root pages // get root pages
const rootSibs = siblingsMap.get(null); const rootSibs = siblingsMap.get(null);
if (rootSibs?.length) { if (rootSibs?.length) {
rootSibs.sort((a, b) => a.name.localeCompare(b.name)); sortSiblings(rootSibs);
// get first position key from the server // get first position key from the server
const nextPosition = await this.pageService.nextPagePosition( const nextPosition = await this.pageService.nextPagePosition(
@@ -292,7 +330,7 @@ export class FileImportTaskService {
siblingsMap.forEach((sibs, parentId) => { siblingsMap.forEach((sibs, parentId) => {
if (parentId === null) return; // root already done if (parentId === null) return; // root already done
sibs.sort((a, b) => a.name.localeCompare(b.name)); sortSiblings(sibs);
let prevPos: string | null = null; let prevPos: string | null = null;
for (const page of sibs) { for (const page of sibs) {
@@ -426,7 +464,7 @@ export class FileImportTaskService {
id: page.id, id: page.id,
slugId: page.slugId, slugId: page.slugId,
title: title || page.name, title: title || page.name,
icon: pageIcon || null, icon: page.icon || pageIcon || null,
content: prosemirrorJson, content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson), textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson), ydoc: await this.importService.createYdoc(prosemirrorJson),
@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ExportMetadata } from '../../../common/helpers/types/export-metadata.types';
export async function buildAttachmentCandidates( export async function buildAttachmentCandidates(
extractDir: string, extractDir: string,
@@ -35,9 +36,15 @@ export function resolveRelativeAttachmentPath(
try { try {
mainRel = decodeURIComponent(mainRel); mainRel = decodeURIComponent(mainRel);
} catch (err) { } catch (err) {
Logger.warn(`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`, 'ImportUtils'); Logger.warn(
`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`,
'ImportUtils',
);
} }
const fallback = path.normalize(path.join(pageDir, mainRel)).split(path.sep).join('/'); const fallback = path
.normalize(path.join(pageDir, mainRel))
.split(path.sep)
.join('/');
if (attachmentCandidates.has(mainRel)) { if (attachmentCandidates.has(mainRel)) {
return mainRel; return mainRel;
@@ -76,3 +83,26 @@ export function stripNotionID(fileName: string): string {
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i; const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
return fileName.replace(notionIdPattern, '').trim(); return fileName.replace(notionIdPattern, '').trim();
} }
export function encodeFilePath(filePath: string): string {
return filePath
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
export async function readDocmostMetadata(
extractDir: string,
): Promise<ExportMetadata | null> {
const metadataPath = path.join(extractDir, 'docmost-metadata.json');
try {
const content = await fs.readFile(metadataPath, 'utf-8');
const metadata = JSON.parse(content) as ExportMetadata;
if (metadata.source === 'docmost' && metadata.pages) {
return metadata;
}
return null;
} catch {
return null;
}
}