Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 63022f1065 feat(printing): print records of host tables as record sheets
PrintingRecordProcesses.register(instance, table) adds a Print process
(printingPrintRecords_{table}) to a host table's records and selections:
choose an approved, exposed PDF printer and copies, review, then each record
is rendered by PrintingRecordDocuments (heading, visible non-binary fields
with display values, wrapped and continued across pages) and queued as its
own PDF job. The job ids come back in the process value printingJobIds so
terminal and web clients can follow them.

Also keeps the init selection for selection actions whose first step is a
form: the UI API hands the selection only to the init call, so the existing
test-print-on-selected-printers actions failed with "No records were
selected" over HTTP. The prepare steps now store it as queryFilterJson.

Version 0.3.1-SNAPSHOT on this branch.

Refs QRun-IO/qqq#1366

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:59:51 -06:00
6 changed files with 831 additions and 1 deletions

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## Unreleased (0.3.1-SNAPSHOT)
- Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a
**Print** process (`printingPrintRecords_{table}`) to a host table's records and
selections. It asks for an approved, exposed PDF printer and a copy count, shows
the standard review, then renders each selected record as a Letter record sheet
(`PrintingRecordDocuments`: heading, visible non-binary fields with display
values, wrapped and continued across pages) and queues one PDF job per record.
The queued job IDs are returned in the process value `printingJobIds`.
- Fix selection actions whose first step is a form (**Print a test page**,
**Print a test label** on selected printers, and **Print**): over the UI API the
record selection reaches only the init call, so the review answered "No records
were selected". The prepare step now keeps the selection as `queryFilterJson`.
## 0.3.0-rc.1 — 2026-10-31
Upgrade the server before the matching Windows connector. Native printing API
+1 -1
View File
@@ -4,7 +4,7 @@
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>0.3.0-rc.1</version>
<version>0.3.1-SNAPSHOT</version>
<name>QQQ Printing</name>
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
<properties>
@@ -376,6 +376,24 @@ public class PrintingProcesses implements BackendStep
currentService();
String request = input.getValueString(TEST_REQUEST_ID);
output.addValue(TEST_REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
keepSelection(input, output);
}
}
/***************************************************************************
** A selection action whose first step is a form loses the init request's
** record selection: the UI API passes it only to the init call (as the
** callback filter) and the extract step, which would store it, runs after
** the form. Keep it as the extract step's own queryFilterJson value.
***************************************************************************/
static void keepSelection(RunBackendStepInput input, RunBackendStepOutput output)
{
String kept = input.getValueString("queryFilterJson");
if((kept == null || kept.isBlank()) && input.getCallback() != null && input.getCallback().getQueryFilter() != null)
{
output.addValue("queryFilterJson", com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(input.getCallback().getQueryFilter()));
}
}
@@ -0,0 +1,205 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
/*******************************************************************************
** A plain Letter "record sheet": a heading, a subtitle and one label/value line
** per field, wrapped and continued across pages. It is the generic document a
** record print renders when the host supplies no template of its own.
*******************************************************************************/
public final class PrintingRecordDocuments
{
static final int MAX_LINES = 2000;
private static final float MARGIN = 54;
private static final int TITLE_SIZE = 16;
private static final int BODY_SIZE = 10;
private static final float LEADING = 14;
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingRecordDocuments()
{
}
/***************************************************************************
** Render one record as a PDF. Field order is the map's order; values are the
** display text the caller already formatted (null values print blank).
***************************************************************************/
public static byte[] pdf(String title, String subtitle, Map<String, String> fields) throws QException
{
try(PDDocument document = new PDDocument(); InputStream fontStream = fontStream())
{
PDType0Font font = PDType0Font.load(document, fontStream, true);
float width = PDRectangle.LETTER.getWidth() - 2 * MARGIN;
int columns = Math.max(20, (int) (width / (font.getStringWidth("0") / 1000 * BODY_SIZE)));
int labelWidth = Math.min(28, Math.max(8, fields.keySet().stream().mapToInt(String::length).max().orElse(8) + 1));
List<String> lines = new ArrayList<>();
for(Map.Entry<String, String> field : fields.entrySet())
{
List<String> wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1);
for(int i = 0; i < wrapped.size() && lines.size() < MAX_LINES; i++)
{
String label = i == 0 ? truncate(field.getKey(), labelWidth - 1) : "";
lines.add(String.format("%-" + labelWidth + "s %s", label, wrapped.get(i)));
}
}
int index = 0;
int pageNumber = 0;
do
{
pageNumber++;
PDPage page = new PDPage(PDRectangle.LETTER);
document.addPage(page);
try(PDPageContentStream content = new PDPageContentStream(document, page))
{
float y = PDRectangle.LETTER.getHeight() - MARGIN;
text(content, font, TITLE_SIZE, MARGIN, y, truncate(title, (int) (width / (font.getStringWidth("0") / 1000 * TITLE_SIZE))));
y -= LEADING + 6;
text(content, font, BODY_SIZE, MARGIN, y, truncate(subtitle + (pageNumber > 1 ? " (continued, page " + pageNumber + ")" : ""), columns));
y -= 6;
content.moveTo(MARGIN, y);
content.lineTo(MARGIN + width, y);
content.stroke();
y -= LEADING + 4;
while(index < lines.size() && y > MARGIN)
{
text(content, font, BODY_SIZE, MARGIN, y, lines.get(index++));
y -= LEADING;
}
}
}
while(index < lines.size());
ByteArrayOutputStream output = new ByteArrayOutputStream();
document.save(output);
return (output.toByteArray());
}
catch(IOException exception)
{
throw new QException("Could not render the record document", exception);
}
}
/***************************************************************************
** Break text at spaces (or hard, for long words) into lines of at most
** width characters; embedded line breaks start new lines.
***************************************************************************/
static List<String> wrap(String text, int width)
{
List<String> lines = new ArrayList<>();
for(String paragraph : text.replace("\r\n", "\n").replace('\r', '\n').replace('\t', ' ').split("\n", -1))
{
String rest = paragraph.strip();
if(rest.isEmpty())
{
lines.add("");
continue;
}
while(rest.length() > width)
{
int cut = rest.lastIndexOf(' ', width);
if(cut <= 0)
{
cut = width;
}
lines.add(rest.substring(0, cut).stripTrailing());
rest = rest.substring(cut).stripLeading();
}
lines.add(rest);
}
return (lines);
}
/***************************************************************************
** Shorten to at most length characters, marking the cut.
***************************************************************************/
static String truncate(String text, int length)
{
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ');
return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~");
}
/***************************************************************************
** Draw one line, replacing characters the bundled font cannot encode.
***************************************************************************/
private static void text(PDPageContentStream content, PDType0Font font, int size, float x, float y, String text) throws IOException
{
StringBuilder safe = new StringBuilder();
text.codePoints().forEach(codePoint ->
{
String character = new String(Character.toChars(codePoint));
try
{
font.encode(character);
safe.append(character);
}
catch(IllegalArgumentException | IOException exception)
{
safe.append('?');
}
});
content.beginText();
content.setFont(font, size);
content.newLineAtOffset(x, y);
content.showText(safe.toString());
content.endText();
}
/***************************************************************************
** The licensed font shipped for the diagnostic documents.
***************************************************************************/
private static InputStream fontStream() throws IOException
{
InputStream stream = PrintingRecordDocuments.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE);
if(stream == null)
{
throw new IOException("Bundled document font is missing");
}
return (stream);
}
}
@@ -0,0 +1,352 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.Serializable;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
/*******************************************************************************
** "Print" on the records of a host table: choose an approved, exposed PDF
** printer and a copy count, review the selection, then each selected record is
** rendered as a record sheet (PrintingRecordDocuments) and queued as its own
** print job through the ordinary durable queue. The queued job IDs are returned
** in the process value printingJobIds (comma separated) so clients can follow
** each job's status in Print jobs.
**
** Register per table with register(instance, tableName); the process is named
** printingPrintRecords_{tableName} (processName(tableName)).
*******************************************************************************/
public final class PrintingRecordProcesses
{
public static final String PREFIX = "printingPrintRecords_";
public static final String PRINTER_FIELD = "printingPrinterId";
public static final String COPIES_FIELD = "printingCopies";
public static final String JOB_IDS = "printingJobIds";
static final String REQUEST_ID = "printingRecordRequestId";
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingRecordProcesses()
{
}
/***************************************************************************
** The record-print process name of a table.
***************************************************************************/
public static String processName(String tableName)
{
return (PREFIX + tableName);
}
/***************************************************************************
** Add the Print action to a table's records and selections.
***************************************************************************/
public static void register(QInstance instance, String tableName) throws QException
{
QTableMetaData table = instance.getTable(tableName);
if(table == null)
{
throw new QException("Cannot add printing to unknown table " + tableName);
}
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, tableName);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, tableName);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "Each selected record will be printed as its own job on the chosen printer after you continue.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
.withName(processName(tableName)).withLabel("Print").withTableName(tableName).withMinInputRecords(1)
.withPermissionRules(PrintingProcesses.permissionRules());
process.withStep(0, new QFrontendStepMetaData().withName("printer").withLabel("Choose a printer")
.withFormFields(List.of(printerField(), new QFieldMetaData(COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1)))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
process.withStep(0, new QBackendStepMetaData().withName("preparePrint").withCode(new QCodeReference(Prepare.class)));
instance.addProcess(process);
}
/***************************************************************************
** Only approved printers that the device currently exposes and that take PDF.
***************************************************************************/
static QFieldMetaData printerField()
{
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter));
}
/***************************************************************************
** The chosen printer, re-read with the caller's permissions and checked.
***************************************************************************/
static QRecord printer(RunBackendStepInput input) throws QException
{
String id = input.getValueString(PRINTER_FIELD);
if(id == null || id.isBlank())
{
throw new QException("Choose a printer");
}
QRecord printer = PrintingProcesses.resolvePrinter(id, null);
if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available"))
|| printer.getValueString("formats") == null || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains("PDF"))
{
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
}
return (printer);
}
/***************************************************************************
** 1 when unset; 1 to 100 otherwise (the protocol's copies range).
***************************************************************************/
static int copies(RunBackendStepInput input) throws QException
{
Integer copies = input.getValueInteger(COPIES_FIELD);
if(copies == null)
{
return (1);
}
if(copies < 1 || copies > 100)
{
throw new QException("Copies must be between 1 and 100");
}
return (copies);
}
/***************************************************************************
** Render one record (display values, visible non-binary fields) and queue it.
***************************************************************************/
static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request) throws QException
{
Serializable primaryKey = selected.getValue(table.getPrimaryKeyField());
GetInput get = new GetInput();
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
get.setTableName(table.getName());
get.setPrimaryKey(primaryKey);
get.setShouldGenerateDisplayValues(true);
get.setShouldTranslatePossibleValues(true);
QRecord record = new GetAction().execute(get).getRecord();
if(record == null)
{
throw new QException("Record " + primaryKey + " of " + table.getLabel() + " is not accessible");
}
Map<String, String> fields = new LinkedHashMap<>();
for(QFieldMetaData field : table.getFields().values())
{
if(Boolean.TRUE.equals(field.getIsHidden()) || field.getType() == QFieldType.BLOB)
{
continue;
}
String display = record.getDisplayValue(field.getName());
Serializable raw = record.getValue(field.getName());
fields.put(field.getLabel() == null ? field.getName() : field.getLabel(), display != null ? display : raw == null ? null : String.valueOf(raw));
}
String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey);
String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel();
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName();
String subtitle = tableLabel + " " + primaryKey + " - printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user);
byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, fields);
try
{
JSONObject job = PrintingProcesses.currentService().submit(new JSONObject()
.put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
.put("title", PrintingRecordDocuments.truncate(tableLabel + ": " + label, 200)).put("format", "PDF")
.put("contentBase64", Base64.getEncoder().encodeToString(bytes))
.put("idempotencyKey", "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))
.put("options", new JSONObject().put("copies", copies).put("scale", "FIT")));
return (job.getString("jobId"));
}
catch(java.sql.SQLException | PrintingException exception)
{
throw new QException("Could not queue the print job: " + exception.getMessage(), exception);
}
}
/***************************************************************************
** One request identity per process run, so a replayed load does not print twice.
***************************************************************************/
public static class Prepare implements BackendStep
{
/***************************************************************************
** Require printing access before any form is shown.
***************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
PrintingProcesses.currentService();
String request = input.getValueString(REQUEST_ID);
output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
PrintingProcesses.keepSelection(input, output);
}
}
/***************************************************************************
** Review: the printer is still usable; the selected records pass through.
***************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null)
.withSingularFutureMessage("record will be printed").withPluralFutureMessage("records will be printed");
/***************************************************************************
** Check the printer and copies once per page; keep the records.
***************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
printer(input);
copies(input);
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
for(QRecord record : input.getRecords())
{
ready.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
output.getRecords().add(record);
}
}
/***************************************************************************
** Standard review summary.
***************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (new ArrayList<>(List.of(ready)));
}
}
/***************************************************************************
** Execute: render and queue each record; report the job IDs.
***************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine done = new ProcessSummaryLine(Status.OK, 0, null)
.withSingularPastMessage("record was sent to the printer").withPluralPastMessage("records were sent to the printer");
private final List<String> jobIds = new ArrayList<>();
/***************************************************************************
** Resolve the printer again (access may have changed since review).
***************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
QRecord printer = printer(input);
int copies = copies(input);
String request = input.getValueString(REQUEST_ID);
if(request == null || request.isBlank())
{
throw new QException("Restart Print to obtain a safe request identity");
}
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
for(QRecord record : input.getRecords())
{
jobIds.add(submit(table, record, printer, copies, request));
done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
output.getRecords().add(record);
}
output.addValue(JOB_IDS, String.join(",", jobIds));
}
/***************************************************************************
** Result summary: what was sent, and the job IDs to follow.
***************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>(List.of(done));
if(!jobIds.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Print job" + (jobIds.size() == 1 ? " " : "s ") + String.join(", ", jobIds) + " queued; follow them in Print jobs."));
}
return (lines);
}
}
}
@@ -0,0 +1,241 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Record printing: the per-table Print process and the record sheet document.
*******************************************************************************/
class PrintingRecordProcessesTest
{
/***************************************************************************
** Clear the shared memory store and context between tests.
***************************************************************************/
@AfterEach
void clear()
{
MemoryRecordStore.getInstance().reset();
QContext.clear();
}
/***************************************************************************
** Printer form, review, then one queued PDF job per selected record.
***************************************************************************/
@Test
void printsEachSelectedRecordOnceAndReturnsTheJobIds() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixtureWithPeople();
String process = PrintingRecordProcesses.processName("person");
assertEquals("printingPrintRecords_person", process);
QFrontendStepMetaData printerStep = (QFrontendStepMetaData) fixture.instance().getProcess(process).getStep("printer");
assertEquals(PrintingTables.PRINTER, printerStep.getFormField(PrintingRecordProcesses.PRINTER_FIELD).getPossibleValueSourceName());
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1,2"));
assertFalse(form.getException().isPresent(), () -> form.getException().toString());
assertEquals("printer", form.getProcessState().getNextStepName().orElseThrow());
var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID())
.withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()).withValue(PrintingRecordProcesses.COPIES_FIELD, 2));
assertFalse(review.getException().isPresent(), () -> review.getException().toString());
assertEquals(0, fixture.service().jobs().length(), "review must not print");
var done = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID())
.withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
assertFalse(done.getException().isPresent(), () -> done.getException().toString());
JSONArray jobs = fixture.service().jobs();
assertEquals(2, jobs.length());
String ids = String.valueOf(done.getValues().get(PrintingRecordProcesses.JOB_IDS));
assertEquals(2, ids.split(",").length);
for(int i = 0; i < jobs.length(); i++)
{
JSONObject job = jobs.getJSONObject(i);
assertTrue(ids.contains(job.getString("jobId")));
assertEquals("PDF", job.getString("format"));
assertTrue(job.getString("title").startsWith("Person: "));
assertEquals(2, job.getJSONObject("options").getInt("copies"));
fixture.service().claimForJob(fixture.device(), job.getString("jobId"));
byte[] content = fixture.service().content(fixture.device(), job.getString("jobId"));
try(PDDocument document = PDDocument.load(content))
{
String text = new PDFTextStripper().getText(document);
assertTrue(text.contains("Person:"));
assertTrue(text.contains("First name"));
assertTrue(text.contains("Ada") || text.contains("Grace"));
assertFalse(text.contains("Secret"), "hidden fields are not printed");
}
}
}
/***************************************************************************
** The UI API passes the selection only to init (as the callback filter);
** the selection must survive the printer form that comes before review.
***************************************************************************/
@Test
void keepsTheInitSelectionAcrossThePrinterForm() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixtureWithPeople();
String process = PrintingRecordProcesses.processName("person");
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withCallback(new QProcessCallback()
{
@Override
public QQueryFilter getQueryFilter()
{
return (new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, List.of(2))));
}
@Override
public Map<String, Serializable> getFieldValues(List<QFieldMetaData> fields)
{
return (Map.of());
}
}));
var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID())
.withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()));
new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID())
.withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
JSONArray jobs = fixture.service().jobs();
assertEquals(1, jobs.length());
assertTrue(jobs.getJSONObject(0).getString("title").contains("Grace"));
}
/***************************************************************************
** A printer that is not approved (or not exposed) is refused at review.
***************************************************************************/
@Test
void refusesAPrinterThatIsNotApproved() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixtureWithPeople();
String process = PrintingRecordProcesses.processName("person");
var device = fixture.service();
device.enablePrinter(fixture.device(), "laser", false);
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1"));
QException refused = assertThrows(QException.class, () -> new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID())
.withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())));
assertTrue(refused.getMessage().contains("approved"), refused::getMessage);
assertEquals(0, fixture.service().jobs().length());
}
/***************************************************************************
** Unknown tables are refused at registration.
***************************************************************************/
@Test
void registerRefusesAnUnknownTable() throws Exception
{
PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture();
assertThrows(QException.class, () -> PrintingRecordProcesses.register(fixture.instance(), "nope"));
}
/***************************************************************************
** Long values wrap, long documents continue on more pages, unencodable
** characters are replaced rather than failing the render.
***************************************************************************/
@Test
void recordSheetWrapsAndContinuesAcrossPages() throws Exception
{
Map<String, String> fields = new LinkedHashMap<>();
fields.put("Notes", "word ".repeat(400));
for(int i = 0; i < 80; i++)
{
fields.put("Field " + i, "value " + i + " 中文");
}
fields.put("Empty", null);
byte[] bytes = PrintingRecordDocuments.pdf("Person: Ada Lovelace", "Person 1", fields);
try(PDDocument document = PDDocument.load(bytes))
{
assertTrue(document.getNumberOfPages() > 1);
String text = new PDFTextStripper().getText(document);
assertTrue(text.contains("Field 79"));
assertTrue(text.contains("continued, page 2"));
}
assertEquals(List.of("abc", "def"), PrintingRecordDocuments.wrap("abc def", 4));
assertEquals(List.of("abcd", "ef"), PrintingRecordDocuments.wrap("abcdef", 4));
assertEquals("ab~", PrintingRecordDocuments.truncate("abcdef", 3));
}
/***************************************************************************
** The process fixture plus a memory "person" table with two records.
***************************************************************************/
private static PrintingProcessesTest.Fixture fixtureWithPeople() throws Exception
{
PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture();
QInstance instance = fixture.instance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.addTable(new QTableMetaData().withName("person").withLabel("Person").withBackendName("memory").withPrimaryKeyField("id")
.withRecordLabelFormat("%s %s").withRecordLabelFields(List.of("firstName", "lastName"))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id"))
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withLabel("First name"))
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withLabel("Last name"))
.withField(new QFieldMetaData("secret", QFieldType.STRING).withLabel("Secret").withIsHidden(true)));
PrintingRecordProcesses.register(instance, "person");
new InsertAction().execute(new InsertInput("person").withRecords(List.of(
new QRecord().withValue("id", 1).withValue("firstName", "Ada").withValue("lastName", "Lovelace").withValue("secret", "x"),
new QRecord().withValue("id", 2).withValue("firstName", "Grace").withValue("lastName", "Hopper"))));
return (fixture);
}
}