Files
template-app/modules/documents/core/export.ts
T

74 lines
3.1 KiB
TypeScript

// The export: one workbook of the lines a person approved. It reads `documents_line`, which only an
// approval writes (target.ts), so there is no filter here that a later change could forget: a line
// that is pending or rejected is not in the table this reads.
//
// The columns are fixed and in English on purpose. The file is an interchange format -- what a
// product's own import or a spreadsheet formula refers to by name -- and a header that changed with
// the reviewer's language would break both. A product that needs another layout (a customer's ERP
// import, say) adds its own exporter beside this one; that belongs to the customer's module.
import { conflict } from "@conusai-kit/core"
import { getFile } from "@conusai-kit/files"
import { documents, lines } from "../contract/index.js"
import type { DocumentScope, DocumentsDeps } from "./lines.js"
import { buildWorkbook, type Cell } from "./xlsx.js"
export const WORKBOOK_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
const PAGE = 200
export const EXPORT_HEADER = ["Label", "Value", "Page", "Source quote", "Edited by a reviewer"] as const
/** `report.final.pdf` becomes `report.final-approved.xlsx`; nothing a path or a header could misread. */
export function exportFilename(source: string): string {
const stem = source.replace(/\.[A-Za-z0-9]{1,5}$/u, "")
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is removed.
const safe = stem.replace(/[\u0000-\u001f\u007f"\\/:*?<>|]/gu, "_").trim()
return `${safe.length > 0 ? safe : "document"}-approved.xlsx`
}
export async function exportApproved(
deps: DocumentsDeps,
scope: DocumentScope,
fileId: string,
): Promise<{ filename: string; contentType: string; bytes: Uint8Array; lineCount: number }> {
const file = await getFile(deps.files, scope, fileId)
const document = (
await deps.data
.forScope(scope)
.of(documents)
.list({ where: [{ field: "fileId", op: "eq", value: file.fileId }], limit: 1 })
).items[0]
if (!document) throw conflict("this document has not been read yet")
const rows: Cell[][] = [[...EXPORT_HEADER]]
let cursor: string | undefined
do {
const page = await deps.data
.forScope(scope)
.of(lines)
.list({
where: [{ field: "documentId", op: "eq", value: document.id }],
order: [
{ field: "page", direction: "asc" },
{ field: "position", direction: "asc" },
],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
for (const line of page.items) {
rows.push([line.label, line.value, line.page, line.quote, line.edited ? "yes" : "no"])
}
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
// An empty workbook would read as "the document had nothing in it", which is not what was
// decided: nobody has approved anything yet.
if (rows.length === 1) throw conflict("no line of this document has been approved")
return {
filename: exportFilename(file.filename),
contentType: WORKBOOK_CONTENT_TYPE,
bytes: buildWorkbook("Approved lines", rows),
lineCount: rows.length - 1,
}
}