Compare commits

...
Author SHA1 Message Date
james.maes 03e09c3c54 Merge remote-tracking branch 'origin/main' into feat/print-to-file 2026-11-01 13:30:41 -06:00
james.maesandClaude Fable 5.1 cccadc9a71 docs(printing): document save as PDF, content prints and the page header
print-agent.sh now prints to ~/Downloads unless QQQ_PRINT_OUT says
otherwise; --dir reports the folder in use. CHANGELOG under Unreleased.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:24:02 -06:00
james.maesandClaude Fable 5.1 51337c35ed feat(tui): Save as PDF, print the content of a screen, app header and footer
The printer picker starts with Save as PDF..., which renders the same
document on the server and saves it the way downloads and exports are
saved (save dialog or download folder, stamped name, no silent overwrite);
:print pdf saves straight away. Ctrl+P on screens that are not records or
grids asks Print what?: Content (the screen's panels as titled sections,
without the sidebar, title bar, key lines or borders) or Screen (as drawn).
Every page carries the app header and footer: instance name, breadcrumb,
server, user, time in the profile's date format, qqq ui <version>.
Breadcrumbs moved to breadcrumbs.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:00 -06:00
james.maesandClaude Fable 5.1 76bbf44144 feat(cli): --to-file on qqq print, print list and print text
--to-file saves the rendered PDF instead of printing: into a folder with
the stamped names, as a named file, or into the download folder by default,
never overwriting a default name. Every print and save carries the app's
header and footer: the instance name, the server, the user, the time in the
profile's date format and qqq <version> · <server>.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:00 -06:00
james.maesandClaude Fable 5.1 f0b1f38bdc feat(core): save as PDF, the app header and footer, content sections
renderToPdf renders a record sheet, table list or text page through the
printing qbit's printingRenderToPdf and downloads it with the stamped
download name. PrintChrome carries the app's header and footer (app,
breadcrumb, server, user, printed at; footer and filter note) on every
print and save request, and text prints take a screen's content as
sections.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:00 -06:00
15 changed files with 764 additions and 78 deletions

No files matched your search

+15
View File
@@ -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
+7 -3
View File
@@ -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.
+76 -9
View File
@@ -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)]) })
})
+2 -1
View File
@@ -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),
})
}
+1 -1
View File
@@ -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'
+58 -2
View File
@@ -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)
})
})
+99 -2
View File
@@ -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()
+56 -1
View File
@@ -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
View File
@@ -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
+3 -27
View File
@@ -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?.())
+35
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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]!
+142 -6
View File
@@ -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…')
})
+3 -2
View File
@@ -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"