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
This commit is contained in:
Philipinho
2026-01-27 13:36:19 +00:00
parent 0245a183e1
commit 666e233225
6 changed files with 115 additions and 4 deletions
@@ -0,0 +1,14 @@
export type ExportPageMetadata = {
pageId: string;
slugId: string;
icon: string | null;
position: string;
parentPath: string | null;
};
export type ExportMetadata = {
version: number;
exportedAt: string;
source: 'docmost';
pages: Record<string, ExportPageMetadata>;
};
@@ -14,6 +14,8 @@ import { StorageService } from '../storage/storage.service';
import {
buildTree,
computeLocalPath,
ExportMetadata,
ExportPageMetadata,
getExportExtension,
getPageTitle,
PageExportTree,
@@ -155,12 +157,15 @@ export class ExportService {
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.content',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
])
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.execute();
const tree = buildTree(pages as Page[]);
@@ -189,10 +194,12 @@ export class ExportService {
includeAttachments: boolean,
): Promise<void> {
const slugIdToPath: Record<string, string> = {};
const pageIdToFilePath: Record<string, string> = {};
const pagesMetadata: Record<string, ExportPageMetadata> = {};
computeLocalPath(tree, format, null, '', slugIdToPath);
const stack: { folder: JSZip; parentPageId: string }[] = [
const stack: { folder: JSZip; parentPageId: string | null }[] = [
{ folder: zip, parentPageId: null },
];
@@ -232,12 +239,33 @@ export class ExportService {
`${pageTitle}${getExportExtension(format)}`,
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) {
const pageFolder = folder.folder(pageTitle);
stack.push({ folder: pageFolder, parentPageId: page.id });
}
}
}
const metadata: ExportMetadata = {
version: 1,
exportedAt: new Date().toISOString(),
source: 'docmost',
pages: pagesMetadata,
};
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
}
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
@@ -7,6 +7,8 @@ import * as path from 'path';
import { Page } from '@docmost/db/types/entity.types';
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
export type { ExportMetadata, ExportPageMetadata } from '../../common/helpers/types/export-metadata.types';
export type PageExportTree = Record<string, Page[]>;
export const INTERNAL_LINK_REGEX =
@@ -15,4 +15,5 @@ export type ImportPageNode = {
parentPageId: string | null;
fileExtension: string;
filePath: string;
icon?: string | null;
};
@@ -24,6 +24,9 @@ import { formatImportHtml } from '../utils/import-formatter';
import {
buildAttachmentCandidates,
collectMarkdownAndHtmlFiles,
DocmostExportMetadata,
encodeFilePath,
readDocmostMetadata,
stripNotionID,
} from '../utils/import.utils';
import { executeTx } from '@docmost/db/utils';
@@ -154,6 +157,7 @@ export class FileImportTaskService {
const { extractDir, fileTask } = opts;
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
const docmostMetadata = await readDocmostMetadata(extractDir);
const pagesMap = new Map<string, ImportPageNode>();
@@ -164,6 +168,9 @@ export class FileImportTaskService {
.join('/'); // normalize to forward-slashes
const ext = path.extname(relPath).toLowerCase();
const encodedPath = encodeFilePath(relPath);
const pageMetadata = docmostMetadata?.pages[encodedPath];
pagesMap.set(relPath, {
id: v7(),
slugId: generateSlugId(),
@@ -172,6 +179,7 @@ export class FileImportTaskService {
parentPageId: null,
fileExtension: ext,
filePath: relPath,
icon: pageMetadata?.icon ?? null,
});
}
@@ -224,6 +232,8 @@ export class FileImportTaskService {
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath);
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
@@ -232,6 +242,7 @@ export class FileImportTaskService {
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
});
}
});
@@ -266,11 +277,39 @@ export class FileImportTaskService {
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
const rootSibs = siblingsMap.get(null);
if (rootSibs?.length) {
rootSibs.sort((a, b) => a.name.localeCompare(b.name));
sortSiblings(rootSibs);
// get first position key from the server
const nextPosition = await this.pageService.nextPagePosition(
@@ -292,7 +331,7 @@ export class FileImportTaskService {
siblingsMap.forEach((sibs, parentId) => {
if (parentId === null) return; // root already done
sibs.sort((a, b) => a.name.localeCompare(b.name));
sortSiblings(sibs);
let prevPos: string | null = null;
for (const page of sibs) {
@@ -426,7 +465,7 @@ export class FileImportTaskService {
id: page.id,
slugId: page.slugId,
title: title || page.name,
icon: pageIcon || null,
icon: page.icon || pageIcon || null,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson),
@@ -76,3 +76,30 @@ export function stripNotionID(fileName: string): string {
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
return fileName.replace(notionIdPattern, '').trim();
}
export function encodeFilePath(filePath: string): string {
return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/');
}
export type {
ExportMetadata as DocmostExportMetadata,
ExportPageMetadata as DocmostExportPageMetadata,
} from '../../../common/helpers/types/export-metadata.types';
import type { ExportMetadata } from '../../../common/helpers/types/export-metadata.types';
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.version === 1 && metadata.pages) {
return metadata;
}
return null;
} catch {
return null;
}
}