import {VariableUiElement} from "../Base/VariableUIElement"; import Toggle from "../Input/Toggle"; import Translations from "../i18n/Translations"; import Svg from "../../Svg"; import DeleteAction from "../../Logic/Osm/Actions/DeleteAction"; import {UIEventSource} from "../../Logic/UIEventSource"; import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import TagRenderingQuestion from "./TagRenderingQuestion"; import Combine from "../Base/Combine"; import {SubtleButton} from "../Base/SubtleButton"; import {FixedUiElement} from "../Base/FixedUiElement"; import {Translation} from "../i18n/Translation"; import BaseUIElement from "../BaseUIElement"; import Constants from "../../Models/Constants"; import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"; import {AndOrTagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson"; import DeleteConfig from "../../Models/ThemeConfig/DeleteConfig"; import {OsmObject} from "../../Logic/Osm/OsmObject"; import {ElementStorage} from "../../Logic/ElementStorage"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import {Changes} from "../../Logic/Osm/Changes"; import {OsmConnection} from "../../Logic/Osm/OsmConnection"; 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 state: the state of the application * @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, state: { osmConnection: OsmConnection; allElements: ElementStorage, layoutToUse?: LayoutConfig, changes?: Changes }, options: DeleteConfig) { const deleteAbility = new DeleteabilityChecker(id, state, options.neededChangesets) const tagsSource = state.allElements.getEventSourceById(id) const isDeleted = new UIEventSource(false) const allowSoftDeletion = !!options.softDeletionTags const confirm = new UIEventSource(false) function doDelete(selected: TagsFilter) { // Selected == the reasons, not the tags of the object const tgs = selected.asChange(tagsSource.data) const deleteReasonMatch = tgs.filter(kv => kv.k === "_delete_reason") if (deleteReasonMatch.length === 0) { return; } const deleteAction = new DeleteAction(id, options.softDeletionTags, { theme: state?.layoutToUse?.id ?? "unkown", specialMotivation: deleteReasonMatch[0]?.v }, deleteAbility.canBeDeleted.data.canBeDeleted ) state.changes?.applyAction(deleteAction) isDeleted.setData(true) } const t = Translations.t.delete const cancelButton = t.cancel.Clone().SetClass("block btn btn-secondary").onClick(() => confirm.setData(false)); const question = new VariableUiElement(tagsSource.map(currentTags => { const config = DeleteWizard.generateDeleteTagRenderingConfig(options.softDeletionTags, options.nonDeleteMappings, options.extraDeleteReasons, currentTags) return new TagRenderingQuestion( tagsSource, config, state, { 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, deleteAbility) } ) })) /** * The button which is shown first. Opening it will trigger the check for deletions */ const deleteButton = new SubtleButton( Svg.delete_icon_svg().SetStyle("width: 1.5rem; height: 1.5rem;"), t.delete.Clone()).onClick( () => { deleteAbility.CheckDeleteability(true) confirm.setData(true); } ) 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( new Toggle( question, new SubtleButton(Svg.envelope_ui(), t.readMessages.Clone()), state.osmConnection.userDetails.map(ud => ud.csCount > Constants.userJourney.addNewPointWithUnreadMessagesUnlock || ud.unreadMessages == 0) ), deleteButton, confirm), new VariableUiElement(deleteAbility.canBeDeleted.map(cbd => new Combine([ Svg.delete_not_allowed_svg().SetStyle("height: 2rem; width: auto").SetClass("mr-2"), new Combine([ t.cannotBeDeleted.Clone(), cbd.reason.Clone().SetClass("subtle"), t.useSomethingElse.Clone().SetClass("subtle")]).SetClass("flex flex-col") ]).SetClass("flex m-2 p-2 rounded-lg bg-gray-200 bg-gray-200"))) , deleteAbility.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)), t.loginToDelete.Clone().onClick(state.osmConnection.AttemptLogin), state.osmConnection.isLoggedIn ), isDeleted), undefined, isShown) } 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: DeleteabilityChecker) { 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: TagsFilter, nonDeleteOptions: { if: TagsFilter; then: Translation }[], extraDeleteReasons: { explanation: Translation; changesetMessage: string }[], currentTags: any): TagRenderingConfig { const t = Translations.t.delete nonDeleteOptions = nonDeleteOptions ?? [] let softDeletionTagsStr = [] if (softDeletionTags !== undefined) { softDeletionTags.asChange(currentTags) } const extraOptionsStr: { if: AndOrTagConfigJson, then: any }[] = [] for (const nonDeleteOption of nonDeleteOptions) { const newIf: string[] = nonDeleteOption.if.asChange({}).map(kv => kv.k + "=" + kv.v) 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, render: "Deleted because {_delete_reason}", freeform: { key: "_delete_reason", addExtraTags: softDeletionTagsStr }, mappings: [ ...extraOptionsStr, { 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 }, { if: { and: [ "_delete_reason=duplicate", ...softDeletionTagsStr ] }, then: t.reasons.duplicate } ] }, "Delete wizard" ) } } class DeleteabilityChecker { public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean, reason: Translation }>; private readonly _id: string; private readonly _allowDeletionAtChangesetCount: number; private readonly _state: { osmConnection: OsmConnection }; constructor(id: string, state: { osmConnection: OsmConnection }, allowDeletionAtChangesetCount?: number) { this._id = id; this._state = state; this._allowDeletionAtChangesetCount = allowDeletionAtChangesetCount ?? Number.MAX_VALUE; this.canBeDeleted = new UIEventSource<{ canBeDeleted?: boolean; reason: Translation }>({ canBeDeleted: undefined, reason: Translations.t.delete.loading }) this.CheckDeleteability(false) } /** * Checks if the currently logged in user can delete the current point. * State is written into this._canBeDeleted * @constructor * @private */ public CheckDeleteability(useTheInternet: boolean): void { const t = Translations.t.delete; const id = this._id; const state = this.canBeDeleted const self = this; 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 = this._state.osmConnection.userDetails.map(ud => { if (ud === undefined) { return undefined; } if (!ud.loggedIn) { return false; } return ud.csCount >= Math.min(Constants.userJourney.deletePointsOfOthersUnlock, this._allowDeletionAtChangesetCount); }) const previousEditors = new UIEventSource(undefined) const allByMyself = previousEditors.map(previous => { if (previous === null || previous === undefined) { // Not yet downloaded return null; } const userId = self._state.osmConnection.userDetails.data.uid; return !previous.some(editor => editor !== userId) }, [self._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 && 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) } 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 true; // unregister this caller! } 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).then(rels => { hasRelations.setData(rels.length > 0) }) OsmObject.DownloadReferencingWays(id).then(ways => { hasWays.setData(ways.length > 0) }) return true; // unregister to only run once }) const hasWaysOrRelations = hasRelations.map(hasRelationsData => { if (hasRelationsData === true) { return true; } if (hasWays.data === true) { return true; } if (hasWays.data === null || hasRelationsData === null) { return null; } 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 }) } else { // alright, this point can be safely deleted! state.setData({ canBeDeleted: true, reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete }) } } ) } }