From 2286ec964f4834651ceeef0e3067faacc5b5837f Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet <pietervdvn@posteo.net> Date: Sun, 9 Mar 2025 23:23:37 +0100 Subject: [PATCH] Fix: fix #2343, properly fix "postfixDistinguished", also when marking as unknown --- src/Logic/Tags/TagTypes.ts | 5 ++ src/Logic/Tags/TagUtils.ts | 74 +++++++++---------- src/Models/ThemeConfig/TagRenderingConfig.ts | 45 ++++++----- src/UI/Popup/TagHint.svelte | 37 +++++----- .../TagRendering/TagRenderingQuestion.svelte | 63 ++++++++-------- 5 files changed, 122 insertions(+), 102 deletions(-) diff --git a/src/Logic/Tags/TagTypes.ts b/src/Logic/Tags/TagTypes.ts index c46d13126..17e37d96e 100644 --- a/src/Logic/Tags/TagTypes.ts +++ b/src/Logic/Tags/TagTypes.ts @@ -25,6 +25,11 @@ export class TagTypes { return <any>and.and } + static uploadableAnd(and: And & UploadableTag): UploadableTag[] { + return <any>and.and + } + + static safeOr(or: Or & OptimizedTag): ((FlatTag | (And & OptimizedTag)) & OptimizedTag)[] { return <any>or.or } diff --git a/src/Logic/Tags/TagUtils.ts b/src/Logic/Tags/TagUtils.ts index c3933cdca..aa92c24bd 100644 --- a/src/Logic/Tags/TagUtils.ts +++ b/src/Logic/Tags/TagUtils.ts @@ -21,7 +21,7 @@ export class TagUtils { ["<=", (a, b) => a <= b], [">=", (a, b) => a >= b], ["<", (a, b) => a < b], - [">", (a, b) => a > b], + [">", (a, b) => a > b] ] public static modeDocumentation: Record< string, @@ -48,7 +48,7 @@ export class TagUtils { "### Removing a key\n" + "\n" + "If a key should be deleted in the OpenStreetMap-database, specify `key=` as well. This can be used e.g. to remove a\n" + - "fixme or value from another mapping if another field is filled out.", + "fixme or value from another mapping if another field is filled out." }, "!=": { name: "strict not equals", @@ -62,7 +62,7 @@ export class TagUtils { "### If key is present\n" + "\n" + "This implies that, to check if a key is present, `key!=` can be used. This will only match if the key is present and not\n" + - "empty.", + "empty." }, "~": { name: "Value matches regex", @@ -73,12 +73,12 @@ export class TagUtils { "The regex is put within braces as to prevent runaway values.\n" + "\nUse `key~*` to indicate that any value is allowed. This is effectively the check that the attribute is present (defined _and_ not empty)." + "\n" + - "Regexes will match the newline character with `.` too - the `s`-flag is enabled by default.", + "Regexes will match the newline character with `.` too - the `s`-flag is enabled by default." }, "~i~": { name: "Value matches case-invariant regex", overpassSupport: true, - docs: "A tag can also be tested against a regex with `key~i~regex`, where the case of the value will be ignored. The regex is still matched against the _entire_ value", + docs: "A tag can also be tested against a regex with `key~i~regex`, where the case of the value will be ignored. The regex is still matched against the _entire_ value" }, "!~": { name: "Value should _not_ match regex", @@ -87,27 +87,27 @@ export class TagUtils { "A tag can also be tested against a regex with `key!~regex`. This filter will match if the value does *not* match the regex. " + "\n If the\n" + "value is allowed to appear anywhere as substring, use `key~.*regex.*`.\n" + - "The regex is put within braces as to prevent runaway values.\n", + "The regex is put within braces as to prevent runaway values.\n" }, "!~i~": { name: "Value does *not* match case-invariant regex", overpassSupport: true, - docs: "A tag can also be tested against a regex with `key~i~regex`, where the case of the value will be ignored. The regex is still matched against the _entire_ value. This filter returns true if the value does *not* match", + docs: "A tag can also be tested against a regex with `key~i~regex`, where the case of the value will be ignored. The regex is still matched against the _entire_ value. This filter returns true if the value does *not* match" }, "~~": { name: "Key and value should match given regex", overpassSupport: true, - docs: "Both the `key` and `value` part of this specification are interpreted as regexes, both the key and value musth completely match their respective regexes", + docs: "Both the `key` and `value` part of this specification are interpreted as regexes, both the key and value musth completely match their respective regexes" }, "~i~~": { name: "Key and value should match a given regex; value is case-invariant", overpassSupport: true, - docs: "Similar to ~~, except that the value is case-invariant", + docs: "Similar to ~~, except that the value is case-invariant" }, "!~i~~": { name: "Key and value should match a given regex; value is case-invariant", overpassSupport: true, - docs: "Similar to !~~, except that the value is case-invariant", + docs: "Similar to !~~, except that the value is case-invariant" }, ":=": { name: "Substitute `... {some_key} ...` and match `key`", @@ -133,24 +133,24 @@ export class TagUtils { "\n" + "```json\n" + "{\n" + - ' "mappings": [\n' + + " \"mappings\": [\n" + " {\n" + - ' "if":"key:={some_other_key}",\n' + - ' "then": "...",\n' + - ' "hideInAnswer": "some_other_key="\n' + + " \"if\":\"key:={some_other_key}\",\n" + + " \"then\": \"...\",\n" + + " \"hideInAnswer\": \"some_other_key=\"\n" + " }\n" + " ]\n" + "}\n" + "```\n" + "\n" + "One can use `key!:=prefix-{other_key}-postfix` as well, to match if `key` is _not_ the same\n" + - "as `prefix-{other_key}-postfix` (with `other_key` substituted by the value)", + "as `prefix-{other_key}-postfix` (with `other_key` substituted by the value)" }, "!:=": { name: "Substitute `{some_key}` should not match `key`", overpassSupport: false, - docs: "See `:=`, except that this filter is inverted", - }, + docs: "See `:=`, except that this filter is inverted" + } } private static keyCounts: { keys: any; tags: any } = key_counts public static readonly numberAndDateComparisonDocs = @@ -175,10 +175,10 @@ export class TagUtils { "\n" + "```json\n" + "{\n" + - ' "osmTags": {\n' + - ' "or": [\n' + - ' "amenity=school",\n' + - ' "amenity=kindergarten"\n' + + " \"osmTags\": {\n" + + " \"or\": [\n" + + " \"amenity=school\",\n" + + " \"amenity=kindergarten\"\n" + " ]\n" + " }\n" + "}\n" + @@ -194,7 +194,7 @@ export class TagUtils { "If the schema-files note a type [`TagConfigJson`](https://github.com/pietervdvn/MapComplete/blob/develop/src/Models/ThemeConfig/Json/TagConfigJson.ts), you can use one of these values.\n" + "\n" + "In some cases, not every type of tags-filter can be used. For example, _rendering_ an option with a regex is\n" + - 'fine (`"if": "brand~[Bb]randname", "then":" The brand is Brandname"`); but this regex can not be used to write a value\n' + + "fine (`\"if\": \"brand~[Bb]randname\", \"then\":\" The brand is Brandname\"`); but this regex can not be used to write a value\n" + "into the database. The theme loader will however refuse to work with such inconsistencies and notify you of this while\n" + "you are building your theme.\n" + "\n" + @@ -205,18 +205,18 @@ export class TagUtils { "\n" + "```json\n" + "{\n" + - ' "and": [\n' + - ' "key=value",\n' + + " \"and\": [\n" + + " \"key=value\",\n" + " {\n" + - ' "or": [\n' + - ' "other_key=value",\n' + - ' "other_key=some_other_value"\n' + + " \"or\": [\n" + + " \"other_key=value\",\n" + + " \"other_key=some_other_value\"\n" + " ]\n" + " },\n" + - ' "key_which_should_be_missing=",\n' + - ' "key_which_should_have_a_value~*",\n' + - ' "key~.*some_regex_a*_b+_[a-z]?",\n' + - ' "height<1"\n' + + " \"key_which_should_be_missing=\",\n" + + " \"key_which_should_have_a_value~*\",\n" + + " \"key~.*some_regex_a*_b+_[a-z]?\",\n" + + " \"height<1\"\n" + " ]\n" + "}\n" + "```\n" + @@ -374,9 +374,9 @@ export class TagUtils { * TagUtils.FlattenMultiAnswer(([new Tag("x","y"), new Tag("a","b")])) // => new And([new Tag("x","y"), new Tag("a","b")]) * TagUtils.FlattenMultiAnswer(([new Tag("x","")])) // => new And([new Tag("x","")]) */ - static FlattenMultiAnswer(tagsFilters: UploadableTag[]): And { + static FlattenMultiAnswer(tagsFilters: UploadableTag[]): UploadableTag[] { if (tagsFilters === undefined) { - return new And([]) + return [] } const keyValues = TagUtils.SplitKeys(tagsFilters) @@ -386,7 +386,7 @@ export class TagUtils { values.sort() and.push(new Tag(key, values.join(";"))) } - return new And(and) + return and } /** @@ -985,10 +985,10 @@ export class TagUtils { return ["", "## `" + mode + "` " + doc.name, "", doc.docs, "", ""].join("\n") }), "## " + - TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") + - " Logical comparators", + TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") + + " Logical comparators", TagUtils.numberAndDateComparisonDocs, - TagUtils.logicalOperator, + TagUtils.logicalOperator ].join("\n") } diff --git a/src/Models/ThemeConfig/TagRenderingConfig.ts b/src/Models/ThemeConfig/TagRenderingConfig.ts index c8ab20a99..4db5a4f05 100644 --- a/src/Models/ThemeConfig/TagRenderingConfig.ts +++ b/src/Models/ThemeConfig/TagRenderingConfig.ts @@ -734,7 +734,7 @@ export default class TagRenderingConfig { singleSelectedMapping: number, multiSelectedMapping: boolean[] | undefined, currentProperties: Record<string, string> - ): UploadableTag { + ): UploadableTag[] { if (typeof freeformValue === "string") { freeformValue = freeformValue?.trim() } @@ -782,14 +782,14 @@ export default class TagRenderingConfig { const freeformOnly = { [this.freeform.key]: freeformValue } const matchingMapping = this.mappings?.find((m) => m.if.matchesProperties(freeformOnly)) if (matchingMapping) { - return new And([matchingMapping.if, ...(matchingMapping.addExtraTags ?? [])]) + return [matchingMapping.if, ...(matchingMapping.addExtraTags ?? [])] } // Either no mappings, or this is a radio-button selected freeform value - const tag = new And([ + const tag = [ new Tag(this.freeform.key, freeformValue), ...(this.freeform.addExtraTags ?? []) - ]) - const newProperties = tag.applyOn(currentProperties) + ] + const newProperties = new And(tag).applyOn(currentProperties) if (this.invalidValues?.matchesProperties(newProperties)) { return undefined } @@ -816,14 +816,9 @@ export default class TagRenderingConfig { ) } const and = TagUtils.FlattenMultiAnswer([...selectedMappings, ...unselectedMappings]) - if (and.and.length === 0) { + if (and.length === 0) { return undefined } - console.log( - ">>> New properties", - TagUtils.asProperties(and, currentProperties), - this.invalidValues - ) if ( this.invalidValues?.matchesProperties(TagUtils.asProperties(and, currentProperties)) ) { @@ -847,15 +842,15 @@ export default class TagRenderingConfig { !someMappingIsShown || singleSelectedMapping === undefined) if (useFreeform) { - return new And([ + return [ new Tag(this.freeform.key, freeformValue), ...(this.freeform.addExtraTags ?? []) - ]) + ] } else if (singleSelectedMapping !== undefined) { - return new And([ + return [ this.mappings[singleSelectedMapping].if, ...(this.mappings[singleSelectedMapping].addExtraTags ?? []) - ]) + ] } else { console.error("TagRenderingConfig.ConstructSpecification has a weird fallback for", { freeformValue, @@ -864,7 +859,6 @@ export default class TagRenderingConfig { currentProperties, useFreeform }) - return undefined } } @@ -998,7 +992,7 @@ export default class TagRenderingConfig { * The keys that should be erased if one has to revert to 'unknown'. * Might give undefined if setting to unknown is not possible */ - public removeToSetUnknown( + private removeToSetUnknown( partOfLayer: LayerConfig, currentTags: Record<string, string> ): string[] | undefined { @@ -1042,6 +1036,23 @@ export default class TagRenderingConfig { return Array.from(toDelete) } + + /** + * Gives all the tags that should be applied to "reset" the freeform key to an "unknown" state + */ + public markUnknown(layer: LayerConfig, currentProperties: Record<string, string>): UploadableTag[] { + if (this.freeform?.postfixDistinguished) { + const allValues = currentProperties[this.freeform.key].split(";").filter( + part => part.split("/")[1]?.trim() !== this.freeform.postfixDistinguished + ) + return [new Tag(this.freeform.key, allValues.join(";"))] + } + + const keys = this.removeToSetUnknown(layer, currentProperties) + + + return keys?.map(k => new Tag(k, "")) + } } export class TagRenderingConfigUtils { diff --git a/src/UI/Popup/TagHint.svelte b/src/UI/Popup/TagHint.svelte index 42e7ba733..49549507c 100644 --- a/src/UI/Popup/TagHint.svelte +++ b/src/UI/Popup/TagHint.svelte @@ -3,29 +3,30 @@ import FromHtml from "../Base/FromHtml.svelte" import { Translation } from "../i18n/Translation" import Tr from "../Base/Tr.svelte" - import type { SpecialVisualizationState } from "../SpecialVisualization" import Translations from "../i18n/Translations" /** * A 'TagHint' will show the given tags in a human readable form. * Depending on the options, it'll link through to the wiki or might be completely hidden */ - export let tags: TagsFilter - export let currentProperties: Record<string, string | any> = {} - /** - * If given, this function will be called to embed the given tags hint into this translation - */ - export let embedIn: ((string: string) => Translation) | undefined = undefined - let tagsExplanation = "" - $: tagsExplanation = tags?.asHumanString(true, false, currentProperties) + export let tags: TagsFilter[] + export let currentProperties: Record<string, string> = {} </script> -<div class="break-words" style="word-break: break-word"> - {#if tags === undefined} - <slot name="no-tags"><Tr cls="subtle" t={Translations.t.general.noTagsSelected} /></slot> - {:else if embedIn === undefined} - <FromHtml src={tagsExplanation} /> - {:else} - <Tr t={embedIn(tagsExplanation)} /> - {/if} -</div> +{#if tags?.length > 0} + {#each tags as tag} + <div class="break-words" style="word-break: break-word"> + {#if tag["value"] === ""} + <del> + {tag["key"]} + </del> + {:else} + <FromHtml src={tag.asHumanString(true, false, currentProperties)} /> + {/if} + </div> + {/each} +{:else} + <slot name="no-tags"> + <Tr cls="subtle" t={Translations.t.general.noTagsSelected} /> + </slot> +{/if} diff --git a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte index 3b354959e..f78dc6916 100644 --- a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource" + import { ImmutableStore, Store, UIEventSource } from "../../../Logic/UIEventSource" import type { SpecialVisualizationState } from "../../SpecialVisualization" import Tr from "../../Base/Tr.svelte" import type { Feature } from "geojson" @@ -31,7 +31,9 @@ import { get } from "svelte/store" import Markdown from "../../Base/Markdown.svelte" import { Utils } from "../../../Utils" + import { TagTypes } from "../../../Logic/Tags/TagTypes" import type { UploadableTag } from "../../../Logic/Tags/TagTypes" + import Popup from "../../Base/Popup.svelte" import If from "../../Base/If.svelte" import DotMenu from "../../Base/DotMenu.svelte" @@ -43,7 +45,7 @@ export let selectedElement: Feature export let state: SpecialVisualizationState export let layer: LayerConfig | undefined - export let selectedTags: UploadableTag = undefined + export let selectedTags: UploadableTag[] = undefined export let extraTags: UIEventSource<Record<string, string>> = new UIEventSource({}) export let clss = "interactive border-interactive" @@ -65,9 +67,9 @@ let checkedMappings: boolean[] /** - * IF set: we can remove the current answer by deleting all those keys + * The tags to apply to mark this answer as "unknown" */ - let settableKeys = tags.mapD((tags) => config.removeToSetUnknown(layer, tags)) + let onMarkUnknown: Store<UploadableTag[] | undefined> = tags.mapD((tags) => config.markUnknown(layer, tags)) let unknownModal = new UIEventSource(false) let searchTerm: UIEventSource<string> = new UIEventSource("") @@ -118,7 +120,7 @@ seenFreeforms.push(newProps[confg.freeform.key]) } return matches - }), + }) ] if (tgs !== undefined && confg.freeform) { @@ -211,7 +213,7 @@ !config.freeform.postfixDistinguished && $tags[config.freeform.key] // We need to have a current value in order to delete it ) { - selectedTags = new Tag(config.freeform.key, "") + selectedTags = [new Tag(config.freeform.key, "")] } else { try { selectedTags = config?.constructChangeSpecification( @@ -227,7 +229,7 @@ freeform: $freeformInput, selectedMapping, checkedMappings, - currentTags: tags.data, + currentTags: tags.data }, " --> ", selectedTags @@ -246,10 +248,10 @@ // Check the type of selectedTags if (selectedTags instanceof Tag) { // Re-define selectedTags as an And - selectedTags = new And([selectedTags, ...extraTagsArray]) + selectedTags = [selectedTags, ...extraTagsArray] } else if (selectedTags instanceof And) { // Add the extraTags to the existing And - selectedTags = new And([...selectedTags.and, ...extraTagsArray]) + selectedTags = [...TagTypes.uploadableAnd(selectedTags), ...extraTagsArray] } else { console.error( "selectedTags is not of type Tag or And, it is a " + JSON.stringify(selectedTags) @@ -258,17 +260,18 @@ } } - function onSave(_ = undefined) { + function onSave() { if (selectedTags === undefined) { return } + const selectedTagsJoined = new And(selectedTags) if (layer === undefined || (layer?.source === null && layer.id !== "favourite")) { /** * This is a special, privileged layer. * We simply apply the tags onto the records */ - const kv = selectedTags.asChange(tags.data) + const kv = selectedTagsJoined.asChange(tags.data) for (const { k, v } of kv) { if (v === undefined) { // Note: we _only_ delete if it is undefined. We _leave_ the empty string and assign it, so that data consumers get correct information @@ -279,13 +282,13 @@ feedback.setData(undefined) } tags.ping() - dispatch("saved", { config, applied: selectedTags }) + dispatch("saved", { config, applied: selectedTagsJoined }) return } - dispatch("saved", { config, applied: selectedTags }) - const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, { + dispatch("saved", { config, applied: selectedTagsJoined }) + const change = new ChangeTagAction(tags.data.id, selectedTagsJoined, tags.data, { theme: tags.data["_orig_theme"] ?? state.theme?.id, - changeType: "answer", + changeType: "answer" }) freeformInput.set(undefined) selectedMapping = undefined @@ -326,10 +329,10 @@ } function clearAnswer() { - const tagsToSet = settableKeys.data.map((k) => new Tag(k, "")) + const tagsToSet: UploadableTag[] = onMarkUnknown.data const change = new ChangeTagAction(tags.data.id, new And(tagsToSet), tags.data, { theme: tags.data["_orig_theme"] ?? state.theme.id, - changeType: "answer", + changeType: "answer" }) freeformInput.set(undefined) selectedMapping = undefined @@ -576,13 +579,7 @@ > <div class="subtle"> <Tr t={Translations.t.unknown.removedKeys} /> - {#each $settableKeys as key} - <code> - <del> - {key} - </del> - </code> - {/each} + <TagHint tags={$onMarkUnknown}></TagHint> </div> </If> <div class="flex w-full justify-end" slot="footer"> @@ -602,7 +599,7 @@ </Popup> <div class="sticky bottom-0 flex flex-wrap justify-between" style="z-index: 11"> - {#if $settableKeys && $isKnown && !matchesEmpty} + {#if $onMarkUnknown?.length > 0 && $isKnown && !matchesEmpty} <button class="as-link small text-sm" on:click={() => unknownModal.set(true)}> <Tr t={Translations.t.unknown.markUnknown} /> </button> @@ -614,8 +611,13 @@ <!-- TagRenderingQuestion-buttons --> <slot name="cancel" /> <slot name="save-button" {selectedTags}> - {#if config.freeform?.key && !checkedMappings?.some((m) => m) && !$freeformInput && !$freeformInputUnvalidated && $tags[config.freeform.key] - && (!config.freeform.postfixDistinguished)} + + <!-- Save-button / delete button --> + {#if config.freeform?.key && + !checkedMappings?.some((m) => m) && + !$freeformInput && !$freeformInputUnvalidated + && $tags[config.freeform.key] + && $isKnown} <button class="primary flex" on:click|stopPropagation|preventDefault={() => onSave()} @@ -637,9 +639,10 @@ </slot> </div> </div> + <!-- Taghint + debug info --> {#if UserRelatedState.SHOW_TAGS_VALUES.indexOf($showTags) >= 0 || ($showTags === "" && numberOfCs >= Constants.userJourney.tagsVisibleAt) || $featureSwitchIsTesting || $featureSwitchIsDebugging} <span class="flex flex-wrap justify-between"> - <TagHint {state} tags={selectedTags} currentProperties={$tags} /> + <TagHint tags={selectedTags} currentProperties={$tags} /> <span class="flex flex-wrap"> {#if $featureSwitchIsTesting} <div class="alert" style="padding: 0; margin: 0; margin-right: 0.5rem"> @@ -647,9 +650,9 @@ </div> {/if} {#if $featureSwitchIsTesting || $featureSwitchIsDebugging} - <a class="small" on:click={() => console.log("Configuration is ", config)}> + <button class="small as-link" on:click={() => console.log("Configuration is ", config)}> {config.id} - </a> + </button> {/if} </span> </span>