Files
outline/server/queues/tasks/ImportJSONTask.ts
T
Tom Moor adbffc0734 chore: clear mechanical lint warnings (Phase 1) (#12198)
* chore: clear mechanical lint warnings

Drops 44 oxlint warnings (559 → 515) by fixing easy mechanical rules
across the codebase: no-useless-escape, no-duplicate-type-constituents,
no-redundant-type-constituents, no-unused-expressions,
no-meaningless-void-operator, require-array-sort-compare, await-thenable.

* chore: drop callback parameter from useCallback deps

The `open` argument is a parameter of the callback, not a closed-over
variable, so it doesn't belong in the deps array.

* chore: promote cleared lint rules to errors

Promotes the rules cleared in this PR from warn to error so future
violations fail the lint:

- no-unused-expressions
- typescript/await-thenable
- typescript/no-duplicate-type-constituents
- typescript/no-meaningless-void-operator
- typescript/require-array-sort-compare

Removes the override that suppressed no-useless-escape on source
files (the global rule is already error) and fixes the 21 escape
violations that this exposed in regex character classes and template
literals.

* chore: address PR review feedback

- usePinnedDocuments: simplify UrlId to plain string instead of the
  intersection trick.
- PlantUML embed: move - to end of character class so it's a literal
  hyphen rather than a range operator.
- checkboxes: type token params as Token | undefined to match the
  actual call sites that pass tokens[index - 2] etc.
2026-04-28 20:00:03 -04:00

247 lines
7.6 KiB
TypeScript

import path from "node:path";
import fs from "fs-extra";
import find from "lodash/find";
import mime from "mime-types";
import { Fragment, Node } from "prosemirror-model";
import { randomUUID } from "node:crypto";
import type { ProsemirrorData } from "@shared/types";
import { schema, serializer } from "@server/editor";
import Logger from "@server/logging/Logger";
import type { FileOperation } from "@server/models";
import { Attachment } from "@server/models";
import type {
AttachmentJSONExport,
CollectionJSONExport,
DocumentJSONExport,
JSONExportMetadata,
} from "@server/types";
import type { FileTreeNode } from "@server/utils/ImportHelper";
import ImportHelper from "@server/utils/ImportHelper";
import type { StructuredImportData } from "./ImportTask";
import ImportTask from "./ImportTask";
export default class ImportJSONTask extends ImportTask {
public async parseData(
dirPath: string,
_: FileOperation
): Promise<StructuredImportData> {
const tree = await ImportHelper.toFileTree(dirPath);
if (!tree) {
throw new Error("Could not find valid content in zip file");
}
return this.parseFileTree(tree.children);
}
/**
* Converts the file structure from zipAsFileTree into documents,
* collections, and attachments.
*
* @param tree An array of FileTreeNode representing root files in the zip
* @returns A StructuredImportData object
*/
private async parseFileTree(
tree: FileTreeNode[]
): Promise<StructuredImportData> {
let rootPath = "";
const output: StructuredImportData = {
collections: [],
documents: [],
attachments: [],
};
// Load metadata
let metadata: JSONExportMetadata | undefined = undefined;
for (const node of tree) {
if (!rootPath) {
rootPath = path.dirname(node.path);
}
if (node.path === "metadata.json") {
try {
metadata = JSON.parse(await fs.readFile(node.path, "utf8"));
} catch (err) {
throw new Error(`Could not parse metadata.json. ${err.message}`);
}
}
}
if (!rootPath) {
throw new Error("Could not find root path");
}
Logger.debug("task", "Importing JSON metadata", { metadata });
function mapDocuments(
documents: { [id: string]: DocumentJSONExport },
collectionId: string
) {
Object.values(documents).forEach((node) => {
const id = randomUUID();
output.documents.push({
...node,
path: "",
text: "",
data: node.data,
icon: node.icon ?? node.emoji,
color: node.color,
createdAt: node.createdAt ? new Date(node.createdAt) : undefined,
updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined,
publishedAt: node.publishedAt ? new Date(node.publishedAt) : null,
collectionId,
externalId: node.id,
mimeType: "application/json",
parentDocumentId: node.parentDocumentId
? find(
output.documents,
(d) => d.externalId === node.parentDocumentId
)?.id
: null,
id,
});
});
}
function mapAttachments(attachments: {
[id: string]: AttachmentJSONExport;
}) {
Object.values(attachments).forEach((node) => {
const id = randomUUID();
const mimeType = mime.lookup(node.key) || "application/octet-stream";
const filePath = path.join(rootPath, node.key);
// Block path traversal attempts
if (node.key.includes("..")) {
throw new Error(`Invalid attachment path: ${node.key}`);
}
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(path.resolve(rootPath) + path.sep)) {
throw new Error(`Invalid attachment path: ${node.key}`);
}
output.attachments.push({
id,
name: node.name,
buffer: () => fs.readFile(filePath),
mimeType,
path: node.key,
externalId: node.id,
});
});
}
// All nodes in the root level should be collections as JSON + metadata
for (const node of tree) {
if (node.children.length > 0 || node.path.endsWith("metadata.json")) {
continue;
}
let item: CollectionJSONExport;
try {
item = JSON.parse(await fs.readFile(node.path, "utf8"));
} catch (err) {
throw new Error(`Could not parse ${node.path}. ${err.message}`);
}
const collectionId = randomUUID();
output.collections.push({
...item.collection,
id: collectionId,
externalId: item.collection.id,
});
if (Object.values(item.documents).length) {
mapDocuments(item.documents, collectionId);
}
if (Object.values(item.attachments).length) {
mapAttachments(item.attachments);
}
}
// Check all of the attachments we've created against urls and
// replace them with the correct redirect urls before continuing.
if (output.attachments.length) {
this.replaceAttachmentURLs(output);
}
return output;
}
private replaceAttachmentURLs(output: StructuredImportData) {
const attachmentTypes = ["attachment", "image", "video"];
const urlRegex = /\/api\/attachments.redirect\?id=(.+)/;
const attachmentExternalIdMap = output.attachments.reduce(
(obj, attachment) => {
if (attachment.externalId) {
obj[attachment.externalId] = attachment;
}
return obj;
},
{} as Record<string, StructuredImportData["attachments"][number]>
);
const getRedirectPath = (existingPath?: string): string | undefined => {
if (!existingPath) {
return;
}
const match = existingPath.match(urlRegex);
if (!match) {
return existingPath;
}
const attachment = attachmentExternalIdMap[match[1]];
// maintain the existing behaviour of using existingPath when attachment id is not present.
return attachment
? Attachment.getRedirectUrl(attachment.id)
: existingPath;
};
const transformAttachmentNode = (node: Node): Node => {
const json = node.toJSON() as ProsemirrorData;
const attrs = json.attrs ?? {};
if (node.type.name === "attachment") {
// attachment node uses 'href' attribute
attrs.href = getRedirectPath(attrs.href as string);
} else if (node.type.name === "image" || node.type.name === "video") {
// image & video nodes use 'src' attribute
attrs.src = getRedirectPath(attrs.src as string);
}
json.attrs = attrs;
return Node.fromJSON(schema, json);
};
const transformFragment = (fragment: Fragment): Fragment => {
const nodes: Node[] = [];
fragment.forEach((node) => {
nodes.push(
attachmentTypes.includes(node.type.name)
? transformAttachmentNode(node)
: node.copy(transformFragment(node.content))
);
});
return Fragment.fromArray(nodes);
};
for (const collection of output.collections) {
const node = Node.fromJSON(schema, collection.data);
const transformedNode = node.copy(transformFragment(node.content));
collection.description = serializer.serialize(transformedNode);
collection.data = transformedNode.toJSON();
}
for (const document of output.documents) {
const node = Node.fromJSON(schema, document.data);
const transformedNode = node.copy(transformFragment(node.content));
document.data = transformedNode.toJSON();
document.text = serializer.serialize(transformedNode);
}
}
}