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",