mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
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:
@@ -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 {
|
import {
|
||||||
buildTree,
|
buildTree,
|
||||||
computeLocalPath,
|
computeLocalPath,
|
||||||
|
ExportMetadata,
|
||||||
|
ExportPageMetadata,
|
||||||
getExportExtension,
|
getExportExtension,
|
||||||
getPageTitle,
|
getPageTitle,
|
||||||
PageExportTree,
|
PageExportTree,
|
||||||
@@ -155,12 +157,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 +194,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 +239,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 = {
|
||||||
|
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) {
|
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 { Page } from '@docmost/db/types/entity.types';
|
||||||
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
|
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 type PageExportTree = Record<string, Page[]>;
|
||||||
|
|
||||||
export const INTERNAL_LINK_REGEX =
|
export const INTERNAL_LINK_REGEX =
|
||||||
|
|||||||
@@ -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,9 @@ import { formatImportHtml } from '../utils/import-formatter';
|
|||||||
import {
|
import {
|
||||||
buildAttachmentCandidates,
|
buildAttachmentCandidates,
|
||||||
collectMarkdownAndHtmlFiles,
|
collectMarkdownAndHtmlFiles,
|
||||||
|
DocmostExportMetadata,
|
||||||
|
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 +157,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 +168,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 +179,7 @@ export class FileImportTaskService {
|
|||||||
parentPageId: null,
|
parentPageId: null,
|
||||||
fileExtension: ext,
|
fileExtension: ext,
|
||||||
filePath: relPath,
|
filePath: relPath,
|
||||||
|
icon: pageMetadata?.icon ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +232,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 +242,7 @@ export class FileImportTaskService {
|
|||||||
parentPageId: null,
|
parentPageId: null,
|
||||||
fileExtension: '.md',
|
fileExtension: '.md',
|
||||||
filePath: mdPath,
|
filePath: mdPath,
|
||||||
|
icon: placeholderMetadata?.icon ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -266,11 +277,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 +331,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 +465,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),
|
||||||
|
|||||||
@@ -76,3 +76,30 @@ 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user