From 72376092ed243497a8971a1b8fb5141a89dcd16a Mon Sep 17 00:00:00 2001 From: Weblate Date: Thu, 24 Jun 2021 12:24:26 +0200 Subject: [PATCH 01/26] Added translation using Weblate (* (generated) (*)) --- langs/*.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 langs/*.json diff --git a/langs/*.json b/langs/*.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/langs/*.json @@ -0,0 +1 @@ +{} From 5b3bd2cbdb622282e5c6da73fc78a868526e0a89 Mon Sep 17 00:00:00 2001 From: Weblate Date: Thu, 24 Jun 2021 12:24:32 +0200 Subject: [PATCH 02/26] Added translation using Weblate (* (generated) (*)) --- langs/shared-questions/*.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 langs/shared-questions/*.json diff --git a/langs/shared-questions/*.json b/langs/shared-questions/*.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/langs/shared-questions/*.json @@ -0,0 +1 @@ +{} From 985e97d43b0f08cf66cf756f2cf9dccd5465b055 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Mon, 28 Jun 2021 18:06:54 +0200 Subject: [PATCH 03/26] First steps for a delete button --- Logic/Osm/OsmConnection.ts | 2 + Logic/Osm/OsmObject.ts | 40 ++++++++++- Models/Constants.ts | 8 ++- UI/Base/Loading.ts | 7 ++ UI/Popup/DeleteButton.ts | 72 +++++++++++++++++++ Utils.ts | 1 + langs/en.json | 9 +++ test.ts | 138 +++++++++++++++++++------------------ test/OsmObject.spec.ts | 32 +++++++++ test/TestAll.ts | 12 ++-- 10 files changed, 246 insertions(+), 75 deletions(-) create mode 100644 UI/Base/Loading.ts create mode 100644 UI/Popup/DeleteButton.ts create mode 100644 test/OsmObject.spec.ts diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index c085c5738..0ef3c5ba2 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -13,6 +13,7 @@ export default class UserDetails { public loggedIn = false; public name = "Not logged in"; + public uid: number; public csCount = 0; public img: string; public unreadMessages = 0; @@ -167,6 +168,7 @@ export class OsmConnection { data.loggedIn = true; console.log("Login completed, userinfo is ", userInfo); data.name = userInfo.getAttribute('display_name'); + data.uid= Number(userInfo.getAttribute("id")) data.csCount = userInfo.getElementsByTagName("changesets")[0].getAttribute("count"); data.img = undefined; diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index 363678d8d..b09f515d0 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -43,11 +43,47 @@ export abstract class OsmObject { } } - public static DownloadHistory(id: string, continuation: (versions: OsmObject[]) => void): void { + /** + * Downloads the ways that are using this node. + * Beware: their geometry will be incomplete! + * @param id + * @param continuation + * @constructor + */ + public static DownloadReferencingWays(id: string, continuation: (referencingWays: OsmWay[]) => void){ + Utils.downloadJson(`https://www.openStreetMap.org/api/0.6/${id}/ways`) + .then(data => { + const ways = data.elements.map(wayInfo => { + const way = new OsmWay(wayInfo.id) + way.LoadData(wayInfo) + return way + }) + continuation(ways) + }) + } + /** + * Downloads the relations that are using this feature. + * Beware: their geometry will be incomplete! + * @param id + * @param continuation + * @constructor + */ + public static DownloadReferencingRelations(id: string, continuation: (referencingRelations: OsmRelation[]) => void){ + Utils.downloadJson(`https://www.openStreetMap.org/api/0.6/${id}/relations`) + .then(data => { + const rels = data.elements.map(wayInfo => { + const rel = new OsmRelation(wayInfo.id) + rel.LoadData(wayInfo) + return rel + }) + continuation(rels) + }) + } + public static DownloadHistory(id: string, continuation: (versions: OsmObject[]) => void): void{ const splitted = id.split("/"); const type = splitted[0]; const idN = splitted[1]; - $.getJSON("https://openStreetMap.org/api/0.6/" + type + "/" + idN + "/history", data => { + $.getJSON("https://www.openStreetMap.org/api/0.6/" + type + "/" + idN + "/history", data => { const elements: any[] = data.elements; const osmObjects: OsmObject[] = [] for (const element of elements) { diff --git a/Models/Constants.ts b/Models/Constants.ts index 4cbec2930..3482b3193 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -7,11 +7,13 @@ export default class Constants { // The user journey states thresholds when a new feature gets unlocked public static userJourney = { moreScreenUnlock: 1, - personalLayoutUnlock: 15, - historyLinkVisible: 20, + personalLayoutUnlock: 5, + historyLinkVisible: 10, + deletePointsOfOthersUnlock: 15, tagsVisibleAt: 25, - mapCompleteHelpUnlock: 50, tagsVisibleAndWikiLinked: 30, + + mapCompleteHelpUnlock: 50, themeGeneratorReadOnlyUnlock: 50, themeGeneratorFullUnlock: 500, addNewPointWithUnreadMessagesUnlock: 500, diff --git a/UI/Base/Loading.ts b/UI/Base/Loading.ts new file mode 100644 index 000000000..8711dec0d --- /dev/null +++ b/UI/Base/Loading.ts @@ -0,0 +1,7 @@ +import {FixedUiElement} from "./FixedUiElement"; + +export default class Loading extends FixedUiElement { + constructor() { + super("Loading..."); // TODO to be improved + } +} \ No newline at end of file diff --git a/UI/Popup/DeleteButton.ts b/UI/Popup/DeleteButton.ts new file mode 100644 index 000000000..a0bc50cf3 --- /dev/null +++ b/UI/Popup/DeleteButton.ts @@ -0,0 +1,72 @@ +import {VariableUiElement} from "../Base/VariableUIElement"; +import {OsmObject} from "../../Logic/Osm/OsmObject"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {Translation} from "../i18n/Translation"; +import State from "../../State"; +import Toggle from "../Input/Toggle"; +import Translations from "../i18n/Translations"; +import Loading from "../Base/Loading"; +import UserDetails from "../../Logic/Osm/OsmConnection"; +import Constants from "../../Models/Constants"; +import {SubtleButton} from "../Base/SubtleButton"; +import Svg from "../../Svg"; +import {Utils} from "../../Utils"; + + +export default class DeleteButton extends Toggle { + constructor(id: string) { + + const hasRelations: UIEventSource = new UIEventSource(null) + OsmObject.DownloadReferencingRelations(id, (rels) => { + hasRelations.setData(rels.length > 0) + }) + + const hasWays: UIEventSource = new UIEventSource(null) + OsmObject.DownloadReferencingWays(id, (ways) => { + hasWays.setData(ways.length > 0) + }) + + const previousEditors = new UIEventSource(null) + OsmObject.DownloadHistory(id, versions => { + const uids = versions.map(version => version.tags["_last_edit:contributor:uid"]) + previousEditors.setData(uids) + }) + const allByMyself = previousEditors.map(previous => { + if (previous === null) { + return null; + } + const userId = State.state.osmConnection.userDetails.data.uid; + return !previous.some(editor => editor !== userId) + }, [State.state.osmConnection.userDetails]) + + const t = Translations.t.deleteButton + + super( + new Toggle( + new VariableUiElement( + hasRelations.map(hasRelations => { + if (hasRelations === null || hasWays.data === null) { + return new Loading() + } + if (hasWays.data || hasRelations) { + return t.partOfOthers.Clone() + } + + return new Toggle( + new SubtleButton(Svg.delete_icon_svg(), t.delete.Clone()), + t.notEnoughExperience.Clone(), + State.state.osmConnection.userDetails.map(userinfo => + allByMyself.data || + userinfo.csCount >= Constants.userJourney.deletePointsOfOthersUnlock, + [allByMyself]) + ) + + }, [hasWays]) + ), + t.onlyEditedByLoggedInUser.Clone().onClick(State.state.osmConnection.AttemptLogin), + State.state.osmConnection.isLoggedIn), + t.isntAPoint, + new UIEventSource(id.startsWith("node")) + ); + } +} \ No newline at end of file diff --git a/Utils.ts b/Utils.ts index fa3e55ef8..81576df9f 100644 --- a/Utils.ts +++ b/Utils.ts @@ -343,6 +343,7 @@ export class Utils { } }; xhr.open('GET', url); + xhr.setRequestHeader("accept","application/json") xhr.send(); }catch(e){ reject(e) diff --git a/langs/en.json b/langs/en.json index dda19d600..0c574bca7 100644 --- a/langs/en.json +++ b/langs/en.json @@ -27,6 +27,15 @@ "intro": "MapComplete is an OpenStreetMap-viewer and editor, which shows you information about a specific theme.", "pickTheme": "Pick a theme below to get started." }, + "deleteButton": { + "delete": "Delete", + "loginToDelete": "You must be logged in to delete a point", + "checkingDeletability": "Inspecting properties to check if this feature can be deleted", + "isntAPoint": "Only points can be deleted", + "onlyEditedByLoggedInUser": "This point has only be edited by yourself, you can safely delete it", + "notEnoughExperience": "You don't have enough experience to delete points made by other people. Make more edits to improve your skills", + "partOfOthers": "This point is part of some way or relation, so you can not delete it" + }, "general": { "loginWithOpenStreetMap": "Login with OpenStreetMap", "welcomeBack": "You are logged in, welcome back!", diff --git a/test.ts b/test.ts index 8ae5dc012..25608b2b0 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,8 @@ -import ValidatedTextField from "./UI/Input/ValidatedTextField"; +import {OsmObject} from "./Logic/Osm/OsmObject"; +import DeleteButton from "./UI/Popup/DeleteButton"; +import Combine from "./UI/Base/Combine"; +import State from "./State"; +/*import ValidatedTextField from "./UI/Input/ValidatedTextField"; import Combine from "./UI/Base/Combine"; import {VariableUiElement} from "./UI/Base/VariableUIElement"; import {UIEventSource} from "./Logic/UIEventSource"; @@ -69,75 +73,77 @@ function TestAllInputMethods() { })).AttachTo("maindiv") } +function TestMiniMap() { -const location = new UIEventSource({ - lon: 4.84771728515625, - lat: 51.17920846421931, - zoom: 14 -}) -const map0 = new Minimap({ - location: location, - allowMoving: true, - background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[2]) -}) -map0.SetStyle("width: 500px; height: 250px; overflow: hidden; border: 2px solid red") - .AttachTo("maindiv") - -const layout = AllKnownLayouts.layoutsList[1] -State.state = new State(layout) -console.log("LAYOUT is", layout.id) - -const feature = { - "type": "Feature", - _matching_layer_id: "bike_repair_station", - "properties": { - id: "node/-1", - "amenity": "bicycle_repair_station" - }, - "geometry": { - "type": "Point", - "coordinates": [ - 4.84771728515625, - 51.17920846421931 - ] - } - } - -; - -State.state.allElements.addOrGetElement(feature) - -const featureSource = new UIEventSource([{ - freshness: new Date(), - feature: feature -}]) - -new ShowDataLayer( - featureSource, - map0.leafletMap, - new UIEventSource(layout) -) - -const map1 = new Minimap({ + const location = new UIEventSource({ + lon: 4.84771728515625, + lat: 51.17920846421931, + zoom: 14 + }) + const map0 = new Minimap({ location: location, allowMoving: true, - background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[5]) - }, -) + background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[2]) + }) + map0.SetStyle("width: 500px; height: 250px; overflow: hidden; border: 2px solid red") + .AttachTo("maindiv") -map1.SetStyle("width: 500px; height: 250px; overflow: hidden; border : 2px solid black") - .AttachTo("extradiv") + const layout = AllKnownLayouts.layoutsList[1] + State.state = new State(layout) + console.log("LAYOUT is", layout.id) + + const feature = { + "type": "Feature", + _matching_layer_id: "bike_repair_station", + "properties": { + id: "node/-1", + "amenity": "bicycle_repair_station" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 4.84771728515625, + 51.17920846421931 + ] + } + } + + ; + + State.state.allElements.addOrGetElement(feature) + + const featureSource = new UIEventSource([{ + freshness: new Date(), + feature: feature + }]) + + new ShowDataLayer( + featureSource, + map0.leafletMap, + new UIEventSource(layout) + ) + + const map1 = new Minimap({ + location: location, + allowMoving: true, + background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[5]) + }, + ) + + map1.SetStyle("width: 500px; height: 250px; overflow: hidden; border : 2px solid black") + .AttachTo("extradiv") + new ShowDataLayer( + featureSource, + map1.leafletMap, + new UIEventSource(layout) + ) - - -new ShowDataLayer( - featureSource, - map1.leafletMap, - new UIEventSource(layout) -) - -featureSource.ping() - -// */ \ No newline at end of file + featureSource.ping() +} +//*/ +State.state= new State(undefined) +new Combine([ + new DeleteButton("node/8598664388"), +]).AttachTo("maindiv") diff --git a/test/OsmObject.spec.ts b/test/OsmObject.spec.ts new file mode 100644 index 000000000..f5145a41e --- /dev/null +++ b/test/OsmObject.spec.ts @@ -0,0 +1,32 @@ +import T from "./TestHelper"; +import {OsmObject} from "../Logic/Osm/OsmObject"; +import ScriptUtils from "../scripts/ScriptUtils"; + +export default class OsmObjectSpec extends T { + constructor() { + super("OsmObject", [ + [ + "Download referencing ways", + () => { + let downloaded = false; + OsmObject.DownloadReferencingWays("node/1124134958", ways => { + downloaded = true; + console.log(ways) + }) + let timeout = 10 + while (!downloaded && timeout >= 0) { + ScriptUtils.sleep(1000) + + timeout--; + } + if(!downloaded){ + throw "Timeout: referencing ways not found" + } + } + + ] + + + ]); + } +} \ No newline at end of file diff --git a/test/TestAll.ts b/test/TestAll.ts index d34d4c5e6..5174e2ad0 100644 --- a/test/TestAll.ts +++ b/test/TestAll.ts @@ -1,5 +1,4 @@ -import {Utils} from "../Utils"; -Utils.runningFromConsole = true; +import {Utils} from "../Utils";Utils.runningFromConsole = true; import TagSpec from "./Tag.spec"; import ImageAttributionSpec from "./ImageAttribution.spec"; import GeoOperationsSpec from "./GeoOperations.spec"; @@ -10,6 +9,10 @@ import OsmConnectionSpec from "./OsmConnection.spec"; import T from "./TestHelper"; import {FixedUiElement} from "../UI/Base/FixedUiElement"; import Combine from "../UI/Base/Combine"; +import OsmObjectSpec from "./OsmObject.spec"; +import ScriptUtils from "../scripts/ScriptUtils"; + + export default class TestAll { private needsBrowserTests: T[] = [new OsmConnectionSpec()] @@ -26,8 +29,9 @@ export default class TestAll { } } } - +ScriptUtils.fixUtils() const allTests = [ + new OsmObjectSpec(), new TagSpec(), new ImageAttributionSpec(), new GeoOperationsSpec(), @@ -39,6 +43,6 @@ const allTests = [ for (const test of allTests) { if (test.failures.length > 0) { - throw "Some test failed" + throw "Some test failed: "+test.failures.join(", ") } } \ No newline at end of file From 30429908458616547b5c4613b13f024f1cdc1474 Mon Sep 17 00:00:00 2001 From: Vinicius Date: Mon, 28 Jun 2021 00:54:14 +0000 Subject: [PATCH 04/26] Translated using Weblate (Portuguese (Brazil)) Currently translated at 25.1% (37 of 147 strings) Translation: MapComplete/Core Translate-URL: https://hosted.weblate.org/projects/mapcomplete/core/pt_BR/ --- langs/pt_BR.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/langs/pt_BR.json b/langs/pt_BR.json index f39644528..e914ed5bf 100644 --- a/langs/pt_BR.json +++ b/langs/pt_BR.json @@ -8,6 +8,16 @@ "pleaseLogin": "Faça login para adicionar uma imagem", "uploadingMultiple": "Fazendo upload de {count} imagens…", "uploadingPicture": "Enviando sua imagem…", - "addPicture": "Adicionar imagem" + "addPicture": "Adicionar imagem", + "isDeleted": "Excluída", + "doDelete": "Remover imagem", + "dontDelete": "Cancelar", + "uploadDone": "Sua foto foi adicionada. Obrigado por ajudar!", + "uploadFailed": "Não foi possível enviar sua foto. Você está conectado à Internet e permite APIs de terceiros? O navegador Brave ou o plugin uMatrix podem bloqueá-los." + }, + "centerMessage": { + "ready": "Concluído!", + "zoomIn": "Amplie para ver ou editar os dados", + "loadingData": "Carregando dados…" } } From 5e84b00ca222e37d11c215f0aca5db96b284fcf3 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 15:34:32 +0200 Subject: [PATCH 05/26] Use clone in translation --- UI/BigComponents/SimpleAddUI.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index cf315337d..75dd3e403 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -41,7 +41,8 @@ export default class SimpleAddUI extends Toggle { constructor(isShown: UIEventSource) { - const loginButton = Translations.t.general.add.pleaseLogin.Clone().onClick(State.state.osmConnection.AttemptLogin); + const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone()) + .onClick(() => State.state.osmConnection.AttemptLogin()); const readYourMessages = new Combine([ Translations.t.general.readYourMessages.Clone().SetClass("alert"), new SubtleButton(Svg.envelope_ui(), From d7e421efc985a063568e1bd974dd1ccfa6ea6455 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 15:37:30 +0200 Subject: [PATCH 06/26] Add addCallbackAndRunD which ignores undefeind and null values --- Logic/UIEventSource.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 7fa5a432d..d18cec86f 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -159,4 +159,11 @@ export class UIEventSource { return newSource; } + addCallbackAndRunD(callback: (data :T ) => void) { + this.addCallbackAndRun(data => { + if(data !== undefined && data !== null){ + callback(data) + } + }) + } } \ No newline at end of file From 6576ac36ccce61757abdd3889328d1420ab06c7e Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 15:38:14 +0200 Subject: [PATCH 07/26] Use addCallbackAndRunD where applicable, which deletes many if(value==undefined) checks --- UI/Input/Checkboxes.ts | 5 +---- UI/Input/ColorPicker.ts | 5 +---- UI/Input/DirectionInput.ts | 2 +- UI/Input/SimpleDatePicker.ts | 5 +---- UI/Input/TextField.ts | 7 ++----- 5 files changed, 6 insertions(+), 18 deletions(-) diff --git a/UI/Input/Checkboxes.ts b/UI/Input/Checkboxes.ts index 3ae6b5fc6..e88d67633 100644 --- a/UI/Input/Checkboxes.ts +++ b/UI/Input/Checkboxes.ts @@ -57,10 +57,7 @@ export default class CheckBoxes extends InputElement { wrapper.appendChild(label) el.appendChild(wrapper) - value.addCallbackAndRun(selectedValues => { - if (selectedValues === undefined) { - return; - } + value.addCallbackAndRunD(selectedValues => { if (selectedValues.indexOf(i) >= 0) { input.checked = true; } diff --git a/UI/Input/ColorPicker.ts b/UI/Input/ColorPicker.ts index ea0abda60..6f808cd35 100644 --- a/UI/Input/ColorPicker.ts +++ b/UI/Input/ColorPicker.ts @@ -16,10 +16,7 @@ private readonly _element : HTMLElement el.type = "color" - this.value.addCallbackAndRun(v => { - if(v === undefined){ - return; - } + this.value.addCallbackAndRunD(v => { el.value =v }); diff --git a/UI/Input/DirectionInput.ts b/UI/Input/DirectionInput.ts index c356d34a8..12689d5d4 100644 --- a/UI/Input/DirectionInput.ts +++ b/UI/Input/DirectionInput.ts @@ -59,7 +59,7 @@ export default class DirectionInput extends InputElement { .ConstructElement() - this.value.addCallbackAndRun(rotation => { + this.value.addCallbackAndRunD(rotation => { const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement cone.style.transform = `rotate(${rotation}deg)`; diff --git a/UI/Input/SimpleDatePicker.ts b/UI/Input/SimpleDatePicker.ts index 78c9db746..706790433 100644 --- a/UI/Input/SimpleDatePicker.ts +++ b/UI/Input/SimpleDatePicker.ts @@ -23,10 +23,7 @@ export default class SimpleDatePicker extends InputElement { } - this.value.addCallbackAndRun(v => { - if(v === undefined){ - return; - } + this.value.addCallbackAndRunD(v => { el.value = v; }); diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index bdf865671..8f7d6ac44 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -64,11 +64,8 @@ export class TextField extends InputElement { const field = inputEl; - this.value.addCallbackAndRun(value => { - if (value === undefined || value === null) { - // We leave the textfield as is - make sure we do not erase it! - return; - } + this.value.addCallbackAndRunD(value => { + // We leave the textfield as is in the case of undefined or null (handled by addCallbackAndRunD) - make sure we do not erase it! field["value"] = value; if (self.IsValid(value)) { self.RemoveClass("invalid") From b5cc8d81940068fcf07c0310951c38071acec408 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 15:41:21 +0200 Subject: [PATCH 08/26] Use addCallbackAndRunD instead off addCallbackAndRun --- Logic/Actors/ImageSearcher.ts | 2 +- Logic/Actors/TitleHandler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Logic/Actors/ImageSearcher.ts b/Logic/Actors/ImageSearcher.ts index 165442d6f..cda9158b2 100644 --- a/Logic/Actors/ImageSearcher.ts +++ b/Logic/Actors/ImageSearcher.ts @@ -66,7 +66,7 @@ export class ImageSearcher extends UIEventSource<{ key: string, url: string }[]> }); if (loadSpecial) { - tags.addCallbackAndRun(tags => { + tags.addCallbackAndRunD(tags => { const wdItem = tags.wikidata; if (wdItem !== undefined) { diff --git a/Logic/Actors/TitleHandler.ts b/Logic/Actors/TitleHandler.ts index 16459bac8..8c807cadd 100644 --- a/Logic/Actors/TitleHandler.ts +++ b/Logic/Actors/TitleHandler.ts @@ -61,7 +61,7 @@ export default class TitleHandler { constructor(layoutToUse: UIEventSource, selectedFeature: UIEventSource, allElementsStorage: ElementStorage) { - new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRun(title => { + new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRunD(title => { document.title = title }) } From 2004c5e6066cf1b480bd9898f2644d99e9147bc4 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 15:42:12 +0200 Subject: [PATCH 09/26] Use addcallbackAndRunD --- Logic/Web/MangroveReviews.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Logic/Web/MangroveReviews.ts b/Logic/Web/MangroveReviews.ts index aeaeeb1c8..036228248 100644 --- a/Logic/Web/MangroveReviews.ts +++ b/Logic/Web/MangroveReviews.ts @@ -10,8 +10,8 @@ export class MangroveIdentity { constructor(mangroveIdentity: UIEventSource) { const self = this; this._mangroveIdentity = mangroveIdentity; - mangroveIdentity.addCallbackAndRun(str => { - if (str === undefined || str === "") { + mangroveIdentity.addCallbackAndRunD(str => { + if (str === "") { return; } mangrove.jwkToKeypair(JSON.parse(str)).then(keypair => { From 8d9a992507e28cf0cffde3e1a596e4c224f5d337 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 15:42:31 +0200 Subject: [PATCH 10/26] Use addcallbackAndRunD --- Logic/SimpleMetaTagger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Logic/SimpleMetaTagger.ts b/Logic/SimpleMetaTagger.ts index fed4ce5c6..f78d0e49d 100644 --- a/Logic/SimpleMetaTagger.ts +++ b/Logic/SimpleMetaTagger.ts @@ -162,7 +162,7 @@ export default class SimpleMetaTagger { } const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); - tagsSource.addCallbackAndRun(tags => { + tagsSource.addCallbackAndRunD(tags => { if (tags.opening_hours === undefined || tags._country === undefined) { return; } From ba04beab2c2f54c27fdd1f96ff2e759ce6a91760 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 16:02:46 +0200 Subject: [PATCH 11/26] Use addCallbackAndRunD --- Logic/FeatureSource/LocalStorageSaver.ts | 6 +----- Logic/FeatureSource/LocalStorageSource.ts | 2 -- Logic/FeatureSource/OsmApiFeatureSource.ts | 5 ++--- Logic/FeatureSource/RegisteringFeatureSource.ts | 4 ++-- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Logic/FeatureSource/LocalStorageSaver.ts b/Logic/FeatureSource/LocalStorageSaver.ts index e45942410..9d4994945 100644 --- a/Logic/FeatureSource/LocalStorageSaver.ts +++ b/Logic/FeatureSource/LocalStorageSaver.ts @@ -16,11 +16,7 @@ export default class LocalStorageSaver implements FeatureSource { constructor(source: FeatureSource, layout: UIEventSource) { this.features = source.features; - this.features.addCallbackAndRun(features => { - if (features === undefined) { - return; - } - + this.features.addCallbackAndRunD(features => { const now = new Date().getTime() features = features.filter(f => layout.data.cacheTimeout > Math.abs(now - f.freshness.getTime())/1000) diff --git a/Logic/FeatureSource/LocalStorageSource.ts b/Logic/FeatureSource/LocalStorageSource.ts index 0723a2fb4..1cabd6839 100644 --- a/Logic/FeatureSource/LocalStorageSource.ts +++ b/Logic/FeatureSource/LocalStorageSource.ts @@ -11,8 +11,6 @@ export default class LocalStorageSource implements FeatureSource { this.features = new UIEventSource<{ feature: any; freshness: Date }[]>([]) const key = LocalStorageSaver.storageKey + layout.data.id layout.addCallbackAndRun(_ => { - - try { const fromStorage = localStorage.getItem(key); if (fromStorage == null) { diff --git a/Logic/FeatureSource/OsmApiFeatureSource.ts b/Logic/FeatureSource/OsmApiFeatureSource.ts index 8719ffb6a..ec1c03a74 100644 --- a/Logic/FeatureSource/OsmApiFeatureSource.ts +++ b/Logic/FeatureSource/OsmApiFeatureSource.ts @@ -3,7 +3,6 @@ import {UIEventSource} from "../UIEventSource"; import {OsmObject} from "../Osm/OsmObject"; import State from "../../State"; import {Utils} from "../../Utils"; -import Loc from "../../Models/Loc"; export default class OsmApiFeatureSource implements FeatureSource { @@ -21,10 +20,10 @@ export default class OsmApiFeatureSource implements FeatureSource { return; } console.debug("Downloading", id, "from the OSM-API") - OsmObject.DownloadObject(id, (element, meta) => { + OsmObject.DownloadObject(id).addCallbackAndRunD(element => { const geojson = element.asGeoJson(); geojson.id = geojson.properties.id; - this.features.setData([{feature: geojson, freshness: meta["_last_edit:timestamp"]}]) + this.features.setData([{feature: geojson, freshness: element.timestamp}]) }) } diff --git a/Logic/FeatureSource/RegisteringFeatureSource.ts b/Logic/FeatureSource/RegisteringFeatureSource.ts index 7c246ee67..e464a60b8 100644 --- a/Logic/FeatureSource/RegisteringFeatureSource.ts +++ b/Logic/FeatureSource/RegisteringFeatureSource.ts @@ -9,8 +9,8 @@ export default class RegisteringFeatureSource implements FeatureSource { constructor(source: FeatureSource) { this.features = source.features; this.name = "RegisteringSource of " + source.name; - this.features.addCallbackAndRun(features => { - for (const feature of features ?? []) { + this.features.addCallbackAndRunD(features => { + for (const feature of features) { State.state.allElements.addOrGetElement(feature.feature) } }) From ec7833b2ee09e4c604b353c4609d0753248eede1 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 18:47:48 +0200 Subject: [PATCH 12/26] Use addCallbackAndRunD --- Logic/MetaTagging.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Logic/MetaTagging.ts b/Logic/MetaTagging.ts index b6dcdf54e..957ed0e37 100644 --- a/Logic/MetaTagging.ts +++ b/Logic/MetaTagging.ts @@ -51,7 +51,7 @@ export default class MetaTagging { layerFuncs.set(layer.id, this.createRetaggingFunc(layer)); } - allKnownFeatures.addCallbackAndRun(newFeatures => { + allKnownFeatures.addCallbackAndRunD(newFeatures => { const featuresPerLayer = new Map(); const allFeatures = Array.from(new Set(features.concat(newFeatures))) From bbfcee686fdb52a2cf2f1748e20d9cce8cefa1dc Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 30 Jun 2021 18:48:23 +0200 Subject: [PATCH 13/26] Refactor OsmObject to use eventsources, add first version of the delete button --- InitUiElements.ts | 13 +- Logic/Actors/SelectedFeatureHandler.ts | 2 +- Logic/Osm/Changes.ts | 4 +- Logic/Osm/ChangesetHandler.ts | 100 ++++++++++-- Logic/Osm/DeleteAction.ts | 207 +++++++++++++++++++++++++ Logic/Osm/OsmConnection.ts | 15 +- Logic/Osm/OsmObject.ts | 180 ++++++++++++--------- UI/BigComponents/UserBadge.ts | 14 +- UI/OpeningHours/PublicHolidayInput.ts | 103 ++++++------ UI/Popup/DeleteButton.ts | 112 ++++++------- UI/Popup/FeatureInfoBox.ts | 2 +- UI/Popup/TagRenderingQuestion.ts | 2 +- UI/ShowDataLayer.ts | 13 +- index.ts | 3 +- langs/en.json | 12 +- 15 files changed, 553 insertions(+), 229 deletions(-) create mode 100644 Logic/Osm/DeleteAction.ts diff --git a/InitUiElements.ts b/InitUiElements.ts index 65a4485e0..0f1143eba 100644 --- a/InitUiElements.ts +++ b/InitUiElements.ts @@ -154,10 +154,7 @@ export class InitUiElements { } State.state.osmConnection.userDetails.map((userDetails: UserDetails) => userDetails?.home) - .addCallbackAndRun(home => { - if (home === undefined) { - return; - } + .addCallbackAndRunD(home => { const color = getComputedStyle(document.body).getPropertyValue("--subtle-detail-color") const icon = L.icon({ iconUrl: Img.AsData(Svg.home_white_bg.replace(/#ffffff/g, color)), @@ -286,10 +283,8 @@ export class InitUiElements { isOpened.setData(false); }) - State.state.selectedElement.addCallbackAndRun(selected => { - if (selected !== undefined) { + State.state.selectedElement.addCallbackAndRunD(_ => { isOpened.setData(false); - } }) isOpened.setData(Hash.hash.data === undefined || Hash.hash.data === "" || Hash.hash.data == "welcome") } @@ -337,11 +332,9 @@ export class InitUiElements { copyrightButton.isEnabled.setData(false); }); - State.state.selectedElement.addCallbackAndRun(feature => { - if (feature !== undefined) { + State.state.selectedElement.addCallbackAndRunD(_ => { layerControlButton.isEnabled.setData(false); copyrightButton.isEnabled.setData(false); - } }) } diff --git a/Logic/Actors/SelectedFeatureHandler.ts b/Logic/Actors/SelectedFeatureHandler.ts index e74708811..3aa5d8e5b 100644 --- a/Logic/Actors/SelectedFeatureHandler.ts +++ b/Logic/Actors/SelectedFeatureHandler.ts @@ -61,7 +61,7 @@ export default class SelectedFeatureHandler { return; // No valid feature selected } // We should have a valid osm-ID and zoom to it - OsmObject.DownloadObject(hash, (element: OsmObject, meta: OsmObjectMeta) => { + OsmObject.DownloadObject(hash).addCallbackAndRunD(element => { const centerpoint = element.centerpoint(); console.log("Zooming to location for select point: ", centerpoint) location.data.lat = centerpoint[0] diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index e06fd2ada..e7fd28698 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -228,9 +228,9 @@ export class Changes implements FeatureSource{ } neededIds = Utils.Dedup(neededIds); - OsmObject.DownloadAll(neededIds, {}, (knownElements) => { + OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => { self.uploadChangesWithLatestVersions(knownElements, newElements, pending) - }); + }) } } \ No newline at end of file diff --git a/Logic/Osm/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 5280510ff..88ad246e5 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -7,6 +7,7 @@ import State from "../../State"; import Locale from "../../UI/i18n/Locale"; import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; import Constants from "../../Models/Constants"; +import {OsmObject} from "./OsmObject"; export class ChangesetHandler { @@ -47,11 +48,20 @@ export class ChangesetHandler { } } + /** + * The full logic to upload a change to one or more elements. + * + * This method will attempt to reuse an existing, open changeset for this theme (or open one if none available). + * Then, it will upload a changes-xml within this changeset (and leave the changeset open) + * When upload is successfull, eventual id-rewriting will be handled (aka: don't worry about that) + * + * If 'dryrun' is specified, the changeset XML will be printed to console instead of being uploaded + * + */ public UploadChangeset( layout: LayoutConfig, allElements: ElementStorage, - generateChangeXML: (csid: string) => string, - continuation: () => void) { + generateChangeXML: (csid: string) => string) { if (this.userDetails.data.csCount == 0) { // The user became a contributor! @@ -62,7 +72,6 @@ export class ChangesetHandler { if (this._dryRun) { const changesetXML = generateChangeXML("123456"); console.log(changesetXML); - continuation(); return; } @@ -97,7 +106,7 @@ export class ChangesetHandler { // Mark the CS as closed... this.currentChangeset.setData(""); // ... and try again. As the cs is closed, no recursive loop can exist - self.UploadChangeset(layout, allElements, generateChangeXML, continuation); + self.UploadChangeset(layout, allElements, generateChangeXML); } ) @@ -105,7 +114,60 @@ export class ChangesetHandler { } } - public CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => { + + /** + * Deletes the element with the given ID from the OSM database. + * DOES NOT PERFORM ANY SAFETY CHECKS! + * + * For the deletion of an element, a new, seperate changeset is created with a slightly changed comment and some extra flags set. + * The CS will be closed afterwards. + * + * If dryrun is specified, will not actually delete the point but print the CS-XML to console instead + * + */ + public DeleteElement(object: OsmObject, + layout: LayoutConfig, + reason: string, + allElements: ElementStorage, + continuation: () => void) { + + function generateChangeXML(csId: string) { + let [lat, lon] = object.centerpoint(); + + let changes = ``; + changes += + `<${object.type} id="${object.id}" version="${object.version}" changeset="${csId}" lat="${lat}" lon="${lon}" />`; + changes += ""; + + return changes; + + } + + + if (this._dryRun) { + const changesetXML = generateChangeXML("123456"); + console.log(changesetXML); + return; + } + + const self = this; + this.OpenChangeset(layout, (csId: string) => { + + // The cs is open - let us actually upload! + const changes = generateChangeXML(csId) + + self.AddChange(csId, changes, allElements, (csId) => { + console.log("Successfully deleted ", object.id) + self.CloseChangeset(csId, continuation) + }, (csId) => { + alert("Deletion failed... Should not happend") + // FAILED + self.CloseChangeset(csId, continuation) + }) + }, true, reason) + } + + private CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => { }) { if (changesetId === undefined) { changesetId = this.currentChangeset.data; @@ -133,15 +195,25 @@ export class ChangesetHandler { private OpenChangeset( layout: LayoutConfig, - continuation: (changesetId: string) => void) { + continuation: (changesetId: string) => void, + isDeletionCS: boolean = false, + deletionReason: string = undefined) { const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : ""; + let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}` + if (isDeletionCS) { + comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}` + if(deletionReason){ + comment += ": "+deletionReason; + } + } let path = window.location.pathname; path = path.substr(1, path.lastIndexOf("/")); const metadata = [ ["created_by", `MapComplete ${Constants.vNumber}`], - ["comment", `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`], + ["comment", comment], + ["deletion", isDeletionCS ? "yes" : undefined], ["theme", layout.id], ["language", Locale.language.data], ["host", window.location.host], @@ -172,11 +244,21 @@ export class ChangesetHandler { }); } + /** + * Upload a changesetXML + * @param changesetId + * @param changesetXML + * @param allElements + * @param continuation + * @param onFail + * @constructor + * @private + */ private AddChange(changesetId: string, changesetXML: string, allElements: ElementStorage, continuation: ((changesetId: string, idMapping: any) => void), - onFail: ((changesetId: string) => void) = undefined) { + onFail: ((changesetId: string, reason: string) => void) = undefined) { this.auth.xhr({ method: 'POST', options: {header: {'Content-Type': 'text/xml'}}, @@ -186,7 +268,7 @@ export class ChangesetHandler { if (response == null) { console.log("err", err); if (onFail) { - onFail(changesetId); + onFail(changesetId, err); } return; } diff --git a/Logic/Osm/DeleteAction.ts b/Logic/Osm/DeleteAction.ts new file mode 100644 index 000000000..8694a522f --- /dev/null +++ b/Logic/Osm/DeleteAction.ts @@ -0,0 +1,207 @@ +import {UIEventSource} from "../UIEventSource"; +import {Translation} from "../../UI/i18n/Translation"; +import Translations from "../../UI/i18n/Translations"; +import {OsmObject} from "./OsmObject"; +import State from "../../State"; +import Constants from "../../Models/Constants"; + +export default class DeleteAction { + + public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean, reason: Translation }>; + private readonly _id: string; + + constructor(id: string) { + this._id = id; + + this.canBeDeleted = new UIEventSource<{canBeDeleted?: boolean; reason: Translation}>({ + canBeDeleted : false, + reason: Translations.t.delete.loading + }) + + this.CheckDeleteability() + } + + + public DoDelete(reason: string): UIEventSource { + const isDeleted = new UIEventSource(false) + + const self = this; + let deletionStarted = false; + this.canBeDeleted.addCallbackAndRun( + canBeDeleted => { + if (!canBeDeleted) { + // We are not allowed to delete (yet), this might change in the future though + return; + } + + if (isDeleted.data) { + // Already deleted... + return; + } + + if (deletionStarted) { + // Deletion is already running... + return; + } + deletionStarted = true; + OsmObject.DownloadObject(self._id).addCallbackAndRun(obj => { + if(obj === undefined){ + return; + } + State.state.osmConnection.changesetHandler.DeleteElement( + obj, + State.state.layoutToUse.data, + reason, + State.state.allElements, + () => { + isDeleted.setData(true) + } + ) + }) + + } + ) + + return isDeleted; + } + + /** + * Checks if the currently logged in user can delete the current point. + * State is written into this._canBeDeleted + * @constructor + * @private + */ + private CheckDeleteability(): void { + const t = Translations.t.delete; + const id = this._id; + const state = this.canBeDeleted + if (!id.startsWith("node")) { + this.canBeDeleted.setData({ + canBeDeleted: false, + reason: t.isntAPoint + }) + return; + } + + // Does the currently logged in user have enough experience to delete this point? + + const deletingPointsOfOtherAllowed = State.state.osmConnection.userDetails.map(ud => { + if (ud === undefined) { + return undefined; + } + if(!ud.loggedIn){ + return false; + } + return ud.csCount >= Constants.userJourney.deletePointsOfOthersUnlock; + }) + + const previousEditors = new UIEventSource(undefined) + + const allByMyself = previousEditors.map(previous => { + if (previous === null || previous === undefined) { + // Not yet downloaded + return null; + } + const userId = State.state.osmConnection.userDetails.data.uid; + return !previous.some(editor => editor !== userId) + }, [State.state.osmConnection.userDetails]) + + + // User allowed OR only edited by self? + const deletetionAllowed = deletingPointsOfOtherAllowed.map(isAllowed => { + if (isAllowed === undefined) { + // No logged in user => definitively not allowed to delete! + return false; + } + if (isAllowed === true) { + return true; + } + + // At this point, the logged in user is not allowed to delete points created/edited by _others_ + // however, we query OSM and if it turns out the current point has only be edited by the current user, deletion is allowed after all! + + if (allByMyself.data === null) { + // We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above + OsmObject.DownloadHistory(id).map(versions => versions.map(version => version.tags["_last_edit:contributor:uid"])).syncWith(previousEditors) + } + if (allByMyself.data === true) { + // Yay! We can download! + return true; + } + if (allByMyself.data === false) { + // Nope, downloading not allowed... + return false; + } + + + // At this point, we don't have enough information yet to decide if the user is allowed to delete the current point... + return undefined; + }, [allByMyself]) + + + const hasRelations: UIEventSource = new UIEventSource(null) + const hasWays: UIEventSource = new UIEventSource(null) + deletetionAllowed.addCallbackAndRunD(deletetionAllowed => { + + if (deletetionAllowed === false) { + // Nope, we are not allowed to delete + state.setData({ + canBeDeleted: false, + reason: t.notEnoughExperience + }) + return; + } + + + // All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations + OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => { + hasRelations.setData(rels.length > 0) + }) + + OsmObject.DownloadReferencingWays(id).addCallbackAndRunD(ways => { + hasWays.setData(ways.length > 0) + }) + }) + + + const hasWaysOrRelations = hasRelations.map(hasRelationsData => { + if (hasRelationsData === true) { + return true; + } + if (hasWays.data === true) { + return true; + } + if (hasWays.data === false && hasRelationsData === false) { + return false; + } + return null; + }, [hasWays]) + + hasWaysOrRelations.addCallbackAndRun( + waysOrRelations => { + if (waysOrRelations == null) { + // Not yet loaded - we still wait a little bit + return; + } + if (waysOrRelations) { + // not deleteble by mapcomplete + state.setData({ + canBeDeleted: false, + reason: t.partOfOthers + }) + } + + // alright, this point can be safely deleted! + state.setData({ + canBeDeleted: true, + reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete + }) + + } + ) + + + } + + +} \ No newline at end of file diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index 0ef3c5ba2..a3df9be9f 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -8,6 +8,7 @@ import Svg from "../../Svg"; import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; import Img from "../../UI/Base/Img"; import {Utils} from "../../Utils"; +import {OsmObject} from "./OsmObject"; export default class UserDetails { @@ -20,6 +21,11 @@ export default class UserDetails { public totalMessages = 0; public dryRun: boolean; home: { lon: number; lat: number }; + public backend: string; + + constructor(backend: string) { + this.backend = backend; + } } export class OsmConnection { @@ -62,9 +68,10 @@ export class OsmConnection { this._singlePage = singlePage; this._oauth_config = OsmConnection._oauth_configs[osmConfiguration] ?? OsmConnection._oauth_configs.osm; console.debug("Using backend", this._oauth_config.url) + OsmObject.SetBackendUrl(this._oauth_config.url + "/") this._iframeMode = Utils.runningFromConsole ? false : window !== window.top; - this.userDetails = new UIEventSource(new UserDetails(), "userDetails"); + this.userDetails = new UIEventSource(new UserDetails(this._oauth_config.url), "userDetails"); this.userDetails.data.dryRun = dryRun; const self =this; this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => { @@ -103,10 +110,8 @@ export class OsmConnection { public UploadChangeset( layout: LayoutConfig, allElements: ElementStorage, - generateChangeXML: (csid: string) => string, - continuation: () => void = () => { - }) { - this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML, continuation); + generateChangeXML: (csid: string) => string) { + this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML); } public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource { diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index b09f515d0..b9d1b9076 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -1,10 +1,17 @@ import * as $ from "jquery" import {Utils} from "../../Utils"; import * as polygon_features from "../../assets/polygon-features.json"; +import {UIEventSource} from "../UIEventSource"; export abstract class OsmObject { + protected static backendURL = "https://www.openstreetmap.org/" + private static polygonFeatures = OsmObject.constructPolygonFeatures() + private static objectCache = new Map>(); + private static referencingWaysCache = new Map>(); + private static referencingRelationsCache = new Map>(); + private static historyCache = new Map>(); type: string; id: number; tags: {} = {}; @@ -12,9 +19,6 @@ export abstract class OsmObject { public changed: boolean = false; timestamp: Date; - - private static polygonFeatures = OsmObject.constructPolygonFeatures() - protected constructor(type: string, id: number) { this.id = id; this.type = type; @@ -23,67 +27,99 @@ export abstract class OsmObject { } } - static DownloadObject(id, continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) { + public static SetBackendUrl(url: string) { + if (!url.endsWith("/")) { + throw "Backend URL must end with a '/'" + } + if (!url.startsWith("http")) { + throw "Backend URL must begin with http" + } + this.backendURL = url; + } + + static DownloadObject(id): UIEventSource { + if (OsmObject.objectCache.has(id)) { + return OsmObject.objectCache.get(id) + } const splitted = id.split("/"); const type = splitted[0]; const idN = splitted[1]; - const newContinuation = (element: OsmObject, meta: OsmObjectMeta) => { - continuation(element, meta); + const src = new UIEventSource(undefined) + OsmObject.objectCache.set(id, src); + const newContinuation = (element: OsmObject) => { + src.setData(element) } switch (type) { case("node"): - return new OsmNode(idN).Download(newContinuation); + new OsmNode(idN).Download(newContinuation); + break; case("way"): - return new OsmWay(idN).Download(newContinuation); + new OsmWay(idN).Download(newContinuation); + break; case("relation"): - return new OsmRelation(idN).Download(newContinuation); + new OsmRelation(idN).Download(newContinuation); + break; } + return src; } /** * Downloads the ways that are using this node. * Beware: their geometry will be incomplete! - * @param id - * @param continuation - * @constructor */ - public static DownloadReferencingWays(id: string, continuation: (referencingWays: OsmWay[]) => void){ - Utils.downloadJson(`https://www.openStreetMap.org/api/0.6/${id}/ways`) + public static DownloadReferencingWays(id: string): UIEventSource { + if (OsmObject.referencingWaysCache.has(id)) { + return OsmObject.referencingWaysCache.get(id); + } + const waysSrc = new UIEventSource([]) + OsmObject.referencingWaysCache.set(id, waysSrc); + Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`) .then(data => { - const ways = data.elements.map(wayInfo => { + const ways = data.elements.map(wayInfo => { const way = new OsmWay(wayInfo.id) way.LoadData(wayInfo) return way }) - continuation(ways) + waysSrc.setData(ways) }) + return waysSrc; } + /** * Downloads the relations that are using this feature. * Beware: their geometry will be incomplete! - * @param id - * @param continuation - * @constructor */ - public static DownloadReferencingRelations(id: string, continuation: (referencingRelations: OsmRelation[]) => void){ - Utils.downloadJson(`https://www.openStreetMap.org/api/0.6/${id}/relations`) + public static DownloadReferencingRelations(id: string): UIEventSource { + if (OsmObject.referencingRelationsCache.has(id)) { + return OsmObject.referencingRelationsCache.get(id); + } + const relsSrc = new UIEventSource([]) + OsmObject.referencingRelationsCache.set(id, relsSrc); + Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`) .then(data => { const rels = data.elements.map(wayInfo => { const rel = new OsmRelation(wayInfo.id) rel.LoadData(wayInfo) return rel }) - continuation(rels) + relsSrc.setData(rels) }) + return relsSrc; } - public static DownloadHistory(id: string, continuation: (versions: OsmObject[]) => void): void{ + + public static DownloadHistory(id: string): UIEventSource { + if (OsmObject.historyCache.has(id)) { + return OsmObject.historyCache.get(id) + } const splitted = id.split("/"); const type = splitted[0]; const idN = splitted[1]; - $.getJSON("https://www.openStreetMap.org/api/0.6/" + type + "/" + idN + "/history", data => { + const src = new UIEventSource([]); + OsmObject.historyCache.set(id, src); + Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${type}/${idN}/history`).then(data => { const elements: any[] = data.elements; const osmObjects: OsmObject[] = [] for (const element of elements) { @@ -103,30 +139,42 @@ export abstract class OsmObject { osmObject?.SaveExtraData(element, []); osmObjects.push(osmObject) } - continuation(osmObjects) + src.setData(osmObjects) + }) + return src; + } + + // bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds) + public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) { + const minlon = bounds[0][1] + const maxlon = bounds[1][1] + const minlat = bounds[1][0] + const maxlat = bounds[0][0]; + const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}` + $.getJSON(url, data => { + const elements: any[] = data.elements; + const objects = OsmObject.ParseObjects(elements) + callback(objects); + }) } - private static constructPolygonFeatures(): Map, blacklist: boolean }> { - const result = new Map, blacklist: boolean }>(); + public static DownloadAll(neededIds): UIEventSource { + // local function which downloads all the objects one by one + // this is one big loop, running one download, then rerunning the entire function - for (const polygonFeature of polygon_features) { - const key = polygonFeature.key; - - if (polygonFeature.polygon === "all") { - result.set(key, {values: null, blacklist: false}) - continue + const allSources: UIEventSource [] = neededIds.map(id => OsmObject.DownloadObject(id)) + const allCompleted = new UIEventSource(undefined).map(_ => { + return !allSources.some(uiEventSource => uiEventSource.data === undefined) + }, allSources) + return allCompleted.map(completed => { + if (completed) { + return allSources.map(src => src.data) } - - const blacklist = polygonFeature.polygon === "blacklist" - result.set(key, {values: new Set(polygonFeature.values), blacklist: blacklist}) - - } - - return result; + return [] + }); } - protected static isPolygon(tags: any): boolean { for (const tagsKey in tags) { if (!tags.hasOwnProperty(tagsKey)) { @@ -145,43 +193,23 @@ export abstract class OsmObject { } } - // bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds) - public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) { - const minlon = bounds[0][1] - const maxlon = bounds[1][1] - const minlat = bounds[1][0] - const maxlat = bounds[0][0]; - const url = `https://www.openstreetmap.org/api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}` - $.getJSON(url, data => { - const elements: any[] = data.elements; - const objects = OsmObject.ParseObjects(elements) - callback(objects); + private static constructPolygonFeatures(): Map, blacklist: boolean }> { + const result = new Map, blacklist: boolean }>(); - }) - } + for (const polygonFeature of polygon_features) { + const key = polygonFeature.key; - //Loads an area from the OSM-api. - - public static DownloadAll(neededIds, knownElements: any = {}, continuation: ((knownObjects: any) => void)) { - // local function which downloads all the objects one by one - // this is one big loop, running one download, then rerunning the entire function - if (neededIds.length == 0) { - continuation(knownElements); - return; - } - const neededId = neededIds.pop(); - - if (neededId in knownElements) { - OsmObject.DownloadAll(neededIds, knownElements, continuation); - return; - } - - OsmObject.DownloadObject(neededId, - function (element) { - knownElements[neededId] = element; // assign the element for later, continue downloading the next element - OsmObject.DownloadAll(neededIds, knownElements, continuation); + if (polygonFeature.polygon === "all") { + result.set(key, {values: null, blacklist: false}) + continue } - ); + + const blacklist = polygonFeature.polygon === "blacklist" + result.set(key, {values: new Set(polygonFeature.values), blacklist: blacklist}) + + } + + return result; } private static ParseObjects(elements: any[]): OsmObject[] { @@ -245,7 +273,7 @@ export abstract class OsmObject { Download(continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) { const self = this; const full = this.type !== "way" ? "" : "/full"; - const url = "https://www.openstreetmap.org/api/0.6/" + this.type + "/" + this.id + full; + const url = `${OsmObject.backendURL}api/0.6/${this.type}/${this.id}${full}`; $.getJSON(url, function (data) { const element = data.elements.pop(); diff --git a/UI/BigComponents/UserBadge.ts b/UI/BigComponents/UserBadge.ts index 26214661a..6b830a5c6 100644 --- a/UI/BigComponents/UserBadge.ts +++ b/UI/BigComponents/UserBadge.ts @@ -56,7 +56,7 @@ export default class UserBadge extends Toggle { let messageSpan = new Link( new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle), - 'https://www.openstreetmap.org/messages/inbox', + `${user.backend}/messages/inbox`, true ) @@ -64,14 +64,14 @@ export default class UserBadge extends Toggle { const csCount = new Link( new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle), - `https://www.openstreetmap.org/user/${user.name}/history`, + `${user.backend}/user/${user.name}/history`, true); if (user.unreadMessages > 0) { messageSpan = new Link( new Combine([Svg.envelope, "" + user.unreadMessages]), - 'https://www.openstreetmap.org/messages/inbox', + '${user.backend}/messages/inbox', true ).SetClass("alert") } @@ -83,22 +83,22 @@ export default class UserBadge extends Toggle { const settings = new Link(Svg.gear_svg(), - `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}/account`, + `${user.backend}/user/${encodeURIComponent(user.name)}/account`, true) const userIcon = new Link( - new Img(user.img) + user.img === undefined ? Svg.osm_logo_ui() : new Img(user.img) .SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 h16 float-left") , - `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`, + `${user.backend}/user/${encodeURIComponent(user.name)}`, true ); const userName = new Link( new FixedUiElement(user.name), - `https://www.openstreetmap.org/user/${user.name}`, + `${user.backend}/user/${user.name}`, true); diff --git a/UI/OpeningHours/PublicHolidayInput.ts b/UI/OpeningHours/PublicHolidayInput.ts index dd3c7b9f5..8c1049a29 100644 --- a/UI/OpeningHours/PublicHolidayInput.ts +++ b/UI/OpeningHours/PublicHolidayInput.ts @@ -17,7 +17,7 @@ export default class PublicHolidayInput extends InputElement { this._value = value; } - + GetValue(): UIEventSource { return this._value; } @@ -25,18 +25,56 @@ export default class PublicHolidayInput extends InputElement { IsValid(t: string): boolean { return true; } - + + protected InnerConstructElement(): HTMLElement { + const dropdown = new DropDown( + Translations.t.general.opening_hours.open_during_ph.Clone(), + [ + {shown: Translations.t.general.opening_hours.ph_not_known.Clone(), value: ""}, + {shown: Translations.t.general.opening_hours.ph_closed.Clone(), value: "off"}, + {shown: Translations.t.general.opening_hours.ph_open_as_usual.Clone(), value: "open"}, + {shown: Translations.t.general.opening_hours.ph_open.Clone(), value: " "}, + ] + ).SetClass("inline-block"); + /* + * Either "" (unknown), " " (opened) or "off" (closed) + * */ + const mode = dropdown.GetValue(); + + + const start = new TextField({ + placeholder: "starthour", + htmlType: "time" + }).SetClass("inline-block"); + const end = new TextField({ + placeholder: "starthour", + htmlType: "time" + }).SetClass("inline-block"); + + const askHours = new Toggle( + new Combine([ + Translations.t.general.opening_hours.opensAt.Clone(), + start, + Translations.t.general.opening_hours.openTill.Clone(), + end + ]), + undefined, + mode.map(mode => mode === " ") + ) + + this.SetupDataSync(mode, start.GetValue(), end.GetValue()) + + return new Combine([ + dropdown, + askHours + ]).ConstructElement() + } + private SetupDataSync(mode: UIEventSource, startTime: UIEventSource, endTime: UIEventSource) { const value = this._value; - value.addCallbackAndRun(ph => { - if (ph === undefined) { - return; - } - const parsed = OH.ParsePHRule(ph); - if (parsed === null) { - return; - } + value.map(ph => OH.ParsePHRule(ph)) + .addCallbackAndRunD(parsed => { mode.setData(parsed.mode) startTime.setData(parsed.start) endTime.setData(parsed.end) @@ -72,50 +110,5 @@ export default class PublicHolidayInput extends InputElement { }, [startTime, endTime] ) } - - - protected InnerConstructElement(): HTMLElement { - const dropdown = new DropDown( - Translations.t.general.opening_hours.open_during_ph.Clone(), - [ - {shown: Translations.t.general.opening_hours.ph_not_known.Clone(), value: ""}, - {shown: Translations.t.general.opening_hours.ph_closed.Clone(), value: "off"}, - {shown:Translations.t.general.opening_hours.ph_open_as_usual.Clone(), value: "open"}, - {shown: Translations.t.general.opening_hours.ph_open.Clone(), value: " "}, - ] - ).SetClass("inline-block"); - /* - * Either "" (unknown), " " (opened) or "off" (closed) - * */ - const mode = dropdown.GetValue(); - - - const start = new TextField({ - placeholder: "starthour", - htmlType: "time" - }).SetClass("inline-block"); - const end = new TextField({ - placeholder: "starthour", - htmlType: "time" - }).SetClass("inline-block"); - - const askHours = new Toggle( - new Combine([ - Translations.t.general.opening_hours.opensAt.Clone(), - start, - Translations.t.general.opening_hours.openTill.Clone(), - end - ]), - undefined, - mode.map(mode => mode === " ") - ) - - this.SetupDataSync(mode, start.GetValue(), end.GetValue()) - - return new Combine([ - dropdown, - askHours - ]).ConstructElement() - } } \ No newline at end of file diff --git a/UI/Popup/DeleteButton.ts b/UI/Popup/DeleteButton.ts index a0bc50cf3..37e0d1270 100644 --- a/UI/Popup/DeleteButton.ts +++ b/UI/Popup/DeleteButton.ts @@ -1,72 +1,72 @@ import {VariableUiElement} from "../Base/VariableUIElement"; -import {OsmObject} from "../../Logic/Osm/OsmObject"; -import {UIEventSource} from "../../Logic/UIEventSource"; -import {Translation} from "../i18n/Translation"; import State from "../../State"; import Toggle from "../Input/Toggle"; import Translations from "../i18n/Translations"; -import Loading from "../Base/Loading"; -import UserDetails from "../../Logic/Osm/OsmConnection"; -import Constants from "../../Models/Constants"; import {SubtleButton} from "../Base/SubtleButton"; import Svg from "../../Svg"; -import {Utils} from "../../Utils"; +import DeleteAction from "../../Logic/Osm/DeleteAction"; +import {Tag} from "../../Logic/Tags/Tag"; +import CheckBoxes from "../Input/Checkboxes"; +import {RadioButton} from "../Input/RadioButton"; +import {FixedInputElement} from "../Input/FixedInputElement"; +import {TextField} from "../Input/TextField"; -export default class DeleteButton extends Toggle { - constructor(id: string) { +export default class DeleteWizard extends Toggle { + /** + * The UI-element which triggers 'deletion' (either soft or hard). + * + * - A 'hard deletion' is if the point is actually deleted from the OSM database + * - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore. + * Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too + * + * A deletion is only possible if the user is logged in. + * A soft deletion is only possible if tags are provided + * A hard deletion is only possible if the user has sufficient rigts + * + * If no deletion is possible at all, the delete button will not be shown - but a reason will be shown instead. + * + * @param id: The id of the element to remove + * @param softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted + */ + constructor(id: string, softDeletionTags? : Tag[]) { + const t = Translations.t.delete - const hasRelations: UIEventSource = new UIEventSource(null) - OsmObject.DownloadReferencingRelations(id, (rels) => { - hasRelations.setData(rels.length > 0) - }) + const deleteAction = new DeleteAction(id); + + const deleteReasons = new RadioButton( + [new FixedInputElement( + t.reasons.test, "test" + ), + new FixedInputElement(t.reasons.disused, "disused"), + new FixedInputElement(t.reasons.notFound, "not found"), + new TextField()] + + ) - const hasWays: UIEventSource = new UIEventSource(null) - OsmObject.DownloadReferencingWays(id, (ways) => { - hasWays.setData(ways.length > 0) - }) - - const previousEditors = new UIEventSource(null) - OsmObject.DownloadHistory(id, versions => { - const uids = versions.map(version => version.tags["_last_edit:contributor:uid"]) - previousEditors.setData(uids) - }) - const allByMyself = previousEditors.map(previous => { - if (previous === null) { - return null; - } - const userId = State.state.osmConnection.userDetails.data.uid; - return !previous.some(editor => editor !== userId) - }, [State.state.osmConnection.userDetails]) - - const t = Translations.t.deleteButton + const deleteButton = new SubtleButton( + Svg.delete_icon_svg(), + t.delete.Clone() + ).onClick(() => deleteAction.DoDelete(deleteReasons.GetValue().data)) + + + super( + + + new Toggle( - new VariableUiElement( - hasRelations.map(hasRelations => { - if (hasRelations === null || hasWays.data === null) { - return new Loading() - } - if (hasWays.data || hasRelations) { - return t.partOfOthers.Clone() - } + deleteButton, + new VariableUiElement(deleteAction.canBeDeleted.map(cbd => cbd.reason.Clone())), + deleteAction.canBeDeleted.map(cbd => cbd.canBeDeleted) + ), + + + + t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin), + State.state.osmConnection.isLoggedIn + ) - return new Toggle( - new SubtleButton(Svg.delete_icon_svg(), t.delete.Clone()), - t.notEnoughExperience.Clone(), - State.state.osmConnection.userDetails.map(userinfo => - allByMyself.data || - userinfo.csCount >= Constants.userJourney.deletePointsOfOthersUnlock, - [allByMyself]) - ) - - }, [hasWays]) - ), - t.onlyEditedByLoggedInUser.Clone().onClick(State.state.osmConnection.AttemptLogin), - State.state.osmConnection.isLoggedIn), - t.isntAPoint, - new UIEventSource(id.startsWith("node")) - ); } } \ No newline at end of file diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index a5fe60199..97dcbefda 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -69,7 +69,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { if (!hasMinimap) { renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap"))) } - + renderings.push( new VariableUiElement( State.state.osmConnection.userDetails.map(userdetails => { diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 92568e9fb..4fc48f2cc 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -35,7 +35,7 @@ export default class TagRenderingQuestion extends Combine { configuration: TagRenderingConfig, units: Unit[], afterSave?: () => void, - cancelButton?: BaseUIElement + cancelButton?: BaseUIElement, ) { if (configuration === undefined) { throw "A question is needed for a question visualization" diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 6aa8407c9..5c55bb3b4 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -62,6 +62,9 @@ export default class ShowDataLayer { const allFeats = features.data.map(ff => ff.feature); geoLayer = self.CreateGeojsonLayer(); for (const feat of allFeats) { + if(feat === undefined){ + continue + } // @ts-ignore geoLayer.addData(feat); } @@ -76,7 +79,13 @@ export default class ShowDataLayer { } if (zoomToFeatures) { + try{ + mp.fitBounds(geoLayer.getBounds()) + + }catch(e){ + console.error(e) + } } @@ -169,8 +178,8 @@ export default class ShowDataLayer { infobox.Activate(); }); const self = this; - State.state.selectedElement.addCallbackAndRun(selected => { - if (selected === undefined || self._leafletMap.data === undefined) { + State.state.selectedElement.addCallbackAndRunD(selected => { + if ( self._leafletMap.data === undefined) { return; } if (leafletLayer.getPopup().isOpen()) { diff --git a/index.ts b/index.ts index ccba3d5ac..70b06bf30 100644 --- a/index.ts +++ b/index.ts @@ -45,7 +45,8 @@ if (location.href.indexOf("buurtnatuur.be") >= 0) { let testing: UIEventSource; -if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { +if (QueryParameters.GetQueryParameter("backend", undefined).data !== "osm-test" && + (location.hostname === "localhost" || location.hostname === "127.0.0.1")) { testing = QueryParameters.GetQueryParameter("test", "true"); // Set to true if testing and changes should NOT be saved testing.setData(testing.data ?? "true") diff --git a/langs/en.json b/langs/en.json index 0c574bca7..9ec787501 100644 --- a/langs/en.json +++ b/langs/en.json @@ -27,14 +27,20 @@ "intro": "MapComplete is an OpenStreetMap-viewer and editor, which shows you information about a specific theme.", "pickTheme": "Pick a theme below to get started." }, - "deleteButton": { + "delete": { "delete": "Delete", "loginToDelete": "You must be logged in to delete a point", - "checkingDeletability": "Inspecting properties to check if this feature can be deleted", + "safeDelete": "This point can be safely deleted", "isntAPoint": "Only points can be deleted", "onlyEditedByLoggedInUser": "This point has only be edited by yourself, you can safely delete it", "notEnoughExperience": "You don't have enough experience to delete points made by other people. Make more edits to improve your skills", - "partOfOthers": "This point is part of some way or relation, so you can not delete it" + "partOfOthers": "This point is part of some way or relation, so you can not delete it", + "loading": "Inspecting properties to check if this feature can be deleted", + "reasons": { + "test": "This was a testing point - the feature was never actually there", + "disused": "This feature is disused or removed", + "notFound": "This feature couldn't be found" + } }, "general": { "loginWithOpenStreetMap": "Login with OpenStreetMap", From 5d3365afb8cc5b674847afc0c5c8dd1549f10783 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 1 Jul 2021 02:26:45 +0200 Subject: [PATCH 14/26] First draft of an element which deletes points --- Logic/Osm/Changes.ts | 1 + Logic/Osm/DeleteAction.ts | 72 +++++---- Logic/Tags/Tag.ts | 2 +- Models/Constants.ts | 2 +- UI/Popup/DeleteButton.ts | 72 --------- UI/Popup/DeleteWizard.ts | 244 +++++++++++++++++++++++++++++++ UI/Popup/EditableTagRendering.ts | 13 +- UI/Popup/QuestionBox.ts | 23 +-- UI/Popup/SaveButton.ts | 8 +- UI/Popup/TagRenderingQuestion.ts | 94 ++++++------ UI/i18n/Translations.ts | 3 + langs/en.json | 21 ++- test.ts | 21 ++- 13 files changed, 407 insertions(+), 169 deletions(-) delete mode 100644 UI/Popup/DeleteButton.ts create mode 100644 UI/Popup/DeleteWizard.ts diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index e7fd28698..ff5de32bf 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -64,6 +64,7 @@ export class Changes implements FeatureSource{ if (elementTags[change.k] !== change.v) { elementTags[change.k] = change.v; console.log("Applied ", change.k, "=", change.v) + // We use 'elementTags.id' here, as we might have retrieved with the id 'node/-1' as new point, but should use the rewritten id this.pending.data.push({elementId: elementTags.id, key: change.k, value: change.v}); } } diff --git a/Logic/Osm/DeleteAction.ts b/Logic/Osm/DeleteAction.ts index 8694a522f..5671504dd 100644 --- a/Logic/Osm/DeleteAction.ts +++ b/Logic/Osm/DeleteAction.ts @@ -8,44 +8,56 @@ import Constants from "../../Models/Constants"; export default class DeleteAction { public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean, reason: Translation }>; + public readonly isDeleted = new UIEventSource(false); private readonly _id: string; + constructor(id: string) { this._id = id; - this.canBeDeleted = new UIEventSource<{canBeDeleted?: boolean; reason: Translation}>({ - canBeDeleted : false, + this.canBeDeleted = new UIEventSource<{ canBeDeleted?: boolean; reason: Translation }>({ + canBeDeleted: undefined, reason: Translations.t.delete.loading }) - - this.CheckDeleteability() + + this.CheckDeleteability(false) } - public DoDelete(reason: string): UIEventSource { - const isDeleted = new UIEventSource(false) - + /** + * Does actually delete the feature; returns the event source 'this.isDeleted' + * If deletion is not allowed, triggers the callback instead + */ + public DoDelete(reason: string, onNotAllowed : () => void): UIEventSource { + const isDeleted = this.isDeleted const self = this; let deletionStarted = false; this.canBeDeleted.addCallbackAndRun( canBeDeleted => { + if (isDeleted.data || deletionStarted) { + // Already deleted... + return; + } + + if(canBeDeleted.canBeDeleted === false){ + // We aren't allowed to delete + deletionStarted = true; + onNotAllowed(); + isDeleted.setData(true); + return; + } + if (!canBeDeleted) { // We are not allowed to delete (yet), this might change in the future though return; } - if (isDeleted.data) { - // Already deleted... - return; - } + - if (deletionStarted) { - // Deletion is already running... - return; - } + deletionStarted = true; OsmObject.DownloadObject(self._id).addCallbackAndRun(obj => { - if(obj === undefined){ + if (obj === undefined) { return; } State.state.osmConnection.changesetHandler.DeleteElement( @@ -58,7 +70,7 @@ export default class DeleteAction { } ) }) - + } ) @@ -71,7 +83,7 @@ export default class DeleteAction { * @constructor * @private */ - private CheckDeleteability(): void { + public CheckDeleteability(useTheInternet: boolean): void { const t = Translations.t.delete; const id = this._id; const state = this.canBeDeleted @@ -89,7 +101,7 @@ export default class DeleteAction { if (ud === undefined) { return undefined; } - if(!ud.loggedIn){ + if (!ud.loggedIn) { return false; } return ud.csCount >= Constants.userJourney.deletePointsOfOthersUnlock; @@ -120,7 +132,7 @@ export default class DeleteAction { // At this point, the logged in user is not allowed to delete points created/edited by _others_ // however, we query OSM and if it turns out the current point has only be edited by the current user, deletion is allowed after all! - if (allByMyself.data === null) { + if (allByMyself.data === null && useTheInternet) { // We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above OsmObject.DownloadHistory(id).map(versions => versions.map(version => version.tags["_last_edit:contributor:uid"])).syncWith(previousEditors) } @@ -142,7 +154,7 @@ export default class DeleteAction { const hasRelations: UIEventSource = new UIEventSource(null) const hasWays: UIEventSource = new UIEventSource(null) deletetionAllowed.addCallbackAndRunD(deletetionAllowed => { - + if (deletetionAllowed === false) { // Nope, we are not allowed to delete state.setData({ @@ -152,6 +164,9 @@ export default class DeleteAction { return; } + if (!useTheInternet) { + return; + } // All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => { @@ -171,6 +186,9 @@ export default class DeleteAction { if (hasWays.data === true) { return true; } + if (hasWays.data === null || hasRelationsData === null) { + return null; + } if (hasWays.data === false && hasRelationsData === false) { return false; } @@ -189,13 +207,15 @@ export default class DeleteAction { canBeDeleted: false, reason: t.partOfOthers }) + }else{ + // alright, this point can be safely deleted! + state.setData({ + canBeDeleted: true, + reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete + }) } - // alright, this point can be safely deleted! - state.setData({ - canBeDeleted: true, - reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete - }) + } ) diff --git a/Logic/Tags/Tag.ts b/Logic/Tags/Tag.ts index a3a465006..c16833ae5 100644 --- a/Logic/Tags/Tag.ts +++ b/Logic/Tags/Tag.ts @@ -51,7 +51,7 @@ export class Tag extends TagsFilter { return new Tag(this.key, TagUtils.ApplyTemplate(this.value as string, tags)); } - asHumanString(linkToWiki: boolean, shorten: boolean) { + asHumanString(linkToWiki?: boolean, shorten?: boolean) { let v = this.value; if (shorten) { v = Utils.EllipsesAfter(v, 25); diff --git a/Models/Constants.ts b/Models/Constants.ts index 3482b3193..5dea2653e 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -9,7 +9,7 @@ export default class Constants { moreScreenUnlock: 1, personalLayoutUnlock: 5, historyLinkVisible: 10, - deletePointsOfOthersUnlock: 15, + deletePointsOfOthersUnlock: 20, tagsVisibleAt: 25, tagsVisibleAndWikiLinked: 30, diff --git a/UI/Popup/DeleteButton.ts b/UI/Popup/DeleteButton.ts deleted file mode 100644 index 37e0d1270..000000000 --- a/UI/Popup/DeleteButton.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {VariableUiElement} from "../Base/VariableUIElement"; -import State from "../../State"; -import Toggle from "../Input/Toggle"; -import Translations from "../i18n/Translations"; -import {SubtleButton} from "../Base/SubtleButton"; -import Svg from "../../Svg"; -import DeleteAction from "../../Logic/Osm/DeleteAction"; -import {Tag} from "../../Logic/Tags/Tag"; -import CheckBoxes from "../Input/Checkboxes"; -import {RadioButton} from "../Input/RadioButton"; -import {FixedInputElement} from "../Input/FixedInputElement"; -import {TextField} from "../Input/TextField"; - - -export default class DeleteWizard extends Toggle { - /** - * The UI-element which triggers 'deletion' (either soft or hard). - * - * - A 'hard deletion' is if the point is actually deleted from the OSM database - * - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore. - * Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too - * - * A deletion is only possible if the user is logged in. - * A soft deletion is only possible if tags are provided - * A hard deletion is only possible if the user has sufficient rigts - * - * If no deletion is possible at all, the delete button will not be shown - but a reason will be shown instead. - * - * @param id: The id of the element to remove - * @param softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted - */ - constructor(id: string, softDeletionTags? : Tag[]) { - const t = Translations.t.delete - - const deleteAction = new DeleteAction(id); - - const deleteReasons = new RadioButton( - [new FixedInputElement( - t.reasons.test, "test" - ), - new FixedInputElement(t.reasons.disused, "disused"), - new FixedInputElement(t.reasons.notFound, "not found"), - new TextField()] - - ) - - const deleteButton = new SubtleButton( - Svg.delete_icon_svg(), - t.delete.Clone() - ).onClick(() => deleteAction.DoDelete(deleteReasons.GetValue().data)) - - - - - super( - - - - new Toggle( - deleteButton, - new VariableUiElement(deleteAction.canBeDeleted.map(cbd => cbd.reason.Clone())), - deleteAction.canBeDeleted.map(cbd => cbd.canBeDeleted) - ), - - - - t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin), - State.state.osmConnection.isLoggedIn - ) - - } -} \ No newline at end of file diff --git a/UI/Popup/DeleteWizard.ts b/UI/Popup/DeleteWizard.ts new file mode 100644 index 000000000..37f6efe5b --- /dev/null +++ b/UI/Popup/DeleteWizard.ts @@ -0,0 +1,244 @@ +import {VariableUiElement} from "../Base/VariableUIElement"; +import State from "../../State"; +import Toggle from "../Input/Toggle"; +import Translations from "../i18n/Translations"; +import Svg from "../../Svg"; +import DeleteAction from "../../Logic/Osm/DeleteAction"; +import {Tag} from "../../Logic/Tags/Tag"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {TagsFilter} from "../../Logic/Tags/TagsFilter"; +import TagRenderingQuestion from "./TagRenderingQuestion"; +import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; +import Combine from "../Base/Combine"; +import {SubtleButton} from "../Base/SubtleButton"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import {Translation} from "../i18n/Translation"; +import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; +import BaseUIElement from "../BaseUIElement"; +import {Changes} from "../../Logic/Osm/Changes"; +import {And} from "../../Logic/Tags/And"; + + +export default class DeleteWizard extends Toggle { + /** + * The UI-element which triggers 'deletion' (either soft or hard). + * + * - A 'hard deletion' is if the point is actually deleted from the OSM database + * - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore. + * Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too + * + * A deletion is only possible if the user is logged in. + * A soft deletion is only possible if tags are provided + * A hard deletion is only possible if the user has sufficient rigts + * + * There is also the possibility to have a 'trojan horse' option. If the user selects that option, it is NEVER removed, but the tags are applied. + * Ideal for the case of "THIS PATH IS ON MY GROUND AND SHOULD BE DELETED IMMEDIATELY OR I WILL GET MY LAWYER" but to mark it as private instead. + * (Note that _delete_reason is used as trigger to do actual deletion - setting such a tag WILL delete from the database with that as changeset comment) + * + * @param id: The id of the element to remove + * @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted + */ + constructor(id: string, + options?: { + noDeleteOptions?: { if: Tag[], then: Translation }[] + softDeletionTags?: Tag[] + }) { + + options = options ?? {} + const deleteAction = new DeleteAction(id); + const tagsSource = State.state.allElements.getEventSourceById(id) + + let softDeletionTags = options.softDeletionTags ?? [] + const allowSoftDeletion = softDeletionTags.length > 0 + + const confirm = new UIEventSource(false) + + + function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) { + if (reason !== undefined) { + tagsToApply.splice(0, 0, { + k: "fixme", + v: `A mapcomplete user marked this feature to be deleted (${reason})` + }) + } + (State.state?.changes ?? new Changes()) + .addTag(id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource); + } + + function doDelete(selected: TagsFilter) { + const tgs = selected.asChange(tagsSource.data) + const deleteReasonMatch = tgs.filter(kv => kv.k === "_delete_reason") + if (deleteReasonMatch.length > 0) { + // We should actually delete! + const deleteReason = deleteReasonMatch[0].v + deleteAction.DoDelete(deleteReason, () => { + // The user doesn't have sufficient permissions to _actually_ delete the feature + // We 'soft delete' instead (and add a fixme) + softDelete(deleteReason, tgs.filter(kv => kv.k !== "_delete_reason")) + + }); + return + } else { + // This is an injected tagging + softDelete(undefined, tgs) + } + + } + + + const t = Translations.t.delete + const cancelButton = t.cancel.Clone().SetClass("block btn btn-secondary").onClick(() => confirm.setData(false)); + const config = DeleteWizard.generateDeleteTagRenderingConfig(softDeletionTags, options.noDeleteOptions) + const question = new TagRenderingQuestion( + tagsSource, + config, + { + cancelButton: cancelButton, + /*Using a custom save button constructor erases all logic to actually save, so we have to listen for the click!*/ + saveButtonConstr: (v) => DeleteWizard.constructConfirmButton(v).onClick(() => { + doDelete(v.data) + }), + bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAction) + } + ) + + /** + * The button which is shown first. Opening it will trigger the check for deletions + */ + const deleteButton = new SubtleButton(Svg.delete_icon_svg(), t.delete).onClick( + () => { + deleteAction.CheckDeleteability(true) + confirm.setData(true); + } + ); + + super( + new Combine([Svg.delete_icon_svg().SetClass("h-16 w-16 p-2 m-2 block bg-gray-300 rounded-full"), + t.isDeleted.Clone()]).SetClass("flex m-2 rounded-full"), + new Toggle( + new Toggle( + new Toggle( + question, + + deleteButton, + confirm), + new VariableUiElement(deleteAction.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse]))), + deleteAction.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)), + t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin), + State.state.osmConnection.isLoggedIn + ), + deleteAction.isDeleted) + + } + + + private static constructConfirmButton(deleteReasons: UIEventSource): BaseUIElement { + const t = Translations.t.delete; + const btn = new Combine([ + Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), + t.delete.Clone() + ]).SetClass("flex btn bg-red-500") + + + const btnNonActive = new Combine([ + Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), + t.delete.Clone() + ]).SetClass("flex btn btn-disabled bg-red-200") + + return new Toggle( + btn, + btnNonActive, + deleteReasons.map(reason => reason !== undefined) + ) + + } + + + private static constructExplanation(tags: UIEventSource, deleteAction: DeleteAction) { + const t = Translations.t.delete; + return new VariableUiElement(tags.map( + currentTags => { + const cbd = deleteAction.canBeDeleted.data; + if (currentTags === undefined) { + return t.explanations.selectReason.Clone().SetClass("subtle"); + } + + const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason") + + if (cbd.canBeDeleted && hasDeletionTag) { + return t.explanations.hardDelete.Clone() + } + return new Combine([t.explanations.softDelete.Subs({reason: cbd.reason}), + new FixedUiElement(currentTags.asHumanString(false, true, currentTags)).SetClass("subtle") + ]).SetClass("flex flex-col") + + + } + , [deleteAction.canBeDeleted] + )).SetClass("block") + } + + private static generateDeleteTagRenderingConfig(softDeletionTags: Tag[], nonDeleteOptions: { + if: Tag[], + then: Translation + }[]) { + const t = Translations.t.delete + nonDeleteOptions = nonDeleteOptions ?? [] + const softDeletionTagsStr = (softDeletionTags ?? []).map(t => t.asHumanString(false, false)) + const nonDeleteOptionsStr: { if: AndOrTagConfigJson, then: any }[] = [] + for (const nonDeleteOption of nonDeleteOptions) { + const newIf: string[] = nonDeleteOption.if.map(tag => tag.asHumanString()) + + nonDeleteOptionsStr.push({ + if: {and: newIf}, + then: nonDeleteOption.then + }) + } + + return new TagRenderingConfig( + { + question: t.whyDelete, + render: "Deleted because {_delete_reason}", + freeform: { + key: "_delete_reason", + addExtraTags: softDeletionTagsStr + }, + mappings: [ + + ...nonDeleteOptionsStr, + + { + if: { + and: [ + "_delete_reason=testing point", + ...softDeletionTagsStr + ] + }, + then: t.reasons.test + }, + { + if: { + and: [ + "_delete_reason=disused", + ...softDeletionTagsStr + ] + }, + then: t.reasons.disused + }, + { + if: { + and: [ + "_delete_reason=not found", + ...softDeletionTagsStr + ] + }, + then: t.reasons.notFound + } + ] + + + }, undefined, "Delete wizard" + ) + } + +} \ No newline at end of file diff --git a/UI/Popup/EditableTagRendering.ts b/UI/Popup/EditableTagRendering.ts index 10ac7be1a..0d735fa92 100644 --- a/UI/Popup/EditableTagRendering.ts +++ b/UI/Popup/EditableTagRendering.ts @@ -43,11 +43,14 @@ export default class EditableTagRendering extends Toggle { editMode.setData(false) }); - const question = new TagRenderingQuestion(tags, configuration,units, - () => { - editMode.setData(false) - }, - cancelbutton) + const question = new TagRenderingQuestion(tags, configuration, + { + units: units, + cancelButton: cancelbutton, + afterSave: () => { + editMode.setData(false) + } + }) rendering = new Toggle( diff --git a/UI/Popup/QuestionBox.ts b/UI/Popup/QuestionBox.ts index 7c17aa0b3..f2c09625e 100644 --- a/UI/Popup/QuestionBox.ts +++ b/UI/Popup/QuestionBox.ts @@ -27,17 +27,20 @@ export default class QuestionBox extends VariableUiElement { } const tagRenderingQuestions = tagRenderings - .map((tagRendering, i) => new TagRenderingQuestion(tagsSource, tagRendering, units, - () => { - // We save - skippedQuestions.ping(); - }, - Translations.t.general.skip.Clone() - .SetClass("btn btn-secondary mr-3") - .onClick(() => { - skippedQuestions.data.push(i); + .map((tagRendering, i) => new TagRenderingQuestion(tagsSource, tagRendering, + { + units: units, + afterSave: () => { + // We save skippedQuestions.ping(); - }) + }, + cancelButton: Translations.t.general.skip.Clone() + .SetClass("btn btn-secondary mr-3") + .onClick(() => { + skippedQuestions.data.push(i); + skippedQuestions.ping(); + }) + } )); const skippedQuestionsButton = Translations.t.general.skippedQuestions.Clone() diff --git a/UI/Popup/SaveButton.ts b/UI/Popup/SaveButton.ts index 618a673e2..f8ad9ba50 100644 --- a/UI/Popup/SaveButton.ts +++ b/UI/Popup/SaveButton.ts @@ -17,16 +17,16 @@ export class SaveButton extends Toggle { const isSaveable = value.map(v => v !== false && (v ?? "") !== "") - - const saveEnabled = Translations.t.general.save.Clone().SetClass(`btn`); - const saveDisabled = Translations.t.general.save.Clone().SetClass(`btn btn-disabled`); + const text = Translations.t.general.save + const saveEnabled = text.Clone().SetClass(`btn`); + const saveDisabled = text.SetClass(`btn btn-disabled`); const save = new Toggle( saveEnabled, saveDisabled, isSaveable ) super( - save, + save, pleaseLogin, osmConnection?.isLoggedIn ?? new UIEventSource(false) ) diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 4fc48f2cc..1da92105b 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -30,23 +30,27 @@ import {Unit} from "../../Customizations/JSON/Denomination"; * Note that the value _migh_ already be known, e.g. when selected or when changing the value */ export default class TagRenderingQuestion extends Combine { - + constructor(tags: UIEventSource, configuration: TagRenderingConfig, - units: Unit[], - afterSave?: () => void, - cancelButton?: BaseUIElement, + options?: { + units?: Unit[], + afterSave?: () => void, + cancelButton?: BaseUIElement, + saveButtonConstr?: (src: UIEventSource) => BaseUIElement, + bottomText?: (src: UIEventSource) => BaseUIElement + } ) { if (configuration === undefined) { throw "A question is needed for a question visualization" } - const applicableUnit = (units ?? []).filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0]; + options = options ?? {} + const applicableUnit = (options.units ?? []).filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0]; const question = new SubstitutedTranslation(configuration.question, tags) .SetClass("question-text"); - - const inputElement = TagRenderingQuestion.GenerateInputElement(configuration, applicableUnit, tags) + const inputElement: InputElement = TagRenderingQuestion.GenerateInputElement(configuration, applicableUnit, tags) const save = () => { const selection = inputElement.GetValue().data; console.log("Save button clicked, the tags are is", selection) @@ -55,48 +59,54 @@ export default class TagRenderingQuestion extends Combine { .addTag(tags.data.id, selection, tags); } - if (afterSave) { - afterSave(); + if (options.afterSave) { + options.afterSave(); } } + if (options.saveButtonConstr === undefined) { + options.saveButtonConstr = v => new SaveButton(v, + State.state?.osmConnection) + .onClick(save) + } - const saveButton = new SaveButton(inputElement.GetValue(), - State.state?.osmConnection) - .onClick(save) + const saveButton = options.saveButtonConstr(inputElement.GetValue()) - - const appliedTags = new VariableUiElement( - inputElement.GetValue().map( - (tagsFilter: TagsFilter) => { - const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000; - if (csCount < Constants.userJourney.tagsVisibleAt) { - return ""; + let bottomTags: BaseUIElement; + if (options.bottomText !== undefined) { + bottomTags = options.bottomText(inputElement.GetValue()) + } else { + bottomTags = new VariableUiElement( + inputElement.GetValue().map( + (tagsFilter: TagsFilter) => { + const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000; + if (csCount < Constants.userJourney.tagsVisibleAt) { + return ""; + } + if (tagsFilter === undefined) { + return Translations.t.general.noTagsSelected.Clone().SetClass("subtle"); + } + if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) { + const tagsStr = tagsFilter.asHumanString(false, true, tags.data); + return new FixedUiElement(tagsStr).SetClass("subtle"); + } + return tagsFilter.asHumanString(true, true, tags.data); } - if (tagsFilter === undefined) { - return Translations.t.general.noTagsSelected.Clone().SetClass("subtle"); - } - if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) { - const tagsStr = tagsFilter.asHumanString(false, true, tags.data); - return new FixedUiElement(tagsStr).SetClass("subtle"); - } - return tagsFilter.asHumanString(true, true, tags.data); - } - ) - ).SetClass("block break-all") - - super ([ + ) + ).SetClass("block break-all") + } + super([ question, - inputElement, - cancelButton, + inputElement, + options.cancelButton, saveButton, - appliedTags] + bottomTags] ) - this .SetClass("question") + this.SetClass("question") } - private static GenerateInputElement(configuration: TagRenderingConfig, applicableUnit: Unit, tagsSource: UIEventSource< any>): InputElement { + private static GenerateInputElement(configuration: TagRenderingConfig, applicableUnit: Unit, tagsSource: UIEventSource): InputElement { let inputEls: InputElement[]; const mappings = (configuration.mappings ?? []) @@ -105,7 +115,7 @@ export default class TagRenderingQuestion extends Combine { return false; } return !(typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(tagsSource.data)); - + }) @@ -255,10 +265,10 @@ export default class TagRenderingQuestion extends Combine { private static GenerateMappingElement( tagsSource: UIEventSource, mapping: { - if: TagsFilter, - then: Translation, - hideInAnswer: boolean | TagsFilter - }, ifNot?: TagsFilter[]): InputElement { + if: TagsFilter, + then: Translation, + hideInAnswer: boolean | TagsFilter + }, ifNot?: TagsFilter[]): InputElement { let tagging = mapping.if; if (ifNot.length > 0) { diff --git a/UI/i18n/Translations.ts b/UI/i18n/Translations.ts index 5a0c81459..eb91b6cd8 100644 --- a/UI/i18n/Translations.ts +++ b/UI/i18n/Translations.ts @@ -30,6 +30,9 @@ export default class Translations { console.error(msg, t); throw msg } + if(t instanceof Translation){ + return t; + } return new Translation(t, context); } diff --git a/langs/en.json b/langs/en.json index 9ec787501..e1a8b9953 100644 --- a/langs/en.json +++ b/langs/en.json @@ -29,17 +29,26 @@ }, "delete": { "delete": "Delete", + "cancel": "Cancel", + "isDeleted": "This feature is deleted", "loginToDelete": "You must be logged in to delete a point", - "safeDelete": "This point can be safely deleted", - "isntAPoint": "Only points can be deleted", - "onlyEditedByLoggedInUser": "This point has only be edited by yourself, you can safely delete it", - "notEnoughExperience": "You don't have enough experience to delete points made by other people. Make more edits to improve your skills", - "partOfOthers": "This point is part of some way or relation, so you can not delete it", - "loading": "Inspecting properties to check if this feature can be deleted", + "safeDelete": "This point can be safely deleted.", + "isntAPoint": "Only points can be deleted, the selected feature is a way, area or relation.", + "onlyEditedByLoggedInUser": "This point has only be edited by yourself, you can safely delete it.", + "notEnoughExperience": "This point was made by someone else.", + "useSomethingElse": "Use another OpenStreetMap-editor to delete it instead", + "partOfOthers": "This point is part of some way or relation and can not be deleted directly.", + "loading": "Inspecting properties to check if this feature can be deleted.", + "whyDelete": "Why should this point be deleted?", "reasons": { "test": "This was a testing point - the feature was never actually there", "disused": "This feature is disused or removed", "notFound": "This feature couldn't be found" + }, + "explanations": { + "selectReason": "Please, select why this feature should be deleted", + "hardDelete": "This point will be deleted in OpenStreetMap. It can be recovered by an experienced contributor", + "softDelete": "This feature will be updated and hidden from this application. {reason}" } }, "general": { diff --git a/test.ts b/test.ts index 25608b2b0..eb29b9921 100644 --- a/test.ts +++ b/test.ts @@ -1,7 +1,12 @@ import {OsmObject} from "./Logic/Osm/OsmObject"; -import DeleteButton from "./UI/Popup/DeleteButton"; +import DeleteButton from "./UI/Popup/DeleteWizard"; import Combine from "./UI/Base/Combine"; import State from "./State"; +import DeleteWizard from "./UI/Popup/DeleteWizard"; +import {UIEventSource} from "./Logic/UIEventSource"; +import {Tag} from "./Logic/Tags/Tag"; +import {QueryParameters} from "./Logic/Web/QueryParameters"; +import {Translation} from "./UI/i18n/Translation"; /*import ValidatedTextField from "./UI/Input/ValidatedTextField"; import Combine from "./UI/Base/Combine"; import {VariableUiElement} from "./UI/Base/VariableUIElement"; @@ -143,7 +148,19 @@ function TestMiniMap() { featureSource.ping() } //*/ +QueryParameters.GetQueryParameter("test", "true").setData("true") State.state= new State(undefined) +const id = "node/5414688303" +State.state.allElements.addElementById(id, new UIEventSource({id: id})) new Combine([ - new DeleteButton("node/8598664388"), + new DeleteWizard(id, { + noDeleteOptions: [ + { + if:[ new Tag("access","private")], + then: new Translation({ + en: "Very private! Delete now or me send lawfull lawyer" + }) + } + ] + }), ]).AttachTo("maindiv") From de5f8f95bb4bbc4d1827845e33c1140155f9a64c Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 1 Jul 2021 02:43:49 +0200 Subject: [PATCH 15/26] Hook deleteWizard into the specialVisualisations (WIP) --- Logic/Osm/DeleteAction.ts | 6 ++-- UI/Popup/DeleteWizard.ts | 5 +-- UI/SpecialVisualizations.ts | 71 ++++++++++++++++++++++++++++--------- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/Logic/Osm/DeleteAction.ts b/Logic/Osm/DeleteAction.ts index 5671504dd..73cb066df 100644 --- a/Logic/Osm/DeleteAction.ts +++ b/Logic/Osm/DeleteAction.ts @@ -10,10 +10,12 @@ export default class DeleteAction { public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean, reason: Translation }>; public readonly isDeleted = new UIEventSource(false); private readonly _id: string; + private readonly _allowDeletionAtChangesetCount: number; - constructor(id: string) { + constructor(id: string, allowDeletionAtChangesetCount?: number) { this._id = id; + this._allowDeletionAtChangesetCount = allowDeletionAtChangesetCount ?? Number.MAX_VALUE; this.canBeDeleted = new UIEventSource<{ canBeDeleted?: boolean; reason: Translation }>({ canBeDeleted: undefined, @@ -104,7 +106,7 @@ export default class DeleteAction { if (!ud.loggedIn) { return false; } - return ud.csCount >= Constants.userJourney.deletePointsOfOthersUnlock; + return ud.csCount >= Math.min(Constants.userJourney.deletePointsOfOthersUnlock, this._allowDeletionAtChangesetCount); }) const previousEditors = new UIEventSource(undefined) diff --git a/UI/Popup/DeleteWizard.ts b/UI/Popup/DeleteWizard.ts index 37f6efe5b..b5f5648ee 100644 --- a/UI/Popup/DeleteWizard.ts +++ b/UI/Popup/DeleteWizard.ts @@ -41,11 +41,12 @@ export default class DeleteWizard extends Toggle { constructor(id: string, options?: { noDeleteOptions?: { if: Tag[], then: Translation }[] - softDeletionTags?: Tag[] + softDeletionTags?: Tag[], + neededChangesets?: number }) { options = options ?? {} - const deleteAction = new DeleteAction(id); + const deleteAction = new DeleteAction(id, options.neededChangesets); const tagsSource = State.state.allElements.getEventSourceById(id) let softDeletionTags = options.softDeletionTags ?? [] diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 447589c14..bdc65c23a 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -24,8 +24,10 @@ import Histogram from "./BigComponents/Histogram"; import Loc from "../Models/Loc"; import {Utils} from "../Utils"; import BaseLayer from "../Models/BaseLayer"; +import DeleteWizard from "./Popup/DeleteWizard"; +import Constants from "../Models/Constants"; -export interface SpecialVisualization{ +export interface SpecialVisualization { funcName: string, constr: ((state: State, tagSource: UIEventSource, argument: string[]) => BaseUIElement), docs: string, @@ -36,6 +38,14 @@ export interface SpecialVisualization{ export default class SpecialVisualizations { + public static specialVisualisationsByName: Map = SpecialVisualizations.byName(); + static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); + static constructMiniMap: (options?: { + background?: UIEventSource, + location?: UIEventSource, + allowMoving?: boolean + }) => BaseUIElement; + static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource, layoutToUse: UIEventSource, enablePopups?: boolean, zoomToFeatures?: boolean) => any; public static specialVisualizations: SpecialVisualization[] = [ { @@ -137,7 +147,7 @@ export default class SpecialVisualizations { zoom = parsed; } } - const locationSource =new UIEventSource({ + const locationSource = new UIEventSource({ lat: Number(properties._lat), lon: Number(properties._lon), zoom: zoom @@ -149,9 +159,9 @@ export default class SpecialVisualizations { allowMoving: false } ) - + locationSource.addCallback(loc => { - if(loc.zoom > zoom){ + if (loc.zoom > zoom) { // We zoom back locationSource.data.zoom = zoom; locationSource.ping(); @@ -370,29 +380,56 @@ export default class SpecialVisualizations { [state.layoutToUse]) ) } + }, + { + funcName: "delete", + docs: `Offers a dialog to (soft) delete the point. The dialog is built to be user friendly and to prevent mistakes. If deletion is not possible, the dialog will hide itself. + +#### Hard deletion if enough experience + +A feature can only be deleted by mapcomplete if: + +- It is a node +- No ways or relations use the node +- The logged-in user has enough experience (at least ${Constants.userJourney.deletePointsOfOthersUnlock} changesets) OR the user is the only one to have edited the point previously +- The user did not select one of the 'non-delete-options' (see below) + +In all other cases, a 'soft deletion' is used. + +#### Soft deletion + +A 'soft deletion' is when the point isn't deleted from OSM but retagged so that it'll won't how up in the mapcomplete theme anymore. +This makes it look like it was deleted, without doing damage. A fixme will be added to the point. + +Note that a soft deletion is _only_ possible if these tags are provided by the theme creator, as they'll be different for every theme + +#### No-delete options + +In some cases, the contributor might want to delete something for the wrong reason (e.g. someone who wants to have a path removed "because the path is on their private property"). +However, the path exists in reality and should thus be on OSM - otherwise the next contributor will pass by and notice "hey, there is a path missing here! Let me redraw it in OSM!) + +The correct approach is to retag the feature in such a way that it is semantically correct *and* that it doesn't show up on the theme anymore. +A no-delete option is offered as 'reason to delete it', but secretly retags. + +`, + args: [], + constr: (state, tagSource, args) => { + return new VariableUiElement(tagSource.map(tags => tags.id).map(id => + new DeleteWizard(id))) + } } ] - - private static byName() : Map{ + + private static byName(): Map { const result = new Map(); for (const specialVisualization of SpecialVisualizations.specialVisualizations) { result.set(specialVisualization.funcName, specialVisualization) } - + return result; } - - public static specialVisualisationsByName: Map = SpecialVisualizations.byName(); - - static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); - static constructMiniMap: (options?: { - background?: UIEventSource, - location?: UIEventSource, - allowMoving?: boolean - }) => BaseUIElement; - static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource, layoutToUse: UIEventSource, enablePopups?: boolean, zoomToFeatures?: boolean) => any; private static GenHelpMessage() { From 70f3ff939bcffee97929d9044a57e710386fd487 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 2 Jul 2021 11:32:42 +0000 Subject: [PATCH 16/26] Translated using Weblate (Russian) Currently translated at 34.8% (142 of 407 strings) Translation: MapComplete/themes Translate-URL: https://hosted.weblate.org/projects/mapcomplete/themes/ru/ --- langs/themes/ru.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/langs/themes/ru.json b/langs/themes/ru.json index 6466db783..efdf2dc6e 100644 --- a/langs/themes/ru.json +++ b/langs/themes/ru.json @@ -1,6 +1,6 @@ { "aed": { - "title": "Открытая карта AED (Автоматизированных внешних дефибрилляторов)", + "title": "Открытая карта АВД (Автоматизированных внешних дефибрилляторов)", "description": "На этой карте вы можете найти и отметить ближайшие дефибрилляторы" }, "artworks": { @@ -522,4 +522,4 @@ "trees": { "title": "Деревья" } -} \ No newline at end of file +} From e4c29ce66021f27bc63aa777c38b6cd573d86ee7 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 14:35:44 +0200 Subject: [PATCH 17/26] First version of a delete button, is working --- Customizations/JSON/DeleteConfig.ts | 47 ++++++++++ Customizations/JSON/DeleteConfigJson.ts | 66 +++++++++++++ Customizations/JSON/FromJSON.ts | 5 +- Customizations/JSON/LayerConfig.ts | 11 +++ Customizations/JSON/LayerConfigJson.ts | 82 ++++++++++++---- Logic/Osm/ChangesetHandler.ts | 18 +++- UI/Popup/DeleteWizard.ts | 94 +++++++++++-------- UI/Popup/FeatureInfoBox.ts | 33 +++++-- UI/ShowDataLayer.ts | 22 +++-- UI/SpecialVisualizations.ts | 53 +---------- .../public_bookcase/public_bookcase.json | 9 +- langs/en.json | 3 +- package.json | 2 +- 13 files changed, 309 insertions(+), 136 deletions(-) create mode 100644 Customizations/JSON/DeleteConfig.ts create mode 100644 Customizations/JSON/DeleteConfigJson.ts diff --git a/Customizations/JSON/DeleteConfig.ts b/Customizations/JSON/DeleteConfig.ts new file mode 100644 index 000000000..3112afd97 --- /dev/null +++ b/Customizations/JSON/DeleteConfig.ts @@ -0,0 +1,47 @@ +import {DeleteConfigJson} from "./DeleteConfigJson"; +import {Translation} from "../../UI/i18n/Translation"; +import {TagsFilter} from "../../Logic/Tags/TagsFilter"; +import Translations from "../../UI/i18n/Translations"; +import {FromJSON} from "./FromJSON"; + +export default class DeleteConfig { + public readonly extraDeleteReasons?: { + explanation: Translation, + changesetMessage: string + }[] + + public readonly nonDeleteMappings?: { if: TagsFilter, then: Translation }[] + + public readonly softDeletionTags?: TagsFilter + public readonly neededChangesets?: number + + constructor(json: DeleteConfigJson, context: string) { + + this.extraDeleteReasons = json.extraDeleteReasons?.map((reason, i) => { + const ctx = `${context}.extraDeleteReasons[${i}]` + if ((reason.changesetMessage ?? "").length <= 5) { + throw `${ctx}.explanation is too short, needs at least 4 characters` + } + return { + explanation: Translations.T(reason.explanation, ctx + ".explanation"), + changesetMessage: reason.changesetMessage + } + }) + this.nonDeleteMappings = json.nonDeleteMappings?.map((nonDelete, i) => { + const ctx = `${context}.extraDeleteReasons[${i}]` + return { + if: FromJSON.Tag(nonDelete.if, ctx + ".if"), + then: Translations.T(nonDelete.then, ctx + ".then") + } + }) + + this.softDeletionTags = null; + if(json.softDeletionTags !== undefined){ + this.softDeletionTags = FromJSON.Tag(json.softDeletionTags,`${context}.softDeletionTags`) + + } + this.neededChangesets = json.neededChangesets + } + + +} \ No newline at end of file diff --git a/Customizations/JSON/DeleteConfigJson.ts b/Customizations/JSON/DeleteConfigJson.ts new file mode 100644 index 000000000..0a605dcdb --- /dev/null +++ b/Customizations/JSON/DeleteConfigJson.ts @@ -0,0 +1,66 @@ +import {AndOrTagConfigJson} from "./TagConfigJson"; + +export interface DeleteConfigJson { + + /*** + * By default, three reasons to delete a point are shown: + * + * - The point does not exist anymore + * - The point was a testing point + * - THe point could not be found + * + * However, for some layers, there might be different or more specific reasons for deletion which can be user friendly to set, e.g.: + * + * - the shop has closed + * - the climbing route has been closed of for nature conservation reasons + * - ... + * + * These reasons can be stated here and will be shown in the list of options the user can choose from + */ + extraDeleteReasons?: { + /** + * The text that will be shown to the user - translatable + */ + explanation: string | any, + /** + * The text that will be uploaded into the changeset or will be used in the fixme in case of a soft deletion + * Should be a few words, in english + */ + changesetMessage: string + }[] + + /** + * In some cases, a (starting) contributor might wish to delete a feature even though deletion is not appropriate. + * (The most relevant case are small paths running over private property. These should be marked as 'private' instead of deleted, as the community might trace the path again from aerial imagery, gettting us back to the original situation). + * + * By adding a 'nonDeleteMapping', an option can be added into the list which will retag the feature. + * It is important that the feature will be retagged in such a way that it won't be picked up by the layer anymore! + */ + nonDeleteMappings?: { if: AndOrTagConfigJson, then: string | any }[], + + /** + * In some cases, the contributor is not allowed to delete the current feature (e.g. because it isn't a point, the point is referenced by a relation or the user isn't experienced enough). + * To still offer the user a 'delete'-option, the feature is retagged with these tags. This is a soft deletion, as the point isn't actually removed from OSM but rather marked as 'disused' + * It is important that the feature will be retagged in such a way that it won't be picked up by the layer anymore! + * + * Example (note that "amenity=" erases the 'amenity'-key alltogether): + * ``` + * { + * "and": ["disussed:amenity=public_bookcase", "amenity="] + * } + * ``` + * + * or (notice the use of the ':='-tag to copy the old value of 'shop=*' into 'disused:shop='): + * ``` + * { + * "and": ["disused:shop:={shop}", "shop="] + * } + * ``` + */ + softDeletionTags?: AndOrTagConfigJson | string, + /*** + * By default, the contributor needs 20 previous changesets to delete points edited by others. + * For some small features (e.g. bicycle racks) this is too much and this requirement can be lowered or dropped, which can be done here. + */ + neededChangesets?: number +} \ No newline at end of file diff --git a/Customizations/JSON/FromJSON.ts b/Customizations/JSON/FromJSON.ts index ef74a9194..d51c972a4 100644 --- a/Customizations/JSON/FromJSON.ts +++ b/Customizations/JSON/FromJSON.ts @@ -9,8 +9,11 @@ import SubstitutingTag from "../../Logic/Tags/SubstitutingTag"; export class FromJSON { - public static SimpleTag(json: string): Tag { + public static SimpleTag(json: string, context?: string): Tag { const tag = Utils.SplitFirst(json, "="); + if(tag.length !== 2){ + throw `Invalid tag: no (or too much) '=' found (in ${context ?? "unkown context"})` + } return new Tag(tag[0], tag[1]); } diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index 142fc2fde..0acf8198f 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -17,6 +17,7 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import {Tag} from "../../Logic/Tags/Tag"; import BaseUIElement from "../../UI/BaseUIElement"; import {Unit} from "./Denomination"; +import DeleteConfig from "./DeleteConfig"; export default class LayerConfig { @@ -47,6 +48,7 @@ export default class LayerConfig { dashArray: TagRenderingConfig; wayHandling: number; public readonly units: Unit[]; + public readonly deletion: DeleteConfig | null presets: { title: Translation, @@ -240,6 +242,15 @@ export default class LayerConfig { this.width = tr("width", "7"); this.rotation = tr("rotation", "0"); this.dashArray = tr("dashArray", ""); + + this.deletion = null; + if(json.deletion === true){ + json.deletion = { + } + } + if(json.deletion !== undefined && json.deletion !== false){ + this.deletion = new DeleteConfig(json.deletion, `${context}.deletion`) + } if (json["showIf"] !== undefined) { diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index 3564699b9..d81307fd9 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -1,5 +1,6 @@ import {TagRenderingConfigJson} from "./TagRenderingConfigJson"; import {AndOrTagConfigJson} from "./TagConfigJson"; +import {DeleteConfigJson} from "./DeleteConfigJson"; /** * Configuration for a single layer @@ -14,7 +15,7 @@ export interface LayerConfigJson { /** * The name of this layer * Used in the layer control panel and the 'Personal theme'. - * + * * If not given, will be hidden (and thus not toggable) in the layer control */ name?: string | any @@ -31,28 +32,28 @@ export interface LayerConfigJson { * There are some options: * * # Query OSM directly - * source: {osmTags: "key=value"} + * source: {osmTags: "key=value"} * will fetch all objects with given tags from OSM. * Currently, this will create a query to overpass and fetch the data - in the future this might fetch from the OSM API - * + * * # Query OSM Via the overpass API with a custom script * source: {overpassScript: ""} when you want to do special things. _This should be really rare_. * This means that the data will be pulled from overpass with this script, and will ignore the osmTags for the query * However, for the rest of the pipeline, the OsmTags will _still_ be used. This is important to enable layers etc... * * - * # A single geojson-file - * source: {geoJson: "https://my.source.net/some-geo-data.geojson"} + * # A single geojson-file + * source: {geoJson: "https://my.source.net/some-geo-data.geojson"} * fetches a geojson from a third party source - * + * * # A tiled geojson source - * source: {geoJson: "https://my.source.net/some-tile-geojson-{layer}-{z}-{x}-{y}.geojson", geoJsonZoomLevel: 14} + * source: {geoJson: "https://my.source.net/some-tile-geojson-{layer}-{z}-{x}-{y}.geojson", geoJsonZoomLevel: 14} * to use a tiled geojson source. The web server must offer multiple geojsons. {z}, {x} and {y} are substituted by the location; {layer} is substituted with the id of the loaded layer * - * - * Note that both geojson-options might set a flag 'isOsmCache' indicating that the data originally comes from OSM too - * - * + * + * Note that both geojson-options might set a flag 'isOsmCache' indicating that the data originally comes from OSM too + * + * * NOTE: the previous format was 'overpassTags: AndOrTagCOnfigJson | string', which is interpreted as a shorthand for source: {osmTags: "key=value"} * While still supported, this is considered deprecated */ @@ -123,7 +124,7 @@ export interface LayerConfigJson { * As a result, on could use a generic pin, then overlay it with a specific icon. * To make things even more practical, one can use all svgs from the folder "assets/svg" and _substitute the color_ in it. * E.g. to draw a red pin, use "pin:#f00", to have a green circle with your icon on top, use `circle:#0f0;` - * + * */ icon?: string | TagRenderingConfigJson; @@ -148,14 +149,14 @@ export interface LayerConfigJson { */ rotation?: string | TagRenderingConfigJson; /** - * A HTML-fragment that is shown below the icon, for example: + * A HTML-fragment that is shown below the icon, for example: *
{name}
- * + * * If the icon is undefined, then the label is shown in the center of the feature. * Note that, if the wayhandling hides the icon then no label is shown as well. */ - label?: string | TagRenderingConfigJson ; - + label?: string | TagRenderingConfigJson; + /** * The color for way-elements and SVG-elements. * If the value starts with "--", the style of the body element will be queried for the corresponding variable instead @@ -230,7 +231,54 @@ export interface LayerConfigJson { * A special value is 'questions', which indicates the location of the questions box. If not specified, it'll be appended to the bottom of the featureInfobox. * */ - tagRenderings?: (string | TagRenderingConfigJson) [] + tagRenderings?: (string | TagRenderingConfigJson) [], + /** + * This block defines under what circumstances the delete dialog is shown for objects of this layer. + * If set, a dialog is shown to the user to (soft) delete the point. + * The dialog is built to be user friendly and to prevent mistakes. + * If deletion is not possible, the dialog will hide itself and show the reason of non-deletability instead. + * + * To configure, the following values are possible: + * + * - false: never ever show the delete button + * - true: show the default delete button + * - undefined: use the mapcomplete default to show deletion or not. Currently, this is the same as 'false' but this will change in the future + * - or: a hash with options (see below) + * + * The delete dialog + * ================= + * + * + * + #### Hard deletion if enough experience + + A feature can only be deleted from OpenStreetMap by mapcomplete if: + + - It is a node + - No ways or relations use the node + - The logged-in user has enough experience OR the user is the only one to have edited the point previously + - The logged-in user has no unread messages (or has a ton of experience) + - The user did not select one of the 'non-delete-options' (see below) + + In all other cases, a 'soft deletion' is used. + + #### Soft deletion + + A 'soft deletion' is when the point isn't deleted from OSM but retagged so that it'll won't how up in the mapcomplete theme anymore. + This makes it look like it was deleted, without doing damage. A fixme will be added to the point. + + Note that a soft deletion is _only_ possible if these tags are provided by the theme creator, as they'll be different for every theme + + #### No-delete options + + In some cases, the contributor might want to delete something for the wrong reason (e.g. someone who wants to have a path removed "because the path is on their private property"). + However, the path exists in reality and should thus be on OSM - otherwise the next contributor will pass by and notice "hey, there is a path missing here! Let me redraw it in OSM!) + + The correct approach is to retag the feature in such a way that it is semantically correct *and* that it doesn't show up on the theme anymore. + A no-delete option is offered as 'reason to delete it', but secretly retags. + + */ + deletion?: boolean | DeleteConfigJson } \ No newline at end of file diff --git a/Logic/Osm/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 88ad246e5..ef9f5f717 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -32,6 +32,14 @@ export class ChangesetHandler { // @ts-ignore for (const node of nodes) { const oldId = parseInt(node.attributes.old_id.value); + if (node.attributes.new_id === undefined) { + // We just removed this point! + const element = allElements.getEventSourceById("node/" + oldId); + element.data._deleted = "yes" + element.ping(); + continue; + } + const newId = parseInt(node.attributes.new_id.value); if (oldId !== undefined && newId !== undefined && !isNaN(oldId) && !isNaN(newId)) { @@ -138,7 +146,7 @@ export class ChangesetHandler { changes += `<${object.type} id="${object.id}" version="${object.version}" changeset="${csId}" lat="${lat}" lon="${lon}" />`; changes += ""; - + continuation() return changes; } @@ -152,10 +160,10 @@ export class ChangesetHandler { const self = this; this.OpenChangeset(layout, (csId: string) => { - + // The cs is open - let us actually upload! const changes = generateChangeXML(csId) - + self.AddChange(csId, changes, allElements, (csId) => { console.log("Successfully deleted ", object.id) self.CloseChangeset(csId, continuation) @@ -203,8 +211,8 @@ export class ChangesetHandler { let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}` if (isDeletionCS) { comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}` - if(deletionReason){ - comment += ": "+deletionReason; + if (deletionReason) { + comment += ": " + deletionReason; } } diff --git a/UI/Popup/DeleteWizard.ts b/UI/Popup/DeleteWizard.ts index b5f5648ee..1b2ab4b76 100644 --- a/UI/Popup/DeleteWizard.ts +++ b/UI/Popup/DeleteWizard.ts @@ -17,7 +17,8 @@ import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; import BaseUIElement from "../BaseUIElement"; import {Changes} from "../../Logic/Osm/Changes"; import {And} from "../../Logic/Tags/And"; - +import Constants from "../../Models/Constants"; +import DeleteConfig from "../../Customizations/JSON/DeleteConfig"; export default class DeleteWizard extends Toggle { /** @@ -39,18 +40,12 @@ export default class DeleteWizard extends Toggle { * @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted */ constructor(id: string, - options?: { - noDeleteOptions?: { if: Tag[], then: Translation }[] - softDeletionTags?: Tag[], - neededChangesets?: number - }) { + options: DeleteConfig) { - options = options ?? {} const deleteAction = new DeleteAction(id, options.neededChangesets); const tagsSource = State.state.allElements.getEventSourceById(id) - let softDeletionTags = options.softDeletionTags ?? [] - const allowSoftDeletion = softDeletionTags.length > 0 + const allowSoftDeletion = !!options.softDeletionTags const confirm = new UIEventSource(false) @@ -80,7 +75,7 @@ export default class DeleteWizard extends Toggle { }); return } else { - // This is an injected tagging + // This is a 'non-delete'-option that was selected softDelete(undefined, tgs) } @@ -89,46 +84,60 @@ export default class DeleteWizard extends Toggle { const t = Translations.t.delete const cancelButton = t.cancel.Clone().SetClass("block btn btn-secondary").onClick(() => confirm.setData(false)); - const config = DeleteWizard.generateDeleteTagRenderingConfig(softDeletionTags, options.noDeleteOptions) - const question = new TagRenderingQuestion( - tagsSource, - config, - { - cancelButton: cancelButton, - /*Using a custom save button constructor erases all logic to actually save, so we have to listen for the click!*/ - saveButtonConstr: (v) => DeleteWizard.constructConfirmButton(v).onClick(() => { - doDelete(v.data) - }), - bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAction) - } - ) + const question = new VariableUiElement(tagsSource.map(currentTags => { + const config = DeleteWizard.generateDeleteTagRenderingConfig(options.softDeletionTags, options.nonDeleteMappings, options.extraDeleteReasons, currentTags) + return new TagRenderingQuestion( + tagsSource, + config, + { + cancelButton: cancelButton, + /*Using a custom save button constructor erases all logic to actually save, so we have to listen for the click!*/ + saveButtonConstr: (v) => DeleteWizard.constructConfirmButton(v).onClick(() => { + doDelete(v.data) + }), + bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAction) + } + ) + })) + /** * The button which is shown first. Opening it will trigger the check for deletions */ - const deleteButton = new SubtleButton(Svg.delete_icon_svg(), t.delete).onClick( + const deleteButton = new SubtleButton( + Svg.delete_icon_ui().SetStyle("width: 2rem; height: 2rem;"), t.delete.Clone()).onClick( () => { deleteAction.CheckDeleteability(true) confirm.setData(true); } - ); + ).SetClass("w-1/2 float-right"); + const isShown = new UIEventSource(id.indexOf("-")< 0) + super( + new Toggle( new Combine([Svg.delete_icon_svg().SetClass("h-16 w-16 p-2 m-2 block bg-gray-300 rounded-full"), t.isDeleted.Clone()]).SetClass("flex m-2 rounded-full"), new Toggle( new Toggle( new Toggle( - question, + new Toggle( + question, + new SubtleButton(Svg.envelope_ui(), t.readMessages.Clone()), + State.state.osmConnection.userDetails.map(ud => ud.csCount > Constants.userJourney.addNewPointWithUnreadMessagesUnlock || ud.unreadMessages == 0) + ), deleteButton, confirm), - new VariableUiElement(deleteAction.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse]))), + new VariableUiElement(deleteAction.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse.Clone()]))), deleteAction.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)), + t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin), State.state.osmConnection.isLoggedIn ), - deleteAction.isDeleted) + deleteAction.isDeleted), + undefined, + isShown) } @@ -163,7 +172,7 @@ export default class DeleteWizard extends Toggle { if (currentTags === undefined) { return t.explanations.selectReason.Clone().SetClass("subtle"); } - + const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason") if (cbd.canBeDeleted && hasDeletionTag) { @@ -179,23 +188,32 @@ export default class DeleteWizard extends Toggle { )).SetClass("block") } - private static generateDeleteTagRenderingConfig(softDeletionTags: Tag[], nonDeleteOptions: { - if: Tag[], - then: Translation - }[]) { + private static generateDeleteTagRenderingConfig(softDeletionTags: TagsFilter, + nonDeleteOptions: { if: TagsFilter; then: Translation }[], + extraDeleteReasons: { explanation: Translation; changesetMessage: string }[], + currentTags: any) { const t = Translations.t.delete nonDeleteOptions = nonDeleteOptions ?? [] - const softDeletionTagsStr = (softDeletionTags ?? []).map(t => t.asHumanString(false, false)) - const nonDeleteOptionsStr: { if: AndOrTagConfigJson, then: any }[] = [] + let softDeletionTagsStr = [] + if (softDeletionTags !== undefined) { + softDeletionTags.asChange(currentTags) + } + const extraOptionsStr: { if: AndOrTagConfigJson, then: any }[] = [] for (const nonDeleteOption of nonDeleteOptions) { - const newIf: string[] = nonDeleteOption.if.map(tag => tag.asHumanString()) + const newIf: string[] = nonDeleteOption.if.asChange({}).map(kv => kv.k + "=" + kv.v) - nonDeleteOptionsStr.push({ + extraOptionsStr.push({ if: {and: newIf}, then: nonDeleteOption.then }) } + for (const extraDeleteReason of (extraDeleteReasons ?? [])) { + extraOptionsStr.push({ + if: {and: ["_delete_reason=" + extraDeleteReason.changesetMessage]}, + then: extraDeleteReason.explanation + }) + } return new TagRenderingConfig( { question: t.whyDelete, @@ -206,7 +224,7 @@ export default class DeleteWizard extends Toggle { }, mappings: [ - ...nonDeleteOptionsStr, + ...extraOptionsStr, { if: { diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index 97dcbefda..f35f73ceb 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -12,6 +12,7 @@ import Constants from "../../Models/Constants"; import SharedTagRenderings from "../../Customizations/SharedTagRenderings"; import BaseUIElement from "../BaseUIElement"; import {VariableUiElement} from "../Base/VariableUIElement"; +import DeleteWizard from "./DeleteWizard"; export default class FeatureInfoBox extends ScrollableFullScreen { @@ -21,7 +22,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { ) { super(() => FeatureInfoBox.GenerateTitleBar(tags, layerConfig), () => FeatureInfoBox.GenerateContent(tags, layerConfig), - tags.data.id); + undefined); if (layerConfig === undefined) { throw "Undefined layerconfig"; @@ -69,19 +70,31 @@ export default class FeatureInfoBox extends ScrollableFullScreen { if (!hasMinimap) { renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap"))) } - + + if (layerConfig.deletion) { + renderings.push( + new VariableUiElement(tags.map(tags => tags.id).map(id => + new DeleteWizard( + id, + layerConfig.deletion + )) + )) + } + renderings.push( new VariableUiElement( - State.state.osmConnection.userDetails.map(userdetails => { - if (userdetails.csCount <= Constants.userJourney.historyLinkVisible - && State.state.featureSwitchIsDebugging.data == false - && State.state.featureSwitchIsTesting.data === false) { - return undefined - } + State.state.osmConnection.userDetails + .map(ud => ud.csCount) + .map(csCount => { + if (csCount <= Constants.userJourney.historyLinkVisible + && State.state.featureSwitchIsDebugging.data == false + && State.state.featureSwitchIsTesting.data === false) { + return undefined + } - return new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("last_edit")); + return new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("last_edit")); - }, [State.state.featureSwitchIsDebugging]) + }, [State.state.featureSwitchIsDebugging, State.state.featureSwitchIsTesting]) ) ) diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 5c55bb3b4..711f6b1c5 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -62,7 +62,7 @@ export default class ShowDataLayer { const allFeats = features.data.map(ff => ff.feature); geoLayer = self.CreateGeojsonLayer(); for (const feat of allFeats) { - if(feat === undefined){ + if (feat === undefined) { continue } // @ts-ignore @@ -79,11 +79,11 @@ export default class ShowDataLayer { } if (zoomToFeatures) { - try{ - - mp.fitBounds(geoLayer.getBounds()) + try { - }catch(e){ + mp.fitBounds(geoLayer.getBounds()) + + } catch (e) { console.error(e) } } @@ -133,7 +133,6 @@ export default class ShowDataLayer { }) }); } - private postProcessFeature(feature, leafletLayer: L.Layer) { const layer: LayerConfig = this._layerDict[feature._matching_layer_id]; if (layer === undefined) { @@ -161,6 +160,7 @@ export default class ShowDataLayer { leafletLayer.on("popupopen", () => { State.state.selectedElement.setData(feature) + if (infobox === undefined) { const tags = State.state.allElements.getEventSourceById(feature.properties.id); infobox = new FeatureInfoBox(tags, layer); @@ -175,11 +175,11 @@ export default class ShowDataLayer { infobox.AttachTo(id) - infobox.Activate(); + infobox.Activate(); }); const self = this; State.state.selectedElement.addCallbackAndRunD(selected => { - if ( self._leafletMap.data === undefined) { + if (self._leafletMap.data === undefined) { return; } if (leafletLayer.getPopup().isOpen()) { @@ -187,8 +187,10 @@ export default class ShowDataLayer { } if (selected.properties.id === feature.properties.id) { // A small sanity check to prevent infinite loops: - // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again - if (selected.geometry.type === feature.geometry.type) { + if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again + + && feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too + ) { leafletLayer.openPopup() } diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index bdc65c23a..346e71e47 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -24,8 +24,6 @@ import Histogram from "./BigComponents/Histogram"; import Loc from "../Models/Loc"; import {Utils} from "../Utils"; import BaseLayer from "../Models/BaseLayer"; -import DeleteWizard from "./Popup/DeleteWizard"; -import Constants from "../Models/Constants"; export interface SpecialVisualization { funcName: string, @@ -38,8 +36,6 @@ export interface SpecialVisualization { export default class SpecialVisualizations { - public static specialVisualisationsByName: Map = SpecialVisualizations.byName(); - static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); static constructMiniMap: (options?: { background?: UIEventSource, location?: UIEventSource, @@ -380,57 +376,10 @@ export default class SpecialVisualizations { [state.layoutToUse]) ) } - }, - { - funcName: "delete", - docs: `Offers a dialog to (soft) delete the point. The dialog is built to be user friendly and to prevent mistakes. If deletion is not possible, the dialog will hide itself. - -#### Hard deletion if enough experience - -A feature can only be deleted by mapcomplete if: - -- It is a node -- No ways or relations use the node -- The logged-in user has enough experience (at least ${Constants.userJourney.deletePointsOfOthersUnlock} changesets) OR the user is the only one to have edited the point previously -- The user did not select one of the 'non-delete-options' (see below) - -In all other cases, a 'soft deletion' is used. - -#### Soft deletion - -A 'soft deletion' is when the point isn't deleted from OSM but retagged so that it'll won't how up in the mapcomplete theme anymore. -This makes it look like it was deleted, without doing damage. A fixme will be added to the point. - -Note that a soft deletion is _only_ possible if these tags are provided by the theme creator, as they'll be different for every theme - -#### No-delete options - -In some cases, the contributor might want to delete something for the wrong reason (e.g. someone who wants to have a path removed "because the path is on their private property"). -However, the path exists in reality and should thus be on OSM - otherwise the next contributor will pass by and notice "hey, there is a path missing here! Let me redraw it in OSM!) - -The correct approach is to retag the feature in such a way that it is semantically correct *and* that it doesn't show up on the theme anymore. -A no-delete option is offered as 'reason to delete it', but secretly retags. - -`, - args: [], - constr: (state, tagSource, args) => { - return new VariableUiElement(tagSource.map(tags => tags.id).map(id => - new DeleteWizard(id))) - } } ] - - private static byName(): Map { - const result = new Map(); - - for (const specialVisualization of SpecialVisualizations.specialVisualizations) { - result.set(specialVisualization.funcName, specialVisualization) - } - - return result; - } - + static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); private static GenHelpMessage() { const helpTexts = diff --git a/assets/layers/public_bookcase/public_bookcase.json b/assets/layers/public_bookcase/public_bookcase.json index 6d8ecc87e..7f76cc46f 100644 --- a/assets/layers/public_bookcase/public_bookcase.json +++ b/assets/layers/public_bookcase/public_bookcase.json @@ -374,5 +374,12 @@ "type": "url" } } - ] + ], + "deletion": { + "softDeletionTags": {"and":[ + "disused:amenity=public_bookcase", + "amenity=" + ]}, + "neededChangesets": 5 + } } \ No newline at end of file diff --git a/langs/en.json b/langs/en.json index e1a8b9953..7f327653c 100644 --- a/langs/en.json +++ b/langs/en.json @@ -49,7 +49,8 @@ "selectReason": "Please, select why this feature should be deleted", "hardDelete": "This point will be deleted in OpenStreetMap. It can be recovered by an experienced contributor", "softDelete": "This feature will be updated and hidden from this application. {reason}" - } + }, + "readMessages": "You have unread messages. Read these before deleting a point - someone might have feedback" }, "general": { "loginWithOpenStreetMap": "Login with OpenStreetMap", diff --git a/package.json b/package.json index 6d1477bed..d09ca2b7c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "generate:layouts": "ts-node scripts/generateLayouts.ts", "generate:docs": "ts-node scripts/generateDocs.ts && ts-node scripts/generateTaginfoProjectFiles.ts", "generate:cache:speelplekken": "npm run generate:layeroverview && ts-node scripts/generateCache.ts speelplekken 14 ../pietervdvn.github.io/speelplekken_cache/ 51.20 4.35 51.09 4.56", - "generate:layeroverview": "ts-node scripts/generateLayerOverview.ts --no-fail", + "generate:layeroverview": "echo {\\\"layers\\\":[], \\\"themes\\\":[]} > ./assets/generated/known_layers_and_themes.json && ts-node scripts/generateLayerOverview.ts --no-fail", "generate:licenses": "ts-node scripts/generateLicenseInfo.ts --no-fail", "generate:report": "cd Docs/Tools && ./compileStats.sh && git commit . -m 'New statistics ands graphs' && git push", "generate:contributor-list": "git log --pretty='%aN' | sort | uniq -c | sort -hr | sed 's/ *\\([0-9]*\\) \\(.*\\)$/{\"contributor\":\"\\2\", \"commits\":\\1}/' | tr '\\n' ',' | sed 's/^/{\"contributors\":[/' | sed 's/,$/]}/' > assets/contributors.json", From 69d4ea765ceca3aac8e872bc33c0225ed77c335b Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 14:54:35 +0200 Subject: [PATCH 18/26] Tweak styling for small screens --- UI/BigComponents/AttributionPanel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UI/BigComponents/AttributionPanel.ts b/UI/BigComponents/AttributionPanel.ts index 8b68a868d..a8fa38660 100644 --- a/UI/BigComponents/AttributionPanel.ts +++ b/UI/BigComponents/AttributionPanel.ts @@ -105,7 +105,7 @@ export default class AttributionPanel extends Combine { const sources = Utils.NoNull(Utils.NoEmpty(license.sources)) return new Combine([ - ``, + ``, new Combine([ new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"), new Combine([license.license, @@ -122,7 +122,7 @@ export default class AttributionPanel extends Combine { ] ).SetClass("block") ]).SetClass("flex flex-col") - ]).SetClass("flex") + ]).SetClass("flex flex-wrap") } private static GenerateLicenses() { From e2591f3dfe454a432ba343c913d985477bd49fb4 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 20:18:52 +0200 Subject: [PATCH 19/26] Toggle in the userbadge on small screens - more map space --- UI/BigComponents/Basemap.ts | 6 ----- UI/BigComponents/UserBadge.ts | 50 ++++++++++++++++++++--------------- css/mobile.css | 25 ------------------ css/userbadge.css | 43 +----------------------------- index.css | 12 +++++++++ index.html | 6 ++--- 6 files changed, 44 insertions(+), 98 deletions(-) diff --git a/UI/BigComponents/Basemap.ts b/UI/BigComponents/Basemap.ts index 4ad5bc8f6..2da6415b6 100644 --- a/UI/BigComponents/Basemap.ts +++ b/UI/BigComponents/Basemap.ts @@ -26,12 +26,6 @@ export class Basemap { attributionControl: extraAttribution !== undefined }); - L.control.scale( - { - position: 'topright', - } - ).addTo(this.map) - // Users are not allowed to zoom to the 'copies' on the left and the right, stuff goes wrong then // We give a bit of leeway for people on the edges diff --git a/UI/BigComponents/UserBadge.ts b/UI/BigComponents/UserBadge.ts index 26214661a..576c47a55 100644 --- a/UI/BigComponents/UserBadge.ts +++ b/UI/BigComponents/UserBadge.ts @@ -1,6 +1,3 @@ -/** - * Handles and updates the user badge - */ import {VariableUiElement} from "../Base/VariableUIElement"; import Svg from "../../Svg"; import State from "../../State"; @@ -21,7 +18,7 @@ export default class UserBadge extends Toggle { const loginButton = Translations.t.general.loginWithOpenStreetMap .Clone() - .SetClass("userbadge-login pt-3 w-full") + .SetClass("userbadge-login pt-3 w-full h-full") .onClick(() => State.state.osmConnection.AttemptLogin()); @@ -32,7 +29,7 @@ export default class UserBadge extends Toggle { }); - const userBadge = userDetails.map(user => { + const userBadge = new VariableUiElement(userDetails.map(user => { { const homeButton = new VariableUiElement( userDetails.map((userinfo) => { @@ -78,7 +75,7 @@ export default class UserBadge extends Toggle { let dryrun = new FixedUiElement(""); if (user.dryRun) { - dryrun = new FixedUiElement("TESTING").SetClass("alert"); + dryrun = new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4"); } const settings = @@ -87,15 +84,7 @@ export default class UserBadge extends Toggle { true) - const userIcon = new Link( - new Img(user.img) - .SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 h16 float-left") - , - `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`, - true - ); - - + const userName = new Link( new FixedUiElement(user.name), `https://www.openstreetmap.org/user/${user.name}`, @@ -113,24 +102,41 @@ export default class UserBadge extends Toggle { .SetClass("userstats") const usertext = new Combine([ - userName, - dryrun, + new Combine([userName, dryrun]).SetClass("flex justify-end w-full"), userStats - ]).SetClass("usertext") + ]).SetClass("flex flex-col sm:w-auto sm:pl-2 overflow-hidden w-0") + const userIcon = + new Img(user.img).SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 min-width-16 h16 float-left") + .onClick(() => { + if(usertext.HasClass("w-0")){ + usertext.RemoveClass("w-0") + usertext.SetClass("w-min pl-2") + }else{ + usertext.RemoveClass("w-min") + usertext.RemoveClass("pl-2") + usertext.SetClass("w-0") + } + }) + return new Combine([ - userIcon, usertext, - ]).SetClass("h-16") + userIcon, + ]).SetClass("h-16 flex bg-white") + } - }); + })); + userBadge.SetClass("inline-block m-0 w-full").SetStyle("pointer-events: all") super( - new VariableUiElement(userBadge), + userBadge, loginButton, State.state.osmConnection.isLoggedIn ) + + this.SetClass("shadow rounded-full h-min overflow-hidden block w-max") + } diff --git a/css/mobile.css b/css/mobile.css index 7165f0cd7..0fd405ac4 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -15,15 +15,6 @@ Contains tweaks for small screens display: none !important; } - #help-button-mobile div { - box-shadow: 0 0 10px #0006; - margin-bottom: 10px; - } - - #geolocate-button { - display: block; - } - #centermessage { top: 30%; left: 15%; @@ -57,21 +48,5 @@ Contains tweaks for small screens } -@media only screen and (max-width: 600px) { - /* Portrait */ - #userbadge-and-search { - display: inline-block; - width: auto; - max-width: 100vw; - } - - .userbadge-login { - min-width: unset; - } - - #userbadge { - margin-bottom: 0.3em; - } -} diff --git a/css/userbadge.css b/css/userbadge.css index 2fe2efec1..8d9897cfe 100644 --- a/css/userbadge.css +++ b/css/userbadge.css @@ -1,16 +1,4 @@ -#userbadge { - display: inline-block; - background-color: var(--background-color); - color: var(--foreground-color); - margin: 0; - margin-bottom: 0.5em; - width: 100%; - min-width: 20em; - pointer-events: all; - border-radius: 999em; - max-width: 100vw; - overflow-x: hidden; -} + .userstats { display: flex; @@ -45,28 +33,6 @@ display: block; } -.usertext { - display: block; - width: max-content; - margin: 0; - - padding: 0.9em; - padding-left: 4.7em; /* Should be half of profile-pic's width + actual padding (same as padding-right)*/ - padding-right: 1.5em; - border-radius: 2em; /*Half border radius width/height*/ - height: 2.2em; /*SHould equal profile-pic height - padding*/ - z-index: 5000; - text-align: left; - background-color: var(--background-color); - color: var(--foreground-color); - background-size: 100%; - - line-height: 0.75em; -} - - - - .userbadge-login { font-weight: bold; font-size: large; @@ -81,10 +47,3 @@ min-width: 20em; pointer-events: all; } - -#userbadge-and-search { - display: inline-block; - width: min-content; - overflow-x: hidden; - max-width: 100vw; -} diff --git a/index.css b/index.css index ebf83ca4b..47178a06f 100644 --- a/index.css +++ b/index.css @@ -93,6 +93,18 @@ a { color: var(--foreground-color); } +.h-min { + height: min-content; +} + +.w-min { + width: min-content; +} + +.space-between{ + justify-content: space-between; +} + .link-underline a { text-decoration: underline 1px #0078a855;; color: #0078A8; diff --git a/index.html b/index.html index eb45c805a..22113fa9d 100644 --- a/index.html +++ b/index.html @@ -59,9 +59,9 @@
- From 3861c5c6343ab1d28bf1556b31f0808d567fcf17 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 20:50:28 +0200 Subject: [PATCH 20/26] Version bump --- Models/Constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/Constants.ts b/Models/Constants.ts index 4cbec2930..4f16c97ac 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import { Utils } from "../Utils"; export default class Constants { - public static vNumber = "0.8.2"; + public static vNumber = "0.8.2a"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { From 952e3e2bc7fb83fa96459a29706e985c48ae3661 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 21:18:19 +0200 Subject: [PATCH 21/26] Styling tweaks --- UI/BigComponents/AttributionPanel.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/UI/BigComponents/AttributionPanel.ts b/UI/BigComponents/AttributionPanel.ts index a8fa38660..cd87c2bf3 100644 --- a/UI/BigComponents/AttributionPanel.ts +++ b/UI/BigComponents/AttributionPanel.ts @@ -65,8 +65,8 @@ export default class AttributionPanel extends Combine { ...Utils.NoNull(Array.from(layoutToUse.data.ExtractImages())) .map(AttributionPanel.IconAttribution) ]); - this.SetClass("flex flex-col link-underline") - this.SetStyle("max-width: calc(100vw - 5em); width: 40em;") + this.SetClass("flex flex-col link-underline overflow-hidden") + this.SetStyle("max-width: calc(100vw - 5em); width: 40rem;") } private static CodeContributors(): BaseUIElement { @@ -120,9 +120,10 @@ export default class AttributionPanel extends Combine { return new Link(sourceLinkContent, lnk, true); }) ] - ).SetClass("block") - ]).SetClass("flex flex-col") - ]).SetClass("flex flex-wrap") + ).SetClass("block m-2") + + ]).SetClass("flex flex-col").SetStyle("width: calc(100% - 50px - 0.5em); min-width: 12rem;") + ]).SetClass("flex flex-wrap border-b border-gray-300 m-2 border-box") } private static GenerateLicenses() { From 6ec261e5164947e409fd2ee9cfd39d2a0a437239 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 21:23:11 +0200 Subject: [PATCH 22/26] Add space between value and denomination, fixes #383 --- Customizations/JSON/Denomination.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Customizations/JSON/Denomination.ts b/Customizations/JSON/Denomination.ts index d460a26d9..fd9308319 100644 --- a/Customizations/JSON/Denomination.ts +++ b/Customizations/JSON/Denomination.ts @@ -151,7 +151,7 @@ export class Denomination { if (stripped === null) { return null; } - return stripped + this.canonical + return stripped + " " + this.canonical.trim() } /** From 040cd5a3b88366570131554202ecf34f1bd04b9f Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 21:28:49 +0200 Subject: [PATCH 23/26] Version bump --- Models/Constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/Constants.ts b/Models/Constants.ts index 5929b73e9..0a048b138 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import { Utils } from "../Utils"; export default class Constants { - public static vNumber = "0.8.2a"; + public static vNumber = "0.8.3"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { From 2f05010648a22e7da5d33b8299627d716103fe77 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 21:39:30 +0200 Subject: [PATCH 24/26] Fix tests --- scripts/ScriptUtils.ts | 12 ++++++++++-- test/OsmObject.spec.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/ScriptUtils.ts b/scripts/ScriptUtils.ts index 54b340a7c..ccf230275 100644 --- a/scripts/ScriptUtils.ts +++ b/scripts/ScriptUtils.ts @@ -1,5 +1,6 @@ import {lstatSync, readdirSync, readFileSync} from "fs"; import {Utils} from "../Utils"; + Utils.runningFromConsole = true import * as https from "https"; import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson"; @@ -52,8 +53,15 @@ export default class ScriptUtils { return new Promise((resolve, reject) => { try { - - https.get(url, (res) => { + const urlObj = new URL(url) + https.get({ + host: urlObj.host, + path: urlObj.pathname, + port: urlObj.port, + headers: { + "accept": "application/json" + } + }, (res) => { const parts: string[] = [] res.setEncoding('utf8'); res.on('data', function (chunk) { diff --git a/test/OsmObject.spec.ts b/test/OsmObject.spec.ts index f5145a41e..075d262bb 100644 --- a/test/OsmObject.spec.ts +++ b/test/OsmObject.spec.ts @@ -9,7 +9,7 @@ export default class OsmObjectSpec extends T { "Download referencing ways", () => { let downloaded = false; - OsmObject.DownloadReferencingWays("node/1124134958", ways => { + OsmObject.DownloadReferencingWays("node/1124134958").addCallbackAndRunD(ways => { downloaded = true; console.log(ways) }) From e3f2c56d3ed055255d32930d554c72bdf3cc5369 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 22:00:36 +0200 Subject: [PATCH 25/26] Fix taginfo script, formatting, docs regeneration --- Customizations/AllKnownLayers.ts | 6 +++--- Customizations/AllKnownLayouts.ts | 3 +-- Customizations/SharedTagRenderings.ts | 4 ---- Docs/SpecialRenderings.md | 2 +- scripts/generateTaginfoProjectFiles.ts | 5 +++-- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Customizations/AllKnownLayers.ts b/Customizations/AllKnownLayers.ts index 2e5ba3391..97e195db1 100644 --- a/Customizations/AllKnownLayers.ts +++ b/Customizations/AllKnownLayers.ts @@ -8,14 +8,14 @@ export default class AllKnownLayers { // Must be below the list... public static sharedLayers: Map = AllKnownLayers.getSharedLayers(); public static sharedLayersJson: Map = AllKnownLayers.getSharedLayersJson(); - + public static sharedUnits: any[] = [] private static getSharedLayers(): Map { const sharedLayers = new Map(); for (const layer of known_layers.layers) { try { - const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits,"shared_layers") + const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits, "shared_layers") sharedLayers.set(layer.id, parsed); sharedLayers[layer.id] = parsed; } catch (e) { @@ -35,7 +35,7 @@ export default class AllKnownLayers { continue; } try { - const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits ,"shared_layer_in_theme") + const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits, "shared_layer_in_theme") sharedLayers.set(layer.id, parsed); sharedLayers[layer.id] = parsed; } catch (e) { diff --git a/Customizations/AllKnownLayouts.ts b/Customizations/AllKnownLayouts.ts index 65c76e2de..54363cc25 100644 --- a/Customizations/AllKnownLayouts.ts +++ b/Customizations/AllKnownLayouts.ts @@ -1,8 +1,7 @@ import LayoutConfig from "./JSON/LayoutConfig"; import AllKnownLayers from "./AllKnownLayers"; import * as known_themes from "../assets/generated/known_layers_and_themes.json" -import {LayoutConfigJson} from "./JSON/LayoutConfigJson"; -import * as all_layouts from "../assets/generated/known_layers_and_themes.json" + export class AllKnownLayouts { diff --git a/Customizations/SharedTagRenderings.ts b/Customizations/SharedTagRenderings.ts index a75a65d90..b3af01c83 100644 --- a/Customizations/SharedTagRenderings.ts +++ b/Customizations/SharedTagRenderings.ts @@ -1,7 +1,6 @@ import TagRenderingConfig from "./JSON/TagRenderingConfig"; import * as questions from "../assets/tagRenderings/questions.json"; import * as icons from "../assets/tagRenderings/icons.json"; -import {Utils} from "../Utils"; export default class SharedTagRenderings { @@ -12,9 +11,6 @@ export default class SharedTagRenderings { const dict = new Map(); function add(key, store) { - if(Utils.runningFromConsole){ - return; - } try { dict.set(key, new TagRenderingConfig(store[key], undefined, `SharedTagRenderings.${key}`)) } catch (e) { diff --git a/Docs/SpecialRenderings.md b/Docs/SpecialRenderings.md index 928c670ff..524b59122 100644 --- a/Docs/SpecialRenderings.md +++ b/Docs/SpecialRenderings.md @@ -42,7 +42,7 @@ image-key | image | Image tag to add the URL to (or image-tag:0, image-tag:1 whe name | default | description ------ | --------- | ------------- -zoomlevel | 18 | The zoomlevel: the higher, the more zoomed in with 1 being the entire world and 19 being really close +zoomlevel | 18 | The (maximum) zoomlevel: the target zoomlevel after fitting the entire feature. The minimap will fit the entire feature, then zoom out to this zoom level. The higher, the more zoomed in with 1 being the entire world and 19 being really close idKey | id | (Matches all resting arguments) This argument should be the key of a property of the feature. The corresponding value is interpreted as either the id or the a list of ID's. The features with these ID's will be shown on this minimap. #### Example usage diff --git a/scripts/generateTaginfoProjectFiles.ts b/scripts/generateTaginfoProjectFiles.ts index 3059957ca..9e4ff2c54 100644 --- a/scripts/generateTaginfoProjectFiles.ts +++ b/scripts/generateTaginfoProjectFiles.ts @@ -47,8 +47,9 @@ function generateLayerUsage(layer: LayerConfig, layout: LayoutConfig): any [] { for (const tr of layer.tagRenderings) { { - const usesImageCarousel = (tr.render?.txt?.indexOf("image_carousel()") ?? -2) > 0 - const usesImageUpload = (tr.render?.txt?.indexOf("image_upload()") ?? -2) > 0 + const usesImageCarousel = (tr.render?.txt?.indexOf("image_carousel") ?? -2) > 0 + const usesImageUpload = (tr.render?.txt?.indexOf("image_upload") ?? -2) > 0 + if (usesImageCarousel || usesImageUpload) { const descrNoUpload = `The layer '${layer.name.txt} shows images based on the keys image, image:0, image:1,... and wikidata, wikipedia, wikimedia_commons and mapillary`; From d0997480c0e0edffdf8dc4087442b14948dcde72 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 3 Jul 2021 22:24:12 +0200 Subject: [PATCH 26/26] Decrese dependency on jquery --- Logic/FeatureSource/GeoJsonSource.ts | 11 +++----- Logic/ImageProviders/Mapillary.ts | 3 ++- Logic/ImageProviders/Wikimedia.ts | 5 ++-- Logic/Osm/Geocoding.ts | 20 ++++++--------- Logic/Osm/OsmObject.ts | 5 ++-- Logic/Osm/Overpass.ts | 15 +++-------- Logic/Web/LiveQueryHandler.ts | 6 ++--- Utils.ts | 38 ++++++++++++++-------------- 8 files changed, 43 insertions(+), 60 deletions(-) diff --git a/Logic/FeatureSource/GeoJsonSource.ts b/Logic/FeatureSource/GeoJsonSource.ts index 58bc6292a..b462c87da 100644 --- a/Logic/FeatureSource/GeoJsonSource.ts +++ b/Logic/FeatureSource/GeoJsonSource.ts @@ -1,6 +1,5 @@ import FeatureSource from "./FeatureSource"; import {UIEventSource} from "../UIEventSource"; -import * as $ from "jquery"; import Loc from "../../Models/Loc"; import State from "../../State"; import {Utils} from "../../Utils"; @@ -152,12 +151,8 @@ export default class GeoJsonSource implements FeatureSource { private LoadJSONFrom(url: string) { const eventSource = this.features; const self = this; - $.getJSON(url, function (json, status) { - if (status !== "success") { - self.onFail(status, url); - return; - } - + Utils.downloadJson(url) + .then(json => { if (json.elements === [] && json.remarks.indexOf("runtime error") > 0) { self.onFail("Runtime error (timeout)", url) return; @@ -193,7 +188,7 @@ export default class GeoJsonSource implements FeatureSource { eventSource.setData(eventSource.data.concat(newFeatures)) - }).fail(msg => self.onFail(msg, url)) + }).catch(msg => self.onFail(msg, url)) } } diff --git a/Logic/ImageProviders/Mapillary.ts b/Logic/ImageProviders/Mapillary.ts index cad71f93b..ebb27fcb0 100644 --- a/Logic/ImageProviders/Mapillary.ts +++ b/Logic/ImageProviders/Mapillary.ts @@ -4,6 +4,7 @@ import ImageAttributionSource from "./ImageAttributionSource"; import BaseUIElement from "../../UI/BaseUIElement"; import {UIEventSource} from "../UIEventSource"; import Svg from "../../Svg"; +import {Utils} from "../../Utils"; export class Mapillary extends ImageAttributionSource { @@ -43,7 +44,7 @@ export class Mapillary extends ImageAttributionSource { const key = Mapillary.ExtractKeyFromURL(url) const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` const source = new UIEventSource(undefined) - $.getJSON(metadataURL, function (data) { + Utils.downloadJson(metadataURL).then(data => { const license = new LicenseInfo(); license.artist = data.properties?.username; license.licenseShortName = "CC BY-SA 4.0"; diff --git a/Logic/ImageProviders/Wikimedia.ts b/Logic/ImageProviders/Wikimedia.ts index 9d74b7769..2899e5bb8 100644 --- a/Logic/ImageProviders/Wikimedia.ts +++ b/Logic/ImageProviders/Wikimedia.ts @@ -1,4 +1,3 @@ -import * as $ from "jquery" import ImageAttributionSource from "./ImageAttributionSource"; import BaseUIElement from "../../UI/BaseUIElement"; import Svg from "../../Svg"; @@ -43,7 +42,7 @@ export class Wikimedia extends ImageAttributionSource { } const self = this; console.log("Loading a wikimedia category: ", url) - $.getJSON(url, (response) => { + Utils.downloadJson(url).then((response) => { let imageOverview = new ImagesInCategory(); let members = response.query?.categorymembers; if (members === undefined) { @@ -78,7 +77,7 @@ export class Wikimedia extends ImageAttributionSource { static GetWikiData(id: number, handleWikidata: ((Wikidata) => void)) { const url = "https://www.wikidata.org/wiki/Special:EntityData/Q" + id + ".json"; - $.getJSON(url, (response) => { + Utils.downloadJson(url).then (response => { const entity = response.entities["Q" + id]; const commons = entity.sitelinks.commonswiki; const wd = new Wikidata(); diff --git a/Logic/Osm/Geocoding.ts b/Logic/Osm/Geocoding.ts index f2ee260f2..259f688dc 100644 --- a/Logic/Osm/Geocoding.ts +++ b/Logic/Osm/Geocoding.ts @@ -1,5 +1,6 @@ -import $ from "jquery" import State from "../../State"; +import {Utils} from "../../Utils"; + export class Geocoding { private static readonly host = "https://nominatim.openstreetmap.org/search?"; @@ -9,17 +10,12 @@ export class Geocoding { osm_type: string, osm_id: string}[]) => void), onFail: (() => void)) { const b = State.state.leafletMap.data.getBounds(); - console.log(b); - $.getJSON( - Geocoding.host + "format=json&limit=1&viewbox=" + + const url = Geocoding.host + "format=json&limit=1&viewbox=" + `${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}`+ - "&accept-language=nl&q=" + query, - function (data) { - handleResult(data); - }).fail(() => { - onFail(); - }); + "&accept-language=nl&q=" + query; + Utils.downloadJson( + url) + .then(handleResult) + .catch(onFail); } - - } diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index b9d1b9076..6f9ec95b3 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -1,4 +1,3 @@ -import * as $ from "jquery" import {Utils} from "../../Utils"; import * as polygon_features from "../../assets/polygon-features.json"; import {UIEventSource} from "../UIEventSource"; @@ -151,7 +150,7 @@ export abstract class OsmObject { const minlat = bounds[1][0] const maxlat = bounds[0][0]; const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}` - $.getJSON(url, data => { + Utils.downloadJson(url).then( data => { const elements: any[] = data.elements; const objects = OsmObject.ParseObjects(elements) callback(objects); @@ -274,7 +273,7 @@ export abstract class OsmObject { const self = this; const full = this.type !== "way" ? "" : "/full"; const url = `${OsmObject.backendURL}api/0.6/${this.type}/${this.id}${full}`; - $.getJSON(url, function (data) { + Utils.downloadJson(url).then(data => { const element = data.elements.pop(); diff --git a/Logic/Osm/Overpass.ts b/Logic/Osm/Overpass.ts index 7a4cbfb79..59a4869f4 100644 --- a/Logic/Osm/Overpass.ts +++ b/Logic/Osm/Overpass.ts @@ -1,8 +1,8 @@ -import * as $ from "jquery" import * as OsmToGeoJson from "osmtogeojson"; import Bounds from "../../Models/Bounds"; import {TagsFilter} from "../Tags/TagsFilter"; import ExtractRelations from "./ExtractRelations"; +import {Utils} from "../../Utils"; /** * Interfaces overpass to get all the latest data @@ -27,14 +27,8 @@ export class Overpass { console.log("Using testing URL") query = Overpass.testUrl; } - $.getJSON(query, - function (json, status) { - if (status !== "success") { - console.log("Query failed") - onFail(status); - return; - } - + Utils.downloadJson(query) + .then(json => { if (json.elements === [] && json.remarks.indexOf("runtime error") > 0) { console.log("Timeout or other runtime error"); onFail("Runtime error (timeout)") @@ -47,8 +41,7 @@ export class Overpass { const osmTime = new Date(json.osm3s.timestamp_osm_base); continuation(geojson, osmTime); - - }).fail(onFail) + }).catch(onFail) } buildQuery(bbox: string): string { diff --git a/Logic/Web/LiveQueryHandler.ts b/Logic/Web/LiveQueryHandler.ts index 9b8fc77c5..b7da5df3b 100644 --- a/Logic/Web/LiveQueryHandler.ts +++ b/Logic/Web/LiveQueryHandler.ts @@ -1,6 +1,6 @@ - import {UIEventSource} from "../UIEventSource"; -import * as $ from "jquery" +import {Utils} from "../../Utils"; + /** * Fetches data from random data sources, used in the metatagging */ @@ -25,7 +25,7 @@ export default class LiveQueryHandler { LiveQueryHandler[url] = source; console.log("Fetching live data from a third-party (unknown) API:",url) - $.getJSON(url, function (data) { + Utils.downloadJson(url).then(data => { for (const shorthandDescription of shorthandsSet) { const descr = shorthandDescription.trim().split(":"); diff --git a/Utils.ts b/Utils.ts index 81576df9f..05ebcbaab 100644 --- a/Utils.ts +++ b/Utils.ts @@ -9,12 +9,10 @@ export class Utils { */ public static runningFromConsole = false; public static readonly assets_path = "./assets/svg/"; - - + public static externalDownloadFunction: (url: string) => Promise; private static knownKeys = ["addExtraTags", "and", "calculatedTags", "changesetmessage", "clustering", "color", "condition", "customCss", "dashArray", "defaultBackgroundId", "description", "descriptionTail", "doNotDownload", "enableAddNewPoints", "enableBackgroundLayerSelection", "enableGeolocation", "enableLayers", "enableMoreQuests", "enableSearch", "enableShareScreen", "enableUserBadge", "freeform", "hideFromOverview", "hideInAnswer", "icon", "iconOverlays", "iconSize", "id", "if", "ifnot", "isShown", "key", "language", "layers", "lockLocation", "maintainer", "mappings", "maxzoom", "maxZoom", "minNeededElements", "minzoom", "multiAnswer", "name", "or", "osmTags", "passAllFeatures", "presets", "question", "render", "roaming", "roamingRenderings", "rotation", "shortDescription", "socialImage", "source", "startLat", "startLon", "startZoom", "tagRenderings", "tags", "then", "title", "titleIcons", "type", "version", "wayHandling", "widenFactor", "width"] private static extraKeys = ["nl", "en", "fr", "de", "pt", "es", "name", "phone", "email", "amenity", "leisure", "highway", "building", "yes", "no", "true", "false"] - static EncodeXmlValue(str) { if (typeof str !== "string") { str = "" + str @@ -72,10 +70,10 @@ export class Utils { return res; } - public static TimesT(count : number, f: ((i: number) => T)): T[] { - let res : T[] = []; + public static TimesT(count: number, f: ((i: number) => T)): T[] { + let res: T[] = []; for (let i = 0; i < count; i++) { - res .push(f(i)); + res.push(f(i)); } return res; } @@ -158,7 +156,7 @@ export class Utils { public static SubstituteKeys(txt: string, tags: any) { for (const key in tags) { - if(!tags.hasOwnProperty(key)) { + if (!tags.hasOwnProperty(key)) { continue } txt = txt.replace(new RegExp("{" + key + "}", "g"), tags[key]) @@ -292,10 +290,10 @@ export class Utils { public static UnMinify(minified: string): string { - if(minified === undefined || minified === null){ + if (minified === undefined || minified === null) { return undefined; } - + const parts = minified.split("|"); let result = parts.shift(); const keys = Utils.knownKeys.concat(Utils.extraKeys); @@ -323,34 +321,36 @@ export class Utils { } return result; } - - public static externalDownloadFunction: (url: string) => Promise; - - public static downloadJson(url: string): Promise{ - if(this.externalDownloadFunction !== undefined){ + + public static downloadJson(url: string): Promise { + if (this.externalDownloadFunction !== undefined) { return this.externalDownloadFunction(url) } return new Promise( (resolve, reject) => { - try{ + try { const xhr = new XMLHttpRequest(); xhr.onload = () => { if (xhr.status == 200) { - resolve(JSON.parse(xhr.response)) + try { + resolve(JSON.parse(xhr.response)) + } catch (e) { + reject("Not a valid json: " + xhr.response) + } } else { reject(xhr.statusText) } }; xhr.open('GET', url); - xhr.setRequestHeader("accept","application/json") + xhr.setRequestHeader("accept", "application/json") xhr.send(); - }catch(e){ + } catch (e) { reject(e) } } ) - + } /**