74 lines
3.1 KiB
TypeScript
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,
|
|
}
|
|
}
|