mapcomplete/UI/ImportFlow/ImportViewerGui.ts

426 lines
17 KiB
TypeScript
Raw Normal View History

2022-01-22 04:01:13 +01:00
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";
2022-01-24 03:09:21 +01:00
import Table from "../Base/Table";
import LeftIndex from "../Base/LeftIndex";
import Toggleable, {Accordeon} from "../Base/Toggleable";
import TableOfContents from "../Base/TableOfContents";
2022-02-15 15:42:09 +01:00
import {LoginToggle} from "../Popup/LoginButton";
2022-01-25 21:55:51 +01:00
import {QueryParameters} from "../../Logic/Web/QueryParameters";
2022-01-22 04:01:13 +01:00
interface NoteProperties {
"id": number,
"url": string,
2022-01-24 03:09:21 +01:00
"date_created": string,
closed_at?: string,
2022-01-22 04:01:13 +01:00
"status": "open" | "closed",
"comments": {
date: string,
uid: number,
user: string,
2022-01-25 21:55:51 +01:00
text: string,
html: string
2022-01-22 04:01:13 +01:00
}[]
}
2022-01-24 03:09:21 +01:00
interface NoteState {
props: NoteProperties,
theme: string,
intro: string,
dateStr: string,
2022-01-25 21:55:51 +01:00
status: "imported" | "already_mapped" | "invalid" | "closed" | "not_found" | "open" | "has_comments"
2022-01-24 03:09:21 +01:00
}
2022-01-22 04:01:13 +01:00
2022-04-23 02:14:31 +02:00
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"
}
)
})
}
}
2022-01-24 03:09:21 +01:00
class MassAction extends Combine {
2022-01-22 04:01:13 +01:00
constructor(state: UserRelatedState, props: NoteProperties[]) {
const textField = ValidatedTextField.ForType("text").ConstructInputElement()
2022-01-22 04:01:13 +01:00
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"
2022-01-24 03:09:21 +01:00
},
{
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"
2022-02-11 02:40:23 +01:00
},
/*
{
// 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)"
},//*/
2022-02-15 15:42:09 +01:00
2022-01-22 04:01:13 +01:00
])
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)
)
2022-02-15 15:42:09 +01:00
, 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"),
2022-01-22 04:01:13 +01:00
actions.GetValue().map(v => v !== undefined && textField.GetValue()?.data?.length > 15, [textField.GetValue()])
),
new Toggle(
2022-01-24 03:09:21 +01:00
new FixedUiElement("Testmode enable").SetClass("alert"), undefined,
2022-01-22 04:01:13 +01:00
state.featureSwitchIsTesting
)
]);
}
}
2022-01-24 03:09:21 +01:00
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")
}
}
2022-01-24 03:09:21 +01:00
class BatchView extends Toggleable {
2022-01-25 21:55:51 +01:00
public static icons = {
2022-01-25 21:55:51 +01:00
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,
2022-01-25 21:55:51 +01:00
}
constructor(noteStates: NoteState[], state?: UserRelatedState) {
noteStates.sort((a, b) => a.props.id - b.props.id)
2022-01-24 03:09:21 +01:00
const {theme, intro, dateStr} = noteStates[0]
2022-01-25 21:55:51 +01:00
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 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<string>(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)
2022-01-25 21:55:51 +01:00
})
2022-01-24 03:09:21 +01:00
const fullTable = new NoteTable(noteStates, state);
2022-01-25 21:55:51 +01:00
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)
})),
2022-01-25 21:55:51 +01:00
{
closeOnClick: false
})
2022-01-24 03:09:21 +01:00
}
}
2022-01-22 04:01:13 +01:00
class ImportInspector extends VariableUiElement {
2022-01-25 21:55:51 +01:00
constructor(userDetails: { uid: number } | { display_name: string, search?: string }, state: UserRelatedState) {
let url;
2022-02-15 15:42:09 +01:00
2022-01-25 21:55:51 +01:00
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"
2022-01-25 21:55:51 +01:00
} 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")
2022-01-25 21:55:51 +01:00
}
2022-01-22 04:01:13 +01:00
const notes: UIEventSource<{ error: string } | { success: { features: { properties: NoteProperties }[] } }> = UIEventSource.FromPromiseWithErr(Utils.downloadJson(url))
super(notes.map(notes => {
if (notes === undefined) {
2022-01-25 21:55:51 +01:00
return new Loading("Loading notes which mention '#import'")
2022-01-22 04:01:13 +01:00
}
if (notes["error"] !== undefined) {
return new FixedUiElement("Something went wrong: " + notes["error"]).SetClass("alert")
}
// We only care about the properties here
2022-01-24 03:09:21 +01:00
const props: NoteProperties[] = notes["success"].features.map(f => f.properties)
const perBatch: NoteState[][] = Array.from(ImportInspector.SplitNotesIntoBatches(props).values());
2022-01-25 21:55:51 +01:00
const els: Toggleable[] = perBatch.map(noteStates => new BatchView(noteStates, state))
2022-01-24 03:09:21 +01:00
const accordeon = new Accordeon(els)
2022-01-25 21:55:51 +01:00
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)
2022-01-24 03:09:21 +01:00
return new LeftIndex(
2022-04-23 02:14:31 +02:00
[new TableOfContents(content, {noTopLevel: true, maxDepth: 1}).SetClass("subtle"),
new DownloadStatisticsButton(perBatch)
],
2022-01-24 03:09:21 +01:00
content
)
2022-01-22 04:01:13 +01:00
}));
}
2022-01-24 03:09:21 +01:00
/**
* 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)
2022-01-25 21:55:51 +01:00
const dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate()
2022-01-24 03:09:21 +01:00
const key = theme + lines[0] + dateStr
if (!perBatch.has(key)) {
perBatch.set(key, [])
}
2022-01-25 21:55:51 +01:00
let status: "open" | "closed" | "imported" | "invalid" | "already_mapped" | "not_found" | "has_comments" = "open"
2022-01-24 03:09:21 +01:00
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"
2022-04-24 11:47:31 +02:00
} else if (["imported","erbij","toegevoegd","added"].some(keyword => lastComment.toLowerCase().indexOf(keyword) >= 0)) {
2022-01-24 03:09:21 +01:00
status = "imported"
} else {
status = "closed"
}
2022-01-25 21:55:51 +01:00
} else if (prop.comments.length > 1) {
status = "has_comments"
2022-01-24 03:09:21 +01:00
}
perBatch.get(key).push({
props: prop,
intro: lines[0],
theme,
dateStr,
status
})
}
return perBatch;
}
2022-01-22 04:01:13 +01:00
}
2022-02-15 15:42:09 +01:00
class ImportViewerGui extends LoginToggle {
2022-01-22 04:01:13 +01:00
constructor() {
const state = new UserRelatedState(undefined)
2022-01-25 21:55:51 +01:00
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")
2022-02-15 15:42:09 +01:00
super(
2022-01-22 04:01:13 +01:00
new VariableUiElement(state.osmConnection.userDetails.map(ud => {
2022-01-25 21:55:51 +01:00
const display_name = displayNameParam.data;
const search = searchParam.data;
if (display_name !== "" && search !== "") {
2022-01-27 20:15:28 +01:00
return new ImportInspector({display_name, search}, undefined);
2022-01-25 21:55:51 +01:00
}
2022-01-22 04:01:13 +01:00
return new ImportInspector(ud, state);
2022-02-15 15:42:09 +01:00
}, [displayNameParam, searchParam])),
"Login to inspect your import flows", state
)
2022-01-22 04:01:13 +01:00
}
2022-01-24 03:09:21 +01:00
}
2022-01-22 04:01:13 +01:00
2022-01-24 03:09:21 +01:00
new ImportViewerGui().AttachTo("main")