diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fc312..9cf7897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - External display rule support along with metadata, subject, target aliasing, base template override, variable structure override and target folder override support +- Array and subtree types for variables +- Support for content (and table rows) repeated by referred array variables +- In both repeated content and variable structure paths it is possible to use literal path (e.g. Data.Clients.Value) + or reference to a variable ### Changed diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesExport.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesExport.groovy index e723066..8c24807 100644 --- a/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesExport.groovy +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesExport.groovy @@ -11,6 +11,8 @@ import com.quadient.migration.api.Migration import com.quadient.migration.api.dto.migrationmodel.VariableStructure import com.quadient.migration.example.common.util.Csv import com.quadient.migration.example.common.util.Mapping +import com.quadient.migration.shared.LiteralPath +import com.quadient.migration.shared.VariableRefPath import java.nio.file.Path import java.nio.file.Paths @@ -90,7 +92,7 @@ static void run(Migration migration, Path filePath) { for (variable in variables) { def variablePathData = existingStructure?.structure?.get(variable.id) def variableName = variablePathData?.name - def inspirePath = variablePathData?.path + def inspirePath = serializeVariablePath(variablePathData?.path) writer.write("${Csv.serialize(variable.id)},") writer.write("${Csv.serialize(variable.name)},") @@ -108,6 +110,13 @@ static void run(Migration migration, Path filePath) { } } +static String serializeVariablePath(path) { + if (path == null) return null + if (path instanceof VariableRefPath) return "@${path.variableId}" + if (path instanceof LiteralPath) return path.path + return path.toString() +} + static String constructDefaultId(List variableStructures) { def baseName = "default-" def number = 1 diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesImport.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesImport.groovy index 28ae9c1..aa32090 100644 --- a/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesImport.groovy +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/common/mapping/VariablesImport.groovy @@ -12,7 +12,9 @@ import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.example.common.util.Csv import com.quadient.migration.example.common.util.Mapping import com.quadient.migration.shared.DataType +import com.quadient.migration.shared.LiteralPath import com.quadient.migration.shared.VariablePathData +import com.quadient.migration.shared.VariableRefPath import java.nio.file.Path @@ -39,8 +41,8 @@ static void run(Migration migration, Path path) { def newName = Csv.deserialize(values.get("inspire_name"), String.class) variablePathData.name = newName - def inspirePath = Csv.deserialize(values.get("inspire_path"), String.class) - variablePathData.path = inspirePath ?: "" + def inspirePathRaw = Csv.deserialize(values.get("inspire_path"), String.class) + variablePathData.path = parseVariablePath(inspirePathRaw) structureMapping.mappings[id] = variablePathData @@ -67,4 +69,10 @@ static void run(Migration migration, Path path) { migration.mappingRepository.upsert(structureId, structureMapping) migration.mappingRepository.applyVariableStructureMapping(structureId) +} + +static parseVariablePath(String raw) { + if (!raw) return new LiteralPath("") + if (raw.startsWith("@")) return new VariableRefPath(raw.substring(1)) + return new LiteralPath(raw) } \ No newline at end of file diff --git a/migration-examples/src/main/groovy/com/quadient/migration/example/example/Import.groovy b/migration-examples/src/main/groovy/com/quadient/migration/example/example/Import.groovy index 7501655..83ac4b4 100644 --- a/migration-examples/src/main/groovy/com/quadient/migration/example/example/Import.groovy +++ b/migration-examples/src/main/groovy/com/quadient/migration/example/example/Import.groovy @@ -8,7 +8,6 @@ package com.quadient.migration.example.example import com.quadient.migration.api.Migration import com.quadient.migration.api.dto.migrationmodel.DisplayRuleRef -import com.quadient.migration.api.dto.migrationmodel.ParagraphStyleRef import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.api.dto.migrationmodel.builder.AttachmentBuilder import com.quadient.migration.api.dto.migrationmodel.builder.DisplayRuleBuilder @@ -16,6 +15,7 @@ import com.quadient.migration.api.dto.migrationmodel.builder.DocumentObjectBuild import com.quadient.migration.api.dto.migrationmodel.builder.ImageBuilder import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphBuilder import com.quadient.migration.api.dto.migrationmodel.builder.ParagraphStyleBuilder +import com.quadient.migration.api.dto.migrationmodel.builder.TableBuilder import com.quadient.migration.api.dto.migrationmodel.builder.TextStyleBuilder import com.quadient.migration.api.dto.migrationmodel.builder.VariableBuilder import com.quadient.migration.api.dto.migrationmodel.builder.VariableStructureBuilder @@ -36,7 +36,6 @@ import com.quadient.migration.shared.Size import java.time.Instant import static com.quadient.migration.example.common.util.InitMigration.initMigration -import static com.quadient.migration.api.dto.migrationmodel.builder.Dsl.table Migration migration = initMigration(this.binding) @@ -50,89 +49,102 @@ def logoHeight = Size.ofMillimeters(13) // Define variables to be used in the address and table def displayHeaderVariable = new VariableBuilder("displayHeader") - .defaultValue("true") - .dataType(DataType.Boolean).build() + .defaultValue("true") + .dataType(DataType.Boolean).build() def displayParagraphVariable = new VariableBuilder("displayParagraph") - .defaultValue("true") - .dataType(DataType.Boolean).build() + .defaultValue("true") + .dataType(DataType.Boolean).build() def displayLastSentenceVariable = new VariableBuilder("displayLastSentence") - .defaultValue("true") - .dataType(DataType.Boolean).build() + .defaultValue("true") + .dataType(DataType.Boolean).build() def nameVariable = new VariableBuilder("name") - .defaultValue("John Doe") - .dataType(DataType.String).build() + .defaultValue("John Doe") + .dataType(DataType.String).build() def addressVariable = new VariableBuilder("address") - .defaultValue("123 Main St") - .dataType(DataType.String).build() + .defaultValue("123 Main St") + .dataType(DataType.String).build() def cityVariable = new VariableBuilder("city") - .defaultValue("Anytown") - .dataType(DataType.String).build() + .defaultValue("Anytown") + .dataType(DataType.String).build() def stateVariable = new VariableBuilder("state") - .defaultValue("Canada") - .dataType(DataType.String).build() + .defaultValue("Canada") + .dataType(DataType.String).build() +def jobNameVariable = new VariableBuilder("jobName") + .dataType(DataType.String).build() + +def addressSubtreeVariable = new VariableBuilder("addressSubtree").name("Address") + .dataType(DataType.SubTree).build() +def clientsArrayVariable = new VariableBuilder("clientsArray").name("Clients") + .dataType(DataType.Array).build() +def jobsArrayVariable = new VariableBuilder("jobsArray").name("jobs") + .dataType(DataType.Array).build() def variableStructure = new VariableStructureBuilder("variableStructure") - .addVariable(displayHeaderVariable.id, "Data.Clients.Value") - .addVariable(displayParagraphVariable.id, "Data.Clients.Value") - .addVariable(displayLastSentenceVariable.id, "Data.Clients.Value") - .addVariable(nameVariable.id, "Data.Clients.Value") - .addVariable(addressVariable.id, "Data.Clients.Value") - .addVariable(cityVariable.id, "Data.Clients.Value") - .addVariable(stateVariable.id, "Data.Clients.Value") + .addVariable(displayHeaderVariable.id, new VariableRef(clientsArrayVariable.id)) + .addVariable(displayParagraphVariable.id, new VariableRef(clientsArrayVariable.id)) + .addVariable(displayLastSentenceVariable.id, new VariableRef(clientsArrayVariable.id)) + .addVariable(nameVariable.id, "Data.Clients.Value") // Literal path that works the same way as reference to clientsArrayVariable + .addVariable(addressVariable.id, new VariableRef(addressSubtreeVariable.id)) + .addVariable(cityVariable.id, new VariableRef(addressSubtreeVariable.id)) + .addVariable(stateVariable.id, "Data.Clients.Value.Address") // Literal path with another subtree level that works the same way as reference to addressSubtreeVariable + .addVariable(jobNameVariable.id, new VariableRef(jobsArrayVariable.id)) + .addVariable(addressSubtreeVariable.id, new VariableRef(clientsArrayVariable.id)) + .addVariable(clientsArrayVariable.id, "Data") + .addVariable(jobsArrayVariable.id, new VariableRef(clientsArrayVariable.id), "Jobs") .build() // Display displayHeaderRule to conditionally display the address. // Header is hidden if displayHeaderVariable is set to false def displayAddressRule = new DisplayRuleBuilder("displayAddressRule") - .internal(false) - .subject("External display rule") - .metadata("key") { it.string("value") } - .group { - it.operator(GroupOp.Or) - it.comparison { it.variable(nameVariable.id).notEquals().value("") } - it.comparison { it.variable(addressVariable.id).notEquals().value("") } - it.comparison { it.variable(cityVariable.id).notEquals().value("") } - it.comparison { it.variable(stateVariable.id).notEquals().value("") } - }.build() + .internal(false) + .subject("External display rule") + .metadata("key") { it.string("value") } + .group { + it.operator(GroupOp.Or) + it.comparison { it.variable(nameVariable.id).notEquals().value("") } + it.comparison { it.variable(addressVariable.id).notEquals().value("") } + it.comparison { it.variable(cityVariable.id).notEquals().value("") } + it.comparison { it.variable(stateVariable.id).notEquals().value("") } + }.build() def dummyDisplayHeaderRule = new DisplayRuleBuilder("dummyDisplayHeaderRule") - .comparison { it.value(true).equals().variable(displayHeaderVariable.id) } - .build() + .comparison { it.value(true).equals().variable(displayHeaderVariable.id) } + .build() def displayHeaderRule = new DisplayRuleBuilder("displayHeaderRule") - .targetId(dummyDisplayHeaderRule.id) - .build() + .targetId(dummyDisplayHeaderRule.id) + .build() def displayParagraphRule = new DisplayRuleBuilder("displayParagraphRule") - .comparison { it.value(true).equals().variable(displayParagraphVariable.id) } - .build() + .comparison { it.value(true).equals().variable(displayParagraphVariable.id) } + .build() def displayLastSentenceRule = new DisplayRuleBuilder("displayLastSentenceRule") - .comparison { it.value(true).equals().variable(displayLastSentenceVariable.id) } - .build() + .comparison { it.value(true).equals().variable(displayLastSentenceVariable.id) } + .build() def displayRuleStateCzechia = new DisplayRuleBuilder("displayRuleStateCzechia") - .comparison { it.value("Czechia").equals().variable(stateVariable.id) } - .build() + .comparison { it.value("Czechia").equals().variable(stateVariable.id) } + .build() def displayRuleStateFrance = new DisplayRuleBuilder("displayRuleStateFrance") - .comparison { it.value("France").equals().variable(stateVariable.id) } - .build() + .comparison { it.value("France").equals().variable(stateVariable.id) } + .build() // Define text and paragraph styles to be used in the document def normalStyle = new TextStyleBuilder("normalStyle") - .definition { - it.size(Size.ofPoints(10)) - it.foregroundColor("#000000") - } - .build() + .definition { + it.size(Size.ofPoints(10)) + it.foregroundColor("#000000") + } + .build() def headingStyle = new TextStyleBuilder("headingStyle") - .definition { - it.size(Size.ofPoints(12)) - it.bold(true) - } - .build() + .definition { + it.size(Size.ofPoints(12)) + it.bold(true) + } + .build() def headingParaStyle = new ParagraphStyleBuilder("headingParagraphStyle") .definition { @@ -142,11 +154,22 @@ def headingParaStyle = new ParagraphStyleBuilder("headingParagraphStyle") .build() def paragraphStyle = new ParagraphStyleBuilder("paragraphStyle") - .definition { - it.firstLineIndent(Size.ofMillimeters(10)) - it.spaceAfter(Size.ofMillimeters(5)) - } - .build() + .definition { + it.firstLineIndent(Size.ofMillimeters(10)) + it.spaceAfter(Size.ofMillimeters(5)) + } + .build() + +def spaceParagraphStyle = new ParagraphStyleBuilder("spaceParagraphStyle") + .definition { + it.spaceAfter(Size.ofPoints(10)) + }.build() + +def compactParagraphStyle = new ParagraphStyleBuilder("compactParagraphStyle") + .definition { + it.additionalLineSpacing(Size.ofMillimeters(1)) + } + .build() // Define image to be used as a logo, base64 encoded image is hardcoded // here for simplicity but any valid image that is saved to the storage @@ -155,12 +178,12 @@ def logoBase64 = "iVBORw0KGgoAAAANSUhEUgAAAV4AAACWCAIAAAAZhXcgAAAACXBIWXMAAC4jAA def logoImageName = "logo.png" migration.storage.write(logoImageName, logoBase64.decodeBase64()) def logo = new ImageBuilder("logo") - .options(new ImageOptions(logoWidth, logoHeight)) - .sourcePath(logoImageName) - .imageType(ImageType.Png) - .subject("Example logo") - .alternateText("Example logo image") - .build() + .options(new ImageOptions(logoWidth, logoHeight)) + .sourcePath(logoImageName) + .imageType(ImageType.Png) + .subject("Example logo") + .alternateText("Example logo image") + .build() def ExampleAttachmentFile = this.class.getClassLoader().getResource('exampleResources/migrationModelExample/ExampleAttachment.pdf') migration.storage.write("exampleAttachment.pdf", ExampleAttachmentFile.bytes) @@ -170,341 +193,356 @@ def exampleAttachment = new AttachmentBuilder("exampleAttachment").attachmentTyp migration.attachmentRepository.upsert(exampleAttachment) -// Table containing some data with the first address row being optionally hidden -// by using displayRuleRef to the display displayHeaderRule defined above. -// The table also contains some merged cells and custom column widths. -def table = table { - it.pdfTaggingRule(TablePdfTaggingRule.Table) - it.pdfAlternateText("Example key value table") - it.addColumnWidth(Size.ofMillimeters(10), 10) - it.addColumnWidth(Size.ofMillimeters(20), 20) - it.addColumnWidth(Size.ofMillimeters(98), 70) - it.minWidth(Size.ofMillimeters(11)) - it.maxWidth(Size.ofMillimeters(1111)) - it.percentWidth(100) - it.alignment(TableAlignment.Right) - - def borderColor = Color.fromHex("#000000") - def borderWidth = Size.ofMillimeters(0.3) - def headerPadding = Size.ofMillimeters(2) - - it.border { it.allBorders(borderColor, borderWidth) } - - it.firstHeaderRow { - it.displayRuleRef = new DisplayRuleRef(displayHeaderRule.id) - it.cell { - it.border { - it.allBorders(borderColor, borderWidth) - it.padding(headerPadding) - it.paddingLeft(Size.ofMillimeters(0)) - it.paddingRight(Size.ofMillimeters(0)) +// Table that contains: +// - column widths, min/max/percent width, alignment, PDF tagging +// - a conditionally hidden first header row (displayRuleRef) +// - merged cells and custom row heights on static data rows +// - dynamically repeated rows (one per job) driven by jobsArrayVariable +// - last footer row + +def borderColor = Color.fromHex("#000000") +def borderWidth = Size.ofMillimeters(0.3) +def headerPadding = Size.ofMillimeters(2) + +def table = new TableBuilder() + .pdfTaggingRule(TablePdfTaggingRule.Table) + .pdfAlternateText("Example key value table") + .addColumnWidth(Size.ofMillimeters(10), 10) + .addColumnWidth(Size.ofMillimeters(20), 20) + .addColumnWidth(Size.ofMillimeters(98), 70) + .minWidth(Size.ofMillimeters(11)) + .maxWidth(Size.ofMillimeters(1111)) + .percentWidth(100) + .alignment(TableAlignment.Right) + .border { it.allBorders(borderColor, borderWidth) } + + .addFirstHeaderRow { + it.displayRuleRef(displayHeaderRule.id) + it.addCell { + it.border { + it.allBorders(borderColor, borderWidth) + it.padding(headerPadding) + it.paddingLeft(Size.ofMillimeters(0)) + it.paddingRight(Size.ofMillimeters(0)) + } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("ID") } } + it.alignment(CellAlignment.Bottom) } - it.paragraph { it.string("ID") } - it.alignment(CellAlignment.Bottom) - } - it.cell { - it.border { - it.allBorders(borderColor, borderWidth) - it.padding(headerPadding) - it.paddingLeft(Size.ofMillimeters(0)) - it.paddingRight(Size.ofMillimeters(0)) + it.addCell() { + it.border { + it.allBorders(borderColor, borderWidth) + it.padding(headerPadding) + it.paddingLeft(Size.ofMillimeters(0)) + it.paddingRight(Size.ofMillimeters(0)) + } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("key") } } } - it.paragraph { it.string("key") } - } - it.cell { - it.border { - it.allBorders(borderColor, borderWidth) - it.padding(headerPadding) - it.paddingLeft(Size.ofMillimeters(0)) - it.paddingRight(Size.ofMillimeters(0)) + it.addCell { + it.border { + it.allBorders(borderColor, borderWidth) + it.padding(headerPadding) + it.paddingLeft(Size.ofMillimeters(0)) + it.paddingRight(Size.ofMillimeters(0)) + } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("value") } } } - it.paragraph { it.string("value") } } - } - it.row { - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.text { it.string("1") } } - it.heightFixed(Size.ofMillimeters(10)) - } - it.cell { - // This cell is merged with the cell to the left on the same row - // and contains the value of the left cell. - it.mergeLeft = true - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("key1") } - it.heightFixed(Size.ofMillimeters(20)) - } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("value1") } - it.heightCustom(Size.ofMillimeters(10), Size.ofMillimeters(20)) + .addRow { + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("1") } } + it.heightFixed(Size.ofMillimeters(10)) + } + it.addCell { + // This cell is merged with the cell to the left on the same row + // and contains the value of the left cell. + it.mergeLeft = true + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("key1") } } + it.heightFixed(Size.ofMillimeters(20)) + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("value1") } } + it.heightCustom(Size.ofMillimeters(10), Size.ofMillimeters(20)) + } } - } - it.row { - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("2") } - } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("key2") } - } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("value2") } + .addRow { + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("2") } } + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("key2") } } + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("value2") } } + } } - } - it.row { - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("3") } - } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("key3") } - } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("value3") } + .addRepeatedRow(new VariableRef(jobsArrayVariable.id)) { + it.addRow { + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("job") } } + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("Job name") } } + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).variableRef(jobNameVariable.id) } } + } + } } - } - it.lastFooterRow { - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("Total") } - } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("3 keys") } + .addLastFooterRow { + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("Total") } } + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("2 keys") } } + } + it.addCell { + it.border { it.allBorders(borderColor, borderWidth) } + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("") } } + } } - it.cell { - it.border { it.allBorders(borderColor, borderWidth) } - it.paragraph { it.string("") } + .build() + +// A simple block demonstrating repeatedContent: iterates over jobsArrayVariable +// and renders one paragraph per job, combining static text with the dynamic jobNameVariable. +def jobListBlock = new DocumentObjectBuilder("jobList", DocumentObjectType.Block) + .internal(true) + .repeatedContent("Data.Clients.Value.Jobs") { + it.paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("Job: ").variableRef(jobNameVariable.id) } } } - } -} + .build() // Header of the document containing the recipient's information. // It uses the variables defined above to dynamically insert the // recipient's name, address, city, and state. // Simple paragraph is used because no styling is needed. def address = new DocumentObjectBuilder("address", DocumentObjectType.Block) - .paragraph { it.variableRef(nameVariable.id) } - .paragraph { it.variableRef(addressVariable.id) } - .paragraph { it.variableRef(cityVariable.id) } - .paragraph { it.variableRef(stateVariable.id) } - .variableStructureRef(variableStructure.id) - .build() + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).variableRef(nameVariable.id) } } + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).variableRef(addressVariable.id) } } + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).variableRef(cityVariable.id) } } + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).variableRef(stateVariable.id) } } + .variableStructureRef(variableStructure.id) + .build() // Footer of the document containing a signature. def signature = new DocumentObjectBuilder("signature", DocumentObjectType.Block) - .paragraph { it.string("Sincerely,") } - .paragraph { it.string("John Smith") } - .paragraph { it.string("CEO of Lorem ipsum") } - .variableStructureRef(variableStructure.id) - .build() + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("Sincerely,") } } + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("John Smith") } } + .paragraph { it.styleRef(compactParagraphStyle).text { it.styleRef(normalStyle).string("CEO of Lorem ipsum") } } + .variableStructureRef(variableStructure.id) + .build() // Sample paragraph containing a heading using headingStyle style, // and body text with normalStyle, both defined above. def paragraph1 = new DocumentObjectBuilder("paragraph1", DocumentObjectType.Block) // No separate file will be created and the content will be inlined instead when block is internal. - .internal(true) - .paragraph { - it.text { - it.styleRef(headingStyle.id) - it.string("Lorem ipsum dolor sit amet\n") + .internal(true) + .paragraph { + it.text { + it.styleRef(headingStyle) + it.string("Lorem ipsum dolor sit amet\n") + } + it.styleRef(headingParaStyle) } - it.styleRef(headingParaStyle.id) - } - .paragraph { - it.styleRef(paragraphStyle.id) - .text { - it.styleRef(normalStyle.id) - it.firstMatch { - it.case { - it.paragraph { - it.styleRef(paragraphStyle.id).text { it.string("Dobrý den") } - }.displayRule(displayRuleStateCzechia.id) - }.case { - it.paragraph { - it.styleRef(paragraphStyle.id).text { it.string("Bonjour") } - }.displayRule(displayRuleStateFrance.id) - }.defaultParagraph { it.styleRef(paragraphStyle.id).string("Good morning") } + .paragraph { + it.styleRef(paragraphStyle) + .text { + it.styleRef(normalStyle) + it.firstMatch { + it.case { + it.paragraph { + it.styleRef(paragraphStyle).text { it.styleRef(normalStyle).string("Dobrý den") } + }.displayRule(displayRuleStateCzechia.id) + }.case { + it.paragraph { + it.styleRef(paragraphStyle).text { it.styleRef(normalStyle).string("Bonjour") } + }.displayRule(displayRuleStateFrance.id) + }.defaultParagraph { it.styleRef(paragraphStyle).text { it.styleRef(normalStyle).string("Good morning") } } + } + it.string(", Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vel diam ut dui vulputate lobortis ac sit amet diam. Donec malesuada eros id vulputate tincidunt. Aenean ac placerat nisi. Morbi porta orci at est interdum, mollis sollicitudin odio pulvinar. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi sem mauris, porta sed erat vel, vestibulum facilisis dui. Maecenas sodales quam neque, ut consectetur ante interdum at.") } - it.string(", Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vel diam ut dui vulputate lobortis ac sit amet diam. Donec malesuada eros id vulputate tincidunt. Aenean ac placerat nisi. Morbi porta orci at est interdum, mollis sollicitudin odio pulvinar. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi sem mauris, porta sed erat vel, vestibulum facilisis dui. Maecenas sodales quam neque, ut consectetur ante interdum at.") - } - } - .build() + } + .build() // Second sample paragraph def paragraph2 = new DocumentObjectBuilder("paragraph2", DocumentObjectType.Block) - .internal(true) - .paragraph { - it.styleRef(new ParagraphStyleRef(paragraphStyle.id)) - .text { - it.string("Donec non porttitor ipsum. Praesent et blandit nulla, quis ullamcorper enim. Curabitur nec rutrum justo. Nunc ac quam a ante consequat ullamcorper eget sit amet tortor. Donec convallis sagittis purus, a feugiat lacus tristique vitae. In a orci risus. Sed elit magna, vestibulum vitae orci sodales, consequat pharetra nisi. Vestibulum non scelerisque elit. Duis feugiat porttitor ante sit amet porta. Fusce at leo posuere, venenatis libero ut, varius dolor. Duis bibendum porta tincidunt.") - } - .text { - it.displayRuleRef(new DisplayRuleRef(displayLastSentenceRule.id)).string("Nulla id nulla odio.") - } - } - .build() + .internal(true) + .paragraph { + it.styleRef(paragraphStyle) + .text { + it.styleRef(normalStyle).string("Donec non porttitor ipsum. Praesent et blandit nulla, quis ullamcorper enim. Curabitur nec rutrum justo. Nunc ac quam a ante consequat ullamcorper eget sit amet tortor. Donec convallis sagittis purus, a feugiat lacus tristique vitae. In a orci risus. Sed elit magna, vestibulum vitae orci sodales, consequat pharetra nisi. Vestibulum non scelerisque elit. Duis feugiat porttitor ante sit amet porta. Fusce at leo posuere, venenatis libero ut, varius dolor. Duis bibendum porta tincidunt.") + } + .text { + it.styleRef(normalStyle).displayRuleRef(new DisplayRuleRef(displayLastSentenceRule.id)).string("Nulla id nulla odio.") + } + } + .build() // Paragraph that is displayed conditionally based on the value of the displayParagraphVariable. // This also demonstrates metadata usage - various metadata types can be attached // to document objects to provide additional information without affecting visible content. def conditionalParagraph = new DocumentObjectBuilder("conditionalParagraph", DocumentObjectType.Block) - .internal(false) - .displayRuleRef(displayParagraphRule.id) - .subject("Conditional Paragraph") - .metadata("DocumentInfo") { mb -> - mb.string("Document type: Technical Example") - mb.string("Version: 1.0") - } - .metadata("Timestamps") { mb -> - mb.dateTime(Instant.parse("2024-01-15T10:30:00Z")) - } - .metadata("Statistics") { mb -> - mb.integer(42L) - mb.float(98.5) - mb.boolean(true) - } - .paragraph { - it.styleRef(new ParagraphStyleRef(paragraphStyle.id)) - .text { - it.string("Integer quis quam semper, accumsan neque at, pellentesque diam. Etiam in blandit dolor. Maecenas sit amet interdum augue, vel pellentesque erat. Suspendisse ut sem in justo rhoncus placerat vitae ut lacus. Etiam consequat bibendum justo ut posuere. Donec aliquam posuere nibh, vehicula pulvinar lectus dictum et. Nullam rhoncus ultrices ipsum et consectetur. Nam tincidunt id purus ac viverra. ") - } - } - .variableStructureRef(variableStructure.id) - .build() + .internal(false) + .displayRuleRef(displayParagraphRule.id) + .subject("Conditional Paragraph") + .metadata("DocumentInfo") { mb -> + mb.string("Document type: Technical Example") + mb.string("Version: 1.0") + } + .metadata("Timestamps") { mb -> mb.dateTime(Instant.parse("2024-01-15T10:30:00Z")) + } + .metadata("Statistics") { mb -> + mb.integer(42L) + mb.float(98.5) + mb.boolean(true) + } + .paragraph { + it.styleRef(paragraphStyle) + .text { + it.styleRef(normalStyle).string("Integer quis quam semper, accumsan neque at, pellentesque diam. Etiam in blandit dolor. Maecenas sit amet interdum augue, vel pellentesque erat. Suspendisse ut sem in justo rhoncus placerat vitae ut lacus. Etiam consequat bibendum justo ut posuere. Donec aliquam posuere nibh, vehicula pulvinar lectus dictum et. Nullam rhoncus ultrices ipsum et consectetur. Nam tincidunt id purus ac viverra. ") + } + } + .variableStructureRef(variableStructure.id) + .build() def firstMatchBlock = new DocumentObjectBuilder("firstMatch", DocumentObjectType.Block) - .internal(true) - .firstMatch { fb -> - fb.case { cb -> - cb.name("Czech Variant").appendContent(new ParagraphBuilder().styleRef(paragraphStyle.id).text { - it.string("Nashledanou.") - }.build()).displayRule(displayRuleStateCzechia.id) - }.case { cb -> - cb.name("French Variant").appendContent(new ParagraphBuilder().styleRef(paragraphStyle.id).text { - it.string("Au revoir.") - }.build()).displayRule(displayRuleStateFrance.id) - }.default(new ParagraphBuilder().styleRef(paragraphStyle.id).text { it.string("Goodbye.") }.build()) - }.build() + .internal(true) + .firstMatch { fb -> + fb.case { cb -> + cb.name("Czech Variant").appendContent(new ParagraphBuilder().styleRef(paragraphStyle).text { + it.styleRef(normalStyle).string("Nashledanou.") + }.build()).displayRule(displayRuleStateCzechia.id) + }.case { cb -> + cb.name("French Variant").appendContent(new ParagraphBuilder().styleRef(paragraphStyle).text { + it.styleRef(normalStyle).string("Au revoir.") + }.build()).displayRule(displayRuleStateFrance.id) + }.default(new ParagraphBuilder().styleRef(paragraphStyle).text { it.styleRef(normalStyle).string("Goodbye.") }.build()) + }.build() // SelectByLanguage demonstrates language-based content selection. def selectByLanguageBlock = new DocumentObjectBuilder("selectByLanguage", DocumentObjectType.Block) - .internal(true) - .selectByLanguage { sb -> - sb.case { cb -> - cb.language("en_us") - cb.appendContent(new ParagraphBuilder().styleRef(paragraphStyle.id).text { - it.string("This document was created in English.") - }.build()) - }.case { cb -> - cb.language("de") - cb.appendContent(new ParagraphBuilder().styleRef(paragraphStyle.id).text { - it.string("Dieses Dokument wurde auf Deutsch erstellt.") - }.build()) - }.case { cb -> - cb.language("es") - cb.appendContent(new ParagraphBuilder().styleRef(paragraphStyle.id).text { - it.string("Este documento fue creado en español.") - }.build()) - } - }.build() + .internal(true) + .selectByLanguage { sb -> + sb.case { cb -> + cb.language("en_us") + cb.appendContent(new ParagraphBuilder().styleRef(paragraphStyle).text { + it.styleRef(normalStyle).string("This document was created in English.") + }.build()) + }.case { cb -> + cb.language("de") + cb.appendContent(new ParagraphBuilder().styleRef(paragraphStyle).text { + it.styleRef(normalStyle).string("Dieses Dokument wurde auf Deutsch erstellt.") + }.build()) + }.case { cb -> + cb.language("es") + cb.appendContent(new ParagraphBuilder().styleRef(paragraphStyle).text { + it.styleRef(normalStyle).string("Este documento fue creado en español.") + }.build()) + } + }.build() // A page object which contains the address, paragraphs, table, and signature. // All the content is absolutely positioned using FlowAreas def paragraph1TopMargin = topMargin + Size.ofCentimeters(2) def signatureTopMargin = pageHeight - Size.ofCentimeters(3) def page = new DocumentObjectBuilder("page1", DocumentObjectType.Page) - .options(new PageOptions(pageWidth, pageHeight)) - .area { - it.position { - it.left(leftMargin) - it.top(topMargin) - it.width(contentWidth) - it.height(Size.ofCentimeters(2)) + .options(new PageOptions(pageWidth, pageHeight)) + .area { + it.position { + it.left(leftMargin) + it.top(topMargin) + it.width(contentWidth) + it.height(Size.ofCentimeters(2)) + } + .documentObjectRef(address.id, displayAddressRule.id) + .interactiveFlowName("Def.InteractiveFlow1") } - .documentObjectRef(address.id, displayAddressRule.id) - .interactiveFlowName("Def.InteractiveFlow0") - } - .area { - it.position { - it.left(leftMargin + contentWidth - logoWidth) - it.top(topMargin) - it.width(logoWidth) - it.height(logoHeight) - }.imageRef(logo.id) - } - .area { - it.position { - it.left(leftMargin) - it.top(paragraph1TopMargin) - it.width(contentWidth) - it.height(pageHeight - Size.ofCentimeters(6)) + .area { + it.position { + it.left(leftMargin + contentWidth - logoWidth) + it.top(topMargin) + it.width(logoWidth) + it.height(logoHeight) + }.imageRef(logo.id) } - .documentObjectRef(paragraph1.id) - .documentObjectRef(paragraph2.id) - .paragraph { it.styleRef(paragraphStyle.id).text { it.content(table) } } - .documentObjectRef(conditionalParagraph.id) - .documentObjectRef(firstMatchBlock.id) - .documentObjectRef(selectByLanguageBlock.id) - .paragraph { - it.styleRef(paragraphStyle.id).text { - it.styleRef(normalStyle.id) - .string("For more information visit ") - .hyperlink("https://github.com/quadient/migration-stack", "Migration Stack GitHub", "Migration Stack GitHub URL link") - } + .area { + it.position { + it.left(leftMargin) + it.top(paragraph1TopMargin) + it.width(contentWidth) + it.height(pageHeight - Size.ofCentimeters(6)) + } + .documentObjectRef(paragraph1.id) + .documentObjectRef(paragraph2.id) + .appendContent(table) + .paragraph { it.styleRef(spaceParagraphStyle) } + .documentObjectRef(jobListBlock.id) + .documentObjectRef(conditionalParagraph.id) + .documentObjectRef(firstMatchBlock.id) + .documentObjectRef(selectByLanguageBlock.id) + .paragraph { + it.styleRef(spaceParagraphStyle).text { + it.styleRef(normalStyle) + .string("For more information visit ") + .hyperlink("https://github.com/quadient/migration-stack", "Migration Stack GitHub", "Migration Stack GitHub URL link") + } + } + } + .area { + it.position { + it.left(leftMargin) + it.top(signatureTopMargin) + it.width(contentWidth) + it.height(Size.ofCentimeters(2)) } - } - .area { - it.position { - it.left(leftMargin) - it.top(signatureTopMargin) - it.width(contentWidth) - it.height(Size.ofCentimeters(2)) + .documentObjectRef(signature.id) + .attachmentRef(exampleAttachment.id) + .flowToNextPage(true) } - .documentObjectRef(signature.id) - .attachmentRef(exampleAttachment.id) - .flowToNextPage(true) - } - .variableStructureRef(variableStructure.id) - .build() + .variableStructureRef(variableStructure.id) + .build() def template = new DocumentObjectBuilder("template", DocumentObjectType.Template) - .documentObjectRef(page.id) - .subject("Document example template") - .pdfMetadata { - it.author(new VariableRef(nameVariable.id)) - it.title("Migration Model Example Template") - it.producer("Quadient") - it.keywords("Migration, Model, Example, Test, Import") - it.subject { it.string("Lorem ipsum dolor sit amet from: ").variableRef(cityVariable.id) } - } - .variableStructureRef(variableStructure.id) - .build() + .documentObjectRef(page.id) + .subject("Document example template") + .pdfMetadata { + it.author(new VariableRef(nameVariable.id)) + it.title("Migration Model Example Template") + it.producer("Quadient") + it.keywords("Migration, Model, Example, Test, Import") + it.subject { it.string("Lorem ipsum dolor sit amet from: ").variableRef(cityVariable.id) } + } + .variableStructureRef(variableStructure.id) + .build() // Insert all content into the database to be used in the deploy task -for (item in [address, signature, paragraph1, paragraph2, conditionalParagraph, page, template, firstMatchBlock, selectByLanguageBlock]) { +for (item in [address, signature, paragraph1, paragraph2, conditionalParagraph, page, template, firstMatchBlock, selectByLanguageBlock, jobListBlock]) { migration.documentObjectRepository.upsert(item) } for (item in [headingStyle, normalStyle]) { migration.textStyleRepository.upsert(item) } -for (item in [displayHeaderVariable, displayParagraphVariable, displayLastSentenceVariable, nameVariable, addressVariable, cityVariable, stateVariable]) { +for (item in [displayHeaderVariable, displayParagraphVariable, displayLastSentenceVariable, nameVariable, addressVariable, cityVariable, stateVariable, jobNameVariable, clientsArrayVariable, addressSubtreeVariable, jobsArrayVariable]) { migration.variableRepository.upsert(item) } for (item in [displayAddressRule, dummyDisplayHeaderRule, displayHeaderRule, displayParagraphRule, displayLastSentenceRule, displayRuleStateCzechia, displayRuleStateFrance]) { migration.displayRuleRepository.upsert(item) } -for (item in [paragraphStyle, headingParaStyle]) { +for (item in [paragraphStyle, headingParaStyle, compactParagraphStyle, spaceParagraphStyle]) { migration.paragraphStyleRepository.upsert(item) } diff --git a/migration-examples/src/test/groovy/VariablesMappingExportTest.groovy b/migration-examples/src/test/groovy/VariablesMappingExportTest.groovy index f521675..8209cb1 100644 --- a/migration-examples/src/test/groovy/VariablesMappingExportTest.groovy +++ b/migration-examples/src/test/groovy/VariablesMappingExportTest.groovy @@ -6,6 +6,7 @@ import com.quadient.migration.api.dto.migrationmodel.VariableStructure import com.quadient.migration.example.common.mapping.VariablesExport import com.quadient.migration.shared.DataType import com.quadient.migration.shared.VariablePathData +import com.quadient.migration.shared.VariableRefPath import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.io.TempDir import java.nio.file.Path @@ -27,11 +28,18 @@ class VariablesMappingExportTest { new Variable("empty", null, [], new CustomFieldMap([:]), DataType.String, null), new Variable("full", "full name", ["foo", "bar"], new CustomFieldMap([foo: "bar", bar: "baz"]), DataType.Boolean, "default"), new Variable("overridden", "full name", ["foo", "bar"], new CustomFieldMap([foo: "bar", bar: "baz"]), DataType.Boolean, "default"), + new Variable("refVar", null, [], new CustomFieldMap([:]), DataType.String, null), ]) when(migration.mappingRepository.getVariableMapping(any())).thenReturn(new MappingItem.Variable(null, null)) when(migration.mappingRepository.getVariableMapping("overridden")).thenReturn(new MappingItem.Variable(null, DataType.Double)) - when(migration.variableStructureRepository.find("test")).thenReturn(new VariableStructure("struct", "", [], new CustomFieldMap([:]), ["overridden": new VariablePathData("override/path", "overridden name")], new VariableRef("full"))) - when(migration.mappingRepository.getVariableStructureMapping(any())).thenReturn(new MappingItem.VariableStructure("", ["overridden": new VariablePathData("override/path", "overridden name")], new VariableRef("full"))) + when(migration.variableStructureRepository.find("test")).thenReturn(new VariableStructure("struct", "", [], new CustomFieldMap([:]), + ["overridden": new VariablePathData("override/path", "overridden name"), + "refVar" : new VariablePathData(new VariableRefPath("full"), "ref name")], + new VariableRef("full"))) + when(migration.mappingRepository.getVariableStructureMapping(any())).thenReturn(new MappingItem.VariableStructure("", + ["overridden": new VariablePathData("override/path", "overridden name"), + "refVar" : new VariablePathData(new VariableRefPath("full"), "ref name")], + new VariableRef("full"))) VariablesExport.run(migration, mappingFile) @@ -42,7 +50,7 @@ class VariablesMappingExportTest { empty,,String,,,,[] full,full name,Boolean,,,true,[foo; bar] overridden,full name,Boolean,override/path,overridden name,,[foo; bar] -""" +refVar,,String,@full,ref name,,[]\n""" Assertions.assertEquals(expectedResult, text.replaceAll("\\r\\n|\\r", "\n")) } diff --git a/migration-examples/src/test/groovy/VariablesMappingImportTest.groovy b/migration-examples/src/test/groovy/VariablesMappingImportTest.groovy index 5b83f85..940963a 100644 --- a/migration-examples/src/test/groovy/VariablesMappingImportTest.groovy +++ b/migration-examples/src/test/groovy/VariablesMappingImportTest.groovy @@ -6,6 +6,7 @@ import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.example.common.mapping.VariablesImport import com.quadient.migration.shared.DataType import com.quadient.migration.shared.VariablePathData +import com.quadient.migration.shared.VariableRefPath import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import java.nio.file.Paths @@ -27,6 +28,7 @@ class VariablesMappingImportTest { unchangedPath,,String,oldPath,,[] withPath,,String,newPath,,[] withPathEmpty,,String,newPath,,[] + withVariableRef,,String,@referencedVar,,[] """.stripIndent() mappingFile.toFile().write(input) @@ -39,6 +41,8 @@ class VariablesMappingImportTest { givenExistingMapping(migration, "withPath", null, null, "existingPath", null, mappings) givenExistingVariable(migration, "withPathEmpty", null, DataType.String, null) givenExistingMapping(migration, "withPathEmpty", null, null, "existingPath", null, mappings) + givenExistingVariable(migration, "withVariableRef", null, DataType.String, null) + givenExistingMapping(migration, "withVariableRef", null, null, null, null, mappings) VariablesImport.run(migration, mappingFile) @@ -50,12 +54,15 @@ class VariablesMappingImportTest { verify(migration.mappingRepository, times(1)).applyVariableMapping("withPath") verify(migration.mappingRepository, times(1)).upsert("withPathEmpty", new MappingItem.Variable(null, DataType.String)) verify(migration.mappingRepository, times(1)).applyVariableMapping("withPathEmpty") + verify(migration.mappingRepository, times(1)).upsert("withVariableRef", new MappingItem.Variable(null, DataType.String)) + verify(migration.mappingRepository, times(1)).applyVariableMapping("withVariableRef") verify(migration.mappingRepository, times(1)).upsert("test", new MappingItem.VariableStructure(null, - ["unchangedEmpty": new VariablePathData("", null), - "unchangedPath" : new VariablePathData("oldPath", null), - "withPath" : new VariablePathData("newPath", null), - "withPathEmpty" : new VariablePathData("newPath", null),] + ["unchangedEmpty" : new VariablePathData("", null), + "unchangedPath" : new VariablePathData("oldPath", null), + "withPath" : new VariablePathData("newPath", null), + "withPathEmpty" : new VariablePathData("newPath", null), + "withVariableRef" : new VariablePathData(new VariableRefPath("referencedVar"), null),] , null)) verify(migration.mappingRepository, times(1)).applyVariableStructureMapping("test") } diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/DocumentContentBuilderBase.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/DocumentContentBuilderBase.kt index daa3a0c..56294ff 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/DocumentContentBuilderBase.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/DocumentContentBuilderBase.kt @@ -5,7 +5,12 @@ import com.quadient.migration.api.dto.migrationmodel.DocumentContent import com.quadient.migration.api.dto.migrationmodel.DocumentObjectRef import com.quadient.migration.api.dto.migrationmodel.ImageRef import com.quadient.migration.api.dto.migrationmodel.AttachmentRef +import com.quadient.migration.api.dto.migrationmodel.VariableRef +import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.RepeatedContentBuilder import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.SelectByLanguageBuilder +import com.quadient.migration.shared.LiteralPath +import com.quadient.migration.shared.VariablePath +import com.quadient.migration.shared.VariableRefPath /** * Base interface for builders that contain a list of DocumentContent. @@ -130,4 +135,33 @@ interface DocumentContentBuilderBase { fun string(text: String): T = apply { this.content.add(ParagraphBuilder().string(text).build()) } as T + + /** + * Adds repeated content to the content using a builder function. + * The content repeats once per element of the given array variable. + * @param variablePath The [VariablePath] referencing the array variable to repeat over. + * @param builder A builder function to build the repeated content. + * @return This builder instance for method chaining. + */ + fun repeatedContent(variablePath: VariablePath, builder: RepeatedContentBuilder.() -> Unit): T = apply { + this.content.add(RepeatedContentBuilder(variablePath).apply(builder).build()) + } as T + + /** + * Adds repeated content to the content using a literal path string. + * @param literalPath The literal data path of the array variable to repeat over. + * @param builder A builder function to build the repeated content. + * @return This builder instance for method chaining. + */ + fun repeatedContent(literalPath: String, builder: RepeatedContentBuilder.() -> Unit): T = + repeatedContent(LiteralPath(literalPath), builder) + + /** + * Adds repeated content to the content using a variable reference. + * @param variableRef The [VariableRef] referencing the array variable to repeat over. + * @param builder A builder function to build the repeated content. + * @return This builder instance for method chaining. + */ + fun repeatedContent(variableRef: VariableRef, builder: RepeatedContentBuilder.() -> Unit): T = + repeatedContent(VariableRefPath(variableRef.id), builder) } \ No newline at end of file diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/ParagraphStyleBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/ParagraphStyleBuilder.kt index ddf9161..a5940cd 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/ParagraphStyleBuilder.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/ParagraphStyleBuilder.kt @@ -23,6 +23,7 @@ class ParagraphStyleBuilder(id: String) : DtoBuilderBase fun styleRef(id: String) = apply { this.targetId = TextStyleRef(id) } fun styleRef(ref: TextStyleRef) = apply { this.targetId = ref } + fun styleRef(style: TextStyle) = apply { this.targetId = TextStyleRef(style.id) } override fun build(): TextStyle { return TextStyle( diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/VariableStructureBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/VariableStructureBuilder.kt index 2801e98..45626b8 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/VariableStructureBuilder.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/VariableStructureBuilder.kt @@ -2,34 +2,66 @@ package com.quadient.migration.api.dto.migrationmodel.builder import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.api.dto.migrationmodel.VariableStructure +import com.quadient.migration.shared.LiteralPath import com.quadient.migration.shared.VariablePathData +import com.quadient.migration.shared.VariableRefPath class VariableStructureBuilder(id: String) : DtoBuilderBase(id) { var structure = mutableMapOf() var languageVariable: VariableRef? = null /** - * Adds a variable to the structure. + * Adds a variable to the structure with a literal path. * * @param id The unique identifier for the variable (used as the map key). - * @param path The path of the variable. + * @param path The literal path of the variable (e.g. "Data.Clients.Value"). * @return This builder instance for method chaining. */ fun addVariable(id: String, path: String) = apply { - structure[id] = VariablePathData(path, null) + structure[id] = VariablePathData(LiteralPath(path), null) return this } /** - * Adds a variable to the structure. + * Adds a variable to the structure with a literal path and a display name override. * * @param id The unique identifier for the variable (used as the map key). - * @param path The path of the variable. + * @param path The literal path of the variable (e.g. "Data.Clients.Value"). * @param name A name to override the variable's default name. * @return This builder instance for method chaining. */ fun addVariable(id: String, path: String, name: String) = apply { - structure[id] = VariablePathData(path, name) + structure[id] = VariablePathData(LiteralPath(path), name) + return this + } + + /** + * Adds a variable to the structure referencing an Array or SubTree variable by its ID. + * The referenced variable must be registered with [DataType.Array] or [DataType.SubTree] + * and must carry a [Variable.path]. + * + * @param id The unique identifier for the variable (used as the map key). + * @param variableRef A [VariableRef] pointing to an Array or SubTree variable. + * @return This builder instance for method chaining. + */ + fun addVariable(id: String, variableRef: VariableRef) = apply { + structure[id] = VariablePathData(VariableRefPath(variableRef.id), null) + return this + } + + /** + * Adds a variable to the structure referencing an Array or SubTree variable by its ID, + * with a display name override. + * The referenced variable must be registered with [DataType.Array] or [DataType.SubTree] + * and must carry a [Variable.path]. + * + * @param id The unique identifier for the variable (used as the map key). + * @param variableRef A [VariableRef] pointing to an Array or SubTree variable. + * @param name A name to override the variable's default name. + * @return This builder instance for method chaining. + */ + fun addVariable(id: String, variableRef: VariableRef, name: String) = apply { + structure[id] = VariablePathData(VariableRefPath(variableRef.id), name) return this } diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/ParagraphBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/ParagraphBuilder.kt index 8a29d98..ecd9135 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/ParagraphBuilder.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/ParagraphBuilder.kt @@ -6,9 +6,11 @@ import com.quadient.migration.api.dto.migrationmodel.Hyperlink import com.quadient.migration.api.dto.migrationmodel.ImageRef import com.quadient.migration.api.dto.migrationmodel.AttachmentRef import com.quadient.migration.api.dto.migrationmodel.Paragraph +import com.quadient.migration.api.dto.migrationmodel.ParagraphStyle import com.quadient.migration.api.dto.migrationmodel.ParagraphStyleRef import com.quadient.migration.api.dto.migrationmodel.StringValue import com.quadient.migration.api.dto.migrationmodel.TextContent +import com.quadient.migration.api.dto.migrationmodel.TextStyle import com.quadient.migration.api.dto.migrationmodel.TextStyleRef import com.quadient.migration.api.dto.migrationmodel.VariableRef @@ -31,6 +33,13 @@ class ParagraphBuilder { */ fun styleRef(styleRefId: String) = apply { this.styleRef = ParagraphStyleRef(styleRefId) } + /** + * Sets the style reference for the paragraph using a [ParagraphStyle] model object. + * @param style The paragraph style whose ID will be used as the reference. + * @return The current instance of [ParagraphBuilder] for method chaining. + */ + fun styleRef(style: ParagraphStyle) = apply { this.styleRef = ParagraphStyleRef(style.id) } + /** * Sets the display rule reference for the paragraph. * This makes the paragraph display conditionally based on the rule. @@ -148,6 +157,13 @@ class ParagraphBuilder { */ fun styleRef(styleRefId: String) = apply { this.styleRef = TextStyleRef(styleRefId) } + /** + * Sets the style reference for the text using a [TextStyle] model object. + * @param style The text style whose ID will be used as the reference. + * @return The current instance of [TextBuilder] for method chaining. + */ + fun styleRef(style: TextStyle) = apply { this.styleRef = TextStyleRef(style.id) } + /** * Sets the display rule reference for the text. * This makes the text display conditionally based on the rule. diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/RepeatedContentBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/RepeatedContentBuilder.kt new file mode 100644 index 0000000..f04523b --- /dev/null +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/RepeatedContentBuilder.kt @@ -0,0 +1,19 @@ +package com.quadient.migration.api.dto.migrationmodel.builder.documentcontent + +import com.quadient.migration.api.dto.migrationmodel.DocumentContent +import com.quadient.migration.api.dto.migrationmodel.RepeatedContent +import com.quadient.migration.api.dto.migrationmodel.builder.DocumentContentBuilderBase +import com.quadient.migration.shared.VariablePath + +class RepeatedContentBuilder(private val variablePath: VariablePath) : DocumentContentBuilderBase { + override val content = mutableListOf() + + /** + * Builds the [RepeatedContent] instance. + * @return The constructed [RepeatedContent] instance. + */ + fun build(): RepeatedContent = RepeatedContent( + variablePath = variablePath, + content = content, + ) +} diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableBuilder.kt index bad695f..bedc1b2 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableBuilder.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableBuilder.kt @@ -3,20 +3,29 @@ package com.quadient.migration.api.dto.migrationmodel.builder import com.quadient.migration.api.dto.migrationmodel.DisplayRuleRef import com.quadient.migration.api.dto.migrationmodel.DocumentContent import com.quadient.migration.api.dto.migrationmodel.Table +import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.api.dto.migrationmodel.builder.documentcontent.BorderOptionsBuilder +import com.quadient.migration.api.dto.migrationmodel.TableRow as TableRowModel import com.quadient.migration.shared.BorderOptions import com.quadient.migration.shared.CellAlignment import com.quadient.migration.shared.CellHeight +import com.quadient.migration.shared.VariablePath +import com.quadient.migration.shared.LiteralPath import com.quadient.migration.shared.Size import com.quadient.migration.shared.TableAlignment import com.quadient.migration.shared.TablePdfTaggingRule +import com.quadient.migration.shared.VariableRefPath -class TableBuilder { - private val rows = mutableListOf() - private var header = mutableListOf() - private var firstHeader = mutableListOf() - private var footer = mutableListOf() - private var lastFooter = mutableListOf() +@DslMarker +annotation class TableBuilderDsl + +@TableBuilderDsl +class TableBuilder : RowBuilderBase { + override val rows = mutableListOf() + private var header = mutableListOf() + private var firstHeader = mutableListOf() + private var footer = mutableListOf() + private var lastFooter = mutableListOf() private val columnWidths = mutableListOf() private var pdfTaggingRule: TablePdfTaggingRule = TablePdfTaggingRule.Default private var pdfAlternateText: String? = null @@ -32,22 +41,47 @@ class TableBuilder { fun maxWidth(size: Size) = apply { this.maxWidth = size } fun percentWidth(percent: Double) = apply { this.percentWidth = percent } fun alignment(alignment: TableAlignment) = apply { this.alignment = alignment } - fun border(init: BorderOptionsBuilder.() -> Unit) = apply { this.border = BorderOptionsBuilder().apply(init).build() } + fun border(init: BorderOptionsBuilder.() -> Unit) = + apply { this.border = BorderOptionsBuilder().apply(init).build() } /** - * Add a row to the table. Rows are added in the order they are defined. - * And all rows must have the same number of cells. + * Add a repeated row group to the table. The rows added to the builder will be repeated + * for each element of the given variable. + * @param variable The literal path or variable reference driving repetition. */ - fun addRow() = Row().apply { rows.add(this) } + fun addRepeatedRow(variable: VariablePath) = RepeatedRowBuilder(variable).apply { rows.add(this) } /** - * Adds multiple rows to the table. This will append the rows to any existing rows. - * @param rows The list of rows to add. - * @return The builder instance for method chaining. + * Add a repeated row group to the table and configure it via [init]. + * @param variable The literal path or variable reference driving repetition. + * @return This builder instance for method chaining. */ - fun addRows(rows: List) = apply { - this.rows.addAll(rows) - } + fun addRepeatedRow(variable: VariablePath, init: RepeatedRowBuilder.() -> Unit): TableBuilder = + apply { rows.add(RepeatedRowBuilder(variable).apply(init)) } + + /** + * Add a repeated row group driven by a literal path (e.g. "Data.Clients"). + */ + fun addRepeatedRow(literalPath: String) = addRepeatedRow(LiteralPath(literalPath)) + + /** + * Add a repeated row group driven by a literal path and configure it via [init]. + * @return This builder instance for method chaining. + */ + fun addRepeatedRow(literalPath: String, init: RepeatedRowBuilder.() -> Unit) = + addRepeatedRow(LiteralPath(literalPath), init) + + /** + * Add a repeated row group driven by a registered variable reference. + */ + fun addRepeatedRow(variableRef: VariableRef) = addRepeatedRow(VariableRefPath(variableRef.id)) + + /** + * Add a repeated row group driven by a registered variable reference and configure it via [init]. + * @return This builder instance for method chaining. + */ + fun addRepeatedRow(variableRef: VariableRef, init: RepeatedRowBuilder.() -> Unit) = + addRepeatedRow(VariableRefPath(variableRef.id), init) /** * Add a column width to the table. Column widths are added in the order they are defined. @@ -66,18 +100,39 @@ class TableBuilder { columnWidths.addAll(width) } - fun addHeaderRow() = Row().apply { header.add(this) } - fun addFirstHeaderRow() = Row().apply { firstHeader.add(this) } - fun addFooterRow() = Row().apply { footer.add(this) } - fun addLastFooterRow() = Row().apply { lastFooter.add(this) } + fun addHeaderRow() = Row().also { header.add(it) } + fun addHeaderRow(init: Row.() -> Unit): TableBuilder = apply { header.add(Row().apply(init)) } + fun addHeaderRow(row: Row) = apply { header.add(row) } + fun addFirstHeaderRow() = Row().also { firstHeader.add(it) } + fun addFirstHeaderRow(init: Row.() -> Unit): TableBuilder = apply { firstHeader.add(Row().apply(init)) } + fun addFirstHeaderRow(row: Row) = apply { firstHeader.add(row) } + fun addFooterRow() = Row().also { footer.add(it) } + fun addFooterRow(init: Row.() -> Unit): TableBuilder = apply { footer.add(Row().apply(init)) } + fun addFooterRow(row: Row) = apply { footer.add(row) } + fun addLastFooterRow() = Row().also { lastFooter.add(it) } + fun addLastFooterRow(init: Row.() -> Unit): TableBuilder = apply { lastFooter.add(Row().apply(init)) } + fun addLastFooterRow(row: Row) = apply { lastFooter.add(row) } + + fun addRepeatedHeaderRow(variable: VariablePath) = RepeatedRowBuilder(variable).also { header.add(it) } + fun addRepeatedHeaderRow(variable: VariablePath, init: RepeatedRowBuilder.() -> Unit): TableBuilder = + apply { header.add(RepeatedRowBuilder(variable).apply(init)) } + fun addRepeatedFirstHeaderRow(variable: VariablePath) = RepeatedRowBuilder(variable).also { firstHeader.add(it) } + fun addRepeatedFirstHeaderRow(variable: VariablePath, init: RepeatedRowBuilder.() -> Unit): TableBuilder = + apply { firstHeader.add(RepeatedRowBuilder(variable).apply(init)) } + fun addRepeatedFooterRow(variable: VariablePath) = RepeatedRowBuilder(variable).also { footer.add(it) } + fun addRepeatedFooterRow(variable: VariablePath, init: RepeatedRowBuilder.() -> Unit): TableBuilder = + apply { footer.add(RepeatedRowBuilder(variable).apply(init)) } + fun addRepeatedLastFooterRow(variable: VariablePath) = RepeatedRowBuilder(variable).also { lastFooter.add(it) } + fun addRepeatedLastFooterRow(variable: VariablePath, init: RepeatedRowBuilder.() -> Unit): TableBuilder = + apply { lastFooter.add(RepeatedRowBuilder(variable).apply(init)) } fun build(): Table { return Table( - rows = rows.map(Row::build), - header = header.map(Row::build), - firstHeader = firstHeader.map(Row::build), - footer = footer.map(Row::build), - lastFooter = lastFooter.map(Row::build), + rows = rows.map(TableRow::build), + header = header.map(TableRow::build), + firstHeader = firstHeader.map(TableRow::build), + footer = footer.map(TableRow::build), + lastFooter = lastFooter.map(TableRow::build), columnWidths = columnWidths.map { Table.ColumnWidth(it.minWidth, it.percentWidth) }, pdfTaggingRule = pdfTaggingRule, pdfAlternateText = pdfAlternateText, @@ -89,15 +144,33 @@ class TableBuilder { ) } - class Row { + /** Builder row type — sealed to allow both [Row] and [RepeatedRowBuilder] in the same list. */ + sealed interface TableRow { + fun build(): TableRowModel + } + + @TableBuilderDsl + class Row : TableRow { val cells = mutableListOf() var displayRuleRef: DisplayRuleRef? = null /** * Add a cell to the row. Cells are added in the order they are defined. - * And all rows must have the same number of cells. + * All rows must have the same number of cells. */ - fun addCell() = Cell().apply { cells.add(this) } + fun addCell() = Cell().also { cells.add(it) } + + /** + * Creates a new [Cell], configures it via [init], appends it, and returns this row. + * @return The row instance for method chaining. + */ + fun addCell(init: Cell.() -> Unit): Row = apply { cells.add(Cell().apply(init)) } + + /** + * Appends a pre-configured [cell] to the row. + * @return The row instance for method chaining. + */ + fun addCell(cell: Cell) = apply { cells.add(cell) } /** * Adds multiple cells to the row. This will append the cells to any existing cells. @@ -111,11 +184,21 @@ class TableBuilder { fun displayRuleRef(id: String) = this.apply { this.displayRuleRef = DisplayRuleRef(id) } fun displayRuleRef(ref: DisplayRuleRef) = this.apply { this.displayRuleRef = ref } - fun build(): Table.Row { + override fun build(): Table.Row { return Table.Row(cells = cells.map { it.build() }, displayRuleRef = displayRuleRef) } } + @TableBuilderDsl + class RepeatedRowBuilder(private val variable: VariablePath) : TableRow, RowBuilderBase { + override val rows = mutableListOf() + + override fun build(): Table.RepeatedRow { + return Table.RepeatedRow(rows = rows.map { (it as Row).build() }, variable) + } + } + + @TableBuilderDsl class Cell : DocumentContentBuilderBase { override val content = mutableListOf() var mergeLeft = false @@ -129,7 +212,8 @@ class TableBuilder { fun heightFixed(size: Size) = apply { height = CellHeight.Fixed(size) } fun heightCustom(minHeight: Size, maxHeight: Size) = apply { height = CellHeight.Custom(minHeight, maxHeight) } fun alignment(alignment: CellAlignment) = apply { this.alignment = alignment } - fun border(init: BorderOptionsBuilder.() -> Unit) = apply { this.border = BorderOptionsBuilder().apply(init).build() } + fun border(init: BorderOptionsBuilder.() -> Unit) = + apply { this.border = BorderOptionsBuilder().apply(init).build() } fun build(): Table.Cell { return Table.Cell( @@ -145,3 +229,34 @@ class TableBuilder { data class ColumnWidth(val minWidth: Size, val percentWidth: Double) } + +/** + * Common interface for builders that manage a collection of [TableBuilder.Row] entries. + * Implemented by both [TableBuilder] and [TableBuilder.RepeatedRowBuilder]. + */ +@Suppress("UNCHECKED_CAST") +@TableBuilderDsl +interface RowBuilderBase { + val rows: MutableList + + /** Creates a new [TableBuilder.Row], appends it, and returns it for further configuration. */ + fun addRow(): TableBuilder.Row = TableBuilder.Row().also { rows.add(it) } + + /** + * Creates a new [TableBuilder.Row], configures it via [init], appends it, and returns this builder. + * @return This builder instance for method chaining. + */ + fun addRow(init: TableBuilder.Row.() -> Unit): T = apply { rows.add(TableBuilder.Row().apply(init)) } as T + + /** + * Appends a pre-configured [row] to this container. + * @return This builder instance for method chaining. + */ + fun addRow(row: TableBuilder.Row): T = apply { rows.add(row) } as T + + /** + * Appends multiple pre-configured [rows] to this container. + * @return This builder instance for method chaining. + */ + fun addRows(rows: List): T = apply { this.rows.addAll(rows) } as T +} \ No newline at end of file diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableDsl.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableDsl.kt index 4fb75d1..dea1c20 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableDsl.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/builder/documentcontent/TableDsl.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.quadient.migration.api.dto.migrationmodel.builder import com.quadient.migration.api.dto.migrationmodel.DisplayRuleRef @@ -13,6 +15,7 @@ import com.quadient.migration.shared.TableAlignment object Dsl { @JvmStatic + @Deprecated("Use TableBuilder instead. TableDsl is a legacy API and will be removed in a future version.") fun table(init: TableDsl.() -> Unit) = TableDsl().apply(init).run { Table( rows = rows.map(TableDsl.Row::build), @@ -32,6 +35,13 @@ object Dsl { } } +/** + * Legacy DSL for building [Table] instances. Use [TableBuilder] instead. + * + * This DSL duplicates the functionality of [TableBuilder] and is kept only for backward compatibility. + * Migrate all usages to [TableBuilder], which supports the same operations and is the actively maintained API. + */ +@Deprecated("Use TableBuilder instead. TableDsl is a legacy API and will be removed in a future version.") @TableDocumentContentDsl class TableDsl { val rows = mutableListOf() diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/DocumentContent.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/DocumentContent.kt index b14cace..b27bdb4 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/DocumentContent.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/DocumentContent.kt @@ -9,6 +9,7 @@ import com.quadient.migration.persistence.migrationmodel.TableEntity import com.quadient.migration.persistence.migrationmodel.DocumentObjectEntityRef import com.quadient.migration.persistence.migrationmodel.ImageEntityRef import com.quadient.migration.persistence.migrationmodel.AttachmentEntityRef +import com.quadient.migration.persistence.migrationmodel.RepeatedContentEntity sealed interface DocumentContent { companion object { @@ -21,6 +22,7 @@ sealed interface DocumentContent { is AreaEntity -> Area.fromDb(entity) is FirstMatchEntity -> FirstMatch.fromDb(entity) is SelectByLanguageEntity -> SelectByLanguage.fromDb(entity) + is RepeatedContentEntity -> RepeatedContent.fromDb(entity) } } } @@ -36,6 +38,7 @@ fun List.toDb(): List { is Area -> it.toDb() is FirstMatch -> it.toDb() is SelectByLanguage -> it.toDb() + is RepeatedContent -> it.toDb() } } } \ No newline at end of file diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/RepeatedContent.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/RepeatedContent.kt new file mode 100644 index 0000000..f84ab2b --- /dev/null +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/RepeatedContent.kt @@ -0,0 +1,38 @@ +package com.quadient.migration.api.dto.migrationmodel + +import com.quadient.migration.persistence.migrationmodel.RepeatedContentEntity +import com.quadient.migration.shared.VariablePath +import com.quadient.migration.shared.VariableRefPath + +data class RepeatedContent( + val variablePath: VariablePath, + val content: List, +) : DocumentContent, RefValidatable { + + override fun collectRefs(): List { + val contentRefs = content.flatMap { + when (it) { + is RefValidatable -> it.collectRefs() + } + } + + val variableRefs = when (val path = variablePath) { + is VariableRefPath -> listOf(VariableRef(path.variableId)) + else -> emptyList() + } + + return contentRefs + variableRefs + } + + companion object { + fun fromDb(entity: RepeatedContentEntity): RepeatedContent = RepeatedContent( + variablePath = entity.variablePath, + content = entity.content.map { DocumentContent.fromDbContent(it) }, + ) + } + + fun toDb() = RepeatedContentEntity( + variablePath = variablePath, + content = content.toDb(), + ) +} diff --git a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/Table.kt b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/Table.kt index b91a073..0bfd37f 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/Table.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/api/dto/migrationmodel/documentcontent/Table.kt @@ -4,16 +4,21 @@ import com.quadient.migration.persistence.migrationmodel.TableEntity import com.quadient.migration.shared.BorderOptions import com.quadient.migration.shared.CellAlignment import com.quadient.migration.shared.CellHeight +import com.quadient.migration.shared.VariablePath import com.quadient.migration.shared.Size import com.quadient.migration.shared.TableAlignment import com.quadient.migration.shared.TablePdfTaggingRule +import com.quadient.migration.shared.VariableRefPath + +/** Sealed type representing either a single table row or a group of rows repeated by an array variable. */ +sealed interface TableRow : RefValidatable data class Table( - val rows: List, - val header: List = emptyList(), - val firstHeader: List = emptyList(), - val footer: List = emptyList(), - val lastFooter: List = emptyList(), + val rows: List, + val header: List = emptyList(), + val firstHeader: List = emptyList(), + val footer: List = emptyList(), + val lastFooter: List = emptyList(), val columnWidths: List, val pdfTaggingRule: TablePdfTaggingRule = TablePdfTaggingRule.Default, val pdfAlternateText: String? = null, @@ -29,11 +34,11 @@ data class Table( companion object { fun fromDb(table: TableEntity): Table = Table( - rows = table.rows.map(Row::fromDb), - header = table.header.map(Row::fromDb), - firstHeader = table.firstHeader.map(Row::fromDb), - footer = table.footer.map(Row::fromDb), - lastFooter = table.lastFooter.map(Row::fromDb), + rows = table.rows.map(::tableRowFromDb), + header = table.header.map(::tableRowFromDb), + firstHeader = table.firstHeader.map(::tableRowFromDb), + footer = table.footer.map(::tableRowFromDb), + lastFooter = table.lastFooter.map(::tableRowFromDb), columnWidths = table.columnWidths.map { ColumnWidth(it.minWidth, it.percentWidth) }, pdfTaggingRule = table.pdfTaggingRule, pdfAlternateText = table.pdfAlternateText, @@ -47,11 +52,11 @@ data class Table( fun toDb(): TableEntity { return TableEntity( - rows = rows.map(Row::toDb), - header = header.map(Row::toDb), - firstHeader = firstHeader.map(Row::toDb), - footer = footer.map(Row::toDb), - lastFooter = lastFooter.map(Row::toDb), + rows = rows.map { it.toDb() }, + header = header.map { it.toDb() }, + firstHeader = firstHeader.map { it.toDb() }, + footer = footer.map { it.toDb() }, + lastFooter = lastFooter.map { it.toDb() }, columnWidths = columnWidths.map { TableEntity.ColumnWidthEntity(it.minWidth, it.percentWidth) }, pdfTaggingRule = pdfTaggingRule, pdfAlternateText = pdfAlternateText, @@ -63,7 +68,7 @@ data class Table( ) } - data class Row(val cells: List, val displayRuleRef: DisplayRuleRef? = null) : RefValidatable { + data class Row(val cells: List, val displayRuleRef: DisplayRuleRef? = null) : TableRow { override fun collectRefs(): List { return cells.flatMap { it.collectRefs() } + listOfNotNull(displayRuleRef) } @@ -81,6 +86,33 @@ data class Table( } } + data class RepeatedRow( + val rows: List, + val variable: VariablePath, + ) : TableRow { + override fun collectRefs(): List { + val rowRefs = rows.flatMap { it.collectRefs() } + val varRef = (variable as? VariableRefPath)?.let { VariableRef(it.variableId) } + return rowRefs + listOfNotNull(varRef) + } + + fun toDb(): TableEntity.RepeatedRow { + return TableEntity.RepeatedRow( + rows = rows.map(Row::toDb), + variable = variable, + ) + } + + companion object { + fun fromDb(row: TableEntity.RepeatedRow): RepeatedRow { + return RepeatedRow( + rows = row.rows.map(Row::fromDb), + variable = row.variable, + ) + } + } + } + data class Cell( val content: List, val mergeLeft: Boolean, @@ -125,3 +157,13 @@ data class Table( data class ColumnWidth(val minWidth: Size, val percentWidth: Double) } + +private fun tableRowFromDb(row: TableEntity.TableRow): TableRow = when (row) { + is TableEntity.Row -> Table.Row.fromDb(row) + is TableEntity.RepeatedRow -> Table.RepeatedRow.fromDb(row) +} + +private fun TableRow.toDb(): TableEntity.TableRow = when (this) { + is Table.Row -> this.toDb() + is Table.RepeatedRow -> this.toDb() +} diff --git a/migration-library/src/main/kotlin/com/quadient/migration/persistence/migrationmodel/DocumentContentEntity.kt b/migration-library/src/main/kotlin/com/quadient/migration/persistence/migrationmodel/DocumentContentEntity.kt index b313fef..8bff546 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/persistence/migrationmodel/DocumentContentEntity.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/persistence/migrationmodel/DocumentContentEntity.kt @@ -3,10 +3,21 @@ package com.quadient.migration.persistence.migrationmodel import com.quadient.migration.shared.BorderOptions import com.quadient.migration.shared.CellAlignment import com.quadient.migration.shared.CellHeight +import com.quadient.migration.shared.VariablePath import com.quadient.migration.shared.Position import com.quadient.migration.shared.Size import com.quadient.migration.shared.TableAlignment +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import com.quadient.migration.shared.TablePdfTaggingRule @Serializable @@ -14,11 +25,11 @@ sealed interface DocumentContentEntity @Serializable data class TableEntity( - val rows: List, - val header: List = emptyList(), - val firstHeader: List = emptyList(), - val footer: List = emptyList(), - val lastFooter: List = emptyList(), + val rows: List, + val header: List = emptyList(), + val firstHeader: List = emptyList(), + val footer: List = emptyList(), + val lastFooter: List = emptyList(), val columnWidths: List, val pdfTaggingRule: TablePdfTaggingRule = TablePdfTaggingRule.Default, val pdfAlternateText: String? = null, @@ -28,8 +39,17 @@ data class TableEntity( val border: BorderOptions? = null, val alignment: TableAlignment = TableAlignment.Left ) : DocumentContentEntity, TextContentEntity { + @Serializable(with = TableRowEntitySerializer::class) + sealed interface TableRow + + @Serializable + data class Row(val cells: List, val displayRuleRef: DisplayRuleEntityRef? = null) : TableRow + @Serializable - data class Row(val cells: List, val displayRuleRef: DisplayRuleEntityRef?) + data class RepeatedRow( + val rows: List, + val variable: VariablePath, + ) : TableRow @Serializable data class Cell( @@ -45,6 +65,31 @@ data class TableEntity( data class ColumnWidthEntity(val minWidth: Size, val percentWidth: Double) } +object TableRowEntitySerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TableEntity.TableRow") + + override fun deserialize(decoder: Decoder): TableEntity.TableRow { + val jsonDecoder = decoder as? JsonDecoder + ?: error("TableEntity.TableRow can only be deserialized from JSON") + val element = jsonDecoder.decodeJsonElement().jsonObject + return when { + "cells" in element -> jsonDecoder.json.decodeFromJsonElement(element) + "rows" in element -> jsonDecoder.json.decodeFromJsonElement(element) + else -> error("Cannot determine TableRow type: neither 'cells' nor 'rows' field present") + } + } + + override fun serialize(encoder: Encoder, value: TableEntity.TableRow) { + val jsonEncoder = encoder as? JsonEncoder + ?: error("TableEntity.TableRow can only be serialized to JSON") + val element = when (value) { + is TableEntity.Row -> jsonEncoder.json.encodeToJsonElement(value) + is TableEntity.RepeatedRow -> jsonEncoder.json.encodeToJsonElement(value) + } + jsonEncoder.encodeJsonElement(element) + } +} + @Serializable data class ParagraphEntity( val content: MutableList, @@ -63,3 +108,9 @@ data class ParagraphEntity( data class AreaEntity( val content: List, val position: Position?, val interactiveFlowName: String?, val flowToNextPage: Boolean = false ) : DocumentContentEntity + +@Serializable +data class RepeatedContentEntity( + val variablePath: VariablePath, + val content: List, +) : DocumentContentEntity diff --git a/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilder.kt index a6e8bde..6dd2ca2 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilder.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilder.kt @@ -23,7 +23,6 @@ import com.quadient.wfdxml.api.layoutnodes.Flow import com.quadient.wfdxml.api.layoutnodes.FlowArea import com.quadient.wfdxml.api.layoutnodes.Page import com.quadient.wfdxml.api.layoutnodes.Pages -import com.quadient.wfdxml.api.layoutnodes.tables.GeneralRowSet import com.quadient.wfdxml.api.layoutnodes.tables.RowSet import com.quadient.wfdxml.api.layoutnodes.Image as WfdXmlImage import com.quadient.wfdxml.api.module.Layout @@ -178,11 +177,12 @@ class DesignerDocumentObjectBuilder( val languageVariable = variableStructure.languageVariable if (languageVariable != null) { val languageVariableModel = variableRepository.findOrFail(languageVariable.id) - val languageVariablePathData = variableStructure.structure[languageVariable.id] - if (languageVariablePathData == null || languageVariablePathData.path.isBlank()) { + val languageVariablePath = variableStructure.structure[languageVariable.id]?.path + ?.resolve(variableStructure, variableRepository::findOrFail)?.takeIf { it.isNotBlank() } + if (languageVariablePath.isNullOrBlank()) { error("Language variable '${languageVariable.id}' or its path not found in variable structure '${variableStructure.id}'.") } - val variable = getOrCreateVariable(layout.data, languageVariableModel.nameOrId(), languageVariableModel, languageVariablePathData.path) + val variable = getOrCreateVariable(layout.data, languageVariableModel.nameOrId(), languageVariableModel, languageVariablePath) layout.data.setLanguageVariable(variable) } @@ -228,7 +228,7 @@ class DesignerDocumentObjectBuilder( ) } - val root = layout.addRoot().setAllowRuntimeModifications(true) + val root = (layout.root ?: layout.addRoot()).setAllowRuntimeModifications(true) if (resolvedStyleDefinitionPath != null) { root.setExternalStylesLayout(resolvedStyleDefinitionPath) } @@ -263,21 +263,17 @@ class DesignerDocumentObjectBuilder( ) } - override fun buildSuccessRowWrappedInConditionRow( + override fun buildConditionRow( layout: Layout, variableStructure: VariableStructure, rule: DisplayRule, - multipleRowSet: GeneralRowSet, - ): GeneralRowSet { + ): WrappedRow { val successRow = layout.addRowSet().setType(RowSet.Type.SINGLE_ROW) - - multipleRowSet.addRowSet( - layout.addRowSet().setType(RowSet.Type.SELECT_BY_INLINE_CONDITION).addLineForSelectByInlineCondition( + val conditionRow = layout.addRowSet().setType(RowSet.Type.SELECT_BY_INLINE_CONDITION) + .addLineForSelectByInlineCondition( rule.toScript(layout, variableStructure, variableRepository::findOrFail), successRow ) - ) - - return successRow + return WrappedRow(conditionRow, successRow) } private fun buildPage( diff --git a/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireBuilderUtils.kt b/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireBuilderUtils.kt index 5894042..dfdd8b3 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireBuilderUtils.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireBuilderUtils.kt @@ -24,6 +24,8 @@ fun getDataType(dataType: DataTypeModel): DataType { DataTypeModel.String -> DataType.STRING DataTypeModel.Boolean -> DataType.BOOL DataTypeModel.Currency -> DataType.CURRENCY + DataTypeModel.Array -> DataType.ARRAY + DataTypeModel.SubTree -> DataType.SUB_TREE } } @@ -115,11 +117,24 @@ fun getFillStyleByColor(layout: Layout, color: com.quadient.wfdxml.api.layoutnod } as? com.quadient.wfdxml.api.layoutnodes.FillStyle } -fun getVariable(data: DataImpl, name: String, parentPath: String): Variable? { - return (data.children).find { +fun getVariable(data: DataImpl, name: String, parentPath: String): VariableImpl? { + val byExistingParentId = data.children.find { val variable = it as VariableImpl variable.name == name && variable.existingParentId == parentPath - } as? Variable + } as? VariableImpl + if (byExistingParentId != null) return byExistingParentId + + val normalizedParent = removeValueFromVariablePath(removeDataFromVariablePath(parentPath)) + val pathParts = if (normalizedParent.isBlank()) { + arrayOf(name) + } else { + (normalizedParent.split(".") + name).toTypedArray() + } + return try { + data.findVariable(*pathParts) + } catch (_: Exception) { + null + } } fun buildFontName(bold: Boolean, italic: Boolean): String { diff --git a/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilder.kt b/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilder.kt index 18e3016..9f014ec 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilder.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilder.kt @@ -17,14 +17,13 @@ import com.quadient.migration.api.dto.migrationmodel.Image import com.quadient.migration.api.dto.migrationmodel.ImageRef import com.quadient.migration.api.dto.migrationmodel.Paragraph import com.quadient.migration.api.dto.migrationmodel.Paragraph.Text -import com.quadient.migration.api.dto.migrationmodel.ParagraphStyleDefinition -import com.quadient.migration.api.dto.migrationmodel.ParagraphStyleRef +import com.quadient.migration.api.dto.migrationmodel.RepeatedContent import com.quadient.migration.api.dto.migrationmodel.ResourceRef import com.quadient.migration.api.dto.migrationmodel.SelectByLanguage import com.quadient.migration.api.dto.migrationmodel.StringValue import com.quadient.migration.api.dto.migrationmodel.Table +import com.quadient.migration.api.dto.migrationmodel.TableRow import com.quadient.migration.api.dto.migrationmodel.TextStyleDefinition -import com.quadient.migration.api.dto.migrationmodel.TextStyleRef import com.quadient.migration.api.dto.migrationmodel.Variable import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.api.dto.migrationmodel.VariableStringContent @@ -100,6 +99,9 @@ import kotlin.collections.ifEmpty import com.quadient.migration.shared.DataType as DataTypeModel import com.quadient.migration.api.dto.migrationmodel.ParagraphStyle import com.quadient.migration.api.dto.migrationmodel.TextStyle +import com.quadient.migration.shared.VariablePath +import com.quadient.migration.shared.LiteralPath +import com.quadient.migration.shared.VariableRefPath import com.quadient.migration.service.resolveTarget abstract class InspireDocumentObjectBuilder( @@ -148,19 +150,27 @@ abstract class InspireDocumentObjectBuilder( protected fun collectLanguages(documentObject: DocumentObject): List { val languages = mutableSetOf() + fun Table.allRows(): List = + (rows + header + firstHeader + footer + lastFooter).flatMap { row -> + when (row) { + is Table.Row -> listOf(row) + is Table.RepeatedRow -> row.rows + } + } + fun collectLanguagesFromContent(content: List) { for (item in content) { when (item) { is SelectByLanguage -> item.cases.forEach { languages.add(it.language) } is Area -> collectLanguagesFromContent(item.content) + is RepeatedContent -> collectLanguagesFromContent(item.content) is FirstMatch -> { item.cases.forEach { case -> collectLanguagesFromContent(case.content) } collectLanguagesFromContent(item.default) } - is Table -> (item.rows + item.header + item.firstHeader + item.footer + item.lastFooter).forEach { row -> - row.cells.forEach { cell -> collectLanguagesFromContent(cell.content) } - } + is Table -> item.allRows() + .forEach { row -> row.cells.forEach { cell -> collectLanguagesFromContent(cell.content) } } is DocumentObjectRef -> { val documentObject = documentObjectRepository.findOrFail(item.id) @@ -177,9 +187,8 @@ abstract class InspireDocumentObjectBuilder( collectLanguagesFromContent(textContent.default) } - is Table -> (textContent.rows + textContent.header + textContent.firstHeader + textContent.footer + textContent.lastFooter).forEach { row -> - row.cells.forEach { cell -> collectLanguagesFromContent(cell.content) } - } + is Table -> textContent.allRows() + .forEach { row -> row.cells.forEach { cell -> collectLanguagesFromContent(cell.content) } } is DocumentObjectRef -> { val documentObject = documentObjectRepository.findOrFail(textContent.id) @@ -213,23 +222,21 @@ abstract class InspireDocumentObjectBuilder( ) } - protected open fun buildSuccessRowWrappedInConditionRow( + protected data class WrappedRow(val outer: GeneralRowSet, val inner: GeneralRowSet) + + protected open fun buildConditionRow( layout: Layout, variableStructure: VariableStructure, rule: DisplayRule, - multipleRowSet: GeneralRowSet - ): GeneralRowSet { + ): WrappedRow { val successRow = layout.addRowSet().setType(RowSet.Type.SINGLE_ROW) - - multipleRowSet.addRowSet( - layout.addRowSet().setType(RowSet.Type.SELECT_BY_CONDITION).addLineForSelectByCondition( + val conditionRow = layout.addRowSet().setType(RowSet.Type.SELECT_BY_CONDITION) + .addLineForSelectByCondition( layout.data.addVariable().setKind(VariableKind.CALCULATED).setDataType(DataType.BOOL) .setScript(rule.toScript(layout, variableStructure, variableRepository::findOrFail)), successRow ) - ) - - return successRow + return WrappedRow(conditionRow, successRow) } fun buildStyleLayoutDelta(textStyles: List, paragraphStyles: List): String { @@ -299,6 +306,7 @@ abstract class InspireDocumentObjectBuilder( is DocumentObjectRef -> flowModels.add(DocumentObject(contentPart)) is AttachmentRef -> flowModels.add(Attachment(contentPart)) is Area -> mutableContent.addAll(idx + 1, contentPart.content.resolveAliases(imageRepository, attachmentRepository)) + is RepeatedContent -> flowModels.add(RepeatedContent(contentPart)) is FirstMatch -> flowModels.add(FirstMatch(contentPart)) is SelectByLanguage -> flowModels.add(SelectByLanguage(contentPart)) } @@ -340,6 +348,16 @@ abstract class InspireDocumentObjectBuilder( buildSelectByLanguage(layout, variableStructure, it.model, name, languages) } } + + is FlowModel.RepeatedContent -> { + if (flowName == null) { + buildRepeatedContent(it.model, layout, variableStructure, null, languages) + } else { + val name = if (flowCount == 1) flowName else "$flowName $flowSuffix" + flowSuffix++ + buildRepeatedContent(it.model, layout, variableStructure, name, languages) + } + } } } } @@ -350,6 +368,7 @@ abstract class InspireDocumentObjectBuilder( data class Attachment(val ref: AttachmentRef) : FlowModel data class FirstMatch(val model: com.quadient.migration.api.dto.migrationmodel.FirstMatch) : FlowModel data class SelectByLanguage(val model: com.quadient.migration.api.dto.migrationmodel.SelectByLanguage) : FlowModel + data class RepeatedContent(val model: com.quadient.migration.api.dto.migrationmodel.RepeatedContent) : FlowModel } protected fun List.toSingleFlow( @@ -406,8 +425,10 @@ abstract class InspireDocumentObjectBuilder( languageVariable = null, ) - val normalizedVariablePaths = variableStructureModel.structure.map { (_, variablePathData) -> - removeDataFromVariablePath(variablePathData.path) + val normalizedVariablePaths = variableStructureModel.structure.map { (variableId, variablePathData) -> + val literalPath = variablePathData.path.resolve(variableStructureModel, variableRepository::findOrFail) + ?: error("Variable '$variableId' referenced as array path has no resolvable literal path in structure") + removeDataFromVariablePath(literalPath) }.filter { it.isNotBlank() } val variableTree = buildVariableTree(normalizedVariablePaths) @@ -883,11 +904,12 @@ abstract class InspireDocumentObjectBuilder( val variableModel = variableRepository.findOrFail(ref.id) val variablePathData = variableStructure.structure[ref.id] - if (variablePathData == null || variablePathData.path.isBlank()) { + val resolvedPath = variablePathData?.path?.resolve(variableStructure, variableRepository::findOrFail)?.takeIf { it.isNotBlank() } + if (resolvedPath.isNullOrBlank()) { this.appendText("""$${variablePathData?.name ?: variableModel.nameOrId()}$""") } else { val variableName = variablePathData.name ?: variableModel.nameOrId() - this.appendVariable(getOrCreateVariable(layout.data, variableName, variableModel, variablePathData.path)) + this.appendVariable(getOrCreateVariable(layout.data, variableName, variableModel, resolvedPath)) } return this @@ -997,59 +1019,194 @@ abstract class InspireDocumentObjectBuilder( } } - private fun List.buildRows(layout: Layout, rowset: GeneralRowSet, variableStructure: VariableStructure, languages: List) { - this.forEach { rowModel -> - val row = if (rowModel.displayRuleRef == null) { - layout.addRowSet().setType(RowSet.Type.SINGLE_ROW).also { rowset.addRowSet(it) } - } else { - val displayRule = displayRuleRepository.findOrFail(rowModel.displayRuleRef.id) + private fun List.buildRowSetGroup( + layout: Layout, variableStructure: VariableStructure, languages: List + ): GeneralRowSet? { + fun TableRow.toRowSet(): GeneralRowSet? = when (this) { + is Table.Row -> buildSingleRowSet(this, layout, variableStructure, languages) + is Table.RepeatedRow -> buildRepeatedRowSet(this, layout, variableStructure, languages) + } - buildSuccessRowWrappedInConditionRow( - layout, variableStructure, displayRule, rowset - ) - } + if (isEmpty()) return null + if (size == 1) return first().toRowSet() + return layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS).also { multipleRowSet -> + forEach { tableRow -> tableRow.toRowSet()?.let { multipleRowSet.addRowSet(it) } } + } + } - rowModel.cells.forEach { cellModel -> - val cellContentFlow = buildDocumentContentAsSingleFlow( - layout, variableStructure, cellModel.content, null, null, languages - ) - val cellFlow = - if (cellContentFlow.type === Flow.Type.SELECT_BY_INLINE_CONDITION || cellContentFlow.type === Flow.Type.SELECT_BY_CONDITION) { - layout.addFlow().setType(Flow.Type.SIMPLE).setSectionFlow(true) - .setWebEditingType(Flow.WebEditingType.SECTION) - .also { it.addParagraph().addText().appendFlow(cellContentFlow) } - } else cellContentFlow - - val cell = layout.addCell().setSpanLeft(cellModel.mergeLeft).setSpanUp(cellModel.mergeUp) - .setFlowToNextPage(true).setFlow(cellFlow) - - when (cellModel.height) { - is CellHeight.Custom -> { - cell.setType(Cell.CellType.CUSTOM) - .setMinHeight(cellModel.height.minHeight.toMeters()) - .setMaxHeight(cellModel.height.maxHeight.toMeters()) - } - is CellHeight.Fixed -> { - cell.setType(Cell.CellType.FIXED_HEIGHT) - .setFixedHeight(cellModel.height.size.toMeters()) - } - null -> {} - } + private fun buildSingleRowSet( + rowModel: Table.Row, layout: Layout, variableStructure: VariableStructure, languages: List + ): GeneralRowSet { + val (outer, inner) = if (rowModel.displayRuleRef == null) { + val rowSet = layout.addRowSet().setType(RowSet.Type.SINGLE_ROW) + WrappedRow(rowSet, rowSet) + } else { + val displayRule = displayRuleRepository.findOrFail(rowModel.displayRuleRef.id) + buildConditionRow(layout, variableStructure, displayRule) + } - when (cellModel.alignment) { - CellAlignment.Top -> cell.setAlignment(Cell.CellVerticalAlignment.TOP) - CellAlignment.Center -> cell.setAlignment(Cell.CellVerticalAlignment.CENTER) - CellAlignment.Bottom -> cell.setAlignment(Cell.CellVerticalAlignment.BOTTOM) - null -> {} + rowModel.cells.forEach { cellModel -> + val cellContentFlow = buildDocumentContentAsSingleFlow( + layout, variableStructure, cellModel.content, null, null, languages + ) + val cellFlow = + if (cellContentFlow.type === Flow.Type.SELECT_BY_INLINE_CONDITION || cellContentFlow.type === Flow.Type.SELECT_BY_CONDITION) { + layout.addFlow().setType(Flow.Type.SIMPLE).setSectionFlow(true) + .setWebEditingType(Flow.WebEditingType.SECTION) + .also { it.addParagraph().addText().appendFlow(cellContentFlow) } + } else cellContentFlow + + val cell = layout.addCell().setSpanLeft(cellModel.mergeLeft).setSpanUp(cellModel.mergeUp) + .setFlowToNextPage(true).setFlow(cellFlow) + + when (cellModel.height) { + is CellHeight.Custom -> { + cell.setType(Cell.CellType.CUSTOM) + .setMinHeight(cellModel.height.minHeight.toMeters()) + .setMaxHeight(cellModel.height.maxHeight.toMeters()) + } + is CellHeight.Fixed -> { + cell.setType(Cell.CellType.FIXED_HEIGHT) + .setFixedHeight(cellModel.height.size.toMeters()) } + null -> {} + } + + when (cellModel.alignment) { + CellAlignment.Top -> cell.setAlignment(Cell.CellVerticalAlignment.TOP) + CellAlignment.Center -> cell.setAlignment(Cell.CellVerticalAlignment.CENTER) + CellAlignment.Bottom -> cell.setAlignment(Cell.CellVerticalAlignment.BOTTOM) + null -> {} + } - buildTableBorderStyle(cellModel.border, layout, cell::setBorderStyle) + buildTableBorderStyle(cellModel.border, layout, cell::setBorderStyle) - row.addCell(cell) + inner.addCell(cell) + } + + return outer + } + + private fun buildRepeatedRowSet( + repeatedRow: Table.RepeatedRow, + layout: Layout, + variableStructure: VariableStructure, + languages: List + ): GeneralRowSet? { + val (varName, varPath) = resolveVariableNameAndPath(repeatedRow.variable, variableStructure) + ?: return buildUnmappedRepeatedRowFallback(repeatedRow, layout, variableStructure, languages) + val arrayVariable = getVariable(layout.data as DataImpl, varName, varPath) + ?: return buildUnmappedRepeatedRowFallback(repeatedRow, layout, variableStructure, languages) + require(arrayVariable.nodeOptionality == NodeOptionality.ARRAY) { + "Variable '$varName' at '$varPath' used in repeated row is not an Array variable" + } + + val repeatedRowSet = layout.addRowSet().setType(RowSet.Type.REPEATED) + if (repeatedRow.rows.size > 1) { + val multipleRowSet = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) + repeatedRowSet.addRowSet(multipleRowSet) + repeatedRow.rows.forEach { multipleRowSet.addRowSet(buildSingleRowSet(it, layout, variableStructure, languages)) } + } else { + repeatedRow.rows.forEach { repeatedRowSet.addRowSet(buildSingleRowSet(it, layout, variableStructure, languages)) } + } + repeatedRowSet.setVariable(arrayVariable) + + val layoutRoot = layout.root ?: layout.addRoot() + layoutRoot.addLockedWebNode(repeatedRowSet) + + return repeatedRowSet + } + + private fun buildUnmappedRepeatedRowFallback( + repeatedRow: Table.RepeatedRow, layout: Layout, variableStructure: VariableStructure, languages: List + ): GeneralRowSet? { + val varName = getVariableNameFromPath(repeatedRow.variable, variableStructure) + val warning = Paragraph("") + + val rows = repeatedRow.rows + val rowsWithWarning = rows.firstOrNull()?.let { firstRow -> + firstRow.cells.firstOrNull()?.let { firstCell -> + val cellWithWarning = firstCell.copy(content = listOf(warning) + firstCell.content) + listOf(firstRow.copy(cells = listOf(cellWithWarning) + firstRow.cells.drop(1))) + rows.drop(1) } + } ?: rows + + return rowsWithWarning.buildRowSetGroup(layout, variableStructure, languages) + } + + private fun getVariableNameFromPath( + variablePath: VariablePath, variableStructure: VariableStructure + ): String = when (variablePath) { + is LiteralPath -> variablePath.path + is VariableRefPath -> variableStructure.structure[variablePath.variableId]?.name + ?: variableRepository.find(variablePath.variableId)?.nameOrId() + ?: variablePath.variableId + } + + private fun buildRepeatedContent( + model: RepeatedContent, + layout: Layout, + variableStructure: VariableStructure, + flowName: String?, + languages: List, + ): Flow { + val (varName, varPath) = resolveVariableNameAndPath(model.variablePath, variableStructure) + ?: return buildUnmappedRepeatedContentFallback(model, layout, variableStructure, languages) + val arrayVariable = getVariable(layout.data as DataImpl, varName, varPath) + ?: return buildUnmappedRepeatedContentFallback(model, layout, variableStructure, languages) + require(arrayVariable.nodeOptionality == NodeOptionality.ARRAY) { + "Variable '$varName' at '$varPath' used in repeated content is not an Array variable" + } + + val innerFlows = buildDocumentContentAsFlows(layout, variableStructure, model.content, languages = languages) + return if (innerFlows.size == 1 && innerFlows[0].type == Flow.Type.SIMPLE) { + val repeatedFlow = innerFlows[0].setType(Flow.Type.REPEATED).setVariable(arrayVariable) + flowName?.let { repeatedFlow.setName(it) } + repeatedFlow + } else { + val repeatedFlow = + layout.addFlow().setType(Flow.Type.REPEATED).setVariable(arrayVariable).setSectionFlow(true) + .setWebEditingType(Flow.WebEditingType.SECTION) + flowName?.let { repeatedFlow.setName(it) } + val repeatedFlowText = repeatedFlow.addParagraph().addText() + innerFlows.forEach { repeatedFlowText.appendFlow(it) } + repeatedFlow } } + private fun buildUnmappedRepeatedContentFallback( + model: RepeatedContent, + layout: Layout, + variableStructure: VariableStructure, + languages: List, + ): Flow { + val varName = getVariableNameFromPath(model.variablePath, variableStructure) + val warning = Paragraph("") + return buildDocumentContentAsSingleFlow( + layout, variableStructure, listOf(warning) + model.content, languages = languages + ) + } + + private fun resolveVariableNameAndPath( + variablePath: VariablePath, variableStructure: VariableStructure + ): Pair? { + val fullPath = when (variablePath) { + is LiteralPath -> variablePath.path + is VariableRefPath -> { + val parentVarPathData = variableStructure.structure[variablePath.variableId] ?: return null + val parentVarName = + parentVarPathData.name ?: variableRepository.findOrFail(variablePath.variableId).nameOrId() + val parentVarPath = + parentVarPathData.path.resolve(variableStructure, variableRepository::findOrFail) ?: return null + "$parentVarPath.$parentVarName" + } + } + val normalized = removeDataFromVariablePath(removeValueFromVariablePath(fullPath)) + if (normalized.isBlank()) return null + val parts = normalized.split(".") + return parts.last() to (if (parts.size > 1) "Data.${parts.dropLast(1).joinToString(".")}" else "Data") + } + fun buildTable( layout: Layout, variableStructure: VariableStructure, model: Table, languages: List ): WfdXmlTable { @@ -1058,7 +1215,11 @@ abstract class InspireDocumentObjectBuilder( if (model.columnWidths.isNotEmpty()) { model.columnWidths.forEach { table.addColumn(it.minWidth.toMeters(), it.percentWidth) } } else { - val numberOfColumns = model.rows.firstOrNull()?.cells?.size ?: 0 + val numberOfColumns = when (val firstRow = model.rows.firstOrNull()) { + is Table.Row -> firstRow.cells.size + is Table.RepeatedRow -> firstRow.rows.firstOrNull()?.cells?.size ?: 0 + null -> 0 + } repeat(numberOfColumns) { table.addColumn() } } @@ -1087,39 +1248,14 @@ abstract class InspireDocumentObjectBuilder( val headerFooterRowSet = layout.addRowSetHeaderFooter() table.setRowSet(headerFooterRowSet) - if (model.header.isNotEmpty()) { - val headerRowSet = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) - headerFooterRowSet.setHeader(headerRowSet) - model.header.buildRows(layout, headerRowSet, variableStructure, languages) - } - - if (model.firstHeader.isNotEmpty()) { - val firstHeaderRowSet = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) - headerFooterRowSet.setFirstHeader(firstHeaderRowSet) - model.firstHeader.buildRows(layout, firstHeaderRowSet, variableStructure, languages) - } - - if (model.footer.isNotEmpty()) { - val footerRowSet = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) - headerFooterRowSet.setFooter(footerRowSet) - model.footer.buildRows(layout, footerRowSet, variableStructure, languages) - } - - if (model.lastFooter.isNotEmpty()) { - val lastFooterRowSet = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) - headerFooterRowSet.setLastFooter(lastFooterRowSet) - model.lastFooter.buildRows(layout, lastFooterRowSet, variableStructure, languages) - } - - val bodyRowSet = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) - headerFooterRowSet.setBody(bodyRowSet) + model.header.buildRowSetGroup(layout, variableStructure, languages)?.let { headerFooterRowSet.setHeader(it) } + model.firstHeader.buildRowSetGroup(layout, variableStructure, languages)?.let { headerFooterRowSet.setFirstHeader(it) } + model.footer.buildRowSetGroup(layout, variableStructure, languages)?.let { headerFooterRowSet.setFooter(it) } + model.lastFooter.buildRowSetGroup(layout, variableStructure, languages)?.let { headerFooterRowSet.setLastFooter(it) } - model.rows.buildRows(layout, bodyRowSet, variableStructure, languages) + model.rows.buildRowSetGroup(layout, variableStructure, languages)?.let { headerFooterRowSet.setBody(it) } } else { - val rowset = layout.addRowSet().setType(RowSet.Type.MULTIPLE_ROWS) - table.setRowSet(rowset) - - model.rows.buildRows(layout, rowset, variableStructure, languages) + model.rows.buildRowSetGroup(layout, variableStructure, languages)?.let { table.setRowSet(it) } } when (model.pdfTaggingRule) { @@ -1298,11 +1434,12 @@ abstract class InspireDocumentObjectBuilder( is VariableRef -> { val variableModel = findVar(ref.id) val variablePathData = variableStructure.structure[ref.id] - if (variablePathData != null && variablePathData.path.isNotBlank()) { + val resolvedPath = variablePathData?.path?.resolve(variableStructure, findVar) + if (!resolvedPath.isNullOrBlank()) { val variableName = variablePathData.name ?: variableModel.nameOrId() getOrCreateVariable( - layout.data, variableName, variableModel, variablePathData.path + layout.data, variableName, variableModel, resolvedPath ) } } @@ -1408,6 +1545,27 @@ fun Literal.toScript( } } +internal fun VariablePath.resolve(variableStructure: VariableStructure, findVariable: (String) -> Variable): String? { + return when (this) { + is LiteralPath -> this.path + is VariableRefPath -> { + val parentVarPathData = variableStructure.structure[this.variableId] ?: return null + val parentVarPath = + parentVarPathData.path.resolve(variableStructure, findVariable)?.takeIf { it.isNotBlank() } + ?: return null + + val parentVar = findVariable(this.variableId) + val parentVarName = parentVarPathData.name ?: parentVar.nameOrId() + + when (parentVar.dataType) { + DataTypeModel.Array -> "$parentVarPath.$parentVarName.Value" + DataTypeModel.SubTree -> "$parentVarPath.$parentVarName" + else -> error("Variable '${this.variableId}' of type ${parentVar.dataType} is used in path. Only Array and Subtree can be referenced.") + } + } + } +} + private fun variableStringContentToScript( variableStringContent: List, layout: Layout, @@ -1434,16 +1592,17 @@ fun variableToScript( ): ScriptResult { val variableModel = findVar(id) val variablePathData = variableStructure.structure[id] - return if (variablePathData == null || variablePathData.path.isBlank()) { + val resolvedPath = variablePathData?.path?.resolve(variableStructure, findVar)?.takeIf { it.isNotBlank() } + return if (resolvedPath.isNullOrBlank()) { Failure(variablePathData?.name ?: variableModel.nameOrId()) } else { val variableName = variablePathData.name ?: variableModel.nameOrId() if (layout != null) { - getOrCreateVariable(layout.data, variableName, variableModel, variablePathData.path) + getOrCreateVariable(layout.data, variableName, variableModel, resolvedPath) } - Success((variablePathData.path.split(".") + variableName).joinToString(".") { pathPart -> + Success((resolvedPath.split(".") + variableName).joinToString(".") { pathPart -> when (pathPart.lowercase()) { "value" -> "Current" "data" -> "DATA" @@ -1470,9 +1629,8 @@ fun WfdXmlVariable.setValueIfAvailable(variableModel: Variable): WfdXmlVariable DataTypeModel.Integer -> this.setValue(defaultVal.toInt()) DataTypeModel.Integer64 -> this.setValue(defaultVal.toLong()) DataTypeModel.Double, DataTypeModel.Currency -> this.setValue(defaultVal.toDouble()) - DataTypeModel.Boolean -> this.setValue( - defaultVal.lowercase().toBooleanStrict() - ) + DataTypeModel.Boolean -> this.setValue(defaultVal.lowercase().toBooleanStrict()) + DataTypeModel.Array, DataTypeModel.SubTree -> {} } } diff --git a/migration-library/src/main/kotlin/com/quadient/migration/shared/DataType.kt b/migration-library/src/main/kotlin/com/quadient/migration/shared/DataType.kt index b09ffb2..8df024a 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/shared/DataType.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/shared/DataType.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable enum class DataType { - DateTime, Integer, Integer64, Double, String, Boolean, Currency; + DateTime, Integer, Integer64, Double, String, Boolean, Currency, Array, SubTree; fun toInteractiveDataType(): String { return when (this) { @@ -15,6 +15,8 @@ enum class DataType { String -> "String" Boolean -> "Bool" Currency -> "Currency" + SubTree -> "SubTree" + Array -> "Array" } } } \ No newline at end of file diff --git a/migration-library/src/main/kotlin/com/quadient/migration/shared/JrdDefinition.kt b/migration-library/src/main/kotlin/com/quadient/migration/shared/JrdDefinition.kt index 772d123..158935d 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/shared/JrdDefinition.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/shared/JrdDefinition.kt @@ -17,6 +17,7 @@ import com.quadient.migration.service.inspirebuilder.VariablePathPart import com.quadient.migration.service.inspirebuilder.buildVariableTree import com.quadient.migration.service.inspirebuilder.removeDataFromVariablePath import com.quadient.migration.service.inspirebuilder.toScript +import com.quadient.migration.service.inspirebuilder.resolve import com.quadient.migration.service.inspirebuilder.variableToScript import com.quadient.migration.shared.BinOp.Equals import com.quadient.migration.shared.BinOp.EqualsCaseInsensitive @@ -62,7 +63,8 @@ data class JrdDefinition( val nodes = mutableListOf(null) val normalizedVariablePaths = variableStructure.structure.map { (_, variablePathData) -> - removeDataFromVariablePath(variablePathData.path) + variablePathData.path.resolve(variableStructure, findVar) + ?.let { removeDataFromVariablePath(it) } ?: "" }.filter { it.isNotBlank() } val variableTree = buildVariableTree(normalizedVariablePaths) @@ -72,8 +74,9 @@ data class JrdDefinition( val findVarWithNodes = { id: String -> val variable = findVar(id) val variablePathData = variableStructure.structure[id] - if (variablePathData != null && variablePathData.path.isNotBlank()) { - val nodePath = variablePathData.path.split('.') + variable.nameOrId() + val resolvedPath = variablePathData?.path?.resolve(variableStructure, findVar) + if (!resolvedPath.isNullOrBlank()) { + val nodePath = resolvedPath.split('.') + variable.nameOrId() if (!nodes.any { it?.nodePath == nodePath }) { val node = Node( @@ -83,7 +86,6 @@ data class JrdDefinition( } } - variable } diff --git a/migration-library/src/main/kotlin/com/quadient/migration/shared/VariablePath.kt b/migration-library/src/main/kotlin/com/quadient/migration/shared/VariablePath.kt new file mode 100644 index 0000000..bec4782 --- /dev/null +++ b/migration-library/src/main/kotlin/com/quadient/migration/shared/VariablePath.kt @@ -0,0 +1,51 @@ +package com.quadient.migration.shared + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = VariablePathSerializer::class) +sealed interface VariablePath + +@Serializable +data class LiteralPath(val path: String) : VariablePath + +@Serializable +data class VariableRefPath(val variableId: String) : VariablePath + +object VariablePathSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("VariablePath") + + override fun deserialize(decoder: Decoder): VariablePath { + val jsonDecoder = decoder as? JsonDecoder ?: error("VariablePath can only be deserialized from JSON") + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonPrimitive -> LiteralPath(element.content) + is JsonObject -> when { + "variableId" in element -> VariableRefPath(element["variableId"]!!.jsonPrimitive.content) + "path" in element -> LiteralPath(element["path"]!!.jsonPrimitive.content) + else -> error("Cannot deserialize VariablePath: expected 'path' or 'variableId' field") + } + + else -> error("Cannot deserialize VariablePath from ${element::class.simpleName}") + } + } + + override fun serialize(encoder: Encoder, value: VariablePath) { + val jsonEncoder = encoder as? JsonEncoder ?: error("VariablePath can only be serialized to JSON") + val element = when (value) { + is LiteralPath -> buildJsonObject { put("path", value.path) } + is VariableRefPath -> buildJsonObject { put("variableId", value.variableId) } + } + jsonEncoder.encodeJsonElement(element) + } +} diff --git a/migration-library/src/main/kotlin/com/quadient/migration/shared/VariableStructure.kt b/migration-library/src/main/kotlin/com/quadient/migration/shared/VariableStructure.kt index 6a6665f..01ac0be 100644 --- a/migration-library/src/main/kotlin/com/quadient/migration/shared/VariableStructure.kt +++ b/migration-library/src/main/kotlin/com/quadient/migration/shared/VariableStructure.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class VariablePathData( - var path: String, + var path: VariablePath, var name: String? = null, -) \ No newline at end of file +) { + constructor(path: String, name: String? = null) : this(LiteralPath(path), name) +} \ No newline at end of file diff --git a/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilderTest.kt b/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilderTest.kt index 387b6ec..f583891 100644 --- a/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilderTest.kt +++ b/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/DesignerDocumentObjectBuilderTest.kt @@ -68,7 +68,6 @@ import com.quadient.migration.tools.model.anArea import com.quadient.migration.tools.shouldBeEqualTo import com.quadient.migration.tools.shouldNotBeEmpty import com.quadient.migration.tools.shouldNotBeNull -import io.mockk.InternalPlatformDsl.toArray import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -523,8 +522,7 @@ class DesignerDocumentObjectBuilderTest { private fun com.fasterxml.jackson.databind.JsonNode.assertRowContent(rowSetId: String, expectedText: String) { val rowSet = this["RowSet"].last { it["Id"].textValue() == rowSetId } - val contentRowSet = this["RowSet"].last { it["Id"].textValue() == rowSet["SubRowId"].textValue() } - val cell = this["Cell"].last { it["Id"].textValue() == contentRowSet["SubRowId"].textValue() } + val cell = this["Cell"].last { it["Id"].textValue() == rowSet["SubRowId"].textValue() } val flowId = cell["FlowId"].textValue() val flow = this["Flow"].last { it["Id"].textValue() == flowId } flow["FlowContent"]["P"]["T"][""].textValue().shouldBeEqualTo(expectedText) diff --git a/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilderTest.kt b/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilderTest.kt index 7a24f38..60a610c 100644 --- a/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilderTest.kt +++ b/migration-library/src/test/kotlin/com/quadient/migration/service/inspirebuilder/InspireDocumentObjectBuilderTest.kt @@ -12,6 +12,7 @@ import com.quadient.migration.api.dto.migrationmodel.ParagraphStyle import com.quadient.migration.api.dto.migrationmodel.TextStyle import com.quadient.migration.api.dto.migrationmodel.TextStyleRef import com.quadient.migration.api.dto.migrationmodel.Variable +import com.quadient.migration.api.dto.migrationmodel.VariableRef import com.quadient.migration.api.dto.migrationmodel.VariableStructure import com.quadient.migration.api.dto.migrationmodel.builder.AttachmentBuilder import com.quadient.migration.api.dto.migrationmodel.builder.DocumentObjectBuilder @@ -33,6 +34,7 @@ import com.quadient.migration.shared.Alignment import com.quadient.migration.shared.BinOp import com.quadient.migration.shared.DataType import com.quadient.migration.shared.DocumentObjectType +import com.quadient.migration.shared.DocumentObjectType.Block import com.quadient.migration.shared.Function import com.quadient.migration.shared.ImageType.* import com.quadient.migration.shared.Literal @@ -371,6 +373,317 @@ class InspireDocumentObjectBuilderTest { variableScript.shouldBeEqualTo("return 'Jon ' + DATA.Clients.Current.Middle_Name.toString() + ' Doe ' + '\$noStruct$';") } + @Test + fun `variableRefPath entry in variable structure resolves to correct context path in script`() { + // given + val clientsArray = mockVar(VariableBuilder("clientsArray").name("Clients").dataType(DataType.Array).build()) + val nameVar = mockVar(VariableBuilder("nameVar").name("name").dataType(DataType.String).build()) + val variableStructure = mockVarStructure( + VariableStructureBuilder("vs1").addVariable(clientsArray.id, "Data") + .addVariable(nameVar.id, VariableRef(clientsArray.id), "Name").build() + ) + + val template = + DocumentObjectBuilder("T_1", DocumentObjectType.Template).variableStructureRef(variableStructure.id) + .paragraph { text { variableRef(nameVar.id) } }.build() + + // when + val result = + subject.buildDocumentObject(template).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val variableData = result["Variable"].first { it["Name"].textValue() == "Name" } + variableData["ParentId"].textValue().shouldBeEqualTo("Data.Clients.Value") + } + + @Test + fun `variableRefPath through subtree resolves to correct context path in script`() { + // given — address subtree inside clients array + val clientsArray = mockVar( + VariableBuilder("clientsArray").name("Clients").dataType(DataType.Array).build() + ) + val addressSubtree = mockVar( + VariableBuilder("addressSubtree").name("Address").dataType(DataType.SubTree).build() + ) + val cityVar = mockVar( + VariableBuilder("cityVar").name("City").dataType(DataType.String).build() + ) + val variableStructure = mockVarStructure( + VariableStructureBuilder("vs2") + .addVariable(clientsArray.id, "Data") + .addVariable(addressSubtree.id, VariableRef(clientsArray.id)) + .addVariable(cityVar.id, VariableRef(addressSubtree.id)) + .build() + ) + + val template = DocumentObjectBuilder("T_2", DocumentObjectType.Template) + .variableStructureRef(variableStructure.id) + .pdfMetadata { author { variableRef(cityVar.id) } } + .build() + + // when + val result = + subject.buildDocumentObject(template).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then — script should reference DATA.Clients.Current.Address.City + val authorVariableId = result["Pages"]["SheetNameVariableId"].last().textValue() + val variableScript = result["Variable"].last { it["Id"].textValue() == authorVariableId }["Script"].textValue() + variableScript.shouldBeEqualTo("return DATA.Clients.Current.Address.City.toString();") + } + + @Test + fun `buildDocumentObject creates repeated rowset with literal array path and two inner rows`() { + // given + val jobNameVar = mockVar(VariableBuilder("jobNameVar").name("Job Name").dataType(DataType.String).build()) + val variableStructure = + mockVarStructure(VariableStructureBuilder("VS_1").addVariable(jobNameVar.id, "Data.Clients.Value").build()) + + val block = mockObj(DocumentObjectBuilder("B_1", Block).table { + addRepeatedRow("Data.Clients.Value") { + addRow { + addCell { string("Name: ") } + addCell { string("Jon") } + } + addRow { + addCell { string("Job: ") } + addCell { paragraph { text { variableRef(jobNameVar.id) } } } + } + } + }.variableStructureRef(variableStructure.id).build()) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val repeatedRowSetId = result["Table"].last()["RowSetId"].textValue() + val repeatedRowSet = result["RowSet"].last { it["Id"].textValue() == repeatedRowSetId } + + repeatedRowSet["RowSetType"].textValue().shouldBeEqualTo("Repeated") + val arrayVarId = repeatedRowSet["VariableId"].textValue() + val arrayVar = result["Variable"].last { it["Id"].textValue() == arrayVarId } + arrayVar["Type"].textValue().shouldBeEqualTo("DataVariable") + arrayVar["VarType"].textValue().shouldBeEqualTo("Array") + + val multipleRowId = repeatedRowSet["SubRowId"].textValue() + val multipleRow = result["RowSet"].last { it["Id"].textValue() == multipleRowId } + multipleRow["RowSetType"].textValue().shouldBeEqualTo("RowSet") + val secondRowId = multipleRow["SubRowId"][1].textValue() + + val secondRow = result["RowSet"].last { it["Id"].textValue() == secondRowId } + secondRow["RowSetType"].textValue().shouldBeEqualTo("Row") + + val secondCellId = secondRow["SubRowId"][1].textValue() + val secondCell = result["Cell"].last { it["Id"].textValue() == secondCellId } + + val secondCellFlow = result["Flow"].last { it["Id"].textValue() == secondCell["FlowId"].textValue() } + val variableId = secondCellFlow["FlowContent"]["P"]["T"]["O"]["Id"].textValue() + + val variable = result["Variable"].first { it["Id"].textValue() == variableId } + variable["Name"].textValue().shouldBeEqualTo("Job Name") + variable["ParentId"].textValue().shouldBeEqualTo("Data.Clients.Value") + + result["Root"]["LockedWebNodes"]["LockedWebNode"].textValue().shouldBeEqualTo(repeatedRowSetId) + } + + @Test + fun `buildDocumentObject creates repeated rowset with variable ref array path`() { + // given + val clientsVar = mockVar(VariableBuilder("clientsVar").name("Clients").dataType(DataType.Array).build()) + val stringVar = mockVar(VariableBuilder("stringVar").dataType(DataType.String).build()) + + val variableStructure = mockVarStructure( + VariableStructureBuilder("VS_1").addVariable(clientsVar.id, "Data") + .addVariable(stringVar.id, VariableRef(clientsVar.id), "Client Name").build() + ) + + val block = mockObj( + DocumentObjectBuilder("B_1", Block).table { + addRepeatedRow(VariableRef(clientsVar.id)) { + addRow { + addCell { string("Client Name") } + addCell { paragraph { text { variableRef(stringVar.id) } } } + } + } + }.variableStructureRef(variableStructure.id).build() + ) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val repeatedRowSetId = result["Table"].last()["RowSetId"].textValue() + val repeatedRowSet = result["RowSet"].last { it["Id"].textValue() == repeatedRowSetId } + + repeatedRowSet["RowSetType"].textValue().shouldBeEqualTo("Repeated") + val arrayVarId = repeatedRowSet["VariableId"].textValue() + val arrayVar = result["Variable"].last { it["Id"].textValue() == arrayVarId } + arrayVar["Type"].textValue().shouldBeEqualTo("DataVariable") + arrayVar["VarType"].textValue().shouldBeEqualTo("Array") + + val innerRow = result["RowSet"].last { it["Id"].textValue() == repeatedRowSet["SubRowId"].textValue() } + innerRow["RowSetType"].textValue().shouldBeEqualTo("Row") + + val secondCell = result["Cell"].last { it["Id"].textValue() == innerRow["SubRowId"][1].textValue() } + val secondCellFlow = result["Flow"].last { it["Id"].textValue() == secondCell["FlowId"].textValue() } + val variableId = secondCellFlow["FlowContent"]["P"]["T"]["O"]["Id"].textValue() + val variable = result["Variable"].first { it["Id"].textValue() == variableId } + variable["Name"].textValue().shouldBeEqualTo("Client Name") + variable["ParentId"].textValue().shouldBeEqualTo("Data.Clients.Value") + } + + @Test + fun `buildDocumentObject creates fallback single row when array variable not mapped in structure`() { + // given + val arrayVar = mockVar(VariableBuilder("arrayVar").name("Clients").dataType(DataType.Array).build()) + val variableStructure = mockVarStructure(VariableStructureBuilder("VS_1").build()) + + val block = mockObj( + DocumentObjectBuilder("B_1", Block).table { + addRepeatedRow(VariableRef(arrayVar.id)) { addRow().addCell().string("Name") } + }.variableStructureRef(variableStructure.id).build() + ) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val tableRowSetId = result["Table"].last()["RowSetId"].textValue() + val tableRowSet = result["RowSet"].last { it["Id"].textValue() == tableRowSetId } + tableRowSet["RowSetType"].textValue().shouldBeEqualTo("Row") + + val cell = result["Cell"].last { it["Id"].textValue() == tableRowSet["SubRowId"].textValue() } + val flow = result["Flow"].last { it["Id"].textValue() == cell["FlowId"].textValue() } + // first paragraph = warning, second paragraph = original "Name" + flow["FlowContent"]["P"][0]["T"][""].textValue().shouldBeEqualTo($$"") + flow["FlowContent"]["P"][1]["T"][""].textValue().shouldBeEqualTo("Name") + } + + @Test + fun `buildDocumentObject creates fallback multiple rows when variable literal path is not registered in the variable structure`() { + // given + val surNameVar = mockVar(VariableBuilder("surname").dataType(DataType.String).build()) + val variableStructure = mockVarStructure(VariableStructureBuilder("VS_1").build()) + + val block = mockObj( + DocumentObjectBuilder("B_1", Block).table { + addRepeatedRow("Data.Clients.Value") { + addRow().addCell().paragraph { text { variableRef(surNameVar.id) } } + addRow().addCell().string("Second") + } + }.variableStructureRef(variableStructure.id).build() + ) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val tableRowSetId = result["Table"].last()["RowSetId"].textValue() + val multipleRowSet = result["RowSet"].last { it["Id"].textValue() == tableRowSetId } + multipleRowSet["RowSetType"].textValue().shouldBeEqualTo("RowSet") + + val firstSingleRow = result["RowSet"].last { it["Id"].textValue() == multipleRowSet["SubRowId"][0].textValue() } + val firstCell = result["Cell"].last { it["Id"].textValue() == firstSingleRow["SubRowId"].textValue() } + val firstFlow = result["Flow"].last { it["Id"].textValue() == firstCell["FlowId"].textValue() } + firstFlow["FlowContent"]["P"][0]["T"][""].textValue() + .shouldBeEqualTo($$"") + firstFlow["FlowContent"]["P"][1]["T"][""].textValue().shouldBeEqualTo($$"$surname$") + + val secondSingleRow = + result["RowSet"].last { it["Id"].textValue() == multipleRowSet["SubRowId"][1].textValue() } + val secondCell = result["Cell"].last { it["Id"].textValue() == secondSingleRow["SubRowId"].textValue() } + val secondFlow = result["Flow"].last { it["Id"].textValue() == secondCell["FlowId"].textValue() } + secondFlow["FlowContent"]["P"]["T"][""].textValue().shouldBeEqualTo("Second") + } + + @Test + fun `buildDocumentObject creates repeated flow with literal array path`() { + // given + val nameVar = mockVar(VariableBuilder("nameVar").name("Name").dataType(DataType.String).build()) + val variableStructure = mockVarStructure( + VariableStructureBuilder("VS_1").addVariable(nameVar.id, "Data.Clients.Value", "Real Name").build() + ) + + val block = mockObj( + DocumentObjectBuilder("B_1", Block) + .repeatedContent("Data.Clients") { + paragraph { text { variableRef(nameVar.id) } } + } + .variableStructureRef(variableStructure.id) + .build() + ) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val repeatedFlow = getFlowAreaContentFlow(result) + val arrayVarId = repeatedFlow["Variable"].textValue() + val arrayVar = result["Variable"].last { it["Id"].textValue() == arrayVarId } + arrayVar["Type"].textValue().shouldBeEqualTo("DataVariable") + arrayVar["VarType"].textValue().shouldBeEqualTo("Array") + repeatedFlow["SectionFlow"].textValue().shouldBeEqualTo("False") + + val nameVarId = repeatedFlow["FlowContent"]["P"]["T"]["O"]["Id"].textValue() + val nameVarNode = result["Variable"].first { it["Id"].textValue() == nameVarId } + nameVarNode["Name"].textValue().shouldBeEqualTo("Real Name") + } + + @Test + fun `buildDocumentObject creates repeated flow with variable ref array path`() { + // given + val clientsVar = mockVar(VariableBuilder("clientsVar").name("Clients").dataType(DataType.Array).build()) + val nameVar = mockVar(VariableBuilder("nameVar").name("name").dataType(DataType.String).build()) + + val variableStructure = mockVarStructure( + VariableStructureBuilder("VS_1") + .addVariable(clientsVar.id, "Data") + .addVariable(nameVar.id, VariableRef(clientsVar.id)) + .build() + ) + val innerBlock = mockObj(DocumentObjectBuilder("B_inner", Block).internal(true).string("The name is: ").build()) + val block = mockObj(DocumentObjectBuilder("B_1", Block).repeatedContent(VariableRef(clientsVar.id)) { + documentObjectRef(innerBlock.id) + paragraph { text { variableRef(nameVar.id) } } + }.variableStructureRef(variableStructure.id).build()) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val repeatedFlow = result["Flow"].last { it["Type"]?.textValue() == "Repeated" } + val arrayVar = result["Variable"].first { it["Id"].textValue() == repeatedFlow["Variable"].textValue() } + arrayVar["Name"].textValue().shouldBeEqualTo("Clients") + repeatedFlow["SectionFlow"].textValue().shouldBeEqualTo("True") + + val repeatedFlowRefs = repeatedFlow["FlowContent"]["P"]["T"]["O"] + repeatedFlowRefs.size().shouldBeEqualTo(2) + } + + @Test + fun `buildDocumentObject creates fallback flow when repeated block variable is not mapped`() { + // given + val arrayVar = mockVar(VariableBuilder("arrayVar").name("Clients").dataType(DataType.Array).build()) + + val block = mockObj( + DocumentObjectBuilder("B_1", Block) + .repeatedContent(VariableRef(arrayVar.id)) { + string("Some content") + } + .build() + ) + + // when + val result = subject.buildDocumentObject(block).let { xmlMapper.readTree(it.trimIndent()) }["Layout"]["Layout"] + + // then + val repeatedFallbackFlow = getFlowAreaContentFlow(result) + repeatedFallbackFlow["Type"].textValue().shouldBeEqualTo("Simple") + repeatedFallbackFlow["SectionFlow"].textValue().shouldBeEqualTo("False") + repeatedFallbackFlow["FlowContent"]["P"][0]["T"][""].textValue() + .shouldBeEqualTo($$"") + repeatedFallbackFlow["FlowContent"]["P"][1]["T"][""].textValue().shouldBeEqualTo("Some content") + } + @Test fun `text style with targetId resolves to target style`() { // given diff --git a/migration-library/src/test/kotlin/com/quadient/migration/shared/JrdDefinitionTest.kt b/migration-library/src/test/kotlin/com/quadient/migration/shared/JrdDefinitionTest.kt index b36027c..b17175e 100644 --- a/migration-library/src/test/kotlin/com/quadient/migration/shared/JrdDefinitionTest.kt +++ b/migration-library/src/test/kotlin/com/quadient/migration/shared/JrdDefinitionTest.kt @@ -63,7 +63,7 @@ class JrdDefinitionTest { ), negation = false )) - json.shouldBeEqualTo(""" + json.replace("\r\n", "\n").shouldBeEqualTo(""" { "InteractivePlusJsonDefinition" : { "Type" : "Rule", @@ -133,7 +133,7 @@ class JrdDefinitionTest { interactiveTenant = "StandardPackage", ), variableStructure, { name -> aVariable(name) }) - result.shouldBeEqualTo($$""" + result.replace("\r\n", "\n").shouldBeEqualTo($$""" { "InteractivePlusJsonDefinition" : { "Type" : "Rule",