diff --git a/src/Models/ThemeConfig/Conversion/ExpandRewrite.ts b/src/Models/ThemeConfig/Conversion/ExpandRewrite.ts new file mode 100644 index 000000000..4e7be25c2 --- /dev/null +++ b/src/Models/ThemeConfig/Conversion/ExpandRewrite.ts @@ -0,0 +1,322 @@ +import { Conversion } from "./Conversion" +import RewritableConfigJson from "../Json/RewritableConfigJson" +import Translations from "../../../UI/i18n/Translations" +import { ConversionContext } from "./ConversionContext" +import { Utils } from "../../../Utils" + +export class ExpandRewrite extends Conversion, T[]> { + constructor() { + super("Applies a rewrite", [], "ExpandRewrite") + } + + /** + * Used for left|right group creation and replacement. + * Every 'keyToRewrite' will be replaced with 'target' recursively. This substitution will happen in place in the object 'tr' + * + * The 'target' object will be cloned, the changes will be applied in this clone + * + * // should substitute strings + * const spec = { + * "someKey": "somevalue {xyz}" + * } + * ExpandRewrite.RewriteParts("{xyz}", "rewritten", spec) // => {"someKey": "somevalue rewritten"} + * + * // should substitute all occurances in strings + * const spec = { + * "someKey": "The left|right side has {key:left|right}" + * } + * ExpandRewrite.RewriteParts("left|right", "left", spec) // => {"someKey": "The left side has {key:left}"} + * + */ + public static RewriteParts(keyToRewrite: string, target: string | any, tr: T): T { + const targetIsTranslation = Translations.isProbablyATranslation(target) + + function replaceRecursive(obj: string | any, target) { + if (obj === keyToRewrite) { + return target + } + + if (typeof obj === "string") { + // This is a simple string - we do a simple replace + while (obj.indexOf(keyToRewrite) >= 0) { + obj = obj.replace(keyToRewrite, target) + } + return obj + } + if (Array.isArray(obj)) { + // This is a list of items + return obj.map((o) => replaceRecursive(o, target)) + } + + if (typeof obj === "object") { + obj = { ...obj } + + const isTr = targetIsTranslation && Translations.isProbablyATranslation(obj) + + for (const key in obj) { + let subtarget = target + if (isTr && target[key] !== undefined) { + // The target is a translation AND the current object is a translation + // This means we should recursively replace with the translated value + subtarget = target[key] + } + + obj[key] = replaceRecursive(obj[key], subtarget) + } + return obj + } + return obj + } + + return replaceRecursive(tr, target) + } + + /** + * Used for check that a key is present in a string somewhere in the object + * + * // should substitute strings + * const spec = { + * "someKey": "somevalue {xyz}" + * } + * ExpandRewrite.contains("{xyz}", spec) // => true + * ExpandRewrite.contains("{abc}", spec) // => false + * + */ + public static contains(keyToRewrite: string, tr: T): boolean { + function findRecursive(obj: string | any): boolean { + if (obj === keyToRewrite) { + return true + } + + if (typeof obj === "string") { + // This is a simple string - we do a simple replace + return obj.indexOf(keyToRewrite) >= 0 + } + if (Array.isArray(obj)) { + // This is a list of items + return obj.some((o) => findRecursive(o)) + } + + if (typeof obj === "object") { + obj = { ...obj } + + for (const key in obj) { + if (findRecursive(obj[key])) { + return true + } + } + return false + } + return false + } + + return findRecursive(tr) + } + + /** + * // should convert simple strings + * const spec = >{ + * rewrite: { + * sourceString: ["xyz","abc"], + * into: [ + * ["X", "A"], + * ["Y", "B"], + * ["Z", "C"]], + * }, + * renderings: "The value of xyz is abc" + * } + * new ExpandRewrite().convertStrict(spec, ConversionContext.test()) // => ["The value of X is A", "The value of Y is B", "The value of Z is C"] + * + * // should rewrite with translations + * const spec = >{ + * rewrite: { + * sourceString: ["xyz","abc"], + * into: [ + * ["X", {en: "value", nl: "waarde"}], + * ["Y", {en: "some other value", nl: "een andere waarde"}], + * }, + * renderings: {en: "The value of xyz is abc", nl: "De waarde van xyz is abc"} + * } + * const expected = [ + * { + * en: "The value of X is value", + * nl: "De waarde van X is waarde" + * }, + * { + * en: "The value of Y is some other value", + * nl: "De waarde van Y is een andere waarde" + * } + * ] + * new ExpandRewrite().convertStrict(spec, ConversionContext.test()) // => expected + * + * + * // should expand sublists + * const spec = >{ + * rewrite: { + * sourceString: ["{{key}}","{{values}}"], + * into: [ + * ["a", [1,2,3] ], + * ["b", [42, 43] ], + * }, + * subexpand: {"options": ["{{values}}"]}, + * renderings: {question: "What are values for {{key}}?", options: [{if: "x={{values}}", then: "{{values}} is value" }] } + * } + * const expected = [ + * {question: "What are values for a?", + * options: [{if: "x=1", then: "1 is value" }, + * {if: "x=2", then: "2 is value" }, + * {if: "x=3", then: "3 is value" } + * ] } + * {question: "What are values for b?", options: [ + * {if: "x=42", then: "42 is value" }, + * {if: "x=43", then: "43 is value" } + * ] } + * ] + * new ExpandRewrite().convertStrict(spec, ConversionContext.test()) // => expected + * + * + * // should expand sublists if there is one + * const spec = >{ + * rewrite: { + * sourceString: ["{{key}}","{{values}}"], + * into: [ + * ["a", [] ], + * ["b", [42, 43] ], + * ["c", null ], + * }, + * subexpand: {"options": ["{{values}}"]}, + * renderings: [ + * {question: "What is {{key}}?", options: [{if: "x={{values}}", then: "{{values}} is value" }], + * {question: "How is {{key}}?", options: [{a: 5}, {b: 6}] }, + * {question: "Why is {{key}}?" } + * ] + * } + * const expected = [ + * {question: "What is a?", + * options: []}, + * {question: "How is a?", options: [{a: 5}, {b: 6}] }, + * {question: "Why is a?" }, + * {question: "What is b?", options: [ + * {if: "x=42", then: "42 is value" }, + * {if: "x=43", then: "43 is value" } + * ] }, + * {question: "How is b?", options: [{a: 5}, {b: 6}] }, + * {question: "Why is b?" }, + * {question: "What is c?"}, + * {question: "How is c?", options: [{a: 5}, {b: 6}] }, + * {question: "Why is c?" }, + * ] + * new ExpandRewrite().convertStrict(spec, ConversionContext.test()) // => expected + */ + convert(json: T | RewritableConfigJson, context: ConversionContext): T[] { + if (json === null || json === undefined) { + return [] + } + + if (json["rewrite"] === undefined) { + // not a rewrite + return [json] + } + + const rewrite = >json + const keysToRewrite = rewrite.rewrite + const results: T[] = [] + + { + // sanity check: rewrite: ["xyz", "longer_xyz"] is not allowed as "longer_xyz" will never be triggered + for (let i = 0; i < keysToRewrite.sourceString.length; i++) { + const guard = keysToRewrite.sourceString[i] + for (let j = i + 1; j < keysToRewrite.sourceString.length; j++) { + const toRewrite = keysToRewrite.sourceString[j] + if (toRewrite.indexOf(guard) >= 0) { + context.err( + `sourcestring[${i}] is a substring of sourcestring[${j}]: ${guard} will be substituted away before ${toRewrite} is reached.` + ) + } + } + } + } + + { + // sanity check: {rewrite: ["a", "b"] should have the right amount of 'intos' in every case + for (let i = 0; i < rewrite.rewrite.into.length; i++) { + const into = keysToRewrite.into[i] + if (into.length !== rewrite.rewrite.sourceString.length) { + context + .enters("into", i) + .err( + `Error in rewrite: there are ${rewrite.rewrite.sourceString.length} keys to rewrite, but entry ${i} has only ${into.length} values` + ) + } + } + } + + let renderings = Array.isArray(rewrite.renderings) + ? rewrite.renderings + : [rewrite.renderings] + for (let i = 0; i < keysToRewrite.into.length; i++) { + let ts: T[] = Utils.Clone(renderings) + for (const tx of ts) { + let t = tx + const sourceKeysToIgnore: string[] = [] + for (const listKey in rewrite.subexpand) { + const original = t[listKey] + if (!original) { + continue + } + const sourceKeys = rewrite.subexpand[listKey].filter((sk) => + ExpandRewrite.contains(sk, original) + ) + if (sourceKeys.length === 0) { + // no delete t[listKey] needed, fixed values we need to retain + continue + } + + if (sourceKeys.length > 1) { + throw ( + "Too much matching sourcekeys for sublist `" + + listKey + + "`: it matches all of " + + sourceKeys.join(", ") + ) + } + + const sourceKey = sourceKeys[0] + sourceKeysToIgnore.push(sourceKey) + const rw = rewrite.rewrite + const values = rw.into[i][rw.sourceString.indexOf(sourceKey)] + + if (!values) { + delete t[listKey] + continue + } + if (!Array.isArray(values)) { + throw ( + "Sublist expansion of `" + + listKey + + "` failed: not an array to expand with:" + + JSON.stringify(values) + ) + } + t[listKey] = [].concat( + ...values.map((v) => ExpandRewrite.RewriteParts(sourceKey, v, original)) + ) + } + + for (let j = 0; j < keysToRewrite.sourceString.length; j++) { + // The string that should be replaced everywhere in `t` + const key = keysToRewrite.sourceString[j] + if (sourceKeysToIgnore.indexOf(key) >= 0) { + continue + } + // The object that `key` should be replaced with + const target = keysToRewrite.into[i][j] + t = ExpandRewrite.RewriteParts(key, target, t) + } + results.push(t) + } + } + + return results + } +} diff --git a/src/Models/ThemeConfig/Conversion/PrepareLayer.ts b/src/Models/ThemeConfig/Conversion/PrepareLayer.ts index 39c03842c..99796ba08 100644 --- a/src/Models/ThemeConfig/Conversion/PrepareLayer.ts +++ b/src/Models/ThemeConfig/Conversion/PrepareLayer.ts @@ -1,5 +1,4 @@ import { - Bypass, Concat, Conversion, DesugaringContext, @@ -32,6 +31,7 @@ import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRende import { ConfigMeta } from "../../../UI/Studio/configMeta" import LineRenderingConfigJson from "../Json/LineRenderingConfigJson" import { ConversionContext } from "./ConversionContext" +import { ExpandRewrite } from "./ExpandRewrite" class ExpandFilter extends DesugaringStep { private static readonly predefinedFilters = ExpandFilter.load_filters() @@ -677,164 +677,6 @@ export class AddEditingElements extends DesugaringStep { } } -export class ExpandRewrite extends Conversion, T[]> { - constructor() { - super("Applies a rewrite", [], "ExpandRewrite") - } - - /** - * Used for left|right group creation and replacement. - * Every 'keyToRewrite' will be replaced with 'target' recursively. This substitution will happen in place in the object 'tr' - * - * // should substitute strings - * const spec = { - * "someKey": "somevalue {xyz}" - * } - * ExpandRewrite.RewriteParts("{xyz}", "rewritten", spec) // => {"someKey": "somevalue rewritten"} - * - * // should substitute all occurances in strings - * const spec = { - * "someKey": "The left|right side has {key:left|right}" - * } - * ExpandRewrite.RewriteParts("left|right", "left", spec) // => {"someKey": "The left side has {key:left}"} - * - */ - public static RewriteParts(keyToRewrite: string, target: string | any, tr: T): T { - const targetIsTranslation = Translations.isProbablyATranslation(target) - - function replaceRecursive(obj: string | any, target) { - if (obj === keyToRewrite) { - return target - } - - if (typeof obj === "string") { - // This is a simple string - we do a simple replace - while (obj.indexOf(keyToRewrite) >= 0) { - obj = obj.replace(keyToRewrite, target) - } - return obj - } - if (Array.isArray(obj)) { - // This is a list of items - return obj.map((o) => replaceRecursive(o, target)) - } - - if (typeof obj === "object") { - obj = { ...obj } - - const isTr = targetIsTranslation && Translations.isProbablyATranslation(obj) - - for (const key in obj) { - let subtarget = target - if (isTr && target[key] !== undefined) { - // The target is a translation AND the current object is a translation - // This means we should recursively replace with the translated value - subtarget = target[key] - } - - obj[key] = replaceRecursive(obj[key], subtarget) - } - return obj - } - return obj - } - - return replaceRecursive(tr, target) - } - - /** - * // should convert simple strings - * const spec = >{ - * rewrite: { - * sourceString: ["xyz","abc"], - * into: [ - * ["X", "A"], - * ["Y", "B"], - * ["Z", "C"]], - * }, - * renderings: "The value of xyz is abc" - * } - * new ExpandRewrite().convertStrict(spec, ConversionContext.test()) // => ["The value of X is A", "The value of Y is B", "The value of Z is C"] - * - * // should rewrite with translations - * const spec = >{ - * rewrite: { - * sourceString: ["xyz","abc"], - * into: [ - * ["X", {en: "value", nl: "waarde"}], - * ["Y", {en: "some other value", nl: "een andere waarde"}], - * }, - * renderings: {en: "The value of xyz is abc", nl: "De waarde van xyz is abc"} - * } - * const expected = [ - * { - * en: "The value of X is value", - * nl: "De waarde van X is waarde" - * }, - * { - * en: "The value of Y is some other value", - * nl: "De waarde van Y is een andere waarde" - * } - * ] - * new ExpandRewrite().convertStrict(spec, ConversionContext.test()) // => expected - */ - convert(json: T | RewritableConfigJson, context: ConversionContext): T[] { - if (json === null || json === undefined) { - return [] - } - - if (json["rewrite"] === undefined) { - // not a rewrite - return [json] - } - - const rewrite = >json - const keysToRewrite = rewrite.rewrite - const ts: T[] = [] - - { - // sanity check: rewrite: ["xyz", "longer_xyz"] is not allowed as "longer_xyz" will never be triggered - for (let i = 0; i < keysToRewrite.sourceString.length; i++) { - const guard = keysToRewrite.sourceString[i] - for (let j = i + 1; j < keysToRewrite.sourceString.length; j++) { - const toRewrite = keysToRewrite.sourceString[j] - if (toRewrite.indexOf(guard) >= 0) { - context.err( - `sourcestring[${i}] is a substring of sourcestring[${j}]: ${guard} will be substituted away before ${toRewrite} is reached.` - ) - } - } - } - } - - { - // sanity check: {rewrite: ["a", "b"] should have the right amount of 'intos' in every case - for (let i = 0; i < rewrite.rewrite.into.length; i++) { - const into = keysToRewrite.into[i] - if (into.length !== rewrite.rewrite.sourceString.length) { - context - .enters("into", i) - .err( - `Error in rewrite: there are ${rewrite.rewrite.sourceString.length} keys to rewrite, but entry ${i} has only ${into.length} values` - ) - } - } - } - - for (let i = 0; i < keysToRewrite.into.length; i++) { - let t = Utils.Clone(rewrite.renderings) - for (let j = 0; j < keysToRewrite.sourceString.length; j++) { - const key = keysToRewrite.sourceString[j] - const target = keysToRewrite.into[i][j] - t = ExpandRewrite.RewriteParts(key, target, t) - } - ts.push(t) - } - - return ts - } -} - /** * Converts a 'special' translation into a regular translation which uses parameters */ diff --git a/src/Models/ThemeConfig/Conversion/ValidationUtils.ts b/src/Models/ThemeConfig/Conversion/ValidationUtils.ts index 1f8bf800f..f82a3577b 100644 --- a/src/Models/ThemeConfig/Conversion/ValidationUtils.ts +++ b/src/Models/ThemeConfig/Conversion/ValidationUtils.ts @@ -3,6 +3,7 @@ import { Utils } from "../../../Utils" import SpecialVisualizations from "../../../UI/SpecialVisualizations" import { RenderingSpecification, SpecialVisualization } from "../../../UI/SpecialVisualization" import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson" +import { render } from "sass" export default class ValidationUtils { public static getAllSpecialVisualisations( @@ -43,6 +44,14 @@ export default class ValidationUtils { if (renderingConfig[cacheName]) { return renderingConfig[cacheName] } + if (!Array.isArray(renderingConfig.mappings ?? [])) { + throw ( + "Mappings of renderingconfig " + + (renderingConfig["id"] ?? renderingConfig.render ?? "") + + " are supposed to be an array but it is: " + + JSON.stringify(renderingConfig.mappings) + ) + } const translations: any[] = Utils.NoNull([ renderingConfig.render, ...(renderingConfig.mappings ?? []).map((m) => m.then), diff --git a/src/Models/ThemeConfig/Json/RewritableConfigJson.ts b/src/Models/ThemeConfig/Json/RewritableConfigJson.ts index 287c8a3ee..dd312842e 100644 --- a/src/Models/ThemeConfig/Json/RewritableConfigJson.ts +++ b/src/Models/ThemeConfig/Json/RewritableConfigJson.ts @@ -47,5 +47,6 @@ export default interface RewritableConfigJson { sourceString: string[] into: (string | any)[][] } - renderings: T + subexpand?: Record + renderings: T | T[] } diff --git a/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts b/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts index ea22b575f..1b4a95ee2 100644 --- a/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts +++ b/test/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts @@ -1,7 +1,6 @@ import { LayerConfigJson } from "../../../../src/Models/ThemeConfig/Json/LayerConfigJson" import LineRenderingConfigJson from "../../../../src/Models/ThemeConfig/Json/LineRenderingConfigJson" import { - ExpandRewrite, PrepareLayer, RewriteSpecial, } from "../../../../src/Models/ThemeConfig/Conversion/PrepareLayer" @@ -10,6 +9,7 @@ import RewritableConfigJson from "../../../../src/Models/ThemeConfig/Json/Rewrit import { describe, expect, it } from "vitest" import { ConversionContext } from "../../../../src/Models/ThemeConfig/Conversion/ConversionContext" +import { ExpandRewrite } from "../../../../src/Models/ThemeConfig/Conversion/ExpandRewrite" describe("ExpandRewrite", () => { it("should not allow overlapping keys", () => {