Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e09c3c54 | ||
|
|
cccadc9a71 | ||
|
|
51337c35ed | ||
|
|
76bbf44144 | ||
|
|
f0b1f38bdc |
No files matched your search
@@ -21,6 +21,21 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
assistant's markdown as blocks with numbered links, one per target (a filter on the primary key is
|
||||
that record; JSON and base64 filters compare by content).
|
||||
|
||||
- **Save as PDF…** is the first entry of the UI's printer picker (record `p`, grid Ctrl+P, `:print`; `:print pdf`
|
||||
saves straight away): the server renders the same record sheet, table list or page (printing
|
||||
`printingRenderToPdf`) and the PDF is saved the way downloads and exports are — the save dialog or, with
|
||||
silent downloads, the download folder, the stamped name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), no
|
||||
silent overwrite. The CLI's `qqq print`, `qqq print list` and `qqq print text` take `--to-file [path]` (a
|
||||
folder, a file, or the download folder by default).
|
||||
- Ctrl+P on screens that are not records or grids asks **Print what?**: **Content** (the default: the screen's
|
||||
panels as a document with their titles, without navigation or borders) or **Screen** (the terminal as drawn).
|
||||
- Every printed or saved page carries the app's header (instance name, breadcrumb, server, user, time in the
|
||||
profile's date format) and footer (`qqq ui <version> · <server>` or `qqq <version> · <server>`, a grid's filter
|
||||
and sort, page n of m); the CLI sends the same.
|
||||
- `scripts/print-agent.sh` prints to `~/Downloads` unless `QQQ_PRINT_OUT` says otherwise (`--dir` reports it).
|
||||
- The kernel adds `renderToPdf`, `renderValues`, `canSaveAsPdf`, `chromeValues` and content sections on text
|
||||
prints; the TUI's breadcrumbs moved to `breadcrumbs.ts`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The assistant's answers render** instead of showing raw markdown, in the TUI panel and in
|
||||
|
||||
@@ -190,10 +190,14 @@ returns at once; `qqq print job status <id> --watch` follows later; `qqq print j
|
||||
job still queued). The printer given with `--printer` (or `qqq printer use`) is remembered per profile.
|
||||
`qqq print list person -w "lastName = Odd" --order -id --columns id,firstName,lastName` prints a table list
|
||||
of the whole result (up to 10,000 rows; `--page 2` for one page), and `some-command | qqq print text --title Log`
|
||||
prints text. In `qqq ui`, **Ctrl+P prints what you see** on every screen: a record sheet, a table list of
|
||||
the grid (all rows or this page), or the screen as text. Tables print only where the server enabled Print
|
||||
prints text. Add `--to-file [folder|file]` to any of them to save the PDF instead of printing (no path: the
|
||||
download folder, with the stamped name exports use). In `qqq ui`, **Ctrl+P prints what you see** on every
|
||||
screen: a record sheet, a table list of the grid (all rows or this page), or a screen's content (or the
|
||||
screen as drawn), each page with the app's header and footer; **Save as PDF…** in the printer picker saves
|
||||
it like a download. Tables print only where the server enabled Print
|
||||
for them (qbit-printing 2.1 `registerAll` enables every table). For local testing without hardware,
|
||||
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to a directory.
|
||||
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to `~/Downloads`
|
||||
(or `QQQ_PRINT_OUT`; `--dir` shows the folder).
|
||||
|
||||
[TRY-IT.md](TRY-IT.md) walks through every command against the sample server.
|
||||
|
||||
|
||||
@@ -22,10 +22,13 @@
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { profileNameForServer, formatDateValue, localeFromEnv, readDateSettings, resolveDateSettings, saveDownload, type PrintChrome, type RenderedPdf, type RenderRequest, FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
|
||||
import { EXIT, UsageError, type Context } from '../context.js'
|
||||
import { ORDER_OPTION, countFooter, instantText, positiveInt, type Json } from './shared.js'
|
||||
import { getLogin } from '../session.js'
|
||||
import { CLI_VERSION } from '../version.js'
|
||||
import { ORDER_OPTION, countFooter, instantText, positiveInt, writeDownload, type Json } from './shared.js'
|
||||
import { resolvedFilterFrom, type QueryOptions } from './table.js'
|
||||
|
||||
const JOB_COLUMNS = ['id', 'title', 'status', 'printer', 'copies', 'created', 'message']
|
||||
@@ -45,6 +48,61 @@ function printerRows(printers: Printer[], remembered: string | undefined): Json[
|
||||
return printers.map((p) => ({ default: p.id === remembered ? '*' : '', name: p.name, device: p.device ?? '', status: p.status ?? '', approved: p.approved ? 'yes' : 'no', exposed: p.exposed ? 'yes' : 'no', formats: p.formats.join(' '), id: p.id }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's header and footer for what the CLI prints: the instance's branding name (else the
|
||||
* profile), the title, the server (profile and host), the signed-in user, the time in the
|
||||
* profile's date format; the footer `qqq <version> · <server>`.
|
||||
*/
|
||||
async function chromeFor(c: Context, title?: string): Promise<PrintChrome> {
|
||||
const t = c.target
|
||||
let host = t.server
|
||||
try {
|
||||
host = new URL(t.server).host
|
||||
} catch {
|
||||
host = t.server
|
||||
}
|
||||
const server = t.name && t.name !== host && t.name !== profileNameForServer(t.server) ? `${t.name} (${host})` : host
|
||||
let app: string | undefined
|
||||
try {
|
||||
app = ((await c.metaData()) as unknown as { branding?: { appName?: string } }).branding?.appName
|
||||
} catch {
|
||||
app = undefined
|
||||
}
|
||||
const login = await getLogin(t).catch(() => undefined)
|
||||
const user = login && login.kind === 'session' ? String((login.user as { name?: string } | undefined)?.name ?? '') : ''
|
||||
let printedAt = new Date().toISOString()
|
||||
try {
|
||||
printedAt = formatDateValue(printedAt, 'DATE_TIME', resolveDateSettings(readDateSettings(t.name), localeFromEnv()), { withSeconds: false })
|
||||
} catch {
|
||||
// the ISO instant stays
|
||||
}
|
||||
return { app: app ?? t.name, title, server, user: user || undefined, printedAt, footerLeft: `qqq ${CLI_VERSION} · ${server}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* --to-file: renders each document on the server and saves it as downloads are saved: a folder
|
||||
* given gets the stamped default names, a file name given is written as given (one document
|
||||
* only), no path saves to the download folder (`qqq config get download-dir`) with the stamped
|
||||
* name, never overwriting.
|
||||
*/
|
||||
async function saveAsPdf(c: Context, printing: Printing, requests: RenderRequest[], out: string | true, title?: string): Promise<void> {
|
||||
const chrome = await chromeFor(c, title)
|
||||
const path = out === true ? undefined : out
|
||||
const folder = path !== undefined && path !== '-' && existsSync(path) && statSync(path).isDirectory()
|
||||
if (path !== undefined && !folder && requests.length > 1) throw new UsageError('--to-file with several documents takes a folder (or no path: the download folder).')
|
||||
const rows: Json[] = []
|
||||
for (const request of requests) {
|
||||
const pdf: RenderedPdf = await printing.renderToPdf(request, new Date(), chrome)
|
||||
if (pdf.message) c.note(pdf.message)
|
||||
const written = folder ? saveDownload(join(path!, pdf.fileName), pdf.bytes, { explicit: false }) : writeDownload(path, pdf.fileName, pdf.bytes)
|
||||
if (written) {
|
||||
c.note(`Saved ${written} (${pdf.bytes.length.toLocaleString('en-US')} bytes)`)
|
||||
rows.push({ saved: written, bytes: pdf.bytes.length })
|
||||
}
|
||||
}
|
||||
if (path !== '-') c.print({ data: rows, rows, columns: ['saved', 'bytes'] })
|
||||
}
|
||||
|
||||
/** Tables the server offers Print on, from the metadata. */
|
||||
async function printableTables(c: Context): Promise<string[]> {
|
||||
const processes = ((await c.metaData() as unknown as Json).processes ?? {}) as Record<string, unknown>
|
||||
@@ -102,16 +160,18 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
.option('-c, --copies <n>', 'copies of each record (1-100)', '1')
|
||||
.option('--no-wait', 'return once the jobs are queued')
|
||||
.option('--timeout <seconds>', 'how long to wait for the jobs', '120')
|
||||
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string }) => {
|
||||
.option('--to-file [path]', 'save each record sheet as a PDF instead of printing: a folder, a file (one record), or the download folder')
|
||||
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
|
||||
const c = ctx()
|
||||
const printing = await printingOf(c)
|
||||
const tables = await printableTables(c)
|
||||
if (!tables.includes(table)) throw new UsageError(`Print is not enabled for table "${table}" on this server${tables.length ? ` (tables with Print: ${tables.join(', ')})` : ''}.`)
|
||||
if (options.toFile !== undefined) return saveAsPdf(c, printing, ids.map((id) => ({ kind: 'record' as const, table, id })), options.toFile)
|
||||
const copies = positiveInt(options.copies, '--copies')
|
||||
const printer = await choosePrinter(c, printing, options.printer)
|
||||
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
|
||||
c.note(`Printing ${ids.length} ${table} record${ids.length === 1 ? '' : 's'} on "${printer.name}"${copies > 1 ? `, ${copies} copies each` : ''} (${printProcessName(table)})`)
|
||||
const jobIds = await printing.print({ table, ids, printer: printer.id, copies })
|
||||
const jobIds = await printing.print({ table, ids, printer: printer.id, copies, chrome: await chromeFor(c) })
|
||||
if (!jobIds.length) throw new Error(`Nothing was printed: no ${table} record ${ids.join(', ')} was found (or you may not see ${ids.length === 1 ? 'it' : 'them'}).`)
|
||||
const jobs = options.wait ? await follow(c, printing, jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: jobIds })
|
||||
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs), !options.wait && `follow: qqq print job status ${jobIds.join(' ')} --watch`]) })
|
||||
@@ -134,7 +194,8 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
.option('--copies <n>', 'copies (1-100)', '1')
|
||||
.option('--no-wait', 'return once the job is queued')
|
||||
.option('--timeout <seconds>', 'how long to wait for the job', '300')
|
||||
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string }) => {
|
||||
.option('--to-file [path]', 'save the table list as a PDF instead of printing: a folder, a file, - for stdout, or the download folder')
|
||||
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
|
||||
const c = ctx()
|
||||
const printing = await printingOf(c)
|
||||
const meta = await c.tableMeta<Json>(table)
|
||||
@@ -148,11 +209,15 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
const filter = await resolvedFilterFrom(c, { ...options, pageSize: '500' }, meta, columns)
|
||||
const pageSize = positiveInt(options.pageSize, '--page-size')
|
||||
const scope = options.page ? { kind: 'page' as const, skip: (positiveInt(options.page, '--page') - 1) * pageSize, limit: pageSize } : { kind: 'all' as const }
|
||||
if (options.toFile !== undefined) {
|
||||
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
|
||||
return saveAsPdf(c, printing, [{ kind: 'list', list: { table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') } }], options.toFile, String(meta.label ?? table))
|
||||
}
|
||||
const printer = await choosePrinter(c, printing, options.printer)
|
||||
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
|
||||
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
|
||||
c.note(`Printing ${scope.kind === 'all' ? 'all rows of' : `page ${options.page} of`} ${meta.label ?? table}${described ? ` where ${described}` : ''} on "${printer.name}"`)
|
||||
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') })
|
||||
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id'), chrome: await chromeFor(c, String(meta.label ?? table)) })
|
||||
if (outcome.message) c.note(outcome.message)
|
||||
if (!outcome.jobIds.length) throw new Error('The server queued no print job.')
|
||||
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
|
||||
@@ -168,14 +233,16 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
.option('--copies <n>', 'copies (1-100)', '1')
|
||||
.option('--no-wait', 'return once the job is queued')
|
||||
.option('--timeout <seconds>', 'how long to wait for the job', '120')
|
||||
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string }) => {
|
||||
.option('--to-file [path]', 'save the text page as a PDF instead of printing: a folder, a file, or the download folder')
|
||||
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
|
||||
const c = ctx()
|
||||
const printing = await printingOf(c)
|
||||
const text = readFileSync(file === undefined || file === '-' ? 0 : file, 'utf8')
|
||||
if (!text.trim()) throw new UsageError('Nothing to print: the text is empty.')
|
||||
if (options.toFile !== undefined) return saveAsPdf(c, printing, [{ kind: 'text', title: options.title, subtitle: options.subtitle, text, orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' }], options.toFile, options.title)
|
||||
const printer = await choosePrinter(c, printing, options.printer)
|
||||
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
|
||||
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' })
|
||||
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT', chrome: await chromeFor(c, options.title) })
|
||||
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
|
||||
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs)]) })
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
|
||||
import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
|
||||
import { Assistant, Collaboration, Printing, downloadProcessFile, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
|
||||
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
|
||||
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
|
||||
|
||||
@@ -190,6 +190,7 @@ export class Context {
|
||||
const outcome = await runProcess({ baseUrl: t.server, fetch: authenticatedFetch(t), meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })
|
||||
return outcome.values
|
||||
},
|
||||
download: (reference) => downloadProcessFile(t.server, authenticatedFetch(t), reference),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Command, CommanderError } from 'commander'
|
||||
import { Context, EXIT, describeError, io } from './context.js'
|
||||
import { versionText } from './version.js'
|
||||
|
||||
export { buildDescription } from './version.js'
|
||||
export { buildDescription, CLI_VERSION } from './version.js'
|
||||
import { registerApi } from './commands/api.js'
|
||||
import { registerAudit } from './commands/audit.js'
|
||||
import { registerAuth } from './commands/auth.js'
|
||||
|
||||
@@ -14,7 +14,8 @@ import assert from 'node:assert/strict'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { after, before, describe, test } from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runCli, sandbox, startFakeServer, type FakeServer } from './harness.js'
|
||||
|
||||
@@ -24,6 +25,7 @@ interface PrintServer {
|
||||
url: string
|
||||
runs: { process: string; values: J; recordIds?: string }[]
|
||||
lists: { process: string; values: J; recordsParam?: string }[]
|
||||
renders: J[]
|
||||
jobs: Map<string, { status: string[]; title: string; reads: number; message?: string }>
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -50,6 +52,7 @@ const CANCEL_META = { name: 'printingCancelSelectedJobs', label: 'Cancel queued
|
||||
async function startPrintServer(): Promise<PrintServer> {
|
||||
const runs: PrintServer['runs'] = []
|
||||
const lists: PrintServer['lists'] = []
|
||||
const renders: PrintServer['renders'] = []
|
||||
const jobs: PrintServer['jobs'] = new Map()
|
||||
const state = new Map<string, { process: string; values: J; recordIds?: string }>()
|
||||
let next = 1
|
||||
@@ -69,10 +72,22 @@ async function startPrintServer(): Promise<PrintServer> {
|
||||
let m: RegExpExecArray | null
|
||||
if (path === '/version') return send(res, 200, { apiVersion: '1.9.0', features: ['metaData', 'tables', 'processes', 'version', 'extensions'], extensions: [{ name: 'printing', version: '2.1.0', pathPrefix: '/qbit/printing' }] })
|
||||
if (path === '/metaData/authentication') return send(res, 200, { type: 'MOCK' })
|
||||
if (path === '/metaData') return send(res, 200, { tables: { person: { name: 'person', label: 'Person' } }, processes: { printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' }, printingCancelSelectedJobs: { name: 'printingCancelSelectedJobs', tableName: 'printing_job' }, printingPrintTableList: { name: 'printingPrintTableList' }, printingPrintText: { name: 'printingPrintText' } }, apps: {} })
|
||||
if (path === '/metaData') return send(res, 200, { tables: { person: { name: 'person', label: 'Person' } }, processes: { printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' }, printingCancelSelectedJobs: { name: 'printingCancelSelectedJobs', tableName: 'printing_job' }, printingPrintTableList: { name: 'printingPrintTableList' }, printingPrintText: { name: 'printingPrintText' }, printingRenderToPdf: { name: 'printingRenderToPdf' } }, apps: {} })
|
||||
if (path === '/metaData/process/printingPrintRecords_person') return send(res, 200, PRINT_META)
|
||||
if (path === '/metaData/process/printingCancelSelectedJobs') return send(res, 200, CANCEL_META)
|
||||
if (path === '/metaData/table/person') return send(res, 200, PERSON_META)
|
||||
if (path === '/metaData/process/printingRenderToPdf') return send(res, 200, { name: 'printingRenderToPdf', label: 'Save as PDF', frontendSteps: [{ name: 'download', label: 'Download', components: [{ type: 'DOWNLOAD_FORM' }], viewFields: [{ name: 'downloadFileName', type: 'STRING', label: 'File' }] }] })
|
||||
if (path === '/processes/printingRenderToPdf/init') {
|
||||
const v = JSON.parse(field(body, 'values') ?? '{}') as J
|
||||
renders.push(v)
|
||||
const name = v.kind === 'RECORD' ? `Person-${v.recordId}` : v.kind === 'LIST' ? 'Person' : String(v.title).replace(/[^A-Za-z0-9]+/g, '-')
|
||||
return send(res, 200, { processUUID: 'render', nextStep: 'download', type: 'COMPLETE', values: { downloadFileName: `${name}.pdf`, serverFilePath: `/tmp/r-${renders.length}.pdf`, ...(v.kind === 'LIST' ? { printingMessage: 'Saved the first 10000 of 12000 rows: the limit is 10000 rows. Narrow the filter to save the rest.' } : {}) } })
|
||||
}
|
||||
if (path.startsWith('/download/')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
||||
res.end(`%PDF-1.7 ${path}`)
|
||||
return
|
||||
}
|
||||
if ((m = /^\/metaData\/process\/(printingPrintTableList|printingPrintText)$/.exec(path))) return send(res, 200, LIST_META(m[1]!))
|
||||
if ((m = /^\/processes\/(printingPrintTableList|printingPrintText)\/init$/.exec(path))) {
|
||||
lists.push({ process: m[1]!, values: JSON.parse(field(body, 'values') ?? '{}') as J, recordsParam: field(body, 'recordsParam') })
|
||||
@@ -132,6 +147,7 @@ async function startPrintServer(): Promise<PrintServer> {
|
||||
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
runs,
|
||||
lists,
|
||||
renders,
|
||||
jobs,
|
||||
close: () => new Promise<void>((resolve) => { server.closeAllConnections(); server.close(() => resolve()) }),
|
||||
}
|
||||
@@ -249,6 +265,11 @@ describe('qqq print and qqq printer', () => {
|
||||
assert.deepEqual(filter.criteria, [{ fieldName: 'lastName', operator: 'EQUALS', values: ['Odd'] }])
|
||||
assert.deepEqual(filter.orderBys, [{ fieldName: 'id', isAscending: false }])
|
||||
assert.equal(run.values.filterDescription, 'lastName EQUALS Odd')
|
||||
// the app header and footer
|
||||
assert.match(String(run.values.footerLeft), /^qqq \d+\.\d+\.\d+ · 127\.0\.0\.1:\d+$/)
|
||||
assert.match(String(run.values.headerServer), /^127\.0\.0\.1:\d+$/)
|
||||
assert.equal(run.values.headerTitle, 'Person')
|
||||
assert.match(String(run.values.headerPrintedAt), /\d/)
|
||||
})
|
||||
|
||||
test('print list --page prints one page; default columns skip hidden and binary fields; bad options are usage errors', async () => {
|
||||
@@ -271,5 +292,40 @@ describe('qqq print and qqq printer', () => {
|
||||
const empty = await qqq(['print', 'text', '-', '--title', 'Nothing'], printServer.url, ' \n')
|
||||
assert.equal(empty.code, 2)
|
||||
})
|
||||
|
||||
test('--to-file saves record sheets instead of printing: a folder gets stamped names; no job is queued', async () => {
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-cli-pdf-'))
|
||||
const before = printServer.runs.length
|
||||
const result = await qqq(['print', 'person', '1', '2', '--to-file', folder])
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
const files = readdirSync(folder).sort()
|
||||
assert.equal(files.length, 2)
|
||||
assert.match(files[0]!, /^Person-1_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
assert.match(readFileSync(join(folder, files[0]!), 'utf8'), /^%PDF-1\.7 \/download\/Person-1\.pdf/)
|
||||
assert.match(result.stderr, /Saved .*Person-2_\d{4}/)
|
||||
assert.equal(printServer.runs.length, before, 'no print job')
|
||||
assert.deepEqual(printServer.renders.slice(-2).map((r) => [r.kind, r.tableName, r.recordId]), [['RECORD', 'person', '1'], ['RECORD', 'person', '2']])
|
||||
assert.match(String(printServer.renders.at(-1)!.footerLeft), /^qqq \d/)
|
||||
const two = await qqq(['print', 'person', '1', '2', '--to-file', join(folder, 'one.pdf')])
|
||||
assert.equal(two.code, 2)
|
||||
assert.match(two.stderr, /takes a folder/)
|
||||
})
|
||||
|
||||
test('print list --to-file writes the named file as given and shows the limit note; without a path it goes to the download folder', async () => {
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-cli-pdf-'))
|
||||
const named = join(folder, 'people.pdf')
|
||||
const result = await qqq(['print', 'list', 'person', '-w', 'lastName = Odd', '--to-file', named])
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
assert.match(readFileSync(named, 'utf8'), /^%PDF/)
|
||||
assert.match(result.stderr, /Saved the first 10000 of 12000 rows/)
|
||||
const render = printServer.renders.at(-1)!
|
||||
assert.deepEqual([render.kind, render.scope, render.printingPrinterId], ['LIST', 'ALL', undefined])
|
||||
const downloads = mkdtempSync(join(tmpdir(), 'qqq-cli-dl-'))
|
||||
assert.equal((await qqq(['config', 'set', 'download-dir', downloads])).code, 0)
|
||||
const text = await qqq(['print', 'text', '-', '--title', 'Build log', '--to-file'], printServer.url, 'hello\n')
|
||||
assert.equal(text.code, 0, text.stderr)
|
||||
assert.match(readdirSync(downloads)[0]!, /^Build-log_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
assert.equal((await qqq(['config', 'set', 'download-dir', ''])).code, 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import { readConfig, writeConfig } from '../session/profiles.js'
|
||||
import { downloadReference } from '../process/screen.js'
|
||||
import { exportFileName } from '../render/files.js'
|
||||
import type { ExtensionRegistry } from './registry.js'
|
||||
|
||||
export const PRINTING = 'printing'
|
||||
@@ -33,6 +35,8 @@ export const COPIES_FIELD = 'printingCopies'
|
||||
export const JOB_IDS_VALUE = 'printingJobIds'
|
||||
export const TABLE_LIST_PROCESS = 'printingPrintTableList'
|
||||
export const TEXT_PROCESS = 'printingPrintText'
|
||||
/** Renders a record sheet, table list or text page to a PDF download (Save as PDF). */
|
||||
export const RENDER_PROCESS = 'printingRenderToPdf'
|
||||
/** The most rows the server prints in one table list. */
|
||||
export const MAX_LIST_ROWS = 10_000
|
||||
/** "All rows" is the default list choice up to this many rows (or whenever the grid is filtered). */
|
||||
@@ -95,6 +99,8 @@ export interface PrintingTransport {
|
||||
call(id: string, input: CallInput): Promise<CallResult>
|
||||
/** Runs a process to its end with these values and selected record ids; returns its final values. */
|
||||
runProcess(processName: string, options: { values: Record<string, unknown>; recordIds: string[] }): Promise<Record<string, unknown>>
|
||||
/** Fetches a process download (GET /download/{file}); needed for Save as PDF. */
|
||||
download?(reference: { fileName: string; query: Record<string, string> }): Promise<{ bytes: Uint8Array; contentType: string }>
|
||||
}
|
||||
|
||||
interface QueryRecord {
|
||||
@@ -203,6 +209,75 @@ export function canPrintLists(meta: { processes?: Record<string, unknown> } | un
|
||||
return Boolean(meta?.processes?.[TABLE_LIST_PROCESS]) && Boolean(meta?.processes?.[TEXT_PROCESS])
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer every printed page carries (printing 0.4.1+ renderers): the app or
|
||||
* instance name, the screen title or breadcrumb, the server, the user and the printed-at time in
|
||||
* the user's date format on top; the client's footer (`qqq ui 1.0.1 · server`), a grid's filter and
|
||||
* sort, and the page number at the bottom.
|
||||
*/
|
||||
export interface PrintChrome {
|
||||
app?: string
|
||||
title?: string
|
||||
server?: string
|
||||
user?: string
|
||||
printedAt?: string
|
||||
footerLeft?: string
|
||||
footerNote?: string
|
||||
}
|
||||
|
||||
/** The process values of a chrome (blank fields left out: the server's defaults apply). */
|
||||
export function chromeValues(chrome: PrintChrome | undefined): Record<string, string> {
|
||||
if (!chrome) return {}
|
||||
const out: Record<string, string> = {}
|
||||
const put = (key: string, value: string | undefined) => {
|
||||
if (value !== undefined && value.trim()) out[key] = value.trim()
|
||||
}
|
||||
put('headerApp', chrome.app)
|
||||
put('headerTitle', chrome.title)
|
||||
put('headerServer', chrome.server)
|
||||
put('headerUser', chrome.user)
|
||||
put('headerPrintedAt', chrome.printedAt)
|
||||
put('footerLeft', chrome.footerLeft)
|
||||
put('footerNote', chrome.footerNote)
|
||||
return out
|
||||
}
|
||||
|
||||
/** One section of a screen's content: a panel's title (blank for none) and its lines. */
|
||||
export interface ContentSection {
|
||||
title: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
/** Whether the server can render documents to a PDF download ("Save as PDF"). */
|
||||
export function canSaveAsPdf(meta: { processes?: Record<string, unknown> } | undefined): boolean {
|
||||
return Boolean(meta?.processes?.[RENDER_PROCESS])
|
||||
}
|
||||
|
||||
/** What Save as PDF renders: a record sheet, a table list, or a text page. */
|
||||
export type RenderRequest =
|
||||
| { kind: 'record'; table: string; id: string }
|
||||
| { kind: 'list'; list: Omit<TableListRequest, 'printer' | 'copies'> }
|
||||
| { kind: 'text'; title: string; subtitle?: string; text: string; sections?: ContentSection[]; orientation?: 'PORTRAIT' | 'LANDSCAPE' }
|
||||
|
||||
/** A PDF rendered for the client: the default file name (stamped), the server's name, the bytes, and a note. */
|
||||
export interface RenderedPdf {
|
||||
fileName: string
|
||||
serverName: string
|
||||
bytes: Uint8Array
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** The process values of a render request. */
|
||||
export function renderValues(request: RenderRequest, chrome?: PrintChrome): Record<string, unknown> {
|
||||
const header = chromeValues(chrome)
|
||||
if (request.kind === 'record') return { kind: 'RECORD', tableName: request.table, recordId: request.id, ...header }
|
||||
if (request.kind === 'text') return { kind: 'TEXT', title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, ...(request.sections?.length ? { sections: JSON.stringify(request.sections) } : {}), orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', ...header }
|
||||
const values = tableListValues({ ...request.list, printer: '' })
|
||||
delete values[PRINTER_FIELD]
|
||||
delete values[COPIES_FIELD]
|
||||
return { kind: 'LIST', ...values, ...header }
|
||||
}
|
||||
|
||||
/** What a grid print covers. */
|
||||
export type ListScope = { kind: 'all' } | { kind: 'page'; skip: number; limit: number } | { kind: 'selected'; ids: string[] } | { kind: 'sheets'; ids: string[] }
|
||||
|
||||
@@ -308,6 +383,7 @@ export interface PrintRequest {
|
||||
/** Printer row id. */
|
||||
printer: string
|
||||
copies?: number
|
||||
chrome?: PrintChrome
|
||||
}
|
||||
|
||||
export interface TableListRequest {
|
||||
@@ -329,12 +405,16 @@ export interface TableListRequest {
|
||||
pageSize?: 'LETTER' | 'A4'
|
||||
/** The primary key field, for a selection. */
|
||||
primaryKeyField?: string
|
||||
chrome?: PrintChrome
|
||||
}
|
||||
|
||||
export interface TextRequest {
|
||||
title: string
|
||||
subtitle?: string
|
||||
text: string
|
||||
/** A screen's content as sections (printed instead of the raw text when given). */
|
||||
sections?: ContentSection[]
|
||||
chrome?: PrintChrome
|
||||
printer: string
|
||||
copies?: number
|
||||
orientation?: 'PORTRAIT' | 'LANDSCAPE'
|
||||
@@ -376,6 +456,7 @@ export function tableListValues(request: TableListRequest): Record<string, unkno
|
||||
pageSize: request.pageSize ?? 'LETTER',
|
||||
[PRINTER_FIELD]: request.printer,
|
||||
[COPIES_FIELD]: request.copies ?? 1,
|
||||
...chromeValues(request.chrome),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +519,7 @@ export class Printing {
|
||||
if (!request.ids.length) throw new Error('nothing to print: no record ids')
|
||||
const copies = request.copies ?? 1
|
||||
if (!Number.isInteger(copies) || copies < 1 || copies > 100) throw new Error('copies must be 1 to 100')
|
||||
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies }, recordIds: request.ids })
|
||||
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies, ...chromeValues(request.chrome) }, recordIds: request.ids })
|
||||
return jobIdsOf(values)
|
||||
}
|
||||
|
||||
@@ -453,10 +534,26 @@ export class Printing {
|
||||
async printText(request: TextRequest): Promise<PrintOutcome> {
|
||||
this.require()
|
||||
if (!request.text.trim()) throw new Error('nothing to print: the text is empty')
|
||||
const values = await this.transport.runProcess(TEXT_PROCESS, { values: { title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: request.copies ?? 1 }, recordIds: [] })
|
||||
const values = await this.transport.runProcess(TEXT_PROCESS, { values: { ...chromeValues(request.chrome), title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, ...(request.sections?.length ? { sections: JSON.stringify(request.sections) } : {}), orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: request.copies ?? 1 }, recordIds: [] })
|
||||
return { jobIds: jobIdsOf(values) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a record sheet, table list or text page to a PDF on the server (no printer, no job)
|
||||
* and downloads it; the default name is the server's (the document title) with the date and
|
||||
* time stamp downloads and exports use: `Customer-Ada-Lovelace_2026-11-01_120301.pdf`.
|
||||
*/
|
||||
async renderToPdf(request: RenderRequest, now: Date = new Date(), chrome?: PrintChrome): Promise<RenderedPdf> {
|
||||
this.require()
|
||||
if (!this.transport.download) throw new Error('this client cannot download process files')
|
||||
if (request.kind === 'text' && !request.text.trim()) throw new Error('nothing to save: the text is empty')
|
||||
const values = await this.transport.runProcess(RENDER_PROCESS, { values: renderValues(request, chrome), recordIds: [] })
|
||||
const reference = downloadReference(values)
|
||||
if (!reference) throw new Error('the server rendered no PDF (no download in its answer)')
|
||||
const { bytes } = await this.transport.download(reference)
|
||||
return { fileName: exportFileName(reference.fileName, 'pdf', now), serverName: reference.fileName, bytes, message: values.printingMessage === undefined ? undefined : String(values.printingMessage) }
|
||||
}
|
||||
|
||||
/** Cancels jobs still queued in QQQ (jobs a device has taken must be handled at the device). */
|
||||
async cancel(ids: string[]): Promise<void> {
|
||||
this.require()
|
||||
|
||||
@@ -10,7 +10,7 @@ import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import { ExtensionNotInstalledError, ExtensionRegistry, knownExtension } from '../extensions/registry.js'
|
||||
import { MAX_LIST_ROWS, Printing, canPrintLists, canPrintTo, listScopeChoices, tableListValues, isFinalJobState, jobIdsOf, jobOf, jobStateSummary, printProcessName, printerOf, readRememberedPrinter, resolvePrinter, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type PrintingTransport } from '../extensions/printing.js'
|
||||
import { MAX_LIST_ROWS, Printing, canSaveAsPdf, chromeValues, renderValues, canPrintLists, canPrintTo, listScopeChoices, tableListValues, isFinalJobState, jobIdsOf, jobOf, jobStateSummary, printProcessName, printerOf, readRememberedPrinter, resolvePrinter, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type PrintingTransport } from '../extensions/printing.js'
|
||||
import { readConfig, upsertProfile } from '../session/profiles.js'
|
||||
|
||||
const withPrinting = ExtensionRegistry.fromVersion({ extensions: [{ name: 'printing', version: '2.0.0' }] })
|
||||
@@ -201,3 +201,58 @@ test('grid print choices: all rows first when filtered or small, this page first
|
||||
assert.equal(canPrintLists({ processes: { printingPrintTableList: {}, printingPrintText: {} } }), true)
|
||||
assert.equal(canPrintLists({ processes: { printingPrintTableList: {} } }), false)
|
||||
})
|
||||
|
||||
test('save as PDF: render values for a record, a list (no printer) and a text page', () => {
|
||||
assert.deepEqual(renderValues({ kind: 'record', table: 'customer', id: '1' }), { kind: 'RECORD', tableName: 'customer', recordId: '1' })
|
||||
const list = renderValues({ kind: 'list', list: { table: 'person', columns: ['id'], filter: { criteria: [], orderBys: [{ fieldName: 'id', isAscending: true }] }, scope: { kind: 'all' }, title: 'Person' } })
|
||||
assert.equal(list.kind, 'LIST')
|
||||
assert.equal(list.scope, 'ALL')
|
||||
assert.equal(list.printingPrinterId, undefined)
|
||||
assert.equal(list.printingCopies, undefined)
|
||||
assert.deepEqual(renderValues({ kind: 'text', title: 'Home', text: 'x' }), { kind: 'TEXT', title: 'Home', text: 'x', orientation: 'PORTRAIT', pageSize: 'LETTER' })
|
||||
assert.equal(canSaveAsPdf({ processes: { printingRenderToPdf: {} } }), true)
|
||||
assert.equal(canSaveAsPdf({ processes: {} }), false)
|
||||
})
|
||||
|
||||
test('save as PDF: renders on the server, downloads the file and names it with the download stamp', async () => {
|
||||
const downloads: unknown[] = []
|
||||
const { runs, transport } = fake(() => ({}), async () => ({ downloadFileName: 'Customer-Ada-Lovelace.pdf', serverFilePath: '/tmp/qqq-print-1.pdf', printingMessage: 'Saved the first 10000 of 12000 rows' }))
|
||||
transport.download = async (reference) => {
|
||||
downloads.push(reference)
|
||||
return { bytes: new TextEncoder().encode('%PDF-1.7'), contentType: 'application/pdf' }
|
||||
}
|
||||
const printing = new Printing(withPrinting, transport)
|
||||
const pdf = await printing.renderToPdf({ kind: 'record', table: 'customer', id: '1' }, new Date(2026, 10, 1, 12, 3, 1))
|
||||
assert.equal(pdf.fileName, 'Customer-Ada-Lovelace_2026-11-01_120301.pdf')
|
||||
assert.equal(pdf.serverName, 'Customer-Ada-Lovelace.pdf')
|
||||
assert.equal(new TextDecoder().decode(pdf.bytes), '%PDF-1.7')
|
||||
assert.equal(pdf.message, 'Saved the first 10000 of 12000 rows')
|
||||
assert.deepEqual(runs[0], { name: 'printingRenderToPdf', values: { kind: 'RECORD', tableName: 'customer', recordId: '1' }, recordIds: [] })
|
||||
assert.deepEqual(downloads, [{ fileName: 'Customer-Ada-Lovelace.pdf', query: { filePath: '/tmp/qqq-print-1.pdf' } }])
|
||||
await assert.rejects(printing.renderToPdf({ kind: 'text', title: 't', text: ' ' }), /empty/)
|
||||
delete transport.download
|
||||
await assert.rejects(printing.renderToPdf({ kind: 'record', table: 'customer', id: '1' }), /cannot download/)
|
||||
await assert.rejects(new Printing(without, transport).renderToPdf({ kind: 'record', table: 'customer', id: '1' }), ExtensionNotInstalledError)
|
||||
const { transport: none } = fake(() => ({}), async () => ({}))
|
||||
none.download = async () => ({ bytes: new Uint8Array(), contentType: '' })
|
||||
await assert.rejects(new Printing(withPrinting, none).renderToPdf({ kind: 'record', table: 'customer', id: '1' }), /no PDF/)
|
||||
})
|
||||
|
||||
test('the app header and footer travel with every request; blank fields are left to the server', async () => {
|
||||
const chrome = { app: 'QQQ All', title: 'Sales › Customer › Ada Lovelace', server: 'local (localhost:8078)', user: 'Admin', printedAt: '2026-11-01 12:03', footerLeft: 'qqq ui 1.0.1 · local', footerNote: ' ' }
|
||||
assert.deepEqual(chromeValues(chrome), { headerApp: 'QQQ All', headerTitle: 'Sales › Customer › Ada Lovelace', headerServer: 'local (localhost:8078)', headerUser: 'Admin', headerPrintedAt: '2026-11-01 12:03', footerLeft: 'qqq ui 1.0.1 · local' })
|
||||
assert.deepEqual(chromeValues(undefined), {})
|
||||
const sections = [{ title: 'Recently viewed', lines: ['Ada Lovelace'] }]
|
||||
const text = renderValues({ kind: 'text', title: 'Home', text: 'raw', sections }, chrome)
|
||||
assert.equal(text.sections, JSON.stringify(sections))
|
||||
assert.equal(text.headerApp, 'QQQ All')
|
||||
assert.equal(renderValues({ kind: 'record', table: 'customer', id: '1' }, chrome).headerTitle, 'Sales › Customer › Ada Lovelace')
|
||||
const { runs, transport } = fake(() => ({}), async () => ({ printingJobIds: 'j1' }))
|
||||
const printing = new Printing(withPrinting, transport)
|
||||
await printing.print({ table: 'customer', ids: ['1'], printer: 'p1', chrome })
|
||||
await printing.printText({ title: 'Home', text: 'raw', sections, printer: 'p1', chrome })
|
||||
await printing.printTableList({ table: 'person', columns: ['id'], filter: {}, scope: { kind: 'all' }, printer: 'p1', chrome: { ...chrome, footerNote: 'Filter: x' } })
|
||||
assert.equal(runs[0]!.values.headerUser, 'Admin')
|
||||
assert.equal(runs[1]!.values.sections, JSON.stringify(sections))
|
||||
assert.equal(runs[2]!.values.footerNote, 'Filter: x')
|
||||
})
|
||||
+12
-4
@@ -84,11 +84,19 @@ passes it on — most do not, so Ctrl+P is the key to rely on):
|
||||
selected row**. The server prints up to 10,000 rows (more prints the first 10,000 and says so), repeats
|
||||
the header on every page, turns the page landscape when the columns need it and puts the table, filter,
|
||||
sort, row count, user and time in the header and footer;
|
||||
- any other screen (home, dashboards, the developer view, SQL results, process results, output) prints the
|
||||
**screen as text**: a monospace PDF of what is drawn, without the status line and F-key bar, sized so a
|
||||
wide terminal keeps its lines.
|
||||
- any other screen (home, dashboards, the developer view, SQL results, process results, Search, History,
|
||||
output) first asks **Print what?**: **Content** (the default) prints the screen's panels as a document —
|
||||
each panel a section under its title, no Apps sidebar, title bar, key lines or borders, wrapped to the
|
||||
page — and **Screen** prints the terminal as drawn (monospace, sized so a wide terminal keeps its lines).
|
||||
|
||||
Then the printer picker: printers that can print now (approved, exposed by a connected print agent, PDF)
|
||||
Every page carries the app's **header** (the instance's name, the screen's breadcrumb such as `People ›
|
||||
Person › Ada Lovelace`, the server, you, and the time in your date format) and **footer** (`qqq ui 1.0.1 ·
|
||||
<server>`, a grid's filter and sort, page n of m).
|
||||
|
||||
Then the printer picker, with **Save as PDF…** first: it renders the same document on the server and saves
|
||||
it the way downloads and exports are saved — the save dialog (or, with `download-mode silent`, straight to
|
||||
the download folder), the stamped name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), never overwriting
|
||||
without asking. `:print pdf` saves without the picker. Then printers that can print now (approved, exposed by a connected print agent, PDF)
|
||||
come first, the others are listed with the reason and cannot be picked; the printer chosen is remembered
|
||||
per profile and preselected next time, marked "(last used)". `:print <printer>` skips the picker (name,
|
||||
unique prefix or id). The title bar and status line show `print 1/3 RECEIVED` while jobs run, and a flash
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react'
|
||||
import { Component, useEffect, useReducer, type ReactNode } from 'react'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { FKeyZone, PaneZone, StatusZone, useMouseDispatcher } from './mouse/app-zones'
|
||||
import { MouseCapture, MouseLayer, MouseRoot } from './mouse/zone'
|
||||
import { presenceText, usePresence } from './presence'
|
||||
import type { AppStore } from './store'
|
||||
import { breadcrumbs } from './breadcrumbs'
|
||||
|
||||
export { appPath, breadcrumbs } from './breadcrumbs'
|
||||
import { glyphs, theme } from './theme'
|
||||
import { pad, trunc } from './util/text'
|
||||
|
||||
@@ -39,32 +41,6 @@ export class ErrorBoundary extends Component<{ children: ReactNode; resetKey: st
|
||||
}
|
||||
}
|
||||
|
||||
/** The apps above a table, process or app in the app tree (labels, outermost first). */
|
||||
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.name === name && n.type === type) return path
|
||||
if (n.children) {
|
||||
const found = appPath(n.children, name, type, [...path, n.label])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
|
||||
export function breadcrumbs(store: AppStore): string[] {
|
||||
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
|
||||
if (!top) return []
|
||||
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
|
||||
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
|
||||
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
|
||||
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
|
||||
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
|
||||
return [top.title()]
|
||||
}
|
||||
|
||||
function TitleBar({ store, width }: { store: AppStore; width: number }) {
|
||||
const t = theme()
|
||||
const presence = usePresence(store.screen?.presenceScope?.())
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The title-bar path of a screen (apps, table, record), shared by the title bar and printed headers. */
|
||||
|
||||
import type { AppTreeNode } from './client'
|
||||
import type { AppStore } from './store'
|
||||
|
||||
/** The apps above a table, process or app in the app tree (labels, outermost first). */
|
||||
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.name === name && n.type === type) return path
|
||||
if (n.children) {
|
||||
const found = appPath(n.children, name, type, [...path, n.label])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
|
||||
export function breadcrumbs(store: AppStore): string[] {
|
||||
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
|
||||
if (!top) return []
|
||||
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
|
||||
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
|
||||
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
|
||||
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
|
||||
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
|
||||
return [top.title()]
|
||||
}
|
||||
+238
-20
@@ -17,12 +17,16 @@
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { exportFileName, FAILED_JOB_STATES, MAX_LIST_ROWS, Printing, canPrintLists, isFinalJobState, listScopeChoices, readRememberedPrinter, resolvePrinter, runProcess, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type ListScope, type Printer, type PrintJob, type PrintOutcome, type ProcessMeta } from '@qrun-io/qqq-core'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { canSaveAsPdf, downloadProcessFile, formatDateValue, profileNameForServer, readConfig, saveDownload, type ContentSection, type PrintChrome, type RenderRequest, exportFileName, FAILED_JOB_STATES, MAX_LIST_ROWS, Printing, canPrintLists, isFinalJobState, listScopeChoices, readRememberedPrinter, resolvePrinter, runProcess, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type ListScope, type Printer, type PrintJob, type PrintOutcome, type ProcessMeta } from '@qrun-io/qqq-core'
|
||||
import { ChoiceOverlay, type Choice } from './overlays/basic'
|
||||
import type { AppStore } from './store'
|
||||
import type { Screen } from './types'
|
||||
import { errorText } from './util/text'
|
||||
import { saveFile } from './overlays/files-helpers'
|
||||
import { breadcrumbs } from './breadcrumbs'
|
||||
import { dateSettingsFor } from './controls/date-settings'
|
||||
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
|
||||
import { history } from './history/record'
|
||||
|
||||
/** The printing module of the store's server, or undefined without a client or the extension. */
|
||||
@@ -35,6 +39,7 @@ export function printingOf(store: AppStore): Printing | undefined {
|
||||
const meta = await client.processMeta(processName) as unknown as ProcessMeta
|
||||
return (await runProcess({ baseUrl: client.server, fetch: client.fetch, meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })).values
|
||||
},
|
||||
download: (reference) => downloadProcessFile(client.server, client.fetch, reference),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,17 +71,26 @@ export function printerChoices(printers: Printer[], rememberedId: string | undef
|
||||
return { choices, initial: remembered >= 0 ? remembered : Math.max(0, firstPrintable) }
|
||||
}
|
||||
|
||||
/** The first entry of the printer picker when the server can render to a PDF download. */
|
||||
export const SAVE_AS_PDF = 'Save as PDF…'
|
||||
|
||||
/**
|
||||
* The printer picker: printable printers first, the last used one on this profile preselected.
|
||||
* With `hint` (`:print <printer>`) the printer is chosen by name, id or unique prefix without the
|
||||
* picker. The choice is remembered for the profile, then `send` queues the print.
|
||||
* The printer picker: "Save as PDF…" first (when the server can render to a download and the
|
||||
* caller says what to render), then the printers, printable ones first, the last used one on this
|
||||
* profile preselected. With `hint` (`:print <printer>`) the printer is chosen by name, id or unique
|
||||
* prefix without the picker; `:print pdf` saves. A printer choice is remembered for the profile,
|
||||
* then `send` queues the print.
|
||||
*/
|
||||
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string): void {
|
||||
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string, renders?: RenderRequest[]): void {
|
||||
const printing = printingOf(store)
|
||||
if (!printing) return store.flash(`Printing needs ${store.client ? 'the printing extension' : 'a server connection'}.`, 'error')
|
||||
const canSave = Boolean(renders?.length) && canSaveAsPdf(store.meta as never)
|
||||
if (hint && /^(pdf|file)$/i.test(hint.trim())) {
|
||||
return canSave ? savePdfs(store, printing, renders!) : store.flash('This server cannot render to PDF (it needs the printingRenderToPdf process of qbit-printing).', 'error')
|
||||
}
|
||||
const profile = store.status.profile
|
||||
store.run(printing.printers(), (printers) => {
|
||||
if (!printers.length) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
|
||||
if (!printers.length && !canSave) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
|
||||
const go = (printer: Printer) => {
|
||||
writeRememberedPrinter(profile, { id: printer.id, name: printer.name })
|
||||
send(printing, printer)
|
||||
@@ -92,7 +106,44 @@ export function pickPrinter(store: AppStore, title: string, send: (printing: Pri
|
||||
return why ? store.flash(`"${printer.name}" cannot print now: ${why}.`, 'error') : go(printer)
|
||||
}
|
||||
const { choices, initial } = printerChoices(printers, readRememberedPrinter(profile)?.id)
|
||||
store.open(new ChoiceOverlay<Printer>(store, title, choices, go, 'printers that cannot print now are shown with the reason; the choice is remembered for this profile', initial))
|
||||
const anyPrintable = choices.some((c) => !c.disabled)
|
||||
const all: Choice<Printer | 'pdf'>[] = [
|
||||
...(canSave ? [{ label: SAVE_AS_PDF, value: 'pdf' as const, detail: 'render it and save it like a download: download folder, file dialog' }] : []),
|
||||
...choices,
|
||||
]
|
||||
const first = canSave && !anyPrintable ? 0 : initial + (canSave ? 1 : 0)
|
||||
store.open(new ChoiceOverlay<Printer | 'pdf'>(store, title, all, (choice) => (choice === 'pdf' ? savePdfs(store, printing, renders!) : go(choice)),
|
||||
'Save as PDF saves where downloads go; printers that cannot print now are shown with the reason; the printer is remembered for this profile', first))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save as PDF: renders each document on the server and saves it as downloads and exports are
|
||||
* saved: the save dialog (or, with silent downloads, the download folder), the stamped default
|
||||
* name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), no silent overwrite. More than one
|
||||
* document go next to the first, each with its own stamped name.
|
||||
*/
|
||||
export function savePdfs(store: AppStore, printing: Printing, renders: RenderRequest[]): void {
|
||||
store.flash(`Rendering ${renders.length === 1 ? 'the PDF' : `${renders.length} PDFs`}…`)
|
||||
const chrome = chromeOf(store)
|
||||
store.run(printing.renderToPdf(renders[0]!, new Date(), chrome), (first) => {
|
||||
saveFile(store, renders.length === 1 ? 'Save as PDF' : `Save ${renders.length} PDFs (the others go next to this one)`, first.fileName, (path) => {
|
||||
const saved = [saveDownload(path, first.bytes, { explicit: true })]
|
||||
const notes = first.message ? [first.message] : []
|
||||
const done = () => {
|
||||
history.record(store, { kind: 'print', summary: `Saved ${saved.length === 1 ? saved[0] : `${saved.length} PDFs to ${dirname(saved[0]!)}`}` })
|
||||
store.message([`Saved ${saved.length === 1 ? saved[0] : `${saved.length} PDFs to ${dirname(saved[0]!)}`} (${first.bytes.length.toLocaleString('en-US')} bytes${saved.length > 1 ? ' for the first' : ''}).`, ...notes].join('\n'), 'info', 'Save as PDF')
|
||||
}
|
||||
const rest = renders.slice(1)
|
||||
if (!rest.length) return done()
|
||||
store.run((async () => {
|
||||
for (const request of rest) {
|
||||
const pdf = await printing.renderToPdf(request, new Date(), chrome)
|
||||
if (pdf.message) notes.push(pdf.message)
|
||||
saved.push(saveDownload(join(dirname(path), pdf.fileName), pdf.bytes, { explicit: false }))
|
||||
}
|
||||
})(), done)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,7 +165,7 @@ export function openPrintDialog(store: AppStore, table: string, ids: string[], w
|
||||
const unavailable = printUnavailable(store, table)
|
||||
if (!printingOf(store) || unavailable) return store.flash(`Print ${unavailable ?? 'needs a server connection'}.`, 'error')
|
||||
if (!ids.length) return store.flash('Nothing to print: select records first (Space).', 'error')
|
||||
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id }).then((jobIds) => ({ jobIds }))), hint)
|
||||
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id, chrome: chromeOf(store) }).then((jobIds) => ({ jobIds }))), hint, ids.map((id) => ({ kind: 'record' as const, table, id })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -161,8 +212,8 @@ export function printTargetOf(screen: Screen | undefined): PrintTarget {
|
||||
* The text of the screen as drawn (the renderer's character frame), without the status line and
|
||||
* the function-key bar, trailing spaces and trailing blank lines removed.
|
||||
*/
|
||||
export function captureScreenText(store: AppStore): string {
|
||||
const frame = store.captureScreen?.() ?? ''
|
||||
export function captureScreenText(store: AppStore, captured?: string): string {
|
||||
const frame = captured ?? store.captureScreen?.() ?? ''
|
||||
const lines = frame.replace(/\r/g, '').split('\n')
|
||||
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop()
|
||||
const body = lines.slice(0, Math.max(0, lines.length - 2)).map((l) => l.replace(/\s+$/, ''))
|
||||
@@ -182,31 +233,198 @@ export function printScreen(store: AppStore, hint?: string): void {
|
||||
if (target.kind === 'record') {
|
||||
if (tableHasPrint(store.meta as never, target.table)) return openPrintDialog(store, target.table, target.ids, target.what, hint)
|
||||
if (!canPrintLists(store.meta as never)) return saveInstead(store, target)
|
||||
return printText(store, target.what, hint)
|
||||
return askWhat(store, target.what, hint)
|
||||
}
|
||||
if (target.kind === 'text') return printText(store, target.title, hint)
|
||||
if (target.kind === 'text') return askWhat(store, target.title, hint)
|
||||
const grid = target.grid
|
||||
const choices = listScopeChoices({ total: grid.total, pageIndex: grid.pageIndex, pageSize: grid.pageSize, pageRows: grid.pageRows, selected: grid.selected, filtered: Boolean(grid.filterText), recordSheets: tableHasPrint(store.meta as never, grid.table) })
|
||||
const scoped = (scope: ListScope) => {
|
||||
if (scope.kind === 'sheets') return openPrintDialog(store, grid.table, scope.ids, `${scope.ids.length} ${grid.label} records`, hint)
|
||||
const what = scope.kind === 'all' ? `${grid.label}: ${grid.total === undefined ? 'all' : Math.min(grid.total, MAX_LIST_ROWS).toLocaleString('en-US')} rows` : scope.kind === 'page' ? `${grid.label}: page ${grid.pageIndex + 1}` : `${grid.label}: ${scope.ids.length} selected rows`
|
||||
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({
|
||||
const list = {
|
||||
table: grid.table, columns: grid.columns.map((c) => c.name), widths: grid.columns.every((c) => c.width) ? grid.columns.map((c) => c.width!) : undefined,
|
||||
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, printer: printer.id, primaryKeyField: grid.primaryKeyField,
|
||||
})), hint)
|
||||
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, primaryKeyField: grid.primaryKeyField,
|
||||
}
|
||||
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({ ...list, printer: printer.id, chrome: chromeOf(store) })), hint, [{ kind: 'list', list }])
|
||||
}
|
||||
if (grid.selectAll) return scoped({ kind: 'all' })
|
||||
store.open(new ChoiceOverlay<ListScope>(store, `Print ${grid.label}: what?`, choices.map((c) => ({ label: c.label, value: c.value, detail: c.detail })), scoped, 'the table list uses the columns, filter and sort you see; the header repeats on every page'))
|
||||
}
|
||||
|
||||
/** Prints the screen's text (monospace PDF). */
|
||||
function printText(store: AppStore, title: string, hint?: string): void {
|
||||
const text = captureScreenText(store)
|
||||
if (!text.trim()) return store.flash('There is nothing on the screen to print.', 'error')
|
||||
const wide = text.split('\n').some((l) => l.length > 110)
|
||||
pickPrinter(store, `Print this screen (${title}) on…`, (p, printer) => queued(store, p, printer, `screen ${title}`, p.printText({ title, subtitle: `qqq ui · ${store.status.server.replace(/^https?:\/\//, '')}`, text, printer: printer.id, orientation: wide ? 'LANDSCAPE' : 'PORTRAIT' })), hint)
|
||||
function printText(store: AppStore, title: string, text: string, sections?: ContentSection[], hint?: string): void {
|
||||
const wide = !sections && text.split('\n').some((l) => l.length > 110)
|
||||
const page = { title, text, sections, orientation: wide ? 'LANDSCAPE' as const : 'PORTRAIT' as const }
|
||||
const chrome = chromeOf(store)
|
||||
const what = sections ? `${title} (content)` : `screen ${title}`
|
||||
pickPrinter(store, `Print ${sections ? 'the content of' : 'this screen'} (${title}) on…`, (p, printer) => queued(store, p, printer, what, p.printText({ ...page, printer: printer.id, chrome })), hint, [{ kind: 'text', ...page }])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// the app's header and footer, and a screen's content as sections
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The header and footer every printed page carries: the app (instance branding, else the
|
||||
* profile), the breadcrumb of the screen, the server (profile and host), the user, the time in
|
||||
* the profile's date format; the footer `qqq ui <version> · <server>` and, for grids, the filter.
|
||||
*/
|
||||
export function chromeOf(store: AppStore, footerNote?: string, now: Date = new Date()): PrintChrome {
|
||||
const branding = (store.meta as { branding?: { appName?: string } } | undefined)?.branding
|
||||
let host = store.status.server
|
||||
try {
|
||||
host = new URL(store.status.server).host
|
||||
} catch {
|
||||
host = store.status.server.replace(/^https?:\/\//, '')
|
||||
}
|
||||
const profile = store.status.profile
|
||||
const server = profile && profile !== host && profile !== profileNameForServer(store.status.server) ? `${profile} (${host})` : host
|
||||
let printedAt = now.toISOString()
|
||||
try {
|
||||
printedAt = formatDateValue(now.toISOString(), 'DATE_TIME', dateSettingsFor(store), { withSeconds: false })
|
||||
} catch {
|
||||
printedAt = now.toISOString()
|
||||
}
|
||||
return {
|
||||
app: branding?.appName ?? profileNote(profile) ?? 'QQQ',
|
||||
title: breadcrumbs(store).join(' › '),
|
||||
server,
|
||||
user: store.status.user,
|
||||
printedAt,
|
||||
footerLeft: `qqq ui ${CLI_VERSION} · ${server}`,
|
||||
footerNote,
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile's description (its note in config.json), if it has one. */
|
||||
function profileNote(profile: string): string | undefined {
|
||||
try {
|
||||
return readConfig().profiles[profile]?.note || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const TOP_LEFT = new Set(['┌', '╭'])
|
||||
const TOP_RIGHT = new Set(['┐', '╮'])
|
||||
const BOTTOM_LEFT = new Set(['└', '╰'])
|
||||
const BOX = /[─│┌┐└┘├┤┬┴┼╭╮╰╯═║╔╗╚╝╟╢╠╣▏▕]/g
|
||||
|
||||
interface Box { top: number; left: number; bottom: number; right: number; title: string }
|
||||
|
||||
/** Boxes drawn with single lines in a character grid (their title from the top edge). */
|
||||
function boxesOf(grid: string[][]): Box[] {
|
||||
const out: Box[] = []
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
const row = grid[r]!
|
||||
for (let c = 0; c < row.length; c++) {
|
||||
if (!TOP_LEFT.has(row[c]!)) continue
|
||||
let right = -1
|
||||
for (let x = c + 1; x < row.length; x++) {
|
||||
if (TOP_RIGHT.has(row[x]!)) { right = x; break }
|
||||
if (TOP_LEFT.has(row[x]!)) break
|
||||
}
|
||||
if (right < 0) continue
|
||||
let bottom = -1
|
||||
for (let y = r + 1; y <= grid.length; y++) {
|
||||
// a panel cut off by the bottom of its region runs to the end
|
||||
if (y === grid.length) { bottom = y; break }
|
||||
const ch = grid[y]![c]
|
||||
if (ch !== undefined && BOTTOM_LEFT.has(ch)) { bottom = y; break }
|
||||
if (ch !== '│' && ch !== '║') { bottom = y; break }
|
||||
}
|
||||
if (bottom < 0) continue
|
||||
const title = row.slice(c + 1, right).join('').replace(/^[─\s]+|[─\s]+$/g, '').trim()
|
||||
out.push({ top: r, left: c, bottom, right, title })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Text lines without box characters, trimmed; blank and border-only lines collapse. */
|
||||
function cleanLines(lines: string[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(BOX, ' ').replace(/\s+$/, '')
|
||||
if (!line.trim()) {
|
||||
if (out.length && out[out.length - 1] !== '') out.push('')
|
||||
continue
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
while (out.length && out[out.length - 1] === '') out.pop()
|
||||
const indent = Math.min(...out.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length))
|
||||
return Number.isFinite(indent) && indent > 0 ? out.map((l) => l.slice(indent)) : out
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen's content as sections, read from the drawn frame: the content panel only (not the
|
||||
* Apps sidebar, the title bar, the status line or the key bar), each inner panel as a section
|
||||
* with its title and lines, the text around them as untitled sections, in reading order, borders
|
||||
* removed and the panel's key hint dropped.
|
||||
*/
|
||||
export function contentSections(frame: string): ContentSection[] {
|
||||
const lines = frame.replace(/\r/g, '').split('\n')
|
||||
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop()
|
||||
const body = lines.slice(1, Math.max(1, lines.length - 2))
|
||||
const grid = body.map((l) => Array.from(l))
|
||||
// the Apps sidebar ("Apps (Tab)", "Apps · type to find") is navigation, not content
|
||||
const boxes = boxesOf(grid).filter((b) => !/^Apps(\s+\(|\s+·|$)/.test(b.title))
|
||||
if (!boxes.length) {
|
||||
const text = cleanLines(body)
|
||||
return text.length ? [{ title: '', lines: text }] : []
|
||||
}
|
||||
const area = (b: Box) => (b.bottom - b.top) * (b.right - b.left)
|
||||
const panel = boxes.reduce((a, b) => (area(b) > area(a) ? b : a))
|
||||
const inside = boxes.filter((b) => b !== panel && b.top > panel.top && b.bottom <= panel.bottom && b.left >= panel.left && b.right <= panel.right)
|
||||
const children = inside.filter((b) => !inside.some((o) => o !== b && b.top > o.top && b.bottom < o.bottom && b.left > o.left && b.right < o.right))
|
||||
const sections: ContentSection[] = []
|
||||
let free: string[] = []
|
||||
const flush = () => {
|
||||
const text = cleanLines(free)
|
||||
if (text.length) sections.push({ title: '', lines: text })
|
||||
free = []
|
||||
}
|
||||
const freeRows: Array<{ row: number; text: string }> = []
|
||||
for (let r = panel.top + 1; r < panel.bottom; r++) {
|
||||
const cells = grid[r]!.slice(panel.left + 1, panel.right).map((ch, i) => (children.some((b) => r >= b.top && r <= b.bottom && panel.left + 1 + i >= b.left && panel.left + 1 + i <= b.right) ? ' ' : ch))
|
||||
freeRows.push({ row: r, text: cells.join('') })
|
||||
}
|
||||
// the panel's last line is its key hint ("a actions · … · Esc back"): not content
|
||||
const lastText = [...freeRows].reverse().find((f) => f.text.trim())
|
||||
if (lastText && /(Esc|a actions| · )/.test(lastText.text) && /(Esc|actions|Enter)/.test(lastText.text)) lastText.text = ''
|
||||
const starts = new Map<number, Box[]>()
|
||||
for (const b of children) starts.set(b.top, [...(starts.get(b.top) ?? []), b].sort((x, y) => x.left - y.left))
|
||||
for (const f of freeRows) {
|
||||
const opening = starts.get(f.row)
|
||||
if (opening) {
|
||||
flush()
|
||||
for (const b of opening) {
|
||||
const text = cleanLines(grid.slice(b.top + 1, b.bottom).map((row) => row.slice(b.left + 1, b.right).join('')))
|
||||
if (b.title || text.length) sections.push({ title: b.title, lines: text })
|
||||
}
|
||||
}
|
||||
free.push(f.text)
|
||||
}
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
/** Ctrl+P on a screen that is not a record or a grid: its content as a document, or the screen as drawn. */
|
||||
function askWhat(store: AppStore, title: string, hint?: string): void {
|
||||
const frame = store.captureScreen?.() ?? ''
|
||||
const sections = contentSections(frame)
|
||||
const screen = captureScreenText(store, frame)
|
||||
if (!screen.trim()) return store.flash('There is nothing on the screen to print.', 'error')
|
||||
if (hint || !sections.length) return printText(store, title, screen, sections.length ? sections : undefined, hint)
|
||||
store.open(new ChoiceOverlay<'content' | 'screen'>(store, 'Print what?', [
|
||||
{ label: 'Content', value: 'content', detail: "the screen's panels as a document: no navigation, no borders" },
|
||||
{ label: 'Screen', value: 'screen', detail: 'the terminal as you see it, line for line' },
|
||||
], (what) => printText(store, title, screen, what === 'content' ? sections : undefined), 'both carry the app header and footer'))
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Without printing: the grid's export, else the screen text saved as a file. */
|
||||
function saveInstead(store: AppStore, target: PrintTarget): void {
|
||||
const screen = store.screen as { exportDialog?: () => void } | undefined
|
||||
|
||||
@@ -16,6 +16,10 @@ export const printing = {
|
||||
enabled: false,
|
||||
/** The 2.1 table-list and text prints (on with the extension unless a test turns them off). */
|
||||
lists: true,
|
||||
/** Save as PDF (printingRenderToPdf). */
|
||||
render: true,
|
||||
/** Render runs: the values each started with. */
|
||||
renders: [] as J[],
|
||||
/** Table-list and text runs: the process and the values it finished with. */
|
||||
listRuns: [] as Array<{ process: string; values: J }>,
|
||||
/** Print runs: the values and record ids each one finished with. */
|
||||
@@ -24,6 +28,8 @@ export const printing = {
|
||||
reset(): void {
|
||||
this.enabled = false
|
||||
this.lists = true
|
||||
this.render = true
|
||||
this.renders = []
|
||||
this.listRuns = []
|
||||
this.runs = []
|
||||
this.jobs.clear()
|
||||
@@ -41,6 +47,7 @@ export function printProcessesMeta(): J {
|
||||
if (!printing.enabled) return {}
|
||||
return {
|
||||
printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' },
|
||||
...(printing.render ? { printingRenderToPdf: { name: 'printingRenderToPdf', label: 'Save as PDF' } } : {}),
|
||||
...(printing.lists ? { printingPrintTableList: { name: 'printingPrintTableList', label: 'Print a table list' }, printingPrintText: { name: 'printingPrintText', label: 'Print text' } } : {}),
|
||||
}
|
||||
}
|
||||
@@ -76,6 +83,16 @@ export async function printRoute(path: string, method: string, body: unknown): P
|
||||
})
|
||||
return json({ type: 'COMPLETE', processUUID: 'pr', nextStep: 'result', values: { printingJobIds: ids.join(',') } })
|
||||
}
|
||||
if (printing.render && path === '/metaData/process/printingRenderToPdf') {
|
||||
return json({ name: 'printingRenderToPdf', label: 'Save as PDF', frontendSteps: [{ name: 'download', label: 'Download', components: [{ type: 'DOWNLOAD_FORM' }], viewFields: [{ name: 'downloadFileName', label: 'File', type: 'STRING' }] }] })
|
||||
}
|
||||
if (printing.render && path === '/processes/printingRenderToPdf/init') {
|
||||
const v = values()
|
||||
printing.renders.push(v)
|
||||
const name = v.kind === 'RECORD' ? `Person-${v.recordId}` : v.kind === 'LIST' ? 'Person' : String(v.title).replace(/[^A-Za-z0-9]+/g, '-')
|
||||
return json({ type: 'COMPLETE', processUUID: 'rd', nextStep: 'download', values: { downloadFileName: `${name}.pdf`, serverFilePath: `/tmp/qqq-print-${printing.renders.length}.pdf` } })
|
||||
}
|
||||
if (printing.render && path.startsWith('/download/')) return new Response(`%PDF-1.7 ${path}`, { status: 200, headers: { 'content-type': 'application/pdf' } })
|
||||
const list = /^\/(?:metaData\/process|processes)\/(printingPrintTableList|printingPrintText)(\/.*)?$/.exec(path)
|
||||
if (list && printing.lists) {
|
||||
const name = list[1]!
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, expect, test } from 'bun:test'
|
||||
import { KeyCodes } from '@opentui/core/testing'
|
||||
import { printerOf, readRememberedPrinter, writeRememberedPrinter } from '@qrun-io/qqq-core'
|
||||
import { captureScreenText, printTargetOf, printerChoices } from '../src/printing'
|
||||
import { printerOf, readRememberedPrinter, writeFilePreferences, writeRememberedPrinter } from '@qrun-io/qqq-core'
|
||||
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { captureScreenText, contentSections, printTargetOf, printerChoices } from '../src/printing'
|
||||
import { printing } from './fake-print'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
|
||||
@@ -14,6 +17,7 @@ let h: Harness | undefined
|
||||
beforeAll(() => isolate())
|
||||
beforeEach(() => {
|
||||
printing.reset()
|
||||
writeFilePreferences({ downloadMode: undefined, downloadDir: undefined })
|
||||
writeRememberedPrinter('fake', undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -72,7 +76,7 @@ test('record: p opens the printer picker, Enter prints, the job is followed in t
|
||||
await press(h, () => h!.setup.mockInput.typeText('Virtual PDF'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.runs.length === 1)
|
||||
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
|
||||
expect(printing.runs[0]).toMatchObject({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
|
||||
f = await waitFor(h, (x) => x.includes('Queued a print job for Person First2 Odd on Virtual PDF printer'))
|
||||
expect(h.store.status.print).toBe('print 0/1 QUEUED')
|
||||
await poll()
|
||||
@@ -102,7 +106,7 @@ test('grid: Space selects records, p, "a record sheet for each selected row" pri
|
||||
expect(f).toContain('Back office laser (last used)')
|
||||
await enter()
|
||||
await waitFor(h, () => printing.runs.length === 1)
|
||||
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
|
||||
expect(printing.runs[0]).toMatchObject({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
|
||||
await waitFor(h, () => h!.store.status.print === 'print 0/2 QUEUED')
|
||||
await poll()
|
||||
await poll()
|
||||
@@ -206,20 +210,88 @@ test('Ctrl+P on a record prints its record sheet; F8 says Print where there is n
|
||||
expect(f).toContain('8Print')
|
||||
expect(printTargetOf(h.store.screen)).toEqual({ kind: 'text', title: h.store.screen!.title() })
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F8))
|
||||
f = await waitFor(h, (x) => x.includes('Print this screen'))
|
||||
f = await waitFor(h, (x) => x.includes('Print what?'))
|
||||
// Content first (the default), Screen second
|
||||
expect(f.indexOf('Content')).toBeLessThan(f.indexOf('Screen '))
|
||||
await enter()
|
||||
f = await waitFor(h, (x) => x.includes('Print the content of'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.listRuns.at(-1)?.values.printingPrinterId !== undefined)
|
||||
const run = printing.listRuns.at(-1)!
|
||||
expect(run.process).toBe('printingPrintText')
|
||||
const sections = JSON.parse(String(run.values.sections)) as Array<{ title: string; lines: string[] }>
|
||||
expect(sections.length).toBeGreaterThan(0)
|
||||
const content = JSON.stringify(sections)
|
||||
expect(content).not.toContain('Apps (Tab)')
|
||||
expect(content).not.toContain('│')
|
||||
expect(content).not.toContain('1Help')
|
||||
// the app header and footer
|
||||
expect(run.values.headerApp).toBe('Fake')
|
||||
expect(run.values.headerTitle).toBe(h.store.screen!.title())
|
||||
expect(String(run.values.headerServer)).toContain('fake.test')
|
||||
expect(run.values.headerUser).toBe(h.store.status.user)
|
||||
expect(String(run.values.headerPrintedAt)).toMatch(/\d/)
|
||||
expect(String(run.values.footerLeft)).toMatch(/^qqq ui \d+\.\d+\.\d+ · .*fake\.test/)
|
||||
})
|
||||
|
||||
test('Print what? → Screen prints the terminal as drawn; a record print carries the breadcrumb', async () => {
|
||||
printing.enabled = true
|
||||
h = await startTui(120, 36)
|
||||
await ctrlP()
|
||||
await waitFor(h, (x) => x.includes('Print what?'))
|
||||
await arrow('down')
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Print this screen'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.listRuns.at(-1)?.values.printingPrinterId !== undefined)
|
||||
const run = printing.listRuns.at(-1)!
|
||||
expect(run.values.sections).toBeUndefined()
|
||||
expect(String(run.values.text)).toContain('Apps')
|
||||
expect(String(run.values.text)).not.toContain('1Help')
|
||||
await command(h, 'table get person 2')
|
||||
await waitFor(h, (f) => f.includes('First2 Odd'))
|
||||
await ctrlP()
|
||||
await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
|
||||
await press(h, () => h!.setup.mockInput.typeText('Virtual PDF'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.runs.length === 1)
|
||||
expect(printing.runs[0]!.values.headerTitle).toBe('People › Person › First2 Odd')
|
||||
})
|
||||
|
||||
test('content sections: the content panel only, inner panels as titled sections in reading order, no borders, no key hint', () => {
|
||||
const frame = [
|
||||
' Fake ─ Home Tester ',
|
||||
'┌─ Apps (Tab) ─┐┌─ Home ───────────────────────────────────────────┐',
|
||||
'│▸ People ││Good morning, Tester │',
|
||||
'│ ││┌─ Recently viewed ─┐┌─ Quick actions ───────────┐ │',
|
||||
'│ │││ Ada Lovelace ││ ▷ Simple Sleep run │ │',
|
||||
'│ │││ Blair Sample ││ + New Person create │ │',
|
||||
'│ ││└───────────────────┘└───────────────────────────┘ │',
|
||||
'│ ││ Applications (2) │',
|
||||
'│ ││ a actions · Tab panels · Esc back │',
|
||||
'└──────────────┘└───────────────────────────────────────────────────┘',
|
||||
' fake.test · fake · mock session · jobs n/a ',
|
||||
' 1Help 2Search 8Print ',
|
||||
].join('\n')
|
||||
const sections = contentSections(frame)
|
||||
expect(sections).toEqual([
|
||||
{ title: '', lines: ['Good morning, Tester'] },
|
||||
{ title: 'Recently viewed', lines: ['Ada Lovelace', 'Blair Sample'] },
|
||||
{ title: 'Quick actions', lines: ['▷ Simple Sleep run', '+ New Person create'] },
|
||||
{ title: '', lines: ['Applications (2)'] },
|
||||
])
|
||||
expect(contentSections('')).toEqual([])
|
||||
// a panel cut off by the bottom of the content panel still becomes a section
|
||||
const clipped = [' t', '┌─ Main ──────────────┐', '│┌─ Apps grid ───────┐│', '││ Alerts Cluster ││', '││ 3 tables ││', ' status', ' keys'].join('\n')
|
||||
expect(contentSections(clipped)).toEqual([{ title: 'Apps grid', lines: ['Alerts Cluster', '3 tables'] }])
|
||||
})
|
||||
|
||||
|
||||
test('Print Screen (when the terminal passes it on) and :print <printer> print without the picker', async () => {
|
||||
printing.enabled = true
|
||||
h = await startTui(120, 36)
|
||||
await press(h, () => h!.store.handleKey({ name: 'printscreen', sequence: '' } as never))
|
||||
await waitFor(h, (x) => x.includes('Print this screen'))
|
||||
await waitFor(h, (x) => x.includes('Print what?'))
|
||||
await esc()
|
||||
await command(h, 'print back office')
|
||||
await waitFor(h, () => printing.listRuns.length === 1 && printing.listRuns[0]!.values.printingPrinterId === 'p3')
|
||||
@@ -232,3 +304,67 @@ test('the screen capture drops the status line and the function-key bar and trim
|
||||
expect(captureScreenText(store)).toBe(' Title bar\n body line')
|
||||
expect(captureScreenText({} as never)).toBe('')
|
||||
})
|
||||
|
||||
test('Save as PDF is the first picker entry: a record renders on the server and saves like a download (silent mode: the download folder, stamped name)', async () => {
|
||||
printing.enabled = true
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-tui-pdf-'))
|
||||
writeFilePreferences({ downloadMode: 'silent', downloadDir: folder })
|
||||
h = await startTui(120, 36)
|
||||
await command(h, 'table get person 2')
|
||||
await waitFor(h, (f) => f.includes('First2 Odd'))
|
||||
await ctrlP()
|
||||
const f = await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
|
||||
const picker = f.split('\n').filter((l) => l.includes('║'))
|
||||
expect(picker.findIndex((l) => l.includes('Save as PDF…'))).toBeLessThan(picker.findIndex((l) => l.includes('Back office laser')))
|
||||
await press(h, () => h!.setup.mockInput.typeText('Save as PDF'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Saved '))
|
||||
expect(printing.renders).toMatchObject([{ kind: 'RECORD', tableName: 'person', recordId: '2' }])
|
||||
expect(printing.runs).toEqual([])
|
||||
const files = readdirSync(folder)
|
||||
expect(files.length).toBe(1)
|
||||
expect(files[0]).toMatch(/^Person-2_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
expect(readFileSync(join(folder, files[0]!), 'utf8')).toStartWith('%PDF-1.7 /download/Person-2.pdf')
|
||||
})
|
||||
|
||||
test('Save as PDF on a grid saves the table list; with the dialog (default mode) the save dialog offers the stamped name', async () => {
|
||||
printing.enabled = true
|
||||
h = await startTui(140, 40)
|
||||
await command(h, 'table query person -w "lastName = Odd"')
|
||||
await waitFor(h, (f) => f.includes('First2 '))
|
||||
await ctrlP()
|
||||
await waitFor(h, (x) => x.includes('Print Person: what?'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Print Person: 15 rows on…'))
|
||||
await press(h, () => h!.setup.mockInput.typeText('Save as PDF'))
|
||||
await enter()
|
||||
const f = await waitFor(h, (x) => x.includes('Save as PDF') && /Person_\d{4}-\d{2}-\d{2}_\d{6}\.pdf/.test(x))
|
||||
expect(f).toContain('Name')
|
||||
const render = printing.renders[0]!
|
||||
expect(render.kind).toBe('LIST')
|
||||
expect(render.scope).toBe('ALL')
|
||||
expect(JSON.parse(String(render.filterJSON)).criteria[0]).toMatchObject({ fieldName: 'lastName', values: ['Odd'] })
|
||||
expect(render.printingPrinterId).toBeUndefined()
|
||||
})
|
||||
|
||||
test(':print pdf saves the screen as a PDF without the picker; a server without the render process shows no Save as PDF entry', async () => {
|
||||
printing.enabled = true
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-tui-pdf-'))
|
||||
writeFilePreferences({ downloadMode: 'silent', downloadDir: folder })
|
||||
h = await startTui(120, 36)
|
||||
await command(h, 'print pdf')
|
||||
await waitFor(h, (x) => x.includes('Saved '))
|
||||
expect(printing.renders[0]!.kind).toBe('TEXT')
|
||||
expect(String(printing.renders[0]!.text)).toContain('Apps')
|
||||
expect(readdirSync(folder)[0]).toMatch(/_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
await esc()
|
||||
printing.render = false
|
||||
// F10 › Reset refreshes the metadata: the render process is gone
|
||||
await command(h, 'reset')
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home')
|
||||
await command(h, 'table get person 2')
|
||||
await waitFor(h, (x) => x.includes('First2 Odd'))
|
||||
await ctrlP()
|
||||
const f = await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
|
||||
expect(f).not.toContain('Save as PDF…')
|
||||
})
|
||||
@@ -14,7 +14,8 @@
|
||||
# QQQ_PRINT_AGENT_DIR qqq-print-agent checkout with tools/virtual-agent
|
||||
# (default <QRun-IO>/qqq-print-agent next to this repo)
|
||||
# QQQ_PRINT_STATE pid, log and device identity (default ${TMPDIR:-/tmp}/qqq-print-agent-<port>)
|
||||
# QQQ_PRINT_OUT where printed documents go (default <state>/printed)
|
||||
# QQQ_PRINT_OUT where printed documents go (default ~/Downloads, as downloads and exports;
|
||||
# the agent's own default too); --dir prints the folder in use
|
||||
# QQQ_PRINT_PRINTER_NAME, QQQ_PRINT_DEVICE_NAME names shown in QQQ
|
||||
#
|
||||
# Then, from the CLI: qqq printer list; qqq print person 1 --printer "Virtual PDF printer"
|
||||
@@ -31,7 +32,7 @@ agent="$agent_dir/tools/virtual-agent/virtual-agent.mjs"
|
||||
port="$(node -e 'const u=new URL(process.argv[1]); console.log(u.port || (u.protocol === "https:" ? 443 : 80))' "$server")"
|
||||
tmp_root="${TMPDIR:-/tmp}"; state_dir="${QQQ_PRINT_STATE:-${tmp_root%/}/qqq-print-agent-$port}"
|
||||
state_dir="${state_dir%/}"
|
||||
out_dir="${QQQ_PRINT_OUT:-$state_dir/printed}"
|
||||
out_dir="${QQQ_PRINT_OUT:-$HOME/Downloads}"
|
||||
pid_file="$state_dir/agent.pid"
|
||||
log_file="$state_dir/agent.log"
|
||||
identity="$state_dir/device.json"
|
||||
|
||||
Reference in new issue
Block a user