Files

177 lines
5.2 KiB
TypeScript

import { Op } from "sequelize";
import { Hour } from "@shared/utils/time";
import { traceFunction } from "@server/logging/tracing";
import type { Document } from "@server/models";
import FileOperation from "@server/models/FileOperation";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import type { APIContext } from "@server/types";
import presentUser from "./user";
type Options = {
/** Whether to render the document's public fields. */
isPublic?: boolean;
/** The root share ID when presenting a shared document. */
shareId?: string;
/** Always include the text of the document in the payload. */
includeText?: boolean;
/** Always include the data of the document in the payload. */
includeData?: boolean;
/** Include the updatedAt timestamp for public documents. */
includeUpdatedAt?: boolean;
/** Include the unresolved comment count. Each call triggers a Redis lookup
* so only enable when the consumer needs the signal (e.g. MCP). */
includeCommentCount?: boolean;
/** Array of backlink document IDs to include in the response. */
backlinkIds?: string[];
};
async function presentDocument(
ctx: APIContext | undefined,
document: Document,
options: Options | null | undefined = {}
) {
options = {
isPublic: false,
...options,
};
const asData = !ctx || Number(ctx?.headers["x-api-version"] ?? 0) >= 3;
const data = await DocumentHelper.toJSON(
document,
options.isPublic
? {
signedUrls: Hour.seconds,
teamId: document.teamId,
removeMarks: ["comment"],
internalUrlBase: `/s/${options.shareId}`,
}
: undefined
);
const text =
!asData || options?.includeText
? await DocumentHelper.toMarkdown(data, { includeTitle: false })
: undefined;
const res: Record<string, unknown> = {
id: document.id,
url: document.path,
urlId: document.urlId,
title: document.title,
data:
options?.includeData === false
? undefined
: asData || options?.includeData
? data
: undefined,
text,
icon: document.icon,
color: document.color,
tasks: {
completed: 0,
total: 0,
},
language: document.language,
createdAt: document.createdAt,
createdBy: undefined,
updatedAt: document.updatedAt,
updatedBy: undefined,
publishedAt: document.publishedAt,
archivedAt: document.archivedAt,
deletedAt: document.deletedAt,
collaboratorIds: [],
revision: document.revisionCount,
fullWidth: document.fullWidth,
collectionId: undefined,
parentDocumentId: undefined,
lastViewedAt: undefined,
isCollectionDeleted: undefined,
backlinkIds: options?.backlinkIds,
};
if (!!document.views && document.views.length > 0) {
res.lastViewedAt = document.views[0].updatedAt;
}
if (options.isPublic && !options.includeUpdatedAt) {
delete res.updatedAt;
}
if (document.summary) {
res.summary = document.summary;
}
if (!options.isPublic) {
res.tasks = document.tasks;
res.isCollectionDeleted = await document.isCollectionDeleted();
res.collectionId = document.collectionId;
res.parentDocumentId = document.parentDocumentId;
res.createdBy = presentUser(document.createdBy);
res.updatedBy = presentUser(document.updatedBy);
res.collaboratorIds = document.collaboratorIds ?? [];
res.templateId = document.templateId;
res.insightsEnabled = document.insightsEnabled;
res.popularityScore = document.popularityScore;
if (options.includeCommentCount) {
res.commentCount = await document.commentCount;
}
if (document.sourceMetadata) {
const source = document.import ?? (await document.$get("import"));
res.sourceMetadata = {
importedAt: source?.createdAt ?? document.createdAt,
importType: source?.format,
createdByName: document.sourceMetadata.createdByName,
fileName: document.sourceMetadata?.fileName,
originalDocumentId: document.sourceMetadata?.originalDocumentId,
};
}
}
return res;
}
export default traceFunction({
spanName: "presenters",
})(presentDocument);
/**
* Batch-present multiple documents, fetching all related FileOperation records
* in a single query instead of one per document.
*
* @param ctx the API context.
* @param documents the documents to present.
* @param options presentation options forwarded to presentDocument.
* @returns array of presented document objects.
*/
export async function presentDocuments(
ctx: APIContext | undefined,
documents: Document[],
options?: Options | null
) {
const opts = { isPublic: false, ...options };
if (!opts.isPublic) {
const importIds = documents
.filter((doc) => doc.sourceMetadata && doc.importId)
.map((doc) => doc.importId!);
if (importIds.length > 0) {
const sources = await FileOperation.unscoped().findAll({
where: { id: { [Op.in]: importIds } },
});
const sourceMap = new Map(sources.map((s) => [s.id, s]));
for (const doc of documents) {
if (doc.importId) {
doc.import = sourceMap.get(doc.importId) ?? null;
}
}
}
}
return Promise.all(
documents.map((document) => presentDocument(ctx, document, opts))
);
}