forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			445 lines
		
	
	
		
			No EOL
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			445 lines
		
	
	
		
			No EOL
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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 {Store, UIEventSource} from "../../Logic/UIEventSource";
 | |
| import {TagsFilter} from "../../Logic/Tags/TagsFilter";
 | |
| import Combine from "../Base/Combine";
 | |
| import {SubtleButton} from "../Base/SubtleButton";
 | |
| import {Translation} from "../i18n/Translation";
 | |
| import BaseUIElement from "../BaseUIElement";
 | |
| import Constants from "../../Models/Constants";
 | |
| import DeleteConfig from "../../Models/ThemeConfig/DeleteConfig";
 | |
| import {OsmObject} from "../../Logic/Osm/OsmObject";
 | |
| import {OsmConnection} from "../../Logic/Osm/OsmConnection";
 | |
| import OsmChangeAction from "../../Logic/Osm/Actions/OsmChangeAction";
 | |
| import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
 | |
| import {InputElement} from "../Input/InputElement";
 | |
| import {RadioButton} from "../Input/RadioButton";
 | |
| import {FixedInputElement} from "../Input/FixedInputElement";
 | |
| import Title from "../Base/Title";
 | |
| import {SubstitutedTranslation} from "../SubstitutedTranslation";
 | |
| import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
 | |
| import TagRenderingQuestion from "./TagRenderingQuestion";
 | |
| 
 | |
| 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: FeaturePipelineState,
 | |
|                 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<boolean>(false)
 | |
| 
 | |
| 
 | |
|         /**
 | |
|          * This function is the actual delete function
 | |
|          */
 | |
|         function doDelete(selected: { deleteReason: string } | { retagTo: TagsFilter }) {
 | |
|             let actionToTake: OsmChangeAction;
 | |
|             if (selected["retagTo"] !== undefined) {
 | |
|                 // no _delete_reason is given, which implies that this is _not_ a deletion but merely a retagging via a nonDeleteMapping
 | |
|                 actionToTake = new ChangeTagAction(
 | |
|                     id,
 | |
|                     selected["retagTo"],
 | |
|                     tagsSource.data,
 | |
|                     {
 | |
|                         theme: state?.layoutToUse?.id ?? "unkown",
 | |
|                         changeType: "special-delete"
 | |
|                     }
 | |
|                 )
 | |
|             } else {
 | |
|                 
 | |
|                 actionToTake = new DeleteAction(id,
 | |
|                     options.softDeletionTags,
 | |
|                     {
 | |
|                         theme: state?.layoutToUse?.id ?? "unkown",
 | |
|                         specialMotivation: selected["deleteReason"]
 | |
|                     },
 | |
|                     deleteAbility.canBeDeleted.data.canBeDeleted
 | |
|                 )
 | |
|             }
 | |
|             state.changes?.applyAction(actionToTake)
 | |
|             isDeleted.setData(true)
 | |
| 
 | |
|         }
 | |
| 
 | |
| 
 | |
|         const t = Translations.t.delete
 | |
|         const cancelButton = t.cancel.SetClass("block btn btn-secondary").onClick(() => confirm.setData(false));
 | |
| 
 | |
|         /**
 | |
|          * 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)
 | |
|             .onClick(
 | |
|                 () => {
 | |
|                     deleteAbility.CheckDeleteability(true)
 | |
|                     confirm.setData(true);
 | |
|                 }
 | |
|             )
 | |
| 
 | |
|         const isShown: Store<boolean> = tagsSource.map(tgs => tgs.id.indexOf("-") < 0)
 | |
| 
 | |
|         const deleteOptionPicker = DeleteWizard.constructMultipleChoice(options, tagsSource, state);
 | |
|         const deleteDialog = new Combine([
 | |
| 
 | |
| 
 | |
|             new Title(new SubstitutedTranslation(t.whyDelete, tagsSource, state)
 | |
|                 .SetClass("question-text"), 3),
 | |
|             deleteOptionPicker,
 | |
|             new Combine([
 | |
|                 DeleteWizard.constructExplanation(deleteOptionPicker.GetValue(), deleteAbility, tagsSource, state),
 | |
|                 new Combine([
 | |
|                     
 | |
|                 cancelButton,
 | |
|                 DeleteWizard.constructConfirmButton(deleteOptionPicker.GetValue())
 | |
|                     .onClick(() => doDelete(deleteOptionPicker.GetValue().data))
 | |
|                 ]).SetClass("flex justify-end flex-wrap-reverse")
 | |
|                 
 | |
|             ]).SetClass("flex mt-2 justify-between")
 | |
| 
 | |
| 
 | |
|         ]).SetClass("question")
 | |
| 
 | |
| 
 | |
|         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]).SetClass("flex m-2 rounded-full"),
 | |
|                 new Toggle(
 | |
|                     new Toggle(
 | |
|                         new Toggle(
 | |
|                             new Toggle(
 | |
|                                 deleteDialog,
 | |
|                                 new SubtleButton(Svg.envelope_ui(), t.readMessages),
 | |
|                                 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,
 | |
|                                     cbd.reason.SetClass("subtle"),
 | |
|                                     t.useSomethingElse.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.onClick(state.osmConnection.AttemptLogin),
 | |
|                     state.osmConnection.isLoggedIn
 | |
|                 ),
 | |
|                 isDeleted),
 | |
|             undefined,
 | |
|             isShown)
 | |
| 
 | |
|     }
 | |
| 
 | |
| 
 | |
|     private static constructConfirmButton(deleteReasons: UIEventSource<any | undefined>): BaseUIElement {
 | |
|         const t = Translations.t.delete;
 | |
|         const btn = new Combine([
 | |
|             Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"),
 | |
|             t.delete
 | |
|         ]).SetClass("flex btn bg-red-500")
 | |
| 
 | |
| 
 | |
|         const btnNonActive = new Combine([
 | |
|             Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"),
 | |
|             t.delete
 | |
|         ]).SetClass("flex btn btn-disabled bg-red-200")
 | |
| 
 | |
|         return new Toggle(
 | |
|             btn,
 | |
|             btnNonActive,
 | |
|             deleteReasons.map(reason => reason !== undefined)
 | |
|         )
 | |
| 
 | |
|     }
 | |
| 
 | |
| 
 | |
|     private static constructExplanation(selectedOption: UIEventSource<
 | |
|         {deleteReason: string} | {retagTo: TagsFilter}>, deleteAction: DeleteabilityChecker,
 | |
|                                         currentTags: UIEventSource<object>,
 | |
|                                         state?: {osmConnection?: OsmConnection}) {
 | |
|         const t = Translations.t.delete;
 | |
|         return new VariableUiElement(selectedOption.map(
 | |
|             selectedOption => {
 | |
|                 if (selectedOption === undefined) {
 | |
|                     return t.explanations.selectReason.SetClass("subtle");
 | |
|                 }
 | |
| 
 | |
|                 const retag: TagsFilter | undefined = selectedOption["retagTo"]
 | |
|                 if(retag !== undefined) {
 | |
|                     // This is a retagging, not a deletion of any kind
 | |
|                     return new Combine([t.explanations.retagNoOtherThemes,
 | |
|                     TagRenderingQuestion.CreateTagExplanation(new UIEventSource<TagsFilter>(retag),
 | |
|                         currentTags, state
 | |
|                         ).SetClass("subtle")
 | |
|                     ])
 | |
|                 }
 | |
| 
 | |
|                 const deleteReason = selectedOption["deleteReason"];
 | |
|                 if(deleteReason !== undefined){
 | |
|                     return new VariableUiElement(deleteAction.canBeDeleted.map(({
 | |
|                         canBeDeleted, reason
 | |
|                     }) => {
 | |
|                         if(canBeDeleted){
 | |
|                             // This is a hard delete for which we give an explanation
 | |
|                             return t.explanations.hardDelete;
 | |
|                         }
 | |
|                         // This is a soft deletion: we explain _why_ the deletion is soft
 | |
|                         return  t.explanations.softDelete.Subs({reason: reason})
 | |
| 
 | |
|                     }))
 | |
|                     
 | |
|                 }
 | |
|             }
 | |
|             , [deleteAction.canBeDeleted]
 | |
|         )).SetClass("block")
 | |
|     }
 | |
| 
 | |
|     private static constructMultipleChoice(config: DeleteConfig, tagsSource: UIEventSource<object>, state: FeaturePipelineState):
 | |
|         InputElement<{ deleteReason: string } | { retagTo: TagsFilter }> {
 | |
| 
 | |
|         const elements: InputElement<{ deleteReason: string } | { retagTo: TagsFilter }>[ ] = []
 | |
| 
 | |
|         for (const nonDeleteOption of config.nonDeleteMappings) {
 | |
|             elements.push(new FixedInputElement(
 | |
|                 new SubstitutedTranslation(nonDeleteOption.then, tagsSource, state),
 | |
|                 {
 | |
|                     retagTo: nonDeleteOption.if
 | |
|                 }
 | |
|             ))
 | |
|         }
 | |
| 
 | |
|         for (const extraDeleteReason of (config.extraDeleteReasons ?? [])) {
 | |
|             elements.push(new FixedInputElement(
 | |
|                 new SubstitutedTranslation(extraDeleteReason.explanation, tagsSource, state),
 | |
|                 {
 | |
|                     deleteReason: extraDeleteReason.changesetMessage
 | |
|                 }
 | |
|             ))
 | |
|         }
 | |
| 
 | |
|         for (const extraDeleteReason of DeleteConfig.defaultDeleteReasons) {
 | |
|             elements.push(new FixedInputElement(
 | |
|                 extraDeleteReason.explanation.Clone(/*Must clone here, as this explanation might be used on many locations*/),
 | |
|                 {
 | |
|                     deleteReason: extraDeleteReason.changesetMessage
 | |
|                 }
 | |
|             ))
 | |
|         }
 | |
| 
 | |
|         return new RadioButton(elements, {selectFirstAsDefault: false});
 | |
|     }
 | |
| 
 | |
| 
 | |
| }
 | |
| 
 | |
| 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<number[]>(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
 | |
|                 const hist = OsmObject.DownloadHistory(id).map(versions => versions.map(version => version.tags["_last_edit:contributor:uid"]))
 | |
|                 hist.addCallbackAndRunD(hist => previousEditors.setData(hist))
 | |
|             }
 | |
|             
 | |
|             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<boolean> = new UIEventSource<boolean>(null)
 | |
|         const hasWays: UIEventSource<boolean> = new UIEventSource<boolean>(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
 | |
|                     })
 | |
|                 }
 | |
| 
 | |
| 
 | |
|             }
 | |
|         )
 | |
| 
 | |
| 
 | |
|     }
 | |
| 
 | |
| 
 | |
| } |