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" ;
2022-06-05 02:24:14 +02:00
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 [ ] ) {
2022-02-12 02:53:41 +01:00
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
2022-06-20 11:26:55 +02:00
state . osmConnection . addCommentToNote ( p . id , txt )
2022-01-24 03:09:21 +01:00
}
} ,
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
2022-03-29 00:20:10 +02: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
2022-03-29 00:20:10 +02: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 ,
2022-03-29 00:20:10 +02:00
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 )
}
2022-03-29 00:20:10 +02:00
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" )
2022-06-05 02:24:14 +02:00
const toggle = new ClickableToggle ( selected , normal , filterOn . sync ( f = > f === status , [ ] , ( selected , previous ) = > {
2022-03-29 00:20:10 +02:00
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
2022-03-29 00:20:10 +02: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" ) ,
] ) ,
2022-03-29 00:20:10 +02:00
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 ) {
2022-01-31 20:52:56 +01:00
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=" +
2022-01-31 20:52:56 +01:00
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" )