diff --git a/Logic/DetermineLayout.ts b/Logic/DetermineLayout.ts index 8c3907f72..e1cba1fc5 100644 --- a/Logic/DetermineLayout.ts +++ b/Logic/DetermineLayout.ts @@ -10,11 +10,12 @@ import {UIEventSource} from "./UIEventSource"; import {LocalStorageSource} from "./Web/LocalStorageSource"; import LZString from "lz-string"; import * as personal from "../assets/themes/personal/personal.json"; -import {FixLegacyTheme, PrepareTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; +import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import SharedTagRenderings from "../Customizations/SharedTagRenderings"; import * as known_layers from "../assets/generated/known_layers.json" import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson"; +import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme"; export default class DetermineLayout { diff --git a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts index 125018406..363cfeb57 100644 --- a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts +++ b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts @@ -29,11 +29,11 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti }, tileIndex, upstream: FeatureSourceForLayer, - metataggingUpdated: UIEventSource + metataggingUpdated?: UIEventSource ) { this.name = "FilteringFeatureSource(" + upstream.name + ")" this.tileIndex = tileIndex - this.bbox = BBox.fromTileIndex(tileIndex) + this.bbox = tileIndex === undefined ? undefined : BBox.fromTileIndex(tileIndex) this.upstream = upstream this.state = state @@ -55,7 +55,7 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti } }) - metataggingUpdated.addCallback(_ => { + metataggingUpdated?.addCallback(_ => { self._is_dirty.setData(true) }) @@ -63,6 +63,7 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti } private update() { + console.log("FIltering", this.upstream.name) const self = this; const layer = this.upstream.layer; const features: { feature: any; freshness: Date }[] = (this.upstream.features.data ?? []); diff --git a/Logic/FeatureSource/Sources/GeoJsonSource.ts b/Logic/FeatureSource/Sources/GeoJsonSource.ts index 8de41dd33..cc89926ca 100644 --- a/Logic/FeatureSource/Sources/GeoJsonSource.ts +++ b/Logic/FeatureSource/Sources/GeoJsonSource.ts @@ -28,7 +28,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { private readonly featureIdBlacklist?: UIEventSource> public constructor(flayer: FilteredLayer, - zxy?: [number, number, number], + zxy?: [number, number, number] | BBox, options?: { featureIdBlacklist?: UIEventSource> }) { @@ -41,23 +41,32 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { this.featureIdBlacklist = options?.featureIdBlacklist let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id); if (zxy !== undefined) { - const [z, x, y] = zxy; - let tile_bbox = BBox.fromTile(z, x, y) + let tile_bbox: BBox; + if (zxy instanceof BBox) { + tile_bbox = zxy; + } else { + const [z, x, y] = zxy; + tile_bbox = BBox.fromTile(z, x, y); + + this.tileIndex = Tiles.tile_index(z, x, y) + this.bbox = BBox.fromTile(z, x, y) + url = url + .replace('{z}', "" + z) + .replace('{x}', "" + x) + .replace('{y}', "" + y) + } let bounds: { minLat: number, maxLat: number, minLon: number, maxLon: number } = tile_bbox if (this.layer.layerDef.source.mercatorCrs) { bounds = tile_bbox.toMercator() } + url = url - .replace('{z}', "" + z) - .replace('{x}', "" + x) - .replace('{y}', "" + y) .replace('{y_min}', "" + bounds.minLat) .replace('{y_max}', "" + bounds.maxLat) .replace('{x_min}', "" + bounds.minLon) .replace('{x_max}', "" + bounds.maxLon) - this.tileIndex = Tiles.tile_index(z, x, y) - this.bbox = BBox.fromTile(z, x, y) + } else { this.tileIndex = Tiles.tile_index(0, 0, 0) this.bbox = BBox.global; @@ -83,7 +92,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { if (self.layer.layerDef.source.mercatorCrs) { json = GeoOperations.GeoJsonToWGS84(json) } - + const time = new Date(); const newFeatures: { feature: any, freshness: Date } [] = [] let i = 0; diff --git a/Logic/GeoOperations.ts b/Logic/GeoOperations.ts index e1acc4dd4..723e70009 100644 --- a/Logic/GeoOperations.ts +++ b/Logic/GeoOperations.ts @@ -683,6 +683,8 @@ export class GeoOperations { throw "CalculateIntersection fallthrough: can not calculate an intersection between features" } + + } diff --git a/Logic/Osm/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 6f758f985..5f08a8d05 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -18,13 +18,13 @@ export class ChangesetHandler { private readonly allElements: ElementStorage; private osmConnection: OsmConnection; private readonly changes: Changes; - private readonly _dryRun: boolean; + private readonly _dryRun: UIEventSource; private readonly userDetails: UIEventSource; private readonly auth: any; private readonly backend: string; constructor(layoutName: string, - dryRun: boolean, + dryRun: UIEventSource, osmConnection: OsmConnection, allElements: ElementStorage, changes: Changes, @@ -67,7 +67,7 @@ export class ChangesetHandler { this.userDetails.data.csCount = 1; this.userDetails.ping(); } - if (this._dryRun) { + if (this._dryRun.data) { const changesetXML = generateChangeXML(123456); console.log("Metatags are", extraMetaTags) console.log(changesetXML); diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index 7f2067c7c..b282a8e2a 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -19,7 +19,6 @@ export default class UserDetails { public img: string; public unreadMessages = 0; public totalMessages = 0; - public dryRun: boolean; home: { lon: number; lat: number }; public backend: string; @@ -47,7 +46,6 @@ export class OsmConnection { public auth; public userDetails: UIEventSource; public isLoggedIn: UIEventSource - _dryRun: boolean; public preferencesHandler: OsmPreferences; public changesetHandler: ChangesetHandler; public readonly _oauth_config: { @@ -55,6 +53,7 @@ export class OsmConnection { oauth_secret: string, url: string }; + private readonly _dryRun: UIEventSource; private fakeUser: boolean; private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []; private readonly _iframeMode: Boolean | boolean; @@ -62,7 +61,7 @@ export class OsmConnection { private isChecking = false; constructor(options: { - dryRun?: false | boolean, + dryRun?: UIEventSource, fakeUser?: false | boolean, allElements: ElementStorage, changes: Changes, @@ -82,7 +81,6 @@ export class OsmConnection { this._iframeMode = Utils.runningFromConsole ? false : window !== window.top; this.userDetails = new UIEventSource(new UserDetails(this._oauth_config.url), "userDetails"); - this.userDetails.data.dryRun = (options.dryRun ?? false) || (options.fakeUser ?? false); if (options.fakeUser) { const ud = this.userDetails.data; ud.csCount = 5678 @@ -99,13 +97,13 @@ export class OsmConnection { self.AttemptLogin() } }); - this._dryRun = options.dryRun; + this._dryRun = options.dryRun ?? new UIEventSource(false); this.updateAuthObject(); this.preferencesHandler = new OsmPreferences(this.auth, this); - this.changesetHandler = new ChangesetHandler(options.layoutName, options.dryRun, this, options.allElements, options.changes, this.auth); + this.changesetHandler = new ChangesetHandler(options.layoutName, this._dryRun, this, options.allElements, options.changes, this.auth); if (options.oauth_token?.data !== undefined) { console.log(options.oauth_token.data) const self = this; @@ -223,7 +221,7 @@ export class OsmConnection { if ((text ?? "") !== "") { textSuffix = "?text=" + encodeURIComponent(text) } - if (this._dryRun) { + if (this._dryRun.data) { console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text) return new Promise((ok, error) => { ok() @@ -246,7 +244,7 @@ export class OsmConnection { } public reopenNote(id: number | string, text?: string): Promise { - if (this._dryRun) { + if (this._dryRun.data) { console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text) return new Promise((ok, error) => { ok() @@ -273,10 +271,10 @@ export class OsmConnection { } public openNote(lat: number, lon: number, text: string): Promise<{ id: number }> { - if (this._dryRun) { + if (this._dryRun.data) { console.warn("Dryrun enabled - not actually opening note with text ", text) - return new Promise((ok, error) => { - ok() + return new Promise<{ id: number }>((ok, error) => { + window.setTimeout(() => ok({id: Math.floor(Math.random() * 1000)}), Math.random() * 5000) }); } const auth = this.auth; @@ -285,15 +283,18 @@ export class OsmConnection { auth.xhr({ method: 'POST', path: `/api/0.6/notes.json`, - options: {header: - {'Content-Type': 'application/json'}}, + options: { + header: + {'Content-Type': 'application/json'} + }, content: JSON.stringify(content) }, function (err, response) { if (err !== null) { error(err) } else { - const id = Number(response.children[0].children[0].children.item("id").innerHTML) + + const id = response.properties.id console.log("OPENED NOTE", id) ok({id}) } @@ -304,7 +305,7 @@ export class OsmConnection { } public addCommentToNode(id: number | string, text: string): Promise { - if (this._dryRun) { + if (this._dryRun.data) { console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id) return new Promise((ok, error) => { ok() @@ -317,7 +318,7 @@ export class OsmConnection { return new Promise((ok, error) => { this.auth.xhr({ method: 'POST', - + path: `/api/0.6/notes.json/${id}/comment?text=${encodeURIComponent(text)}` }, function (err, response) { if (err !== null) { diff --git a/Logic/State/UserRelatedState.ts b/Logic/State/UserRelatedState.ts index d77026845..7895bb2ee 100644 --- a/Logic/State/UserRelatedState.ts +++ b/Logic/State/UserRelatedState.ts @@ -43,7 +43,7 @@ export default class UserRelatedState extends ElementsState { this.osmConnection = new OsmConnection({ changes: this.changes, - dryRun: this.featureSwitchIsTesting.data, + dryRun: this.featureSwitchIsTesting, fakeUser: this.featureSwitchFakeUser.data, allElements: this.allElements, oauth_token: QueryParameters.GetQueryParameter( diff --git a/Logic/Tags/RegexTag.ts b/Logic/Tags/RegexTag.ts index f747156b6..53357d604 100644 --- a/Logic/Tags/RegexTag.ts +++ b/Logic/Tags/RegexTag.ts @@ -10,6 +10,13 @@ export class RegexTag extends TagsFilter { constructor(key: string | RegExp, value: RegExp | string, invert: boolean = false) { super(); this.key = key; + if (typeof value === "string") { + if (value.indexOf("^") < 0 && value.indexOf("$") < 0) { + value = "^" + value + "$" + } + value = new RegExp(value) + } + this.value = value; this.invert = invert; this.matchesEmpty = RegexTag.doesMatch("", this.value); @@ -109,7 +116,7 @@ export class RegexTag extends TagsFilter { console.error("Cannot export regex tag to asChange; ", this.key, this.value) return [] } - + AsJson() { return this.asHumanString() } diff --git a/Logic/Tags/TagUtils.ts b/Logic/Tags/TagUtils.ts index 42da0dd47..2b41ed988 100644 --- a/Logic/Tags/TagUtils.ts +++ b/Logic/Tags/TagUtils.ts @@ -192,16 +192,16 @@ export class TagUtils { } const f = (value: string | undefined) => { - if(value === undefined){ + if (value === undefined) { return false; } - let b = Number(value?.trim() ) + let b = Number(value?.trim()) if (isNaN(b)) { - if(value.endsWith(" UTC")) { + if (value.endsWith(" UTC")) { value = value.replace(" UTC", "+00") } b = new Date(value).getTime() - if(isNaN(b)){ + if (isNaN(b)) { return false } } @@ -218,7 +218,7 @@ export class TagUtils { } return new RegexTag( split[0], - new RegExp("^" + split[1] + "$"), + split[1], true ); } @@ -228,8 +228,8 @@ export class TagUtils { split[1] = "..*" } return new RegexTag( - new RegExp("^" + split[0] + "$"), - new RegExp("^" + split[1] + "$") + split[0], + split[1] ); } if (tag.indexOf("!:=") >= 0) { @@ -248,7 +248,7 @@ export class TagUtils { } return new RegexTag( split[0], - new RegExp("^" + split[1] + "$"), + new RegExp("^" + split[1] + "$"), true ); } @@ -259,7 +259,7 @@ export class TagUtils { } return new RegexTag( split[0], - new RegExp("^" + split[1] + "$"), + split[1], true ); } @@ -273,7 +273,7 @@ export class TagUtils { } return new RegexTag( split[0], - new RegExp("^" + split[1] + "$") + split[1] ); } if (tag.indexOf("=") >= 0) { diff --git a/Models/ThemeConfig/Conversion/Conversion.ts b/Models/ThemeConfig/Conversion/Conversion.ts new file mode 100644 index 000000000..e0e97aa4e --- /dev/null +++ b/Models/ThemeConfig/Conversion/Conversion.ts @@ -0,0 +1,171 @@ +import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"; +import {LayerConfigJson} from "../Json/LayerConfigJson"; +import {Utils} from "../../../Utils"; + +export interface DesugaringContext { + tagRenderings: Map + sharedLayers: Map +} + +export abstract class Conversion { + public readonly modifiedAttributes: string[]; + protected readonly doc: string; + + constructor(doc: string, modifiedAttributes: string[] = []) { + this.modifiedAttributes = modifiedAttributes; + this.doc = doc + "\n\nModified attributes are\n" + modifiedAttributes.join(", "); + } + + public static strict(fixed: { errors: string[], warnings: string[], result?: T }): T { + if (fixed?.errors?.length > 0) { + throw fixed.errors.join("\n"); + } + fixed.warnings?.forEach(w => console.warn(w)) + return fixed.result; + } + + public convertStrict(state: DesugaringContext, json: TIn, context: string): TOut { + const fixed = this.convert(state, json, context) + return DesugaringStep.strict(fixed) + } + + abstract convert(state: DesugaringContext, json: TIn, context: string): { result: TOut, errors: string[], warnings: string[] } + + public convertAll(state: DesugaringContext, jsons: TIn[], context: string): { result: TOut[], errors: string[], warnings: string[] } { + const result = [] + const errors = [] + const warnings = [] + for (let i = 0; i < jsons.length; i++) { + const json = jsons[i]; + const r = this.convert(state, json, context + "[" + i + "]") + result.push(r.result) + errors.push(...r.errors) + warnings.push(...r.warnings) + } + return { + result, + errors, + warnings + } + } + +} + +export abstract class DesugaringStep extends Conversion { +} + +export class OnEvery extends DesugaringStep { + private readonly key: string; + private readonly step: DesugaringStep; + + constructor(key: string, step: DesugaringStep) { + super("Applies " + step.constructor.name + " onto every object of the list `key`", [key]); + this.step = step; + this.key = key; + } + + convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { + json = {...json} + const step = this.step + const key = this.key; + const r = step.convertAll(state, (json[key]), context + "." + key) + json[key] = r.result + return { + result: json, + errors: r.errors, + warnings: r.warnings + }; + } +} + +export class OnEveryConcat extends DesugaringStep { + private readonly key: string; + private readonly step: Conversion; + + constructor(key: string, step: Conversion) { + super(`Applies ${step.constructor.name} onto every object of the list \`${key}\`. The results are concatenated and used as new list`, [key]); + this.step = step; + this.key = key; + } + + convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { + json = {...json} + const step = this.step + const key = this.key; + const values = json[key] + if (values === undefined) { + // Move on - nothing to see here! + return { + result: json, + errors: [], + warnings: [] + } + } + const r = step.convertAll(state, (values), context + "." + key) + const vals: X[][] = r.result + json[key] = [].concat(...vals) + return { + result: json, + errors: r.errors, + warnings: r.warnings + }; + + } +} + +export class Fuse extends DesugaringStep { + private readonly steps: DesugaringStep[]; + + constructor(doc: string, ...steps: DesugaringStep[]) { + super((doc ?? "") + "This fused pipeline of the following steps: " + steps.map(s => s.constructor.name).join(", "), + Utils.Dedup([].concat(...steps.map(step => step.modifiedAttributes))) + ); + this.steps = steps; + } + + convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { + const errors = [] + const warnings = [] + for (let i = 0; i < this.steps.length; i++) { + const step = this.steps[i]; + let r = step.convert(state, json, context + "(fusion " + this.constructor.name + "." + i + ")") + errors.push(...r.errors) + warnings.push(...r.warnings) + json = r.result + if (errors.length > 0) { + break; + } + } + return { + result: json, + errors, + warnings + }; + } + +} + +export class SetDefault extends DesugaringStep { + private readonly value: any; + private readonly key: string; + private readonly _overrideEmptyString: boolean; + + constructor(key: string, value: any, overrideEmptyString = false) { + super("Sets " + key + " to a default value if undefined"); + this.key = key; + this.value = value; + this._overrideEmptyString = overrideEmptyString; + } + + convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { + if (json[this.key] === undefined || (json[this.key] === "" && this._overrideEmptyString)) { + json = {...json} + json[this.key] = this.value + } + + return { + errors: [], warnings: [], + result: json + }; + } +} \ No newline at end of file diff --git a/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts b/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts index b8f78fe5b..bb5c3ddee 100644 --- a/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts +++ b/Models/ThemeConfig/Conversion/CreateNoteImportLayer.ts @@ -1,29 +1,69 @@ -import {Conversion, DesugaringContext} from "./LegacyJsonConvert"; +import {Conversion, DesugaringContext} from "./Conversion"; import LayerConfig from "../LayerConfig"; import {LayerConfigJson} from "../Json/LayerConfigJson"; import Translations from "../../../UI/i18n/Translations"; -import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; -import {And} from "../../../Logic/Tags/And"; +import PointRenderingConfigJson from "../Json/PointRenderingConfigJson"; -export default class CreateNoteImportLayer extends Conversion { +export default class CreateNoteImportLayer extends Conversion { + /** + * A closed note is included if it is less then 'n'-days closed + * @private + */ + private readonly _includeClosedNotesDays: number; - constructor() { + constructor(includeClosedNotesDays= 0) { super([ "Advanced conversion which deducts a layer showing all notes that are 'importable' (i.e. a note that contains a link to some MapComplete theme, with hash '#import').", "The import buttons and matches will be based on the presets of the given theme", ].join("\n\n"), []) + this._includeClosedNotesDays = includeClosedNotesDays; } - convert(state: DesugaringContext, layer: LayerConfig, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } { + convert(state: DesugaringContext, layerJson: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } { const errors = [] const warnings = [] const t = Translations.t.importLayer; - const possibleTags: TagsFilter[] = layer.presets.map(p => new And(p.tags)) + /** + * The note itself will contain `tags=k=v;k=v;k=v;... + * This must be matched with a regex. + * This is a simple JSON-object as how it'll be put into the layerConfigJson directly + */ + const isShownIfAny : any[] = [] + const layer = new LayerConfig(layerJson, "while constructing a note-import layer") + for (const preset of layer.presets) { + const mustMatchAll = [] + for (const tag of preset.tags) { + const key = tag.key + const value = tag.value + const condition = "_tags~(^|.*;)"+key+"\="+value+"($|;.*)" + mustMatchAll.push(condition) + } + isShownIfAny.push({and:mustMatchAll}) + } + + const pointRenderings = (layerJson.mapRendering??[]).filter(r => r!== null && r["location"] !== undefined); + const firstRender = (pointRenderings [0]) + const icon = firstRender.icon + const iconBadges = [] + if(icon !== undefined){ + iconBadges.push({ + if: {and:[]}, + then:icon + }) + } + + const importButton = {} + { + const translations = t.importButton.Subs({layerId: layer.id, title: layer.presets[0].title}).translations + for (const key in translations) { + importButton[key] = "{"+translations[key]+"}" + } + } const result : LayerConfigJson = { "id": "note_import_"+layer.id, - "name": t.layerName.Subs({title: layer.title.render}).translations, + // By disabling the name, the import-layers won't pollute the filter view "name": t.layerName.Subs({title: layer.title.render}).translations, "description": t.description.Subs({title: layer.title.render}).translations, "source": { "osmTags": { @@ -31,27 +71,32 @@ export default class CreateNoteImportLayer extends Conversion {const lines = feat.properties['_first_comment'].split('\\n'); const matchesMapCompleteURL = lines.map(l => l.match(\".*https://mapcomplete.osm.be/\\([a-zA-Z_-]+\\)\\(.html\\).*#import\")); const matchedIndexes = matchesMapCompleteURL.map((doesMatch, i) => [doesMatch !== null, i]).filter(v => v[0]).map(v => v[1]); return matchedIndexes[0] })()", - "_intro:=(() => {const lines = feat.properties['_first_comment'].split('\\n'); lines.splice(feat.get('_trigger_index')-1, lines.length); return lines.map(l => l == '' ? '
' : l).join('');})()", - "_tags:=(() => {let lines = feat.properties['_first_comment'].split('\\n').map(l => l.trim()); lines.splice(0, feat.get('_trigger_index') + 1); lines = lines.filter(l => l != ''); return lines.join(';');})()" + "_first_comment=feat.get('comments')[0].text.toLowerCase()", + "_trigger_index=(() => {const lines = feat.properties['_first_comment'].split('\\n'); const matchesMapCompleteURL = lines.map(l => l.match(\".*https://mapcomplete.osm.be/\\([a-zA-Z_-]+\\)\\(.html\\).*#import\")); const matchedIndexes = matchesMapCompleteURL.map((doesMatch, i) => [doesMatch !== null, i]).filter(v => v[0]).map(v => v[1]); return matchedIndexes[0] })()", + "_comments_count=feat.get('comments').length", + "_intro=(() => {const lines = feat.properties['_first_comment'].split('\\n'); lines.splice(feat.get('_trigger_index')-1, lines.length); return lines.filter(l => l !== '').join('
');})()", + "_tags=(() => {let lines = feat.properties['_first_comment'].split('\\n').map(l => l.trim()); lines.splice(0, feat.get('_trigger_index') + 1); lines = lines.filter(l => l != ''); return lines.join(';');})()" ], "isShown": { "render": "no", "mappings": [ + { + "if": "comments!~.*https://mapcomplete.osm.be.*", + "then":"no" + }, { "if": {and: ["_trigger_index~*", - {or: possibleTags.map(tf => tf.AsJson())} + {or: isShownIfAny} ]}, "then": "yes" } @@ -63,25 +108,34 @@ export default class CreateNoteImportLayer extends Conversion1" + }, { "id": "import", - "render": "{import_button(public_bookcase, _tags, There might be a public bookcase here,./assets/svg/addSmall.svg,,,id)}" + "render": importButton, + condition: "closed_at=" }, { "id": "close_note_", - "render": "{close_note(Does not exist
, ./assets/svg/close.svg, id, This feature does not exist)}" + "render": "{close_note(Does not exist
, ./assets/svg/close.svg, id, This feature does not exist)}", + condition: "closed_at=" }, { "id": "close_note_mapped", - "render": "{close_note(Already mapped, ./assets/svg/checkmark.svg, id, Already mapped)}" + "render": "{close_note(Already mapped, ./assets/svg/checkmark.svg, id, Already mapped)}", + condition: "closed_at=" + }, + { + "id": "handled", + "render": t.importHandled.translations, + condition: "closed_at~*" }, { "id": "comment", @@ -90,6 +144,10 @@ export default class CreateNoteImportLayer extends Conversion - sharedLayers: Map -} - -export abstract class Conversion { - public readonly modifiedAttributes: string[]; - protected readonly doc: string; - - constructor(doc: string, modifiedAttributes: string[] = []) { - this.modifiedAttributes = modifiedAttributes; - this.doc = doc + "\n\nModified attributes are\n" + modifiedAttributes.join(", "); - } - - public static strict(fixed: { errors: string[], warnings: string[], result?: T }): T { - if (fixed?.errors?.length > 0) { - throw fixed.errors.join("\n"); - } - fixed.warnings?.forEach(w => console.warn(w)) - return fixed.result; - } - - public convertStrict(state: DesugaringContext, json: TIn, context: string): TOut { - const fixed = this.convert(state, json, context) - return DesugaringStep.strict(fixed) - } - - abstract convert(state: DesugaringContext, json: TIn, context: string): { result: TOut, errors: string[], warnings: string[] } - - public convertAll(state: DesugaringContext, jsons: TIn[], context: string): { result: TOut[], errors: string[], warnings: string[] } { - const result = [] - const errors = [] - const warnings = [] - for (let i = 0; i < jsons.length; i++) { - const json = jsons[i]; - const r = this.convert(state, json, context + "[" + i + "]") - result.push(r.result) - errors.push(...r.errors) - warnings.push(...r.warnings) - } - return { - result, - errors, - warnings - } - } - -} - -export abstract class DesugaringStep extends Conversion { -} - -class OnEvery extends DesugaringStep { - private readonly key: string; - private readonly step: DesugaringStep; - - constructor(key: string, step: DesugaringStep) { - super("Applies " + step.constructor.name + " onto every object of the list `key`", [key]); - this.step = step; - this.key = key; - } - - convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { - json = {...json} - const step = this.step - const key = this.key; - const r = step.convertAll(state, (json[key]), context + "." + key) - json[key] = r.result - return { - result: json, - errors: r.errors, - warnings: r.warnings - }; - } -} - -class OnEveryConcat extends DesugaringStep { - private readonly key: string; - private readonly step: Conversion; - - constructor(key: string, step: Conversion) { - super(`Applies ${step.constructor.name} onto every object of the list \`${key}\``, [key]); - this.step = step; - this.key = key; - } - - convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { - json = {...json} - const step = this.step - const key = this.key; - const values = json[key] - if (values === undefined) { - // Move on - nothing to see here! - return { - result: json, - errors: [], - warnings: [] - } - } - const r = step.convertAll(state, (values), context + "." + key) - const vals: X[][] = r.result - json[key] = [].concat(...vals) - return { - result: json, - errors: r.errors, - warnings: r.warnings - }; - - } -} - -class Fuse extends DesugaringStep { - private readonly steps: DesugaringStep[]; - - constructor(doc: string, ...steps: DesugaringStep[]) { - super((doc ?? "") + "This fused pipeline of the following steps: " + steps.map(s => s.constructor.name).join(", "), - Utils.Dedup([].concat(...steps.map(step => step.modifiedAttributes))) - ); - this.steps = steps; - } - - convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { - const errors = [] - const warnings = [] - for (let i = 0; i < this.steps.length; i++) { - const step = this.steps[i]; - let r = step.convert(state, json, context + "(fusion " + this.constructor.name + "." + i + ")") - errors.push(...r.errors) - warnings.push(...r.warnings) - json = r.result - if (errors.length > 0) { - break; - } - } - return { - result: json, - errors, - warnings - }; - } - -} - -class AddMiniMap extends DesugaringStep { - constructor() { - super("Adds a default 'minimap'-element to the tagrenderings if none of the elements define such a minimap", ["tagRenderings"]); - } - - /** - * Returns true if this tag rendering has a minimap in some language. - * Note: this minimap can be hidden by conditions - */ - private static hasMinimap(renderingConfig: TagRenderingConfigJson): boolean { - const translations: Translation[] = Utils.NoNull([renderingConfig.render, ...(renderingConfig.mappings ?? []).map(m => m.then)]); - for (const translation of translations) { - for (const key in translation.translations) { - if (!translation.translations.hasOwnProperty(key)) { - continue - } - const template = translation.translations[key] - const parts = SubstitutedTranslation.ExtractSpecialComponents(template) - const hasMiniMap = parts.filter(part => part.special !== undefined).some(special => special.special.func.funcName === "minimap") - if (hasMiniMap) { - return true; - } - } - } - return false; - } - - convert(state: DesugaringContext, layerConfig: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } { - - - - const hasMinimap = layerConfig.tagRenderings?.some(tr => AddMiniMap.hasMinimap( tr)) ?? true - if (!hasMinimap) { - layerConfig = {...layerConfig} - layerConfig.tagRenderings = [...layerConfig.tagRenderings] - layerConfig.tagRenderings.push(state.tagRenderings.get("minimap")) - } - - return { - errors:[], - warnings: [], - result: layerConfig - }; - } -} - -class ExpandTagRendering extends Conversion { - constructor() { - super("Converts a tagRenderingSpec into the full tagRendering", []); - } - - convert(state: DesugaringContext, json: string | TagRenderingConfigJson | { builtin: string | string[]; override: any }, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } { - const errors = [] - const warnings = [] - - return { - result: this.convertUntilStable(state, json, warnings, errors, context), - errors, warnings - }; - } - - private lookup(state: DesugaringContext, name: string): TagRenderingConfigJson[] { - if (state.tagRenderings.has(name)) { - return [state.tagRenderings.get(name)] - } - if (name.indexOf(".") >= 0) { - const spl = name.split("."); - const layer = state.sharedLayers.get(spl[0]) - if (spl.length === 2 && layer !== undefined) { - const id = spl[1]; - - const layerTrs = layer.tagRenderings.filter(tr => tr["id"] !== undefined) - let matchingTrs: TagRenderingConfigJson[] - if (id === "*") { - matchingTrs = layerTrs - } else if (id.startsWith("*")) { - const id_ = id.substring(1) - matchingTrs = layerTrs.filter(tr => tr.group === id_) - } else { - matchingTrs = layerTrs.filter(tr => tr.id === id) - } - - - for (let i = 0; i < matchingTrs.length; i++) { - // The matched tagRenderings are 'stolen' from another layer. This means that they must match the layer condition before being shown - const found = Utils.Clone(matchingTrs[i]); - if (found.condition === undefined) { - found.condition = layer.source.osmTags - } else { - found.condition = {and: [found.condition, layer.source.osmTags]} - } - matchingTrs[i] = found - } - - if (matchingTrs.length !== 0) { - return matchingTrs - } - } - } - return undefined; - } - - private convertOnce(state: DesugaringContext, tr: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] { - if (tr === "questions") { - return [{ - id: "questions" - }] - } - - - if (typeof tr === "string") { - const lookup = this.lookup(state, tr); - if (lookup !== undefined) { - return lookup - } - warnings.push(ctx + "A literal rendering was detected: " + tr) - return [{ - render: tr, - id: tr.replace(/![a-zA-Z0-9]/g, "") - }] - } - - if (tr["builtin"] !== undefined) { - let names = tr["builtin"] - if (typeof names === "string") { - names = [names] - } - - for (const key of Object.keys(tr)) { - if (key === "builtin" || key === "override" || key === "id" || key.startsWith("#")) { - continue - } - errors.push("At " + ctx + ": an object calling a builtin can only have keys `builtin` or `override`, but a key with name `" + key + "` was found. This won't be picked up! The full object is: " + JSON.stringify(tr)) - } - - const trs: TagRenderingConfigJson[] = [] - for (const name of names) { - const lookup = this.lookup(state, name) - if (lookup === undefined) { - errors.push(ctx + ": The tagRendering with identifier " + name + " was not found.\n\tDid you mean one of " + Array.from(state.tagRenderings.keys()).join(", ") + "?") - continue - } - for (let foundTr of lookup) { - foundTr = Utils.Clone(foundTr) - Utils.Merge(tr["override"] ?? {}, foundTr) - trs.push(foundTr) - } - } - return trs; - } - - return [tr] - } - - private convertUntilStable(state: DesugaringContext, spec: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] { - const trs = this.convertOnce(state, spec, warnings, errors, ctx); - - const result = [] - for (const tr of trs) { - if (tr["builtin"] !== undefined) { - const stable = this.convertUntilStable(state, tr, warnings, errors, ctx + "(RECURSIVE RESOLVE)") - result.push(...stable) - } else { - result.push(tr) - } - } - - return result; - } -} - -class ExpandGroupRewrite extends Conversion<{ - rewrite: { - sourceString: string, - into: string[] - }[], - renderings: (string | { builtin: string, override: any } | TagRenderingConfigJson)[] -} | TagRenderingConfigJson, TagRenderingConfigJson[]> { - - - private static expandSubTagRenderings = new ExpandTagRendering() - - constructor() { - super( - "Converts a rewrite config for tagRenderings into the expanded form" - ); - } - - convert(state: DesugaringContext, json: - { - rewrite: - { sourceString: string; into: string[] }[]; renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[] - } | TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } { - - if (json["rewrite"] === undefined) { - return {result: [json], errors: [], warnings: []} - } - let config = <{ - rewrite: - { sourceString: string; into: string[] }[]; - renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[] - }>json; - - - const subRenderingsRes = ExpandGroupRewrite.expandSubTagRenderings.convertAll(state, config.renderings, context); - const subRenderings: TagRenderingConfigJson[] = [].concat(subRenderingsRes.result); - const errors = subRenderingsRes.errors; - const warnings = subRenderingsRes.warnings; - - - const rewrittenPerGroup = new Map() - - // The actual rewriting - for (const rewrite of config.rewrite) { - const source = rewrite.sourceString; - for (const target of rewrite.into) { - const groupName = target; - const trs: TagRenderingConfigJson[] = [] - - for (const tr of subRenderings) { - trs.push(this.prepConfig(source, target, tr)) - } - if (rewrittenPerGroup.has(groupName)) { - rewrittenPerGroup.get(groupName).push(...trs) - - } else { - rewrittenPerGroup.set(groupName, trs) - - } - } - } - - // Add questions box for this category - rewrittenPerGroup.forEach((group, groupName) => { - group.push({ - id: "questions", - group: groupName - }) - }) - - - rewrittenPerGroup.forEach((group, _) => { - group.forEach(tr => { - if (tr.id === undefined || tr.id === "") { - errors.push("A tagrendering has an empty ID after expanding the tag") - } - }) - }) - - return { - result: [].concat(...Array.from(rewrittenPerGroup.values())), - errors, warnings - }; - } - - /* Used for left|right group creation and replacement */ - private prepConfig(keyToRewrite: string, target: string, tr: TagRenderingConfigJson) { - - function replaceRecursive(transl: string | any) { - if (typeof transl === "string") { - return transl.replace(keyToRewrite, target) - } - if (transl.map !== undefined) { - return transl.map(o => replaceRecursive(o)) - } - transl = {...transl} - for (const key in transl) { - transl[key] = replaceRecursive(transl[key]) - } - return transl - } - - const orig = tr; - tr = replaceRecursive(tr) - - tr.id = target + "-" + orig.id - tr.group = target - return tr - } -} +import {DesugaringContext, DesugaringStep, Fuse, OnEvery} from "./Conversion"; export class UpdateLegacyLayer extends DesugaringStep { @@ -822,235 +395,3 @@ export class ValidateThemeAndLayers extends Fuse { } } -class AddDependencyLayersToTheme extends DesugaringStep { - constructor() { - super("If a layer has a dependency on another layer, these layers are added automatically on the theme. (For example: defibrillator depends on 'walls_and_buildings' to snap onto. This layer is added automatically)", ["layers"]); - } - - private static CalculateDependencies(alreadyLoaded: LayerConfigJson[], allKnownLayers: Map, themeId: string): LayerConfigJson[] { - const dependenciesToAdd: LayerConfigJson[] = [] - const loadedLayerIds: Set = new Set(alreadyLoaded.map(l => l.id)); - - // Verify cross-dependencies - let unmetDependencies: { neededLayer: string, neededBy: string, reason: string, context?: string }[] = [] - do { - const dependencies: { neededLayer: string, reason: string, context?: string, neededBy: string }[] = [] - - for (const layerConfig of alreadyLoaded) { - const layerDeps = DependencyCalculator.getLayerDependencies(new LayerConfig(layerConfig)) - dependencies.push(...layerDeps) - } - - // During the generate script, builtin layers are verified but not loaded - so we have to add them manually here - // Their existance is checked elsewhere, so this is fine - unmetDependencies = dependencies.filter(dep => !loadedLayerIds.has(dep.neededLayer)) - for (const unmetDependency of unmetDependencies) { - if (loadedLayerIds.has(unmetDependency.neededLayer)) { - continue - } - const dep = allKnownLayers.get(unmetDependency.neededLayer) - if (dep === undefined) { - const message = - ["Loading a dependency failed: layer " + unmetDependency.neededLayer + " is not found, neither as layer of " + themeId + " nor as builtin layer.", - "This layer is needed by " + unmetDependency.neededBy, - unmetDependency.reason + " (at " + unmetDependency.context + ")", - "Loaded layers are: " + alreadyLoaded.map(l => l.id).join(",") - - ] - throw message.join("\n\t"); - } - dependenciesToAdd.unshift(dep) - loadedLayerIds.add(dep.id); - unmetDependencies = unmetDependencies.filter(d => d.neededLayer !== unmetDependency.neededLayer) - } - - } while (unmetDependencies.length > 0) - - return dependenciesToAdd; - } - - convert(state: DesugaringContext, theme: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } { - const allKnownLayers: Map = state.sharedLayers; - const knownTagRenderings: Map = state.tagRenderings; - const errors = []; - const warnings = []; - const layers: LayerConfigJson[] = theme.layers; // Layers should be expanded at this point - - knownTagRenderings.forEach((value, key) => { - value.id = key; - }) - - const dependencies = AddDependencyLayersToTheme.CalculateDependencies(layers, allKnownLayers, theme.id); - if (dependencies.length > 0) { - - warnings.push(context + ": added " + dependencies.map(d => d.id).join(", ") + " to the theme as they are needed") - } - layers.unshift(...dependencies); - - return { - result: { - ...theme, - layers: layers - }, - errors, - warnings - }; - } -} - -class SetDefault extends DesugaringStep { - private readonly value: any; - private readonly key: string; - private readonly _overrideEmptyString: boolean; - - constructor(key: string, value: any, overrideEmptyString = false) { - super("Sets " + key + " to a default value if undefined"); - this.key = key; - this.value = value; - this._overrideEmptyString = overrideEmptyString; - } - - convert(state: DesugaringContext, json: T, context: string): { result: T; errors: string[]; warnings: string[] } { - if (json[this.key] === undefined || (json[this.key] === "" && this._overrideEmptyString)) { - json = {...json} - json[this.key] = this.value - } - - return { - errors: [], warnings: [], - result: json - }; - } -} - -export class PrepareLayer extends Fuse { - constructor() { - super( - "Fully prepares and expands a layer for the LayerConfig.", - new OnEveryConcat("tagRenderings", new ExpandGroupRewrite()), - new OnEveryConcat("tagRenderings", new ExpandTagRendering()), - new SetDefault("titleIcons", ["defaults"]), - new OnEveryConcat("titleIcons", new ExpandTagRendering()) - ); - } -} - -class SubstituteLayer extends Conversion<(string | LayerConfigJson), LayerConfigJson[]> { - constructor() { - super("Converts the identifier of a builtin layer into the actual layer, or converts a 'builtin' syntax with override in the fully expanded form", []); - } - - convert(state: DesugaringContext, json: string | LayerConfigJson, context: string): { result: LayerConfigJson[]; errors: string[]; warnings: string[] } { - const errors = [] - const warnings = [] - if (typeof json === "string") { - const found = state.sharedLayers.get(json) - if (found === undefined) { - return { - result: null, - errors: [context + ": The layer with name " + json + " was not found as a builtin layer"], - warnings - } - } - return { - result: [found], - errors, warnings - } - } - - if (json["builtin"] !== undefined) { - let names = json["builtin"] - if (typeof names === "string") { - names = [names] - } - const layers = [] - for (const name of names) { - const found = Utils.Clone(state.sharedLayers.get(name)) - if (found === undefined) { - errors.push(context + ": The layer with name " + json + " was not found as a builtin layer") - continue - } - if (json["override"]["tagRenderings"] !== undefined && (found["tagRenderings"] ?? []).length > 0) { - errors.push(`At ${context}: when overriding a layer, an override is not allowed to override into tagRenderings. Use "+tagRenderings" or "tagRenderings+" instead to prepend or append some questions.`) - } - try { - Utils.Merge(json["override"], found); - layers.push(found) - } catch (e) { - errors.push(`At ${context}: could not apply an override due to: ${e}.\nThe override is: ${JSON.stringify(json["override"],)}`) - } - } - return { - result: layers, - errors, warnings - } - - } - - return { - result: [json], - errors, warnings - }; - } - -} - -class AddDefaultLayers extends DesugaringStep { - - constructor() { - super("Adds the default layers, namely: " + Constants.added_by_default.join(", "), ["layers"]); - } - - convert(state: DesugaringContext, json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } { - const errors = [] - const warnings = [] - json.layers = [...json.layers] - - if (json.id === "personal") { - json.layers = [] - for (const publicLayer of AllKnownLayouts.AllPublicLayers()) { - const id = publicLayer.id - const config = state.sharedLayers.get(id) - if(Constants.added_by_default.indexOf(id) >= 0){ - continue; - } - if(config === undefined){ - // This is a layer which is coded within a public theme, not as separate .json - continue - } - json.layers.push(config) - } - const publicIds = AllKnownLayouts.AllPublicLayers().map(l => l.id) - publicIds.map(id => state.sharedLayers.get(id)) - } - - for (const layerName of Constants.added_by_default) { - const v = state.sharedLayers.get(layerName) - if (v === undefined) { - errors.push("Default layer " + layerName + " not found") - } - json.layers.push(v) - } - - return { - result: json, - errors, - warnings - }; - } - -} - -export class PrepareTheme extends Fuse { - constructor() { - super( - "Fully prepares and expands a theme", - new OnEveryConcat("layers", new SubstituteLayer()), - new SetDefault("socialImage", "assets/SocialImage.png", true), - new AddDefaultLayers(), - new AddDependencyLayersToTheme(), - new OnEvery("layers", new PrepareLayer()), - new OnEvery("layers", new AddMiniMap()) - ); - } -} \ No newline at end of file diff --git a/Models/ThemeConfig/Conversion/PrepareLayer.ts b/Models/ThemeConfig/Conversion/PrepareLayer.ts new file mode 100644 index 000000000..deea492e7 --- /dev/null +++ b/Models/ThemeConfig/Conversion/PrepareLayer.ts @@ -0,0 +1,252 @@ +import {Conversion, DesugaringContext, Fuse, OnEveryConcat, SetDefault} from "./Conversion"; +import {LayerConfigJson} from "../Json/LayerConfigJson"; +import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"; +import {Utils} from "../../../Utils"; + +class ExpandTagRendering extends Conversion { + constructor() { + super("Converts a tagRenderingSpec into the full tagRendering", []); + } + + convert(state: DesugaringContext, json: string | TagRenderingConfigJson | { builtin: string | string[]; override: any }, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } { + const errors = [] + const warnings = [] + + return { + result: this.convertUntilStable(state, json, warnings, errors, context), + errors, warnings + }; + } + + private lookup(state: DesugaringContext, name: string): TagRenderingConfigJson[] { + if (state.tagRenderings.has(name)) { + return [state.tagRenderings.get(name)] + } + if (name.indexOf(".") >= 0) { + const spl = name.split("."); + const layer = state.sharedLayers.get(spl[0]) + if (spl.length === 2 && layer !== undefined) { + const id = spl[1]; + + const layerTrs = layer.tagRenderings.filter(tr => tr["id"] !== undefined) + let matchingTrs: TagRenderingConfigJson[] + if (id === "*") { + matchingTrs = layerTrs + } else if (id.startsWith("*")) { + const id_ = id.substring(1) + matchingTrs = layerTrs.filter(tr => tr.group === id_) + } else { + matchingTrs = layerTrs.filter(tr => tr.id === id) + } + + + for (let i = 0; i < matchingTrs.length; i++) { + // The matched tagRenderings are 'stolen' from another layer. This means that they must match the layer condition before being shown + const found = Utils.Clone(matchingTrs[i]); + if (found.condition === undefined) { + found.condition = layer.source.osmTags + } else { + found.condition = {and: [found.condition, layer.source.osmTags]} + } + matchingTrs[i] = found + } + + if (matchingTrs.length !== 0) { + return matchingTrs + } + } + } + return undefined; + } + + private convertOnce(state: DesugaringContext, tr: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] { + if (tr === "questions") { + return [{ + id: "questions" + }] + } + + + if (typeof tr === "string") { + const lookup = this.lookup(state, tr); + if (lookup !== undefined) { + return lookup + } + warnings.push(ctx + "A literal rendering was detected: " + tr) + return [{ + render: tr, + id: tr.replace(/![a-zA-Z0-9]/g, "") + }] + } + + if (tr["builtin"] !== undefined) { + let names = tr["builtin"] + if (typeof names === "string") { + names = [names] + } + + for (const key of Object.keys(tr)) { + if (key === "builtin" || key === "override" || key === "id" || key.startsWith("#")) { + continue + } + errors.push("At " + ctx + ": an object calling a builtin can only have keys `builtin` or `override`, but a key with name `" + key + "` was found. This won't be picked up! The full object is: " + JSON.stringify(tr)) + } + + const trs: TagRenderingConfigJson[] = [] + for (const name of names) { + const lookup = this.lookup(state, name) + if (lookup === undefined) { + errors.push(ctx + ": The tagRendering with identifier " + name + " was not found.\n\tDid you mean one of " + Array.from(state.tagRenderings.keys()).join(", ") + "?") + continue + } + for (let foundTr of lookup) { + foundTr = Utils.Clone(foundTr) + Utils.Merge(tr["override"] ?? {}, foundTr) + trs.push(foundTr) + } + } + return trs; + } + + return [tr] + } + + private convertUntilStable(state: DesugaringContext, spec: string | any, warnings: string[], errors: string[], ctx: string): TagRenderingConfigJson[] { + const trs = this.convertOnce(state, spec, warnings, errors, ctx); + + const result = [] + for (const tr of trs) { + if (tr["builtin"] !== undefined) { + const stable = this.convertUntilStable(state, tr, warnings, errors, ctx + "(RECURSIVE RESOLVE)") + result.push(...stable) + } else { + result.push(tr) + } + } + + return result; + } +} + +class ExpandGroupRewrite extends Conversion<{ + rewrite: { + sourceString: string, + into: string[] + }[], + renderings: (string | { builtin: string, override: any } | TagRenderingConfigJson)[] +} | TagRenderingConfigJson, TagRenderingConfigJson[]> { + + + private static expandSubTagRenderings = new ExpandTagRendering() + + constructor() { + super( + "Converts a rewrite config for tagRenderings into the expanded form" + ); + } + + convert(state: DesugaringContext, json: + { + rewrite: + { sourceString: string; into: string[] }[]; renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[] + } | TagRenderingConfigJson, context: string): { result: TagRenderingConfigJson[]; errors: string[]; warnings: string[] } { + + if (json["rewrite"] === undefined) { + return {result: [json], errors: [], warnings: []} + } + let config = <{ + rewrite: + { sourceString: string; into: string[] }[]; + renderings: (string | { builtin: string; override: any } | TagRenderingConfigJson)[] + }>json; + + + const subRenderingsRes = ExpandGroupRewrite.expandSubTagRenderings.convertAll(state, config.renderings, context); + const subRenderings: TagRenderingConfigJson[] = [].concat(subRenderingsRes.result); + const errors = subRenderingsRes.errors; + const warnings = subRenderingsRes.warnings; + + + const rewrittenPerGroup = new Map() + + // The actual rewriting + for (const rewrite of config.rewrite) { + const source = rewrite.sourceString; + for (const target of rewrite.into) { + const groupName = target; + const trs: TagRenderingConfigJson[] = [] + + for (const tr of subRenderings) { + trs.push(this.prepConfig(source, target, tr)) + } + if (rewrittenPerGroup.has(groupName)) { + rewrittenPerGroup.get(groupName).push(...trs) + + } else { + rewrittenPerGroup.set(groupName, trs) + + } + } + } + + // Add questions box for this category + rewrittenPerGroup.forEach((group, groupName) => { + group.push({ + id: "questions", + group: groupName + }) + }) + + + rewrittenPerGroup.forEach((group, _) => { + group.forEach(tr => { + if (tr.id === undefined || tr.id === "") { + errors.push("A tagrendering has an empty ID after expanding the tag") + } + }) + }) + + return { + result: [].concat(...Array.from(rewrittenPerGroup.values())), + errors, warnings + }; + } + + /* Used for left|right group creation and replacement */ + private prepConfig(keyToRewrite: string, target: string, tr: TagRenderingConfigJson) { + + function replaceRecursive(transl: string | any) { + if (typeof transl === "string") { + return transl.replace(keyToRewrite, target) + } + if (transl.map !== undefined) { + return transl.map(o => replaceRecursive(o)) + } + transl = {...transl} + for (const key in transl) { + transl[key] = replaceRecursive(transl[key]) + } + return transl + } + + const orig = tr; + tr = replaceRecursive(tr) + + tr.id = target + "-" + orig.id + tr.group = target + return tr + } +} + + +export class PrepareLayer extends Fuse { + constructor() { + super( + "Fully prepares and expands a layer for the LayerConfig.", + new OnEveryConcat("tagRenderings", new ExpandGroupRewrite()), + new OnEveryConcat("tagRenderings", new ExpandTagRendering()), + new SetDefault("titleIcons", ["defaults"]), + new OnEveryConcat("titleIcons", new ExpandTagRendering()) + ); + } +} \ No newline at end of file diff --git a/Models/ThemeConfig/Conversion/PrepareTheme.ts b/Models/ThemeConfig/Conversion/PrepareTheme.ts new file mode 100644 index 000000000..9d92d8076 --- /dev/null +++ b/Models/ThemeConfig/Conversion/PrepareTheme.ts @@ -0,0 +1,316 @@ +import {Conversion, DesugaringContext, DesugaringStep, Fuse, OnEvery, OnEveryConcat, SetDefault} from "./Conversion"; +import {LayoutConfigJson} from "../Json/LayoutConfigJson"; +import {PrepareLayer} from "./PrepareLayer"; +import {LayerConfigJson} from "../Json/LayerConfigJson"; +import {Utils} from "../../../Utils"; +import Constants from "../../Constants"; +import {AllKnownLayouts} from "../../../Customizations/AllKnownLayouts"; +import CreateNoteImportLayer from "./CreateNoteImportLayer"; +import LayerConfig from "../LayerConfig"; +import {TagRenderingConfigJson} from "../Json/TagRenderingConfigJson"; +import {Translation} from "../../../UI/i18n/Translation"; +import {SubstitutedTranslation} from "../../../UI/SubstitutedTranslation"; +import DependencyCalculator from "../DependencyCalculator"; + +class SubstituteLayer extends Conversion<(string | LayerConfigJson), LayerConfigJson[]> { + constructor() { + super("Converts the identifier of a builtin layer into the actual layer, or converts a 'builtin' syntax with override in the fully expanded form", []); + } + + convert(state: DesugaringContext, json: string | LayerConfigJson, context: string): { result: LayerConfigJson[]; errors: string[]; warnings: string[] } { + const errors = [] + const warnings = [] + if (typeof json === "string") { + const found = state.sharedLayers.get(json) + if (found === undefined) { + return { + result: null, + errors: [context + ": The layer with name " + json + " was not found as a builtin layer"], + warnings + } + } + return { + result: [found], + errors, warnings + } + } + + if (json["builtin"] !== undefined) { + let names = json["builtin"] + if (typeof names === "string") { + names = [names] + } + const layers = [] + for (const name of names) { + const found = Utils.Clone(state.sharedLayers.get(name)) + if (found === undefined) { + errors.push(context + ": The layer with name " + json + " was not found as a builtin layer") + continue + } + if (json["override"]["tagRenderings"] !== undefined && (found["tagRenderings"] ?? []).length > 0) { + errors.push(`At ${context}: when overriding a layer, an override is not allowed to override into tagRenderings. Use "+tagRenderings" or "tagRenderings+" instead to prepend or append some questions.`) + } + try { + Utils.Merge(json["override"], found); + layers.push(found) + } catch (e) { + errors.push(`At ${context}: could not apply an override due to: ${e}.\nThe override is: ${JSON.stringify(json["override"],)}`) + } + } + return { + result: layers, + errors, warnings + } + + } + + return { + result: [json], + errors, warnings + }; + } + +} + +class AddDefaultLayers extends DesugaringStep { + + constructor() { + super("Adds the default layers, namely: " + Constants.added_by_default.join(", "), ["layers"]); + } + + convert(state: DesugaringContext, json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } { + const errors = [] + const warnings = [] + json.layers = [...json.layers] + + if (json.id === "personal") { + json.layers = [] + for (const publicLayer of AllKnownLayouts.AllPublicLayers()) { + const id = publicLayer.id + const config = state.sharedLayers.get(id) + if (Constants.added_by_default.indexOf(id) >= 0) { + continue; + } + if (config === undefined) { + // This is a layer which is coded within a public theme, not as separate .json + continue + } + json.layers.push(config) + } + const publicIds = AllKnownLayouts.AllPublicLayers().map(l => l.id) + publicIds.map(id => state.sharedLayers.get(id)) + } + + for (const layerName of Constants.added_by_default) { + const v = state.sharedLayers.get(layerName) + if (v === undefined) { + errors.push("Default layer " + layerName + " not found") + } + json.layers.push(v) + } + + return { + result: json, + errors, + warnings + }; + } + +} + +class AddImportLayers extends DesugaringStep { + constructor() { + super("For every layer in the 'layers'-list, create a new layer which'll import notes. (Note that priviliged layers and layers which have a geojson-source set are ignored)", ["layers"]); + } + + convert(state: DesugaringContext, json: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } { + const errors = [] + const warnings = [] + + json = {...json} + const allLayers: LayerConfigJson[] = json.layers; + json.layers = [...json.layers] + + + const creator = new CreateNoteImportLayer() + for (let i1 = 0; i1 < allLayers.length; i1++) { + const layer = allLayers[i1]; + if (Constants.priviliged_layers.indexOf(layer.id) >= 0) { + // Priviliged layers are skipped + continue + } + + if (layer.source["geoJson"] !== undefined) { + // Layer which don't get their data from OSM are skipped + continue + } + + if (layer.title === undefined || layer.name === undefined) { + // Anonymous layers and layers without popup are skipped + continue + } + + if (layer.presets === undefined || layer.presets.length == 0) { + // A preset is needed to be able to generate a new point + continue; + } + + try { + + const importLayerResult = creator.convert(state, layer, context + ".(noteimportlayer)[" + i1 + "]") + errors.push(...importLayerResult.errors) + warnings.push(...importLayerResult.warnings) + if (importLayerResult.result !== undefined) { + warnings.push("Added an import layer to theme " + json.id + ", namely " + importLayerResult.result.id) + json.layers.push(importLayerResult.result) + } + } catch (e) { + errors.push("Could not generate an import-layer for " + layer.id + " due to " + e) + } + } + + + return { + errors, + warnings, + result: json + }; + } +} + +class AddMiniMap extends DesugaringStep { + constructor() { + super("Adds a default 'minimap'-element to the tagrenderings if none of the elements define such a minimap", ["tagRenderings"]); + } + + /** + * Returns true if this tag rendering has a minimap in some language. + * Note: this minimap can be hidden by conditions + */ + private static hasMinimap(renderingConfig: TagRenderingConfigJson): boolean { + const translations: Translation[] = Utils.NoNull([renderingConfig.render, ...(renderingConfig.mappings ?? []).map(m => m.then)]); + for (const translation of translations) { + for (const key in translation.translations) { + if (!translation.translations.hasOwnProperty(key)) { + continue + } + const template = translation.translations[key] + const parts = SubstitutedTranslation.ExtractSpecialComponents(template) + const hasMiniMap = parts.filter(part => part.special !== undefined).some(special => special.special.func.funcName === "minimap") + if (hasMiniMap) { + return true; + } + } + } + return false; + } + + convert(state: DesugaringContext, layerConfig: LayerConfigJson, context: string): { result: LayerConfigJson; errors: string[]; warnings: string[] } { + + + const hasMinimap = layerConfig.tagRenderings?.some(tr => AddMiniMap.hasMinimap(tr)) ?? true + if (!hasMinimap) { + layerConfig = {...layerConfig} + layerConfig.tagRenderings = [...layerConfig.tagRenderings] + layerConfig.tagRenderings.push(state.tagRenderings.get("minimap")) + } + + return { + errors: [], + warnings: [], + result: layerConfig + }; + } +} + +class AddDependencyLayersToTheme extends DesugaringStep { + constructor() { + super("If a layer has a dependency on another layer, these layers are added automatically on the theme. (For example: defibrillator depends on 'walls_and_buildings' to snap onto. This layer is added automatically)", ["layers"]); + } + + private static CalculateDependencies(alreadyLoaded: LayerConfigJson[], allKnownLayers: Map, themeId: string): LayerConfigJson[] { + const dependenciesToAdd: LayerConfigJson[] = [] + const loadedLayerIds: Set = new Set(alreadyLoaded.map(l => l.id)); + + // Verify cross-dependencies + let unmetDependencies: { neededLayer: string, neededBy: string, reason: string, context?: string }[] = [] + do { + const dependencies: { neededLayer: string, reason: string, context?: string, neededBy: string }[] = [] + + for (const layerConfig of alreadyLoaded) { + const layerDeps = DependencyCalculator.getLayerDependencies(new LayerConfig(layerConfig)) + dependencies.push(...layerDeps) + } + + // During the generate script, builtin layers are verified but not loaded - so we have to add them manually here + // Their existance is checked elsewhere, so this is fine + unmetDependencies = dependencies.filter(dep => !loadedLayerIds.has(dep.neededLayer)) + for (const unmetDependency of unmetDependencies) { + if (loadedLayerIds.has(unmetDependency.neededLayer)) { + continue + } + const dep = allKnownLayers.get(unmetDependency.neededLayer) + if (dep === undefined) { + const message = + ["Loading a dependency failed: layer " + unmetDependency.neededLayer + " is not found, neither as layer of " + themeId + " nor as builtin layer.", + "This layer is needed by " + unmetDependency.neededBy, + unmetDependency.reason + " (at " + unmetDependency.context + ")", + "Loaded layers are: " + alreadyLoaded.map(l => l.id).join(",") + + ] + throw message.join("\n\t"); + } + dependenciesToAdd.unshift(dep) + loadedLayerIds.add(dep.id); + unmetDependencies = unmetDependencies.filter(d => d.neededLayer !== unmetDependency.neededLayer) + } + + } while (unmetDependencies.length > 0) + + return dependenciesToAdd; + } + + convert(state: DesugaringContext, theme: LayoutConfigJson, context: string): { result: LayoutConfigJson; errors: string[]; warnings: string[] } { + const allKnownLayers: Map = state.sharedLayers; + const knownTagRenderings: Map = state.tagRenderings; + const errors = []; + const warnings = []; + const layers: LayerConfigJson[] = theme.layers; // Layers should be expanded at this point + + knownTagRenderings.forEach((value, key) => { + value.id = key; + }) + + const dependencies = AddDependencyLayersToTheme.CalculateDependencies(layers, allKnownLayers, theme.id); + if (dependencies.length > 0) { + + warnings.push(context + ": added " + dependencies.map(d => d.id).join(", ") + " to the theme as they are needed") + } + layers.unshift(...dependencies); + + return { + result: { + ...theme, + layers: layers + }, + errors, + warnings + }; + } +} + + +export class PrepareTheme extends Fuse { + constructor() { + super( + "Fully prepares and expands a theme", + new OnEveryConcat("layers", new SubstituteLayer()), + new SetDefault("socialImage", "assets/SocialImage.png", true), + new AddDefaultLayers(), + new AddDependencyLayersToTheme(), + new OnEvery("layers", new PrepareLayer()), + new AddImportLayers(), + new OnEvery("layers", new AddMiniMap()) + ); + } +} \ No newline at end of file diff --git a/UI/Base/Loading.ts b/UI/Base/Loading.ts index e577f2847..9fe03da94 100644 --- a/UI/Base/Loading.ts +++ b/UI/Base/Loading.ts @@ -1,11 +1,11 @@ -import {Translation} from "../i18n/Translation"; import Combine from "./Combine"; import Svg from "../../Svg"; import Translations from "../i18n/Translations"; +import BaseUIElement from "../BaseUIElement"; export default class Loading extends Combine { - constructor(msg?: Translation | string) { - const t = Translations.T(msg) ?? Translations.t.general.loading.Clone(); + constructor(msg?: BaseUIElement | string) { + const t = Translations.W(msg) ?? Translations.t.general.loading; t.SetClass("pl-2") super([ Svg.loading_svg().SetClass("animate-spin").SetStyle("width: 1.5rem; height: 1.5rem;"), diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 7466f65ce..09f11130a 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -37,7 +37,7 @@ export default class Minimap { /** * Construct a minimap */ - public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => { + public static createMiniMap: (options?: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => { throw "CreateMinimap hasn't been initialized yet. Please call MinimapImplementation.initialize()" } diff --git a/UI/Base/MinimapImplementation.ts b/UI/Base/MinimapImplementation.ts index e261650f3..be8067bd7 100644 --- a/UI/Base/MinimapImplementation.ts +++ b/UI/Base/MinimapImplementation.ts @@ -28,7 +28,7 @@ export default class MinimapImplementation extends BaseUIElement implements Mini private readonly _addLayerControl: boolean; private readonly _options: MinimapOptions; - private constructor(options: MinimapOptions) { + private constructor(options?: MinimapOptions) { super() options = options ?? {} this.leafletMap = options.leafletMap ?? new UIEventSource(undefined) @@ -290,12 +290,6 @@ export default class MinimapImplementation extends BaseUIElement implements Mini map.setView([loc.lat, loc.lon], loc.zoom) }) - location.map(loc => loc.zoom) - .addCallback(zoom => { - if (Math.abs(map.getZoom() - zoom) > 0.1) { - map.setZoom(zoom, {}); - } - }) if (self.bounds !== undefined) { self.bounds.setData(BBox.fromLeafletBounds(map.getBounds())) diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 56c7e9b6e..86ebb251f 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -43,6 +43,7 @@ export default class SimpleAddUI extends Toggle { constructor(isShown: UIEventSource, filterViewIsOpened: UIEventSource, state: { + featureSwitchIsTesting: UIEventSource, layoutToUse: LayoutConfig, osmConnection: OsmConnection, changes: Changes, @@ -155,6 +156,7 @@ export default class SimpleAddUI extends Toggle { private static CreateAllPresetsPanel(selectedPreset: UIEventSource, state: { + featureSwitchIsTesting: UIEventSource; filteredLayers: UIEventSource, featureSwitchFilter: UIEventSource, osmConnection: OsmConnection @@ -162,10 +164,9 @@ export default class SimpleAddUI extends Toggle { const presetButtons = SimpleAddUI.CreatePresetButtons(state, selectedPreset) let intro: BaseUIElement = Translations.t.general.add.intro; - let testMode: BaseUIElement = undefined; - if (state.osmConnection?.userDetails?.data?.dryRun) { - testMode = Translations.t.general.testing.Clone().SetClass("alert") - } + let testMode: BaseUIElement = new Toggle(Translations.t.general.testing.SetClass("alert"), + undefined, + state.featureSwitchIsTesting); return new Combine([intro, testMode, presetButtons]).SetClass("flex flex-col") diff --git a/UI/BigComponents/UserBadge.ts b/UI/BigComponents/UserBadge.ts index 19a1e86c5..b9ac3459a 100644 --- a/UI/BigComponents/UserBadge.ts +++ b/UI/BigComponents/UserBadge.ts @@ -73,10 +73,11 @@ export default class UserBadge extends Toggle { ).SetClass("alert") } - let dryrun = new FixedUiElement(""); - if (user.dryRun) { - dryrun = new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4"); - } + let dryrun = new Toggle( + new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4"), + undefined, + state.featureSwitchIsTesting + ) const settings = new Link(Svg.gear, diff --git a/UI/ImportFlow/AskMetadata.ts b/UI/ImportFlow/AskMetadata.ts new file mode 100644 index 000000000..87875f250 --- /dev/null +++ b/UI/ImportFlow/AskMetadata.ts @@ -0,0 +1,100 @@ +import Combine from "../Base/Combine"; +import {FlowStep} from "./FlowStep"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import ValidatedTextField from "../Input/ValidatedTextField"; +import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource"; +import Title from "../Base/Title"; +import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts"; +import {DropDown} from "../Input/DropDown"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import BaseUIElement from "../BaseUIElement"; +import {FixedUiElement} from "../Base/FixedUiElement"; + +export class AskMetadata extends Combine implements FlowStep<{ + features: any[], + wikilink: string, + intro: string, + source: string, + theme: string +}> { + + public readonly Value: UIEventSource<{ + features: any[], + wikilink: string, + intro: string, + source: string, + theme: string + }>; + public readonly IsValid: UIEventSource; + + constructor(params: ({ features: any[], layer: LayerConfig })) { + + const introduction = ValidatedTextField.InputForType("text", { + value: LocalStorageSource.Get("import-helper-introduction-text"), + inputStyle: "width: 100%" + }) + + const wikilink = ValidatedTextField.InputForType("string", { + value: LocalStorageSource.Get("import-helper-wikilink-text"), + inputStyle: "width: 100%" + }) + + const source = ValidatedTextField.InputForType("string", { + value: LocalStorageSource.Get("import-helper-source-text"), + inputStyle: "width: 100%" + }) + + let options : {value: string, shown: BaseUIElement}[]= AllKnownLayouts.layoutsList + .filter(th => th.layers.some(l => l.id === params.layer.id)) + .filter(th => th.id !== "personal") + .map(th => ({ + value: th.id, + shown: th.title + })) + + options.splice(0,0, { + shown: new FixedUiElement("Select a theme"), + value: undefined + }) + + const theme = new DropDown("Which theme should be linked in the note?",options) + + ValidatedTextField.InputForType("string", { + value: LocalStorageSource.Get("import-helper-theme-text"), + inputStyle: "width: 100%" + }) + + super([ + new Title("Set metadata"), + "Before adding " + params.features.length + " notes, please provide some extra information.", + "Please, write an introduction for someone who sees the note", + introduction.SetClass("w-full border border-black"), + "What is the source of this data? If 'source' is set in the feature, this value will be ignored", + source.SetClass("w-full border border-black"), + "On what wikipage can one find more information about this import?", + wikilink.SetClass("w-full border border-black"), + theme + ]); + this.SetClass("flex flex-col") + + this.Value = introduction.GetValue().map(intro => { + return { + features: params.features, + wikilink: wikilink.GetValue().data, + intro, + source: source.GetValue().data, + theme: theme.GetValue().data + + } + }, [wikilink.GetValue(), source.GetValue(), theme.GetValue()]) + + this.IsValid = this.Value.map(obj => { + if(obj === undefined){ + return false; + } + return obj.theme !== undefined && obj.features !== undefined && obj.wikilink !== undefined && obj.intro !== undefined && obj.source !== undefined; + }) + } + + +} \ No newline at end of file diff --git a/UI/ImportFlow/CompareToAlreadyExistingNotes.ts b/UI/ImportFlow/CompareToAlreadyExistingNotes.ts new file mode 100644 index 000000000..5b48ef6a0 --- /dev/null +++ b/UI/ImportFlow/CompareToAlreadyExistingNotes.ts @@ -0,0 +1,134 @@ +import Combine from "../Base/Combine"; +import {FlowStep} from "./FlowStep"; +import {BBox} from "../../Logic/BBox"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {DesugaringContext} from "../../Models/ThemeConfig/Conversion/Conversion"; +import CreateNoteImportLayer from "../../Models/ThemeConfig/Conversion/CreateNoteImportLayer"; +import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; +import GeoJsonSource from "../../Logic/FeatureSource/Sources/GeoJsonSource"; +import MetaTagging from "../../Logic/MetaTagging"; +import RelationsTracker from "../../Logic/Osm/RelationsTracker"; +import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource"; +import Minimap from "../Base/Minimap"; +import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"; +import FeatureInfoBox from "../Popup/FeatureInfoBox"; +import {ImportUtils} from "./ImportUtils"; +import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json"; +import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"; +import Title from "../Base/Title"; +import Toggle from "../Input/Toggle"; +import Loading from "../Base/Loading"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import * as known_layers from "../../assets/generated/known_layers.json" +import {LayerConfigJson} from "../../Models/ThemeConfig/Json/LayerConfigJson"; + +/** + * Filters out points for which the import-note already exists, to prevent duplicates + */ +export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, geojson: any }> { + + public IsValid: UIEventSource + public Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, geojson: any }> + + + constructor(state, params: { bbox: BBox, layer: LayerConfig, geojson: { features: any[] } }) { + + const convertState: DesugaringContext = { + sharedLayers: new Map(), + tagRenderings: new Map() + } + + const layerConfig = known_layers.filter(l => l.id === params.layer.id)[0] + const importLayerJson = new CreateNoteImportLayer(365).convertStrict(convertState, layerConfig, "CompareToAlreadyExistingNotes") + const importLayer = new LayerConfig(importLayerJson, "import-layer-dynamic") + const flayer: FilteredLayer = { + appliedFilters: new UIEventSource>(new Map()), + isDisplayed: new UIEventSource(true), + layerDef: importLayer + } + const unfiltered = new GeoJsonSource(flayer, params.bbox.padAbsolute(0.0001)) + unfiltered.features.map(f => MetaTagging.addMetatags( + f, + { + memberships: new RelationsTracker(), + getFeaturesWithin: (layerId, bbox: BBox) => [], + getFeatureById: (id: string) => undefined + }, + importLayer, + state, + { + includeDates: true, + // We assume that the non-dated metatags are already set by the cache generator + includeNonDates: true + } + ) + ) + const data = new FilteringFeatureSource(state, undefined, unfiltered) + data.features.addCallbackD(features => console.log("Loaded and filtered features are", features)) + const map = Minimap.createMiniMap() + map.SetClass("w-full").SetStyle("height: 500px") + + const comparison = Minimap.createMiniMap({ + location: map.location, + + }) + comparison.SetClass("w-full").SetStyle("height: 500px") + + new ShowDataLayer({ + layerToShow: importLayer, + state, + zoomToFeatures: true, + leafletMap: map.leafletMap, + features: data, + popup: (tags, layer) => new FeatureInfoBox(tags, layer, state) + }) + + + const maxDistance = new UIEventSource(5) + + const partitionedImportPoints = ImportUtils.partitionFeaturesIfNearby(params.geojson, data.features + .map(ff => ({features: ff.map(ff => ff.feature)})), maxDistance) + + + new ShowDataLayer({ + layerToShow: new LayerConfig(import_candidate), + state, + zoomToFeatures: true, + leafletMap: comparison.leafletMap, + features: new StaticFeatureSource(partitionedImportPoints.map(p => p.hasNearby), false), + popup: (tags, layer) => new FeatureInfoBox(tags, layer, state) + }) + + super([ + new Title("Compare with already existing 'to-import'-notes"), + new Toggle( + new Loading("Fetching notes from OSM"), + new Combine([ + map, + "The following (red) elements are elements to import which are nearby a matching element that is already up for import. These won't be imported", + + new Toggle( + new FixedUiElement("All of the proposed points have (or had) an import note already").SetClass("alert w-full block").SetStyle("padding: 0.5rem"), + new VariableUiElement(partitionedImportPoints.map(({noNearby}) => noNearby.length + " elements can be imported")).SetClass("thanks p-8"), + partitionedImportPoints.map(({noNearby}) => noNearby.length === 0) + ).SetClass("w-full"), + comparison, + ]).SetClass("flex flex-col"), + unfiltered.features.map(ff => ff === undefined || ff.length === 0) + ), + + + ]); + this.SetClass("flex flex-col") + this.Value = partitionedImportPoints.map(({noNearby}) => ({ + geojson: {features: noNearby, type: "FeatureCollection"}, + bbox: params.bbox, + layer: params.layer + })) + + this.IsValid = data.features.map(ff => ff.length > 0 && partitionedImportPoints.data.noNearby.length > 0, [partitionedImportPoints]) + } + +} \ No newline at end of file diff --git a/UI/ImportFlow/ConfirmProcess.ts b/UI/ImportFlow/ConfirmProcess.ts new file mode 100644 index 000000000..d541b9480 --- /dev/null +++ b/UI/ImportFlow/ConfirmProcess.ts @@ -0,0 +1,32 @@ +import Combine from "../Base/Combine"; +import {FlowStep} from "./FlowStep"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import Link from "../Base/Link"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import CheckBoxes from "../Input/Checkboxes"; +import Title from "../Base/Title"; + +export class ConfirmProcess extends Combine implements FlowStep { + + public IsValid: UIEventSource + public Value: UIEventSource + + constructor(v: T) { + + const toConfirm = [ + new Combine(["I have read the ", new Link("import guidelines on the OSM wiki", "https://wiki.openstreetmap.org/wiki/Import_guidelines", true)]), + new FixedUiElement("I did contact the (local) community about this import"), + new FixedUiElement("The license of the data to import allows it to be imported into OSM. They are allowed to be redistributed commercially, with only minimal attribution"), + new FixedUiElement("The process is documented on the OSM-wiki (you'll need this link later)") + ]; + + const licenseClear = new CheckBoxes(toConfirm) + super([ + new Title("Did you go through the import process?"), + licenseClear + ]); + this.SetClass("link-underline") + this.IsValid = licenseClear.GetValue().map(selected => toConfirm.length == selected.length) + this.Value = new UIEventSource(v) + } +} \ No newline at end of file diff --git a/UI/ImportFlow/ConflationChecker.ts b/UI/ImportFlow/ConflationChecker.ts index 0de95de1e..e2501b7b3 100644 --- a/UI/ImportFlow/ConflationChecker.ts +++ b/UI/ImportFlow/ConflationChecker.ts @@ -27,10 +27,12 @@ import * as currentview from "../../assets/layers/current_view/current_view.json import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json" import {GeoOperations} from "../../Logic/GeoOperations"; import FeatureInfoBox from "../Popup/FeatureInfoBox"; +import {ImportUtils} from "./ImportUtils"; + /** * Given the data to import, the bbox and the layer, will query overpass for similar items */ -export default class ConflationChecker extends Combine implements FlowStep { +export default class ConflationChecker extends Combine implements FlowStep<{features: any[], layer: LayerConfig}> { public readonly IsValid public readonly Value @@ -44,19 +46,21 @@ export default class ConflationChecker extends Combine implements FlowStep const layer = params.layer; const toImport = params.geojson; let overpassStatus = new UIEventSource<{ error: string } | "running" | "success" | "idle" | "cached" >("idle") - + const cacheAge = new UIEventSource(undefined); const fromLocalStorage = IdbLocalStorage.Get<[any, Date]>("importer-overpass-cache-" + layer.id, { whenLoaded: (v) => { if (v !== undefined) { console.log("Loaded from local storage:", v) const [geojson, date] = v; const timeDiff = (new Date().getTime() - date.getTime()) / 1000; - console.log("The cache is ", timeDiff, "seconds old") + console.log("Loaded ", geojson.features.length," features; cache is ", timeDiff, "seconds old") + cacheAge.setData(timeDiff) if (timeDiff < 24 * 60 * 60) { // Recently cached! overpassStatus.setData("cached") return; } + cacheAge.setData(-1) } // Load the data! const url = Constants.defaultOverpassUrls[1] @@ -115,7 +119,7 @@ export default class ConflationChecker extends Combine implements FlowStep layerToShow:new LayerConfig(currentview), state, leafletMap: osmLiveData.leafletMap, - enablePopups: undefined, + popup: undefined, zoomToFeatures: true, features: new StaticFeatureSource([ bbox.asGeoJson({}) @@ -161,17 +165,10 @@ export default class ConflationChecker extends Combine implements FlowStep toImport.features.some(imp => maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) ) }, [nearbyCutoff.GetValue()]), false); + const paritionedImport = ImportUtils.partitionFeaturesIfNearby(toImport, geojson, nearbyCutoff.GetValue().map(Number)); // Featuresource showing OSM-features which are nearby a toImport-feature - const toImportWithNearby = new StaticFeatureSource(geojson.map(osmData => { - if(osmData?.features === undefined){ - return [] - } - const maxDist = Number(nearbyCutoff.GetValue().data) - return toImport.features.filter(imp => - osmData.features.some(f => - maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) ) - }, [nearbyCutoff.GetValue()]), false); + const toImportWithNearby = new StaticFeatureSource(paritionedImport.map(els =>els?.hasNearby ?? []), false); new ShowDataLayer({ layerToShow:layer, @@ -192,6 +189,38 @@ export default class ConflationChecker extends Combine implements FlowStep }) + const conflationMaps = new Combine([ + new VariableUiElement( + geojson.map(geojson => { + if (geojson === undefined) { + return undefined; + } + return new SubtleButton(Svg.download_svg(), "Download the loaded geojson from overpass").onClick(() => { + Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "), "mapcomplete-" + layer.id + ".geojson", { + mimetype: "application/json+geo" + }) + }); + })), + new VariableUiElement(cacheAge.map(age => { + if(age === undefined){ + return undefined; + } + if(age < 0){ + return new FixedUiElement("Cache was expired") + } + return new FixedUiElement("Loaded data is from the cache and is "+Utils.toHumanTime(age)+" old") + })), + + new Title("Live data on OSM"), + osmLiveData, + new Combine(["The live data is shown if the zoomlevel is at least ", zoomLevel, ". The current zoom level is ", new VariableUiElement(osmLiveData.location.map(l => ""+l.zoom))]).SetClass("flex"), + + new Title("Nearby features"), + new Combine([ "The following map shows features to import which have an OSM-feature within ", nearbyCutoff, "meter"]).SetClass("flex"), + new FixedUiElement("The red elements on the following map will not be imported!").SetClass("alert"), + "Set the range to 0 or 1 if you want to import them all", + matchedFeaturesMap]).SetClass("flex flex-col") + super([ new Title("Comparison with existing data"), new VariableUiElement(overpassStatus.map(d => { @@ -205,38 +234,19 @@ export default class ConflationChecker extends Combine implements FlowStep return new Loading("Querying overpass...") } if(d === "cached"){ - return new FixedUiElement("Fetched data from local storage") + return conflationMaps } if(d === "success"){ - return new FixedUiElement("Data loaded") + return conflationMaps } return new FixedUiElement("Unexpected state "+d).SetClass("alert") - })), - new VariableUiElement( - geojson.map(geojson => { - if (geojson === undefined) { - return undefined; - } - return new SubtleButton(Svg.download_svg(), "Download the loaded geojson from overpass").onClick(() => { - Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "), "mapcomplete-" + layer.id + ".geojson", { - mimetype: "application/json+geo" - }) - }); - })), + })) - new Title("Live data on OSM"), - osmLiveData, - new Combine(["The live data is shown if the zoomlevel is at least ", zoomLevel, ". The current zoom level is ", new VariableUiElement(osmLiveData.location.map(l => ""+l.zoom))]).SetClass("flex"), - - new Title("Nearby features"), - new Combine([ "The following map shows features to import which have an OSM-feature within ", nearbyCutoff, "meter"]).SetClass("flex"), - new FixedUiElement("The red elements on the following map will not be imported!").SetClass("alert"), - "Set the range to 0 or 1 if you want to import them all", - matchedFeaturesMap ]) - this.IsValid = new UIEventSource(false) - this.Value = new UIEventSource(undefined) - } + this.Value = paritionedImport.map(feats => ({features: feats?.noNearby, layer: params.layer})) + this.Value.addCallbackAndRun(v => console.log("ConflationChecker-step value is ", v)) + this.IsValid = this.Value.map(v => v?.features !== undefined && v.features.length > 0) + } } \ No newline at end of file diff --git a/UI/ImportFlow/CreateNotes.ts b/UI/ImportFlow/CreateNotes.ts new file mode 100644 index 000000000..60379a164 --- /dev/null +++ b/UI/ImportFlow/CreateNotes.ts @@ -0,0 +1,82 @@ +import Combine from "../Base/Combine"; +import {OsmConnection} from "../../Logic/Osm/OsmConnection"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import Title from "../Base/Title"; +import Toggle from "../Input/Toggle"; +import Loading from "../Base/Loading"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import Link from "../Base/Link"; + +export class CreateNotes extends Combine { + + constructor(state: { osmConnection: OsmConnection }, v: { features: any[]; wikilink: string; intro: string; source: string, theme: string }) { + + const createdNotes: UIEventSource = new UIEventSource([]) + const failed = new UIEventSource([]) + const currentNote = createdNotes.map(n => n.length) + + for (const f of v.features) { + + const src = f.properties["source"] ?? f.properties["src"] ?? v.source + delete f.properties["source"] + delete f.properties["src"] + + const tags: string [] = [] + for (const key in f.properties) { + if(f.properties[key] === ""){ + continue + } + tags.push(key + "=" + f.properties[key].replace(/=/, "\\=").replace(/;/g, "\\;").replace(/\n/g, "\\n")) + } + const lat = f.geometry.coordinates[1] + const lon = f.geometry.coordinates[0] + const text = [v.intro, + '', + "Source: " + src, + 'More information at ' + v.wikilink, + '', + 'Import this point easily with', + `https://mapcomplete.osm.be/${v.theme}.html?z=18&lat=${lat}&lon=${lon}#import`, + ...tags].join("\n") + + state.osmConnection.openNote( + lat, lon, text) + .then(({id}) => { + createdNotes.data.push(id) + createdNotes.ping() + }, err => { + failed.data.push(err) + failed.ping() + }) + } + + super([ + new Title("Creating notes"), + "Hang on while we are importing...", + new Toggle( + new Loading(new VariableUiElement(currentNote.map(count => new FixedUiElement("Imported " + count + " out of " + v.features.length + " notes")))), + new FixedUiElement("All done!"), + currentNote.map(count => count < v.features.length) + ), + new VariableUiElement(failed.map(failed => { + + if (failed.length === 0) { + return undefined + } + return new Combine([ + new FixedUiElement("Some entries failed").SetClass("alert"), + ...failed + ]).SetClass("flex flex-col") + + })), + new VariableUiElement(createdNotes.map(notes => { + const links = notes.map(n => + new Link(new FixedUiElement("https://openstreetmap.org/note/" + n), "https://openstreetmap.org/note/" + n, true)); + return new Combine(links).SetClass("flex flex-col"); + })) + ]) + this.SetClass("flex flex-col"); + } + +} \ No newline at end of file diff --git a/UI/ImportFlow/DataPanel.ts b/UI/ImportFlow/DataPanel.ts index 85b3a9515..51f7cde23 100644 --- a/UI/ImportFlow/DataPanel.ts +++ b/UI/ImportFlow/DataPanel.ts @@ -21,7 +21,23 @@ import Table from "../Base/Table"; import {VariableUiElement} from "../Base/VariableUIElement"; import {FixedUiElement} from "../Base/FixedUiElement"; import {FlowStep} from "./FlowStep"; -import {Layer} from "leaflet"; +import ScrollableFullScreen from "../Base/ScrollableFullScreen"; +import {AllTagsPanel} from "../SpecialVisualizations"; +import Title from "../Base/Title"; + +class PreviewPanel extends ScrollableFullScreen { + + constructor(tags, layer) { + super( + _ => new FixedUiElement("Element to import"), + _ => new Combine(["The tags are:", + new AllTagsPanel(tags) + ]).SetClass("flex flex-col"), + "element" + ); + } + +} /** * Shows the data to import on a map, asks for the correct layer to be selected @@ -36,7 +52,6 @@ export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer: const t = Translations.t.importHelper; const propertyKeys = new Set() - console.log("Datapanel input got ", geojson) for (const f of geojson.features) { Object.keys(f.properties).forEach(key => propertyKeys.add(key)) } @@ -56,6 +71,7 @@ export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer: !layer.source.osmTags.matchesProperties(f.properties) ) if (!mismatched) { + console.log("Autodected layer", layer.id) layerPicker.GetValue().setData(layer); layerPicker.GetValue().addCallback(_ => autodetected.setData(false)) autodetected.setData(true) @@ -96,25 +112,22 @@ export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer: map.SetClass("w-full").SetStyle("height: 500px") new ShowDataMultiLayer({ - layers: new UIEventSource(AllKnownLayouts.AllPublicLayers().map(l => ({ + layers: new UIEventSource(AllKnownLayouts.AllPublicLayers() + .filter(l => l.source.geojsonSource === undefined) + .map(l => ({ layerDef: l, isDisplayed: new UIEventSource(true), appliedFilters: new UIEventSource>(undefined) }))), zoomToFeatures: true, features: new StaticFeatureSource(matching, false), - state: { - ...state, - filteredLayers: new UIEventSource(undefined), - backgroundLayer: background - }, leafletMap: map.leafletMap, - + popup: (tag, layer) => new PreviewPanel(tag, layer).SetClass("font-lg") }) var bbox = matching.map(feats => BBox.bboxAroundAll(feats.map(f => new BBox([f.geometry.coordinates])))) super([ - "Has " + geojson.features.length + " features", + new Title(geojson.features.length + " features to import"), layerPicker, new Toggle("Automatically detected layer", undefined, autodetected), new Table(["", "Key", "Values", "Unique values seen"], diff --git a/UI/ImportFlow/FlowStep.ts b/UI/ImportFlow/FlowStep.ts index 3b70e76ef..62509af15 100644 --- a/UI/ImportFlow/FlowStep.ts +++ b/UI/ImportFlow/FlowStep.ts @@ -8,7 +8,7 @@ import {VariableUiElement} from "../Base/VariableUIElement"; import Toggle from "../Input/Toggle"; import {UIElement} from "../UIElement"; -export interface FlowStep extends BaseUIElement{ +export interface FlowStep extends BaseUIElement { readonly IsValid: UIEventSource readonly Value: UIEventSource } @@ -16,70 +16,97 @@ export interface FlowStep extends BaseUIElement{ export class FlowPanelFactory { private _initial: FlowStep; private _steps: ((x: any) => FlowStep)[]; - private _stepNames: string[]; - - private constructor(initial: FlowStep, steps: ((x:any) => FlowStep)[], stepNames: string[]) { + private _stepNames: (string | BaseUIElement)[]; + + private constructor(initial: FlowStep, steps: ((x: any) => FlowStep)[], stepNames: (string | BaseUIElement)[]) { this._initial = initial; this._steps = steps; this._stepNames = stepNames; } - - public static start (step: FlowStep): FlowPanelFactory{ - return new FlowPanelFactory(step, [], []) + + public static start(name: string | BaseUIElement, step: FlowStep): FlowPanelFactory { + return new FlowPanelFactory(step, [], [name]) } - - public then(name: string, construct: ((t:T) => FlowStep)): FlowPanelFactory{ + + public then(name: string | BaseUIElement, construct: ((t: T) => FlowStep)): FlowPanelFactory { return new FlowPanelFactory( this._initial, this._steps.concat([construct]), this._stepNames.concat([name]) ) } - - public finish(construct: ((t: T, backButton?: BaseUIElement) => BaseUIElement)) : BaseUIElement { + + public finish(name: string | BaseUIElement, construct: ((t: T, backButton?: BaseUIElement) => BaseUIElement)): { + flow: BaseUIElement, + furthestStep: UIEventSource, + titles: (string | BaseUIElement)[] + } { + const furthestStep = new UIEventSource(0) // Construct all the flowpanels step by step (in reverse order) - const nextConstr : ((t:any, back?: UIElement) => BaseUIElement)[] = this._steps.map(_ => undefined) + const nextConstr: ((t: any, back?: UIElement) => BaseUIElement)[] = this._steps.map(_ => undefined) nextConstr.push(construct) - - for (let i = this._steps.length - 1; i >= 0; i--){ - const createFlowStep : (value) => FlowStep = this._steps[i]; + for (let i = this._steps.length - 1; i >= 0; i--) { + const createFlowStep: (value) => FlowStep = this._steps[i]; + const isConfirm = i == this._steps.length - 1; nextConstr[i] = (value, backButton) => { - console.log("Creating flowSTep ", this._stepNames[i]) const flowStep = createFlowStep(value) - return new FlowPanel(flowStep, nextConstr[i + 1], backButton); + furthestStep.setData(i + 1); + const panel = new FlowPanel(flowStep, nextConstr[i + 1], backButton, isConfirm); + panel.isActive.addCallbackAndRun(active => { + if (active) { + furthestStep.setData(i + 1); + } + }) + return panel } } - - return new FlowPanel(this._initial, nextConstr[0],undefined) + + const flow = new FlowPanel(this._initial, nextConstr[0]) + flow.isActive.addCallbackAndRun(active => { + if (active) { + furthestStep.setData(0); + } + }) + return { + flow, + furthestStep, + titles: this._stepNames + } } - + } export class FlowPanel extends Toggle { - + public isActive: UIEventSource + constructor( initial: (FlowStep), - constructNextstep: ((input: T, backButton: BaseUIElement) => BaseUIElement), - backbutton?: BaseUIElement + constructNextstep: ((input: T, backButton: BaseUIElement) => BaseUIElement), + backbutton?: BaseUIElement, + isConfirm = false ) { const t = Translations.t.general; - + const currentStepActive = new UIEventSource(true); - let nextStep: UIEventSource= new UIEventSource(undefined) + let nextStep: UIEventSource = new UIEventSource(undefined) const backButtonForNextStep = new SubtleButton(Svg.back_svg(), t.back).onClick(() => { currentStepActive.setData(true) }) - - let elements : (BaseUIElement | string)[] = [] - if(initial !== undefined){ + + let elements: (BaseUIElement | string)[] = [] + if (initial !== undefined) { // Startup the flow elements = [ initial, new Combine([ backbutton, new Toggle( - new SubtleButton(Svg.back_svg().SetStyle("transform: rotate(180deg);"), t.next).onClick(() => { + new SubtleButton( + isConfirm ? Svg.checkmark_svg() : + Svg.back_svg().SetStyle("transform: rotate(180deg);"), + isConfirm ? t.confirm : t.next + ).onClick(() => { const v = initial.Value.data; nextStep.setData(constructNextstep(v, backButtonForNextStep)) currentStepActive.setData(false) @@ -88,18 +115,18 @@ export class FlowPanel extends Toggle { initial.IsValid ) ]).SetClass("flex w-full justify-end space-x-2") - + ] } - - + + super( new Combine(elements).SetClass("h-full flex flex-col justify-between"), new VariableUiElement(nextStep), currentStepActive ); + this.isActive = currentStepActive } - - + } \ No newline at end of file diff --git a/UI/ImportFlow/ImportHelperGui.ts b/UI/ImportFlow/ImportHelperGui.ts index 09f139bdf..1faa95ac8 100644 --- a/UI/ImportFlow/ImportHelperGui.ts +++ b/UI/ImportFlow/ImportHelperGui.ts @@ -9,11 +9,18 @@ import MoreScreen from "../BigComponents/MoreScreen"; import MinimapImplementation from "../Base/MinimapImplementation"; import Translations from "../i18n/Translations"; import Constants from "../../Models/Constants"; -import {FlowPanel, FlowPanelFactory} from "./FlowStep"; +import {FlowPanelFactory} from "./FlowStep"; import {RequestFile} from "./RequestFile"; import {DataPanel} from "./DataPanel"; -import {FixedUiElement} from "../Base/FixedUiElement"; import ConflationChecker from "./ConflationChecker"; +import {AskMetadata} from "./AskMetadata"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import {ConfirmProcess} from "./ConfirmProcess"; +import {CreateNotes} from "./CreateNotes"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import List from "../Base/List"; +import {CompareToAlreadyExistingNotes} from "./CompareToAlreadyExistingNotes"; export default class ImportHelperGui extends LoginToggle { constructor() { @@ -24,28 +31,51 @@ export default class ImportHelperGui extends LoginToggle { // We disable the userbadge, as various 'showData'-layers will give a read-only view in this case state.featureSwitchUserbadge.setData(false) + const {flow, furthestStep, titles} = + FlowPanelFactory + .start("Select file", new RequestFile()) + .then("Inspect data", geojson => new DataPanel(state, geojson)) + .then("Compare with open notes", v => new CompareToAlreadyExistingNotes(state, v)) + .then("Compare with existing data", v => new ConflationChecker(state, v)) + .then("License and community check", v => new ConfirmProcess(v)) + .then("Metadata", (v:{features:any[], layer: LayerConfig}) => new AskMetadata(v)) + .finish("Note creation", v => new CreateNotes(state, v)); + + const toc = new List( + titles.map((title, i) => new VariableUiElement(furthestStep.map(currentStep => { + if(i > currentStep){ + return new Combine([title]).SetClass("subtle"); + } + if(i == currentStep){ + return new Combine([title]).SetClass("font-bold"); + } + if(i < currentStep){ + return title + } + + + }))) + , true) + const leftContents: BaseUIElement[] = [ new BackToIndex().SetClass("block pl-4"), + toc, + new Toggle(new FixedUiElement("Testmode - won't actually import notes").SetClass("alert"), undefined, state.featureSwitchIsTesting), LanguagePicker.CreateLanguagePicker(Translations.t.importHelper.title.SupportedLanguages())?.SetClass("mt-4 self-end flex-col"), ].map(el => el?.SetClass("pl-4")) const leftBar = new Combine([ - new Combine(leftContents).SetClass("sticky top-4 m-4") - ]).SetClass("block w-full md:w-2/6 lg:w-1/6") + new Combine(leftContents).SetClass("sticky top-4 m-4"), + ]).SetClass("block w-full md:w-2/6 lg:w-1/6") + + - const mainPanel = - FlowPanelFactory - .start(new RequestFile()) - .then("datapanel", geojson => new DataPanel(state, geojson)) - .then("conflation", v => new ConflationChecker(state, v)) - .finish(_ => new FixedUiElement("All done!")) - super( new Toggle( new Combine([ leftBar, - mainPanel.SetClass("m-8 w-full mb-24") + flow.SetClass("m-8 w-full mb-24") ]).SetClass("h-full block md:flex") , diff --git a/UI/ImportFlow/ImportUtils.ts b/UI/ImportFlow/ImportUtils.ts new file mode 100644 index 000000000..37296dd29 --- /dev/null +++ b/UI/ImportFlow/ImportUtils.ts @@ -0,0 +1,28 @@ +import {UIEventSource} from "../../Logic/UIEventSource"; +import {GeoOperations} from "../../Logic/GeoOperations"; + +export class ImportUtils { + public static partitionFeaturesIfNearby(toPartitionFeatureCollection: ({ features: any[] }), compareWith: UIEventSource<{ features: any[] }>, cutoffDistanceInMeters: UIEventSource): UIEventSource<{ hasNearby: any[], noNearby: any[] }> { + return compareWith.map(osmData => { + if (osmData?.features === undefined) { + return undefined + } + const maxDist = cutoffDistanceInMeters.data + + + const hasNearby = [] + const noNearby = [] + for (const toImportElement of toPartitionFeatureCollection.features) { + const hasNearbyFeature = osmData.features.some(f => + maxDist >= GeoOperations.distanceBetween(toImportElement.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) + if (hasNearbyFeature) { + hasNearby.push(toImportElement) + } else { + noNearby.push(toImportElement) + } + } + + return {hasNearby, noNearby} + }, [cutoffDistanceInMeters]); + } +} \ No newline at end of file diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts index c1101efb4..93db06458 100644 --- a/UI/Input/ValidatedTextField.ts +++ b/UI/Input/ValidatedTextField.ts @@ -504,7 +504,8 @@ export default class ValidatedTextField { mapBackgroundLayer?: UIEventSource, unit?: Unit, args?: (string | number | boolean)[] // Extra arguments for the inputHelper, - feature?: any + feature?: any, + inputStyle?: string }): InputElement { options = options ?? {}; options.placeholder = options.placeholder ?? type; diff --git a/UI/NewPoint/ConfirmLocationOfPoint.ts b/UI/NewPoint/ConfirmLocationOfPoint.ts index 7bdbb79bd..23a49d8d3 100644 --- a/UI/NewPoint/ConfirmLocationOfPoint.ts +++ b/UI/NewPoint/ConfirmLocationOfPoint.ts @@ -19,6 +19,7 @@ export default class ConfirmLocationOfPoint extends Combine { constructor( state: { + featureSwitchIsTesting: UIEventSource; osmConnection: OsmConnection, featurePipeline: FeaturePipeline, backgroundLayer?: UIEventSource @@ -167,8 +168,11 @@ export default class ConfirmLocationOfPoint extends Combine { ).onClick(cancel) super([ - state.osmConnection.userDetails.data.dryRun ? - Translations.t.general.testing.Clone().SetClass("alert") : undefined, + new Toggle( + Translations.t.general.testing.SetClass("alert"), + undefined, + state.featureSwitchIsTesting + ), disableFiltersOrConfirm, cancelButton, preset.description, diff --git a/UI/Popup/ImportButton.ts b/UI/Popup/ImportButton.ts index 2d5bc2f37..eaa147e7a 100644 --- a/UI/Popup/ImportButton.ts +++ b/UI/Popup/ImportButton.ts @@ -141,7 +141,7 @@ ${Utils.special_visualizations_importRequirementDocs} if(tagSpec.indexOf(" ")< 0 && tagSpec.indexOf(";") < 0 && tagSource.data[args.tags] !== undefined){ // This is probably a key tagSpec = tagSource.data[args.tags] - console.warn("Using tagspec tagSource.data["+args.tags+"] which is ",tagSpec) + console.debug("The import button is using tags from properties["+args.tags+"] of this object, namely ",tagSpec) } const importClicked = new UIEventSource(false); @@ -201,7 +201,7 @@ ${Utils.special_visualizations_importRequirementDocs} if(tags.indexOf(" ") < 0 && tags.indexOf(";") < 0 && originalFeatureTags.data[tags] !== undefined){ // This might be a property to expand... const items : string = originalFeatureTags.data[tags] - console.warn("Using tagspec tagSource.data["+tags+"] which is ",items) + console.debug("The import button is using tags from properties["+tags+"] of this object, namely ",items) baseArgs["newTags"] = TagApplyButton.generateTagsToApply(items, originalFeatureTags) }else{ baseArgs["newTags"] = TagApplyButton.generateTagsToApply(tags, originalFeatureTags) diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 8d89a61f3..d7d83b060 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -55,6 +55,45 @@ export interface SpecialVisualization { getLayerDependencies?: (argument: string[]) => string[] } +export class AllTagsPanel extends VariableUiElement { + + constructor(tags: UIEventSource, state?) { + + const calculatedTags = [].concat( + SimpleMetaTagger.lazyTags, + ...(state?.layoutToUse?.layers?.map(l => l.calculatedTags?.map(c => c[0]) ?? []) ?? [])) + + + super(tags.map(tags => { + const parts = []; + for (const key in tags) { + if (!tags.hasOwnProperty(key)) { + continue + } + let v = tags[key] + if (v === "") { + v = "empty string" + } + parts.push([key, v ?? "undefined"]); + } + + for (const key of calculatedTags) { + const value = tags[key] + if (value === undefined) { + continue + } + parts.push(["" + key + "", value]) + } + + return new Table( + ["key", "value"], + parts + ) + .SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;").SetClass("zebra-table") + })) + } +} + export default class SpecialVisualizations { public static specialVisualizations = SpecialVisualizations.init() @@ -99,37 +138,7 @@ export default class SpecialVisualizations { funcName: "all_tags", docs: "Prints all key-value pairs of the object - used for debugging", args: [], - constr: ((state, tags: UIEventSource) => { - const calculatedTags = [].concat( - SimpleMetaTagger.lazyTags, - ...(state?.layoutToUse?.layers?.map(l => l.calculatedTags?.map(c => c[0]) ?? []) ?? [])) - return new VariableUiElement(tags.map(tags => { - const parts = []; - for (const key in tags) { - if (!tags.hasOwnProperty(key)) { - continue - } - let v = tags[key] - if (v === "") { - v = "empty string" - } - parts.push([key, v ?? "undefined"]); - } - - for (const key of calculatedTags) { - const value = tags[key] - if (value === undefined) { - continue - } - parts.push(["" + key + "", value]) - } - - return new Table( - ["key", "value"], - parts - ) - })).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;").SetClass("zebra-table") - }) + constr: ((state, tags: UIEventSource) => new AllTagsPanel(tags, state)) }, { funcName: "image_carousel", @@ -339,7 +348,7 @@ export default class SpecialVisualizations { const mangrove = MangroveReviews.Get(Number(tgs._lon), Number(tgs._lat), encodeURIComponent(subject), state.mangroveIdentity, - state.osmConnection._dryRun + state.featureSwitchIsTesting.data ); const form = new ReviewForm((r, whenDone) => mangrove.AddReview(r, whenDone), state.osmConnection); return new ReviewElement(mangrove.GetSubjectUri(), mangrove.GetReviews(), form); @@ -743,10 +752,6 @@ export default class SpecialVisualizations { return t.addCommentAndClose }))).onClick(() => { const id = tags.data[args[1] ?? "id"] - if (state.featureSwitchIsTesting.data) { - console.log("Testmode: Not actually closing note...") - return; - } state.osmConnection.closeNote(id, txt.data).then(_ => { tags.data["closed_at"] = new Date().toISOString(); tags.ping() @@ -760,10 +765,6 @@ export default class SpecialVisualizations { return t.reopenNoteAndComment }))).onClick(() => { const id = tags.data[args[1] ?? "id"] - if (state.featureSwitchIsTesting.data) { - console.log("Testmode: Not actually reopening note...") - return; - } state.osmConnection.reopenNote(id, txt.data).then(_ => { tags.data["closed_at"] = undefined; tags.ping() diff --git a/assets/layers/note/note.json b/assets/layers/note/note.json index 63c447570..10a55aaf6 100644 --- a/assets/layers/note/note.json +++ b/assets/layers/note/note.json @@ -6,7 +6,7 @@ "description": "This layer shows notes on OpenStreetMap. Having this layer in your theme will trigger the 'add new note' functionality in the 'addNewPoint'-popup (or if your theme has no presets, it'll enable adding notes)", "source": { "osmTags": "id~*", - "geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?closed=7&bbox={x_min},{y_min},{x_max},{y_max}", + "geoJson": "https://api.openstreetmap.org/api/0.6/notes.json?limit=10000&closed=7&bbox={x_min},{y_min},{x_max},{y_max}", "geoJsonZoomLevel": 12, "maxCacheAge": 0 }, @@ -29,7 +29,8 @@ "_opened_by_anonymous_user:=feat.get('comments')[0].user === undefined", "_first_user:=feat.get('comments')[0].user", "_first_user_lc:=feat.get('comments')[0].user?.toLowerCase()", - "_first_user_id:=feat.get('comments')[0].uid" + "_first_user_id:=feat.get('comments')[0].uid", + "_is_import_note:=(() => {const lines = feat.properties['_first_comment'].split('\\n'); const matchesMapCompleteURL = lines.map(l => l.match(\".*https://mapcomplete.osm.be/\\([a-zA-Z_-]+\\)\\(.html\\).*#import\")); const matchedIndexes = matchesMapCompleteURL.map((doesMatch, i) => [doesMatch !== null, i]).filter(v => v[0]).map(v => v[1]); return matchedIndexes[0] })()" ], "titleIcons": [ { @@ -201,6 +202,17 @@ } } ] + }, + { + "id": "no_imports", + "options": [ + { + "osmTags": "_is_import_note=", + "question": { + "en": "Hide import notes" + } + } + ] } ] } \ No newline at end of file diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index 9111ea1b5..9aa741e1c 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -1493,6 +1493,10 @@ video { padding: 0.125rem; } +.p-8 { + padding: 2rem; +} + .pb-12 { padding-bottom: 3rem; } diff --git a/langs/en.json b/langs/en.json index 03bf34682..a5a111ed1 100644 --- a/langs/en.json +++ b/langs/en.json @@ -465,7 +465,9 @@ "importLayer": { "layerName": "Possible {title}", "description": "A layer which imports entries for {title}", - "popupTitle": "Possible {title}" + "popupTitle": "Possible {title}", + "importButton": "import_button({layerId}, _tags, There might be a {title} here,./assets/svg/addSmall.svg,,,id)", + "importHandled": "
This feature has been handled! Thanks for your effort
" }, "importHelper": { "title": "Import helper", @@ -477,5 +479,5 @@ "selectLayer": "Select a layer...", "selectFileTitle": "Select file", "validateDataTitle": "Validate data" - } + } } diff --git a/langs/layers/en.json b/langs/layers/en.json index 674c9ac5f..b18ba3b95 100644 --- a/langs/layers/en.json +++ b/langs/layers/en.json @@ -3354,6 +3354,13 @@ "question": "Only show open notes" } } + }, + "8": { + "options": { + "0": { + "question": "Hide import notes" + } + } } }, "name": "OpenStreetMap notes", diff --git a/langs/nl.json b/langs/nl.json index 342d34497..1dfaa6eb8 100644 --- a/langs/nl.json +++ b/langs/nl.json @@ -314,5 +314,12 @@ }, "multi_apply": { "autoApply": "Wijzigingen aan eigenschappen {attr_names} zullen ook worden uitgevoerd op {count} andere objecten." + }, + "importLayer": { + "layerName": "Hier is misschien een {title}", + "description": "Deze laag toont kaart-nota's die wijzen op een {title}", + "popupTitle": "Mogelijkse {title}", + "importButton": "import_button({layerId}, _tags, Hier is een {title}, voeg toe...,./assets/svg/addSmall.svg,,,id)", + "importHandled": "
Dit punt is afgehandeld. Bedankt om mee te helpen!
" } } diff --git a/scripts/generateLayerOverview.ts b/scripts/generateLayerOverview.ts index f0b63f47f..052d73399 100644 --- a/scripts/generateLayerOverview.ts +++ b/scripts/generateLayerOverview.ts @@ -5,9 +5,6 @@ import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson"; import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import Constants from "../Models/Constants"; import { - DesugaringContext, - PrepareLayer, - PrepareTheme, ValidateLayer, ValidateThemeAndLayers } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; @@ -16,6 +13,9 @@ import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingCon import * as questions from "../assets/tagRenderings/questions.json"; import * as icons from "../assets/tagRenderings/icons.json"; import PointRenderingConfigJson from "../Models/ThemeConfig/Json/PointRenderingConfigJson"; +import {PrepareLayer} from "../Models/ThemeConfig/Conversion/PrepareLayer"; +import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme"; +import {DesugaringContext} from "../Models/ThemeConfig/Conversion/Conversion"; // This scripts scans 'assets/layers/*.json' for layer definition files and 'assets/themes/*.json' for theme definition files. // It spits out an overview of those to be used to load them diff --git a/test/CreateNoteImportLayer.spec.ts b/test/CreateNoteImportLayer.spec.ts index 4a35df7f2..2e64984f4 100644 --- a/test/CreateNoteImportLayer.spec.ts +++ b/test/CreateNoteImportLayer.spec.ts @@ -1,10 +1,11 @@ import T from "./TestHelper"; import CreateNoteImportLayer from "../Models/ThemeConfig/Conversion/CreateNoteImportLayer"; import * as bookcases from "../assets/layers/public_bookcase/public_bookcase.json" -import {DesugaringContext, PrepareLayer} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; +import {DesugaringContext} from "../Models/ThemeConfig/Conversion/Conversion"; import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"; +import {PrepareLayer} from "../Models/ThemeConfig/Conversion/PrepareLayer"; export default class CreateNoteImportLayerSpec extends T { @@ -17,7 +18,7 @@ export default class CreateNoteImportLayerSpec extends T { } const layerPrepare = new PrepareLayer() - const layer = new LayerConfig(layerPrepare.convertStrict(desugaringState, bookcases, "ImportLayerGeneratorTest:Parse bookcases"), "ImportLayerGeneratorTest: init bookcases-layer") + const layer =layerPrepare.convertStrict(desugaringState, bookcases, "ImportLayerGeneratorTest:Parse bookcases") const generator = new CreateNoteImportLayer() const generatedLayer = generator.convertStrict(desugaringState, layer, "ImportLayerGeneratorTest: convert") // fs.writeFileSync("bookcases-import-layer.generated.json", JSON.stringify(generatedLayer, null, " "), "utf8") diff --git a/test/Tag.spec.ts b/test/Tag.spec.ts index 47994bfca..948cb0359 100644 --- a/test/Tag.spec.ts +++ b/test/Tag.spec.ts @@ -112,6 +112,12 @@ export default class TagSpec extends T { equal(compare.matchesProperties({"key": "5"}), true); equal(compare.matchesProperties({"key": "4.2"}), false); + const importMatch = TagUtils.Tag("tags~(^|.*;)amenity=public_bookcase($|;.*)") + equal(importMatch.matchesProperties({"tags": "amenity=public_bookcase;name=test"}), true) + equal(importMatch.matchesProperties({"tags": "amenity=public_bookcase"}), true) + equal(importMatch.matchesProperties({"tags": "name=test;amenity=public_bookcase"}), true) + equal(importMatch.matchesProperties({"tags": "amenity=bench"}), false) + })], ["Is equivalent test", (() => { diff --git a/test/Theme.spec.ts b/test/Theme.spec.ts index 39f0dfa47..b05680d50 100644 --- a/test/Theme.spec.ts +++ b/test/Theme.spec.ts @@ -3,10 +3,10 @@ import * as assert from "assert"; import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import * as bookcaseLayer from "../assets/generated/layers/public_bookcase.json" -import {PrepareTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson"; import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import Constants from "../Models/Constants"; +import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme"; export default class ThemeSpec extends T { constructor() {