This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user