Changes after the independent review of the layout preview. A person who may not author a template cannot obtain the picture of an unpublished draft by naming a revision. The revision table also holds the frozen drafts that the retained preview writes; a named revision is now resolved only when it is a publication, and any other row is answered "Document definition not found.", with the type and the words of an identity that does not exist, from the service, from the action before and after its form, and from the widget. A draft is previewed through its template alone. The widget of the "Preview layout" action is requested by the identifier of the run and reads the person's choices from the state of that run. A client sends every plain process value as a parameter of the widget request, and the framework's widget route logs the parameters of a request that fails or takes over a second, so the chosen record's identity and the sample's name were logged for a slow preview. The check step now holds the accepted choices as one structured process value, which is not sent, and the widget no longer reads a revision, record, sample, stock or resolution from any request parameter. A source record is read within the channel and credential ceilings the print path applies to the source table, asked before the record is read. A label whose picture alone is refused (PREVIEW_TOO_LARGE and no other refusal) is no longer said to be unprintable: "This label is too dense to preview; printing is not affected." The help of the form says that the longest values picture is drawn with capital W, is indicative, and that the renderer's proof at publication decides. The README records what a host must configure for label previews: no-store for widget responses, which neither a widget renderer nor the widget route sets, and what the request log holds. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
332 lines
22 KiB
Java
332 lines
22 KiB
Java
/*
|
|
* 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.documentgeneration;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.Optional;
|
|
import java.util.function.UnaryOperator;
|
|
import java.util.stream.Stream;
|
|
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
|
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
|
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
|
import com.kingsrook.qqq.backend.core.context.QContext;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
|
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessMetaDataAdjustment;
|
|
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessState;
|
|
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.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.help.QHelpContent;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
|
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.QFunctionInputMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
|
|
|
|
/*******************************************************************************
|
|
** The "Preview layout" action on a template (its current draft) and on a
|
|
** published revision: a form for the data, the stock and the resolution, and
|
|
** a result that is the preview widget.
|
|
**
|
|
** The process keeps the person's choices and nothing else. The picture, the
|
|
** values it was drawn from and its diagnostics are never process values: the
|
|
** result step names the widget, the client fetches it, and the picture exists
|
|
** only in that answer. QQQ keeps the state of a finished process for hours
|
|
** (and, with a shared state provider, in a table), so a picture of record
|
|
** data must not be put there.
|
|
**
|
|
** The widget is fetched by the identifier of the run, not by the choices. A
|
|
** client sends every plain process value (a text, a number, a boolean) as a
|
|
** parameter of the widget request, and QQQ's widget route writes the
|
|
** parameters of a request that fails or takes over a second to the
|
|
** application log. So once the form is accepted its choices, the record among
|
|
** them, are held as one structured process value (`CHOICES`), which is not a
|
|
** plain value and so not a request parameter, and the widget reads them from
|
|
** the state of the person's own run.
|
|
*******************************************************************************/
|
|
final class GenerationPreviewProcesses
|
|
{
|
|
static final String DATA = "document_generation_preview_data";
|
|
static final String RESOLUTION = "document_generation_preview_resolution";
|
|
static final String CHOICES = "document_generation_preview_choices";
|
|
|
|
private static final List<String> FORM = List.of("dataSource", "sampleName", "sourceRecordId", "stockId", "targetDpi");
|
|
private static final String RUN = "[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}";
|
|
|
|
/*******************************************************************************
|
|
** Metadata namespace.
|
|
*******************************************************************************/
|
|
private GenerationPreviewProcesses()
|
|
{
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Called once by the producer after the generation and authoring processes.
|
|
*******************************************************************************/
|
|
static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException
|
|
{
|
|
instance.addPossibleValueSource(new QPossibleValueSource().withName(DATA).withLabel("Preview data").withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
|
.withEnumValues(List.of(new QPossibleValue<>("SAMPLE", "Sample data written in the scene"), new QPossibleValue<>("LONGEST", "Longest values"),
|
|
new QPossibleValue<>("RECORD", "A record I choose")))
|
|
.withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help("Where the values of a layout preview come from: a sample the author wrote in the scene, the same sample "
|
|
+ "with every bound text filled with capital W to the maximum length it declares, or one source record the person may read. The longest values picture is indicative: "
|
|
+ "W is not the widest character of every font, and the renderer's own proof of the declared length, made when the template is published, decides whether every value fits.")));
|
|
instance.addPossibleValueSource(new QPossibleValueSource().withName(RESOLUTION).withLabel("Preview resolution").withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.INTEGER)
|
|
.withEnumValues(List.of(new QPossibleValue<>(203, "203 dpi"), new QPossibleValue<>(300, "300 dpi"), new QPossibleValue<>(600, "600 dpi")))
|
|
.withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help("The printer resolutions a label can be drawn for. A version 1 scene is drawn only at the resolution of its stock.")));
|
|
instance.addWidget(GenerationPreviewWidget.metaData());
|
|
boolean selector = instance.getPossibleValueSource(GenerationAuthoringProcesses.PREVIEW_SOURCE) != null;
|
|
for(String table : List.of(GenerationTables.TEMPLATE, GenerationTables.REVISION))
|
|
{
|
|
boolean draft = GenerationTables.TEMPLATE.equals(table);
|
|
QFrontendStepMetaData options = new QFrontendStepMetaData().withName("options").withLabel("Preview layout")
|
|
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
|
.withHelpContent(GenerationTables.help("Choose what the label is drawn with. Sample data and longest values come from the scene and involve no record. "
|
|
+ "A record is read with your own permissions. Nothing is saved, and no file is created."))
|
|
.withFormField(new QFieldMetaData("dataSource", QFieldType.STRING).withLabel("Data to draw with").withIsRequired(true).withPossibleValueSourceName(DATA)
|
|
.withHelpContent(GenerationTables.help("Sample data uses a sample written in the scene. Longest values fills every bound text with capital W to the maximum length "
|
|
+ "it declares, so overflow shows. That picture is indicative: W is not the widest character of every font, and the renderer's own proof of the declared length, "
|
|
+ "made when the template is published, decides whether every value fits. A record uses one source record you can read.")))
|
|
.withFormField(new QFieldMetaData("sampleName", QFieldType.STRING).withLabel("Sample (blank = the first in the scene)").withMaxLength(64)
|
|
.withHelpContent(GenerationTables.help("The name of a sample the scene declares under samples. Samples are written by the template's author and must be synthetic: "
|
|
+ "never copy real record data into a scene.")));
|
|
if(selector)
|
|
{
|
|
options.withFormField(new QFieldMetaData("sourceTable", QFieldType.STRING).withLabel("Records this template belongs to").withIsEditable(false)
|
|
.withPossibleValueSourceName(GenerationAuthoringProcesses.SOURCES)
|
|
.withHelpContent(GenerationTables.help("The table whose records this template is generated for. It is set on the template and cannot be changed here.")));
|
|
}
|
|
QFieldMetaData record = new QFieldMetaData("sourceRecordId", QFieldType.STRING).withLabel("Record (only for a record preview)").withMaxLength(255)
|
|
.withHelpContent(GenerationTables.help("The record to draw the label with. Only records you can read are offered, and one you cannot read is refused as not found. "
|
|
+ "Its values are used for this one picture and are not stored or logged."));
|
|
if(selector)
|
|
{
|
|
record.setPossibleValueSourceName(GenerationAuthoringProcesses.PREVIEW_SOURCE);
|
|
}
|
|
options.withFormField(record)
|
|
.withFormField(new QFieldMetaData("stockId", QFieldType.INTEGER).withLabel("Stock (blank = " + (draft ? "the template's own)" : "the revision's own)"))
|
|
.withPossibleValueSourceName(GenerationTables.STOCK)
|
|
.withHelpContent(GenerationTables.help("Draw the label on another stock to see how the layout behaves there. The template is not changed.")))
|
|
.withFormField(new QFieldMetaData("targetDpi", QFieldType.INTEGER).withLabel("Resolution (blank = the stock's own)").withPossibleValueSourceName(RESOLUTION)
|
|
.withHelpContent(GenerationTables.help("The printer resolution to draw for. A version 2 scene can be drawn at 203, 300 or 600 dpi; "
|
|
+ "a version 1 scene only at the resolution of its stock.")));
|
|
instance.addProcess(new QProcessMetaData().withName(name(table)).withTableName(table).withLabel("Preview layout").withIcon(new QIcon("image"))
|
|
.withMinInputRecords(1).withMaxInputRecords(1)
|
|
.withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help((draft ? "Draw the current draft of this template" : "Draw this published revision")
|
|
+ " as the label that would print, with its warnings and refusals, before anything is printed. Nothing is saved and no file is created."))
|
|
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class)))
|
|
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
|
|
.withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(table))))
|
|
.withStep(options)
|
|
.withStep(new QBackendStepMetaData().withName("check").withCode(new QCodeReference(Check.class)))
|
|
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Layout preview")
|
|
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", GenerationPreviewWidget.NAME))
|
|
.withHelpContent(GenerationTables.help("The picture is decoded from the exact bytes that would be sent to the printer. The first line says whether it is exact "
|
|
+ "and whether the label can print; the lines below the picture give its size and each refusal, warning and note with the element it concerns."))));
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** One stable name per table: the draft on a template, the snapshot on a revision.
|
|
*******************************************************************************/
|
|
static String name(String table)
|
|
{
|
|
return (table + "_preview_layout");
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** The choices an accepted form left in the state of the person's own run,
|
|
** for the widget of the result step. QQQ answers only the person who started
|
|
** the run, in the session that started it, and only while they may still run
|
|
** the action; a run that is unknown, has expired or holds no choices is one
|
|
** refusal. The choices are authorised again by the preview service.
|
|
*******************************************************************************/
|
|
static DocumentPreviewService.Options choices(String processUUID) throws QException
|
|
{
|
|
Optional<ProcessState> state = processUUID != null && processUUID.matches(RUN) ? RunProcessAction.getStateForUser(processUUID, null) : Optional.empty();
|
|
if(state.isEmpty() || !(state.get().getValues().get(CHOICES) instanceof Map<?, ?> chosen))
|
|
{
|
|
throw new QBadRequestException("This preview is no longer available. Run Preview layout again.");
|
|
}
|
|
return (options(name -> chosen.get(name) == null ? null : String.valueOf(chosen.get(name))));
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** The choices of the form, as the form submits them or as the state of the
|
|
** run holds them. Anything that is not a choice the form offers is refused.
|
|
*******************************************************************************/
|
|
static DocumentPreviewService.Options options(UnaryOperator<String> value) throws QBadRequestException
|
|
{
|
|
try
|
|
{
|
|
return (new DocumentPreviewService.Options(integer(value.apply("previewTemplateId")), integer(value.apply("previewRevisionId")), GenerationPorts.Format.ZPL,
|
|
DocumentPreviewService.Data.valueOf(value.apply("dataSource")), text(value.apply("sampleName")), text(value.apply("sourceRecordId")),
|
|
integer(value.apply("stockId")), integer(value.apply("targetDpi"))));
|
|
}
|
|
catch(RuntimeException invalid)
|
|
{
|
|
throw new QBadRequestException("Choose the data, the stock and the resolution of the preview from the form.");
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** An absent or blank value is no choice.
|
|
*******************************************************************************/
|
|
private static String text(String value)
|
|
{
|
|
return (value == null || value.isBlank() ? null : value);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** A whole number, or no choice.
|
|
*******************************************************************************/
|
|
private static Integer integer(String value)
|
|
{
|
|
return (text(value) == null ? null : Integer.valueOf(value.trim()));
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Who may open the action: an author on a template, a viewer on a revision.
|
|
** A record preview also needs `generate`, which the service checks when a
|
|
** record is chosen.
|
|
*******************************************************************************/
|
|
public static class Permission implements CustomPermissionChecker
|
|
{
|
|
/*******************************************************************************
|
|
** Discovery and direct execution both apply the rule.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
|
{
|
|
GenerationSecurity.require(name(GenerationTables.TEMPLATE).equals(metadata.getName()) ? "configure" : "view");
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Before the form: which template or revision, its source table, and the
|
|
** samples its scene declares.
|
|
*******************************************************************************/
|
|
public static class Prepare implements BackendStep
|
|
{
|
|
/*******************************************************************************
|
|
** Only the selected record's identity is used; everything else is read
|
|
** again under the person's authority.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
|
{
|
|
new Permission().checkPermissionsThrowing(input, input.getProcess());
|
|
if(input.getRecords() == null || input.getRecords().size() != 1)
|
|
{
|
|
throw new QBadRequestException("Select one template or one published revision.");
|
|
}
|
|
Integer id = input.getRecords().getFirst().getValueInteger("id");
|
|
boolean draft = name(GenerationTables.TEMPLATE).equals(input.getProcess().getName());
|
|
QInstance instance = QContext.getQInstance();
|
|
DocumentPreviewService.Choices choices = new DocumentPreviewService(GenerationProcesses.config(instance)).choices(draft ? id : null, draft ? null : id);
|
|
output.addValue(draft ? "previewTemplateId" : "previewRevisionId", id);
|
|
output.addValue("sourceTable", choices.sourceTable());
|
|
output.addValue("dataSource", DocumentPreviewService.Data.SAMPLE.name());
|
|
ProcessMetaDataAdjustment adjustment = new ProcessMetaDataAdjustment();
|
|
if(!choices.sampleNames().isEmpty())
|
|
{
|
|
List<QPossibleValue<?>> names = new ArrayList<>();
|
|
choices.sampleNames().forEach(name -> names.add(new QPossibleValue<>(name, name)));
|
|
QFieldMetaData sample = field(input, "sampleName");
|
|
sample.setInlinePossibleValueSource(new QPossibleValueSource().withName("document_generation_preview_samples").withType(QPossibleValueSourceType.ENUM)
|
|
.withIdType(QFieldType.STRING).withEnumValues(names));
|
|
adjustment.withUpdatedField(sample);
|
|
}
|
|
var source = instance.getTable(choices.sourceTable());
|
|
var selector = instance.getPossibleValueSource(GenerationAuthoringProcesses.sourceRecordChoices(choices.sourceTable()));
|
|
if(source != null && selector != null)
|
|
{
|
|
QFieldMetaData record = field(input, "sourceRecordId");
|
|
record.setType(source.getField(source.getPrimaryKeyField()).getType());
|
|
record.setLabel("Record (only for a record preview): " + Objects.requireNonNullElse(source.getLabel(), source.getName()));
|
|
record.setPossibleValueSourceName(selector.getName());
|
|
adjustment.withUpdatedField(record);
|
|
}
|
|
output.setProcessMetaDataAdjustment(adjustment);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** A copy of one form field, to adjust for this run only.
|
|
*******************************************************************************/
|
|
private static QFieldMetaData field(RunBackendStepInput input, String name)
|
|
{
|
|
return (input.getProcess().getFrontendStep("options").getFormFields().stream().filter(candidate -> name.equals(candidate.getName())).findFirst().orElseThrow().clone());
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** After the form: refuse at once what the preview would refuse, so the
|
|
** person sees the reason on the form. Nothing is drawn, and nothing is kept
|
|
** but the choices.
|
|
*******************************************************************************/
|
|
public static class Check implements BackendStep
|
|
{
|
|
/*******************************************************************************
|
|
** The same checks the widget's request makes again when it draws. The
|
|
** accepted choices then leave the plain process values, which a client
|
|
** sends with the widget request, for the one structured value the widget
|
|
** reads from the state of the run.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
|
{
|
|
new Permission().checkPermissionsThrowing(input, input.getProcess());
|
|
new DocumentPreviewService(GenerationProcesses.config(QContext.getQInstance())).check(options(input::getValueString));
|
|
LinkedHashMap<String, String> chosen = new LinkedHashMap<>();
|
|
for(String name : Stream.concat(Stream.of("previewTemplateId", "previewRevisionId"), FORM.stream()).toList())
|
|
{
|
|
String value = input.getValueString(name);
|
|
if(value != null)
|
|
{
|
|
chosen.put(name, value);
|
|
}
|
|
}
|
|
FORM.forEach(output.getValues()::remove);
|
|
output.addValue(CHOICES, chosen);
|
|
}
|
|
}
|
|
}
|