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, {ClickableToggle} 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 {LoginToggle} from "../Popup/LoginButton"; 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 DownloadStatisticsButton extends SubtleButton { constructor(states: NoteState[][]) { super(Svg.statistics_svg(), "Download statistics"); this.onClick(() => { const st: NoteState[] = [].concat(...states) const fields = [ "id", "status", "theme", "date_created", "date_closed", "days_open", "intro", "...comments" ] const values : string[][] = st.map(note => { return [note.props.id+"", note.status, note.theme, note.props.date_created?.substr(0, note.props.date_created.length - 3), note.props.closed_at?.substr(0, note.props.closed_at.length - 3) ?? "", JSON.stringify( note.intro), ...note.props.comments.map(c => JSON.stringify(c.user)+": "+JSON.stringify(c.text)) ] }) Utils.offerContentsAsDownloadableFile( [fields, ...values].map(c => c.join(", ")).join("\n"), "mapcomplete_import_notes_overview.csv", { mimetype: "text/csv" } ) }) } } class MassAction extends Combine { constructor(state: UserRelatedState, props: NoteProperties[]) { const textField = ValidatedTextField.ForType("text").ConstructInputElement() const actions = new DropDown<{ predicate: (p: NoteProperties) => boolean, action: (p: NoteProperties) => Promise }>("On which notes should an action be performed?", [ { value: undefined, shown: "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" }, /* { // This was a one-off for one of the first imports value:{ predicate: p => p.status === "open" && p.comments[0].text.split("\n").find(l => l.startsWith("note=")) !== undefined, action: async p => { const note = p.comments[0].text.split("\n").find(l => l.startsWith("note=")).substr("note=".length) state.osmConnection.addCommentToNode(p.id, note) } }, shown:"On every open note, read the 'note='-tag and and this note as comment. (This action ignores the textfield)" },//*/ ]) const handledNotesCounter = new UIEventSource(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) ) , new VariableUiElement(textField.GetValue().map(txt => "Type a text of at least 15 characters to apply the action. Currently, there are " + (txt?.length ?? 0) + " characters")).SetClass("alert"), 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 NoteTable extends Combine { constructor(noteStates: NoteState[], state?: UserRelatedState) { const typicalComment = noteStates[0].props.comments[0].html const table = new Table( ["id", "status", "last comment", "last modified by"], noteStates.map(ns => { const link = new Link( "" + ns.props.id, "https://openstreetmap.org/note/" + ns.props.id, true ) let last_comment = ""; const last_comment_props = ns.props.comments[ns.props.comments.length - 1] const before_last_comment = ns.props.comments[ns.props.comments.length - 2] if (ns.props.comments.length > 1) { last_comment = last_comment_props.text if (last_comment === undefined && before_last_comment?.uid === last_comment_props.uid) { last_comment = before_last_comment.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, new Link(last_comment_props.user, "https://www.openstreetmap.org/user/" + last_comment_props.user, true) ] }), {sortable: true} ).SetClass("zebra-table link-underline"); super([ new Title("Mass apply an action on " + noteStates.length + " notes below"), state !== undefined ? new MassAction(state, noteStates.map(ns => ns.props)).SetClass("block") : undefined, table, new Title("Example note", 4), new FixedUiElement(typicalComment).SetClass("literal-code link-underline"), ]) this.SetClass("flex flex-col") } } class BatchView extends Toggleable { public static icons = { open: Svg.compass_svg, has_comments: Svg.speech_bubble_svg, imported: Svg.addSmall_svg, already_mapped: Svg.checkmark_svg, not_found: Svg.not_found_svg, closed: Svg.close_svg, invalid: Svg.invalid_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() for (const noteState of noteStates) { const st = noteState.status const c = statusHist.get(st) ?? 0 statusHist.set(st, c + 1) } const unresolvedTotal = (statusHist.get("open") ?? 0) + (statusHist.get("has_comments") ?? 0) const badges: (BaseUIElement)[] = [ new FixedUiElement(dateStr).SetClass("literal-code rounded-full"), new FixedUiElement(noteStates.length + " total").SetClass("literal-code rounded-full ml-1 border-4 border-gray") .onClick(() => filterOn.setData(undefined)), unresolvedTotal === 0 ? new Combine([Svg.party_svg().SetClass("h-6 m-1"), "All done!"]) .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black") : new FixedUiElement(Math.round(100 - 100 * unresolvedTotal / noteStates.length) + "%").SetClass("literal-code rounded-full ml-1") ] const filterOn = new UIEventSource(undefined) Object.keys(BatchView.icons).forEach(status => { const count = statusHist.get(status) if (count === undefined) { return undefined } const normal = new Combine([BatchView.icons[status]().SetClass("h-6 m-1"), count + " " + status]) .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black") const selected = new Combine([BatchView.icons[status]().SetClass("h-6 m-1"), count + " " + status]) .SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border-4 border-black animate-pulse") const toggle = new ClickableToggle(selected, normal, filterOn.sync(f => f === status, [], (selected, previous) => { if (selected) { return status; } if (previous === status) { return undefined } return previous })).ToggleOnClick() badges.push(toggle) }) const fullTable = new NoteTable(noteStates, state); super( new Combine([ new Title(theme + ": " + intro, 2), new Combine(badges).SetClass("flex flex-wrap"), ]), new VariableUiElement(filterOn.map(filter => { if (filter === undefined) { return fullTable } return new NoteTable(noteStates.filter(ns => ns.status === filter), state) })), { 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"), new DownloadStatisticsButton(perBatch) ], content ) })); } /** * Creates distinct batches of note, where 'date', 'intro' and 'theme' are identical */ private static SplitNotesIntoBatches(props: NoteProperties[]): Map { const perBatch = new Map() 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 (["imported","erbij","toegevoegd","added"].some(keyword => lastComment.toLowerCase().indexOf(keyword) >= 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 LoginToggle { 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); } return new ImportInspector(ud, state); }, [displayNameParam, searchParam])), "Login to inspect your import flows", state ) } } new ImportViewerGui().AttachTo("main")