Files
gitmost/apps/server/src/integrations/export/utils.ts
a c9d252cf2a fix(review): address PR #230 review — payload type, breadcrumb helper, tests
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>
2026-06-27 20:09:48 +03:00

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);
}