forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			318 lines
		
	
	
		
			No EOL
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			318 lines
		
	
	
		
			No EOL
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import Combine from "../Base/Combine";
 | |
| import UserRelatedState from "../../Logic/State/UserRelatedState";
 | |
| import {VariableUiElement} from "../Base/VariableUIElement";
 | |
| import {Utils} from "../../Utils";
 | |
| import {UIEventSource} from "../../Logic/UIEventSource";
 | |
| import Title from "../Base/Title";
 | |
| import Translations from "../i18n/Translations";
 | |
| import Loading from "../Base/Loading";
 | |
| import {FixedUiElement} from "../Base/FixedUiElement";
 | |
| import Link from "../Base/Link";
 | |
| import {DropDown} from "../Input/DropDown";
 | |
| import BaseUIElement from "../BaseUIElement";
 | |
| import ValidatedTextField from "../Input/ValidatedTextField";
 | |
| import {SubtleButton} from "../Base/SubtleButton";
 | |
| import Svg from "../../Svg";
 | |
| import Toggle from "../Input/Toggle";
 | |
| import Table from "../Base/Table";
 | |
| import LeftIndex from "../Base/LeftIndex";
 | |
| import Toggleable, {Accordeon} from "../Base/Toggleable";
 | |
| import TableOfContents from "../Base/TableOfContents";
 | |
| import LoginButton from "../Popup/LoginButton";
 | |
| import BackToIndex from "../BigComponents/BackToIndex";
 | |
| import {QueryParameters} from "../../Logic/Web/QueryParameters";
 | |
| 
 | |
| interface NoteProperties {
 | |
|     "id": number,
 | |
|     "url": string,
 | |
|     "date_created": string,
 | |
|     closed_at?: string,
 | |
|     "status": "open" | "closed",
 | |
|     "comments": {
 | |
|         date: string,
 | |
|         uid: number,
 | |
|         user: string,
 | |
|         text: string,
 | |
|         html: string
 | |
|     }[]
 | |
| }
 | |
| 
 | |
| interface NoteState {
 | |
|     props: NoteProperties,
 | |
|     theme: string,
 | |
|     intro: string,
 | |
|     dateStr: string,
 | |
|     status: "imported" | "already_mapped" | "invalid" | "closed" | "not_found" | "open" | "has_comments"
 | |
| }
 | |
| 
 | |
| class MassAction extends Combine {
 | |
|     constructor(state: UserRelatedState, props: NoteProperties[]) {
 | |
|         const textField = ValidatedTextField.InputForType("text")
 | |
| 
 | |
|         const actions = new DropDown<{
 | |
|             predicate: (p: NoteProperties) => boolean,
 | |
|             action: (p: NoteProperties) => Promise<void>
 | |
|         }>("On which notes should an action be performed?", [
 | |
|             {
 | |
|                 value: undefined,
 | |
|                 shown: <string | BaseUIElement>"Pick an option..."
 | |
|             },
 | |
|             {
 | |
|                 value: {
 | |
|                     predicate: p => p.status === "open",
 | |
|                     action: async p => {
 | |
|                         const txt = textField.GetValue().data
 | |
|                         state.osmConnection.closeNote(p.id, txt)
 | |
|                     }
 | |
|                 },
 | |
|                 shown: "Add comment to every open note and close all notes"
 | |
|             },
 | |
|             {
 | |
|                 value: {
 | |
|                     predicate: p => p.status === "open",
 | |
|                     action: async p => {
 | |
|                         const txt = textField.GetValue().data
 | |
|                         state.osmConnection.addCommentToNode(p.id, txt)
 | |
|                     }
 | |
|                 },
 | |
|                 shown: "Add comment to every open note"
 | |
|             }
 | |
|         ])
 | |
| 
 | |
|         const handledNotesCounter = new UIEventSource<number>(undefined)
 | |
|         const apply = new SubtleButton(Svg.checkmark_svg(), "Apply action")
 | |
|             .onClick(async () => {
 | |
|                 const {predicate, action} = actions.GetValue().data
 | |
|                 for (let i = 0; i < props.length; i++) {
 | |
|                     handledNotesCounter.setData(i)
 | |
|                     const prop = props[i]
 | |
|                     if (!predicate(prop)) {
 | |
|                         continue
 | |
|                     }
 | |
|                     await action(prop)
 | |
|                 }
 | |
|                 handledNotesCounter.setData(props.length)
 | |
|             })
 | |
|         super([
 | |
| 
 | |
|             actions,
 | |
|             textField.SetClass("w-full border border-black"),
 | |
|             new Toggle(
 | |
|                 new Toggle(
 | |
|                     apply,
 | |
| 
 | |
|                     new Toggle(
 | |
|                         new Loading(new VariableUiElement(handledNotesCounter.map(state => {
 | |
|                             if (state === props.length) {
 | |
|                                 return "All done!"
 | |
|                             }
 | |
|                             return "Handling note " + (state + 1) + " out of " + props.length;
 | |
|                         }))),
 | |
|                         new Combine([Svg.checkmark_svg().SetClass("h-8"), "All done!"]).SetClass("thanks flex p-4"),
 | |
|                         handledNotesCounter.map(s => s < props.length)
 | |
|                     ),
 | |
|                     handledNotesCounter.map(s => s === undefined)
 | |
|                 )
 | |
| 
 | |
|                 , undefined,
 | |
|                 actions.GetValue().map(v => v !== undefined && textField.GetValue()?.data?.length > 15, [textField.GetValue()])
 | |
|             ),
 | |
|             new Toggle(
 | |
|                 new FixedUiElement("Testmode enable").SetClass("alert"), undefined,
 | |
|                 state.featureSwitchIsTesting
 | |
|             )
 | |
|         ]);
 | |
|     }
 | |
| 
 | |
| }
 | |
| 
 | |
| 
 | |
| class BatchView extends Toggleable {
 | |
| 
 | |
|     private static icons = {
 | |
|         open: Svg.compass_svg,
 | |
|         has_comments: Svg.speech_bubble_svg,
 | |
|         imported: Svg.addSmall_svg,
 | |
|         already_mapped: Svg.checkmark_svg,
 | |
|         invalid: Svg.invalid_svg,
 | |
|         closed: Svg.close_svg,
 | |
|         not_found: Svg.not_found_svg,
 | |
|     }
 | |
| 
 | |
|     constructor(noteStates: NoteState[], state?: UserRelatedState) {
 | |
| 
 | |
|         noteStates.sort((a, b) => a.props.id - b.props.id)
 | |
| 
 | |
|         const {theme, intro, dateStr} = noteStates[0]
 | |
| 
 | |
|         const statusHist = new Map<string, number>()
 | |
|         for (const noteState of noteStates) {
 | |
|             const st = noteState.status
 | |
|             const c = statusHist.get(st) ?? 0
 | |
|             statusHist.set(st, c + 1)
 | |
|         }
 | |
| 
 | |
|         const badges: (BaseUIElement)[] = [new FixedUiElement(dateStr).SetClass("literal-code rounded-full")]
 | |
|         statusHist.forEach((count, status) => {
 | |
|             const icon = BatchView.icons[status]().SetClass("h-6 m-1")
 | |
|             badges.push(new Combine([icon, count + " " + status])
 | |
|                 .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black"))
 | |
|         })
 | |
| 
 | |
|         const typicalComment = noteStates[0].props.comments[0].html
 | |
| 
 | |
| 
 | |
|         super(
 | |
|             new Combine([
 | |
|                 new Title(theme + ": " + intro, 2),
 | |
|                 new Combine(badges).SetClass("flex flex-wrap"),
 | |
|             ]),
 | |
|             new Combine([
 | |
|                 new Title("Example note", 4),
 | |
|                 new FixedUiElement(typicalComment).SetClass("literal-code link-underline"),
 | |
|                 new Title("Mass apply an action"),
 | |
|                 state !== undefined ? new MassAction(state, noteStates.map(ns => ns.props)).SetClass("block") : undefined,
 | |
|                 new Table(
 | |
|                     ["id", "status", "last comment"],
 | |
|                     noteStates.map(ns => {
 | |
|                         const link = new Link(
 | |
|                             "" + ns.props.id,
 | |
|                             "https://openstreetmap.org/note/" + ns.props.id, true
 | |
|                         )
 | |
|                         let last_comment = "";
 | |
|                         if (ns.props.comments.length > 1) {
 | |
|                             last_comment = ns.props.comments[ns.props.comments.length - 1].text
 | |
|                         }
 | |
|                         const statusIcon = BatchView.icons[ns.status]().SetClass("h-4 w-4 shrink-0")
 | |
|                         return [link, new Combine([statusIcon, ns.status]).SetClass("flex"), last_comment]
 | |
|                     })
 | |
|                 ).SetClass("zebra-table link-underline")
 | |
|             ]).SetClass("flex flex-col"),
 | |
|             {
 | |
|                 closeOnClick: false
 | |
|             })
 | |
| 
 | |
|     }
 | |
| }
 | |
| 
 | |
| class ImportInspector extends VariableUiElement {
 | |
| 
 | |
|     constructor(userDetails: { uid: number } | { display_name: string, search?: string }, state: UserRelatedState) {
 | |
|         let url;
 | |
|         
 | |
|         if (userDetails["uid"] !== undefined) {
 | |
|             url = "https://api.openstreetmap.org/api/0.6/notes/search.json?user=" + userDetails["uid"] + "&closed=730&limit=10000&sort=created_at&q=%23import"
 | |
|         } else {
 | |
|             url = "https://api.openstreetmap.org/api/0.6/notes/search.json?display_name=" +
 | |
|                 encodeURIComponent(userDetails["display_name"]) + "&limit=10000&closed=730&sort=created_at&q=" + encodeURIComponent(userDetails["search"] ?? "#import")
 | |
|         }
 | |
| 
 | |
| 
 | |
|         const notes: UIEventSource<{ error: string } | { success: { features: { properties: NoteProperties }[] } }> = UIEventSource.FromPromiseWithErr(Utils.downloadJson(url))
 | |
|         super(notes.map(notes => {
 | |
| 
 | |
|             if (notes === undefined) {
 | |
|                 return new Loading("Loading notes which mention '#import'")
 | |
|             }
 | |
|             if (notes["error"] !== undefined) {
 | |
|                 return new FixedUiElement("Something went wrong: " + notes["error"]).SetClass("alert")
 | |
|             }
 | |
|             // We only care about the properties here
 | |
|             const props: NoteProperties[] = notes["success"].features.map(f => f.properties)
 | |
|             const perBatch: NoteState[][] = Array.from(ImportInspector.SplitNotesIntoBatches(props).values());
 | |
|             const els: Toggleable[] = perBatch.map(noteStates => new BatchView(noteStates, state))
 | |
| 
 | |
|             const accordeon = new Accordeon(els)
 | |
|             let contents = [];
 | |
|             if (state?.osmConnection?.isLoggedIn?.data) {
 | |
|                 contents =
 | |
|                     [
 | |
|                         new Title(Translations.t.importInspector.title, 1),
 | |
|                         new SubtleButton(undefined, "Create a new batch of imports", {url: 'import_helper.html'})]
 | |
|             }
 | |
|             contents.push(accordeon)
 | |
|             const content = new Combine(contents)
 | |
|             return new LeftIndex(
 | |
|                 [new TableOfContents(content, {noTopLevel: true, maxDepth: 1}).SetClass("subtle")],
 | |
|                 content
 | |
|             )
 | |
| 
 | |
|         }));
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Creates distinct batches of note, where 'date', 'intro' and 'theme' are identical
 | |
|      */
 | |
|     private static SplitNotesIntoBatches(props: NoteProperties[]): Map<string, NoteState[]> {
 | |
|         const perBatch = new Map<string, NoteState[]>()
 | |
|         const prefix = "https://mapcomplete.osm.be/"
 | |
|         for (const prop of props) {
 | |
|             const lines = prop.comments[0].text.split("\n")
 | |
|             const trigger = lines.findIndex(l => l.startsWith(prefix) && l.endsWith("#import"))
 | |
|             if (trigger < 0) {
 | |
|                 continue
 | |
|             }
 | |
|             let theme = lines[trigger].substr(prefix.length)
 | |
|             theme = theme.substr(0, theme.indexOf("."))
 | |
|             const date = Utils.ParseDate(prop.date_created)
 | |
|             const dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate()
 | |
|             const key = theme + lines[0] + dateStr
 | |
|             if (!perBatch.has(key)) {
 | |
|                 perBatch.set(key, [])
 | |
|             }
 | |
|             let status: "open" | "closed" | "imported" | "invalid" | "already_mapped" | "not_found" | "has_comments" = "open"
 | |
|             if (prop.closed_at !== undefined) {
 | |
|                 const lastComment = prop.comments[prop.comments.length - 1].text.toLowerCase()
 | |
|                 if (lastComment.indexOf("does not exist") >= 0) {
 | |
|                     status = "not_found"
 | |
|                 } else if (lastComment.indexOf("already mapped") >= 0) {
 | |
|                     status = "already_mapped"
 | |
|                 } else if (lastComment.indexOf("invalid") >= 0 || lastComment.indexOf("incorrecto") >= 0) {
 | |
|                     status = "invalid"
 | |
|                 } else if (lastComment.indexOf("imported") >= 0) {
 | |
|                     status = "imported"
 | |
|                 } else {
 | |
|                     status = "closed"
 | |
|                 }
 | |
|             } else if (prop.comments.length > 1) {
 | |
|                 status = "has_comments"
 | |
|             }
 | |
| 
 | |
|             perBatch.get(key).push({
 | |
|                 props: prop,
 | |
|                 intro: lines[0],
 | |
|                 theme,
 | |
|                 dateStr,
 | |
|                 status
 | |
|             })
 | |
|         }
 | |
|         return perBatch;
 | |
|     }
 | |
| }
 | |
| 
 | |
| class ImportViewerGui extends Combine {
 | |
| 
 | |
|     constructor() {
 | |
|         const state = new UserRelatedState(undefined)
 | |
|         const displayNameParam = QueryParameters.GetQueryParameter("user", "", "The username of the person whom you want to see the notes for");
 | |
|         const searchParam = QueryParameters.GetQueryParameter("search", "", "A text that should be included in the first comment of the note to be shown")
 | |
|         super([
 | |
|             new VariableUiElement(state.osmConnection.userDetails.map(ud => {
 | |
|                 const display_name = displayNameParam.data;
 | |
|                 const search = searchParam.data;
 | |
|                 if (display_name !== "" && search !== "") {
 | |
|                     return new ImportInspector({display_name, search}, undefined);
 | |
|                 }
 | |
| 
 | |
|                 if (ud === undefined || ud.loggedIn === false) {
 | |
|                     return new Combine([new LoginButton("Login to inspect your import flows", state),
 | |
|                         new BackToIndex()
 | |
|                     ])
 | |
|                 }
 | |
|                 return new ImportInspector(ud, state);
 | |
|             }, [displayNameParam, searchParam]))
 | |
|         ]);
 | |
|     }
 | |
| }
 | |
| 
 | |
| new ImportViewerGui().AttachTo("main") |