Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192): - export/utils: correct the misleading getInternalLinkPageName comment — a bare `v1.2` loses its last dot-segment (`v1`); dots survive only in multi-segment names like `v1.2.md` -> `v1.2`. - share: extract toPublicSharePayload(page, share): PublicSharePayload, an explicit allowlist type+mapper replacing the inline literal in the /shares/page-info anonymous path (#218). Add share.controller.spec.ts that stubs getSharedPage returning internal fields and asserts the response key set EXACTLY equals the whitelist (page + share), so any `...shareData` regression or new leaking field fails. Also key-tests the extracted mapper. - breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId) (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode, dropping the as-any casts; else null) and unit-test all three branches. - share-modal: RTL test asserting enabling a share calls mutateAsync with includeSubPages: false (#216 security default). - share.service: one-line note at getSharedPage on the deferred consolidation of the ancestor-aware match into resolveReadableSharePage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
5.5 KiB
TypeScript
190 lines
5.5 KiB
TypeScript
import { jsonToNode } from 'src/collaboration/collaboration.util';
|
|
import { Logger } from '@nestjs/common';
|
|
import { ExportFormat } from './dto/export-dto';
|
|
import { Node } from '@tiptap/pm/model';
|
|
import { validate as isValidUUID } from 'uuid';
|
|
import * as path from 'path';
|
|
import { Page } from '@docmost/db/types/entity.types';
|
|
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
|
|
|
|
export type PageExportTree = Record<string, Page[]>;
|
|
|
|
export const INTERNAL_LINK_REGEX =
|
|
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
|
|
|
export function getExportExtension(format: string) {
|
|
if (format === ExportFormat.HTML) {
|
|
return '.html';
|
|
}
|
|
|
|
if (format === ExportFormat.Markdown) {
|
|
return '.md';
|
|
}
|
|
return;
|
|
}
|
|
|
|
export function getPageTitle(title: string) {
|
|
return title ? title : 'untitled';
|
|
}
|
|
|
|
export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) {
|
|
const doc = jsonToNode(prosemirrorJson);
|
|
if (!doc) return null;
|
|
|
|
// Helper function to replace specific URL prefixes
|
|
const replacePrefix = (url: string): string => {
|
|
const prefixes = ['/files', '/api/files'];
|
|
for (const prefix of prefixes) {
|
|
if (url.startsWith(prefix)) {
|
|
return url.replace(prefix, 'files');
|
|
}
|
|
}
|
|
return url;
|
|
};
|
|
|
|
doc?.descendants((node: Node) => {
|
|
if (isAttachmentNode(node.type.name)) {
|
|
if (node.attrs.src) {
|
|
// @ts-ignore
|
|
node.attrs.src = replacePrefix(node.attrs.src);
|
|
}
|
|
if (node.attrs.url) {
|
|
// @ts-ignore
|
|
node.attrs.url = replacePrefix(node.attrs.url);
|
|
}
|
|
}
|
|
});
|
|
|
|
return doc.toJSON();
|
|
}
|
|
|
|
export function replaceInternalLinks(
|
|
prosemirrorJson: any,
|
|
slugIdToPath: Record<string, string>,
|
|
currentPagePath: string,
|
|
baseUrl?: string,
|
|
) {
|
|
const doc = jsonToNode(prosemirrorJson);
|
|
|
|
doc.descendants((node: Node) => {
|
|
for (const mark of node.marks) {
|
|
if (mark.type.name === 'link' && mark.attrs.href) {
|
|
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
|
|
if (match) {
|
|
const markLink = mark.attrs.href;
|
|
|
|
const slugId = extractPageSlugId(match[5]);
|
|
const localPath = slugIdToPath[slugId];
|
|
|
|
if (!localPath) {
|
|
if (baseUrl && mark.attrs.href.startsWith('/')) {
|
|
//@ts-expect-error
|
|
mark.attrs.href = `${baseUrl}${mark.attrs.href}`;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const relativePath = computeRelativePath(currentPagePath, localPath);
|
|
|
|
//@ts-expect-error
|
|
mark.attrs.href = relativePath;
|
|
//@ts-expect-error
|
|
mark.attrs.target = '_self';
|
|
if (node.isText) {
|
|
// if link and text are same, use page title
|
|
if (markLink === node.text) {
|
|
//@ts-expect-error
|
|
node.text = getInternalLinkPageName(relativePath, currentPagePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return doc.toJSON();
|
|
}
|
|
|
|
export function getInternalLinkPageName(path: string, currentFilePath?: string): string {
|
|
// Strip a trailing file extension from the basename, but only when there IS
|
|
// one: an extensionless link target (e.g. "My Page") has no extension to drop,
|
|
// so `split('.').slice(0,-1)` would otherwise collapse it to an empty string,
|
|
// producing an internal link with no visible text (#204 export bug). The last
|
|
// dot-segment is always treated as an extension and dropped whenever there is
|
|
// more than one segment, so dots are preserved only in multi-segment names
|
|
// like `v1.2.md` -> `v1.2`; a bare `v1.2` becomes `v1`.
|
|
const base = path?.split('/').pop();
|
|
const parts = base?.split('.');
|
|
const name = parts && parts.length > 1 ? parts.slice(0, -1).join('.') : base;
|
|
try {
|
|
return decodeURIComponent(name);
|
|
} catch (err) {
|
|
if (currentFilePath) {
|
|
Logger.warn(
|
|
`URI malformed in page ${currentFilePath}: ${name}. Falling back to raw name.`,
|
|
'ExportUtils',
|
|
);
|
|
}
|
|
return name;
|
|
}
|
|
}
|
|
|
|
export function extractPageSlugId(input: string): string {
|
|
if (!input) {
|
|
return undefined;
|
|
}
|
|
const parts = input.split('-');
|
|
return parts.length > 1 ? parts[parts.length - 1] : input;
|
|
}
|
|
|
|
export function buildTree(pages: Page[]): PageExportTree {
|
|
const tree: PageExportTree = {};
|
|
const titleCount: Record<string, Record<string, number>> = {};
|
|
|
|
for (const page of pages) {
|
|
const parentPageId = page.parentPageId;
|
|
|
|
if (!titleCount[parentPageId]) {
|
|
titleCount[parentPageId] = {};
|
|
}
|
|
|
|
let title = getPageTitle(page.title);
|
|
|
|
if (titleCount[parentPageId][title]) {
|
|
title = `${title} (${titleCount[parentPageId][title]})`;
|
|
titleCount[parentPageId][getPageTitle(page.title)] += 1;
|
|
} else {
|
|
titleCount[parentPageId][title] = 1;
|
|
}
|
|
|
|
page.title = title;
|
|
if (!tree[parentPageId]) {
|
|
tree[parentPageId] = [];
|
|
}
|
|
tree[parentPageId].push(page);
|
|
}
|
|
return tree;
|
|
}
|
|
|
|
export function computeLocalPath(
|
|
tree: PageExportTree,
|
|
format: string,
|
|
parentPageId: string | null,
|
|
currentPath: string,
|
|
slugIdToPath: Record<string, string>,
|
|
) {
|
|
const children = tree[parentPageId] || [];
|
|
|
|
for (const page of children) {
|
|
const title = encodeURIComponent(getPageTitle(page.title));
|
|
const localPath = `${currentPath}${title}`;
|
|
slugIdToPath[page.slugId] = `${localPath}${getExportExtension(format)}`;
|
|
|
|
computeLocalPath(tree, format, page.id, `${localPath}/`, slugIdToPath);
|
|
}
|
|
}
|
|
|
|
function computeRelativePath(from: string, to: string) {
|
|
return path.relative(path.dirname(from), to);
|
|
}
|