2023-01-14 03:23:40 +01:00
import Histogram from "./Histogram" ;
import Utils from "./Utils" ;
import { ChangeSetData } from "./OsmCha" ;
import OsmUserInfo from "./OsmUserInfo" ;
2023-01-16 03:11:44 +01:00
import Config , { MapCompleteUsageOverview } from "./Config" ;
2023-01-14 03:23:40 +01:00
import MastodonPoster from "./Mastodon" ;
2023-01-18 01:08:02 +01:00
import Overpass from "./Overpass" ;
2023-01-18 23:05:55 +01:00
import ImageUploader from "./ImageUploader" ;
2023-01-14 03:23:40 +01:00
2023-01-16 01:54:51 +01:00
type ImageInfo = { image : string , changeset : ChangeSetData }
2023-01-14 03:23:40 +01:00
export class Postbuilder {
private static readonly metakeys = [
"answer" ,
2023-01-18 01:08:02 +01:00
"create" ,
2023-01-14 03:23:40 +01:00
"add-image" ,
"move" ,
"delete" ,
"plantnet-ai-detection" ,
"link-image"
]
2023-01-16 03:11:44 +01:00
private readonly _config : MapCompleteUsageOverview ;
private readonly _globalConfig : Config
2023-01-18 01:08:02 +01:00
2023-01-14 03:23:40 +01:00
private readonly _poster : MastodonPoster ;
private readonly _changesetsMade : ChangeSetData [ ] ;
2023-01-16 03:11:44 +01:00
constructor ( config : MapCompleteUsageOverview , globalConfig : Config , poster : MastodonPoster , changesetsMade : ChangeSetData [ ] ) {
this . _globalConfig = globalConfig ;
2023-01-14 03:23:40 +01:00
this . _poster = poster ;
this . _config = config ;
// Ignore 'custom' themes, they can be confusing for uninitiated users and give ugly link + we don't endorse them
this . _changesetsMade = changesetsMade . filter ( cs = > ! cs . properties . theme . startsWith ( "http://" ) && ! cs . properties . theme . startsWith ( "https://" ) )
;
}
2023-01-18 23:05:55 +01:00
getStatisticsFor ( changesetsMade? : ChangeSetData [ ] ) : {
total : number ,
2023-01-18 01:08:02 +01:00
answered? : number ,
created? : number ,
2023-01-18 23:05:55 +01:00
addImage? : number ,
deleted : number ,
moved? : number ,
summaryText? : string
} {
2023-01-14 03:23:40 +01:00
const stats : Record < string , number > = { }
changesetsMade ? ? = this . _changesetsMade
let total = 0
for ( const changeset of changesetsMade ) {
for ( const metakey of Postbuilder . metakeys ) {
if ( changeset . properties [ metakey ] ) {
stats [ metakey ] = ( stats [ metakey ] ? ? 0 ) + changeset . properties [ metakey ]
total += changeset . properties [ metakey ]
}
}
}
let overview : string [ ] = [ ]
2023-01-18 01:08:02 +01:00
const { answer , move , create } = stats
2023-01-14 03:23:40 +01:00
const deleted = stats . delete
const images = stats [ "add-image" ]
const plantnetDetected = stats [ "plantnet-ai-detection" ]
const linkedImages = stats [ "link-image" ]
2023-01-18 01:08:02 +01:00
const poi = this . _config . poiName ? ? "point"
const pois = this . _config . poisName ? ? "points"
2023-01-18 23:05:55 +01:00
if ( create ) {
2023-01-18 01:08:02 +01:00
if ( create == 1 ) {
2023-01-18 23:05:55 +01:00
overview . push ( "added one " + poi )
2023-01-18 01:08:02 +01:00
} else {
2023-01-18 23:05:55 +01:00
overview . push ( "added " + create + " " + pois )
2023-01-18 01:08:02 +01:00
}
}
2023-01-14 03:23:40 +01:00
if ( answer ) {
if ( answer == 1 ) {
overview . push ( "answered one question" )
} else {
overview . push ( "answered " + answer + " questions" )
}
}
if ( images ) {
if ( images == 1 ) {
overview . push ( "uploaded one image" )
} else {
overview . push ( "uploaded " + images + " images" )
}
}
if ( move ) {
if ( move == 1 ) {
2023-01-18 23:05:55 +01:00
overview . push ( "moved one " + poi )
2023-01-14 03:23:40 +01:00
} else {
2023-01-18 23:05:55 +01:00
overview . push ( "moved " + move + " " + pois )
2023-01-14 03:23:40 +01:00
}
}
if ( deleted ) {
if ( deleted == 1 ) {
2023-01-18 23:05:55 +01:00
overview . push ( "deleted one " + poi )
2023-01-14 03:23:40 +01:00
} else {
2023-01-18 23:05:55 +01:00
overview . push ( "deleted " + deleted + " " + pois )
2023-01-14 03:23:40 +01:00
}
}
if ( plantnetDetected ) {
if ( plantnetDetected == 1 ) {
overview . push ( "detected one plant species with plantnet.org" )
} else {
overview . push ( "detected " + plantnetDetected + " plant species with plantnet.org" )
}
}
if ( linkedImages ) {
if ( linkedImages == 1 ) {
overview . push ( "linked one linked" )
} else {
overview . push ( "linked " + linkedImages + " images" )
}
}
let summaryText = Utils . commasAnd ( overview )
return {
total : total ,
addImage : stats [ "add-image" ] ,
deleted : stats.delete ,
answered : stats.answer ,
moved : stats.move ,
summaryText
}
}
async createOverviewForContributor ( uid : string , changesetsMade : ChangeSetData [ ] ) : Promise < string > {
2023-01-16 03:11:44 +01:00
const userinfo = new OsmUserInfo ( Number ( uid ) , this . _globalConfig )
2023-01-14 03:23:40 +01:00
const inf = await userinfo . getUserInfo ( )
const themes = new Histogram ( changesetsMade , cs = > cs . properties . theme )
2023-01-18 01:08:02 +01:00
let username = await userinfo . GetMastodonUsername ( this . _poster ) ? ? inf . display_name
2023-01-16 01:54:51 +01:00
2023-01-14 03:23:40 +01:00
const statistics = this . getStatisticsFor ( changesetsMade )
2023-01-18 01:08:02 +01:00
let thematicMaps = " with the thematic maps " + Utils . commasAnd ( themes . keys ( ) )
if ( this . _config ? . themeWhitelist ? . length === 1 ) {
thematicMaps = ""
} else if ( themes . keys ( ) . length === 1 ) {
thematicMaps = " with the thematic map " + Utils . commasAnd ( themes . keys ( ) )
2023-01-14 03:23:40 +01:00
}
2023-01-18 01:08:02 +01:00
return username + " " + statistics . summaryText + thematicMaps
2023-01-14 03:23:40 +01:00
}
async createOverviewForTheme ( theme : string , changesetsMade : ChangeSetData [ ] ) : Promise < string > {
const statistics = this . getStatisticsFor ( changesetsMade )
const contributorCount = new Set ( changesetsMade . map ( cs = > cs . properties . uid ) ) . size
let contribCountStr = contributorCount + " contributors"
if ( contributorCount == 1 ) {
contribCountStr = "one contributor"
}
return ` ${ contribCountStr } ${ statistics . summaryText } on https://mapcomplete.osm.be/ ${ theme } `
}
2023-01-18 23:05:55 +01:00
/ * *
* Creates a new list of images , sorted by priority .
* It tries to order them in such a way that the number of contributors is as big as possible .
* However , it is biased to select pictures from certain themes too
* @param images
* /
public selectImages ( images : ImageInfo [ ] ) :
2023-01-14 03:23:40 +01:00
ImageInfo [ ] {
2023-01-18 23:05:55 +01:00
2023-01-14 03:23:40 +01:00
const themeBonus = {
climbing : 1 ,
rainbow_crossings : 1 ,
binoculars : 2 ,
artwork : 2 ,
ghost_bikes : 1 ,
trees : 2 ,
bookcases : 1 ,
playgrounds : 1 ,
aed : 1 ,
benches : 1 ,
nature : 1
}
const alreadyEncounteredUid = new Map < string , number > ( )
2023-01-16 01:54:51 +01:00
2023-01-14 03:23:40 +01:00
const result : ImageInfo [ ] = [ ]
2023-01-18 23:05:55 +01:00
for ( let i = 0 ; i < images . length ; i ++ ) {
2023-01-14 03:23:40 +01:00
let bestImageScore : number = - 999999999
let bestImageOptions : ImageInfo [ ] = [ ]
for ( const image of images ) {
const props = image . changeset . properties
2023-01-16 01:54:51 +01:00
const uid = "" + props . uid
2023-01-14 03:23:40 +01:00
2023-01-16 03:11:44 +01:00
if ( result . findIndex ( i = > i . image === image . image ) >= 0 ) {
2023-01-14 03:23:40 +01:00
continue
}
let score = 0
if ( alreadyEncounteredUid . has ( uid ) ) {
score -= 100 * alreadyEncounteredUid . get ( uid )
}
score += themeBonus [ props . theme ] ? ? 0
if ( score > bestImageScore ) {
bestImageScore = score
bestImageOptions = [ image ]
} else if ( score === bestImageScore ) {
bestImageOptions . push ( image )
}
}
const ri = Math . floor ( ( bestImageOptions . length - 1 ) * Math . random ( ) )
const randomBestImage = bestImageOptions [ ri ]
result . push ( randomBestImage )
const theme = randomBestImage . changeset . properties . theme
themeBonus [ theme ] = ( themeBonus [ theme ] ? ? 0 ) - 1
const uid = randomBestImage . changeset . properties . uid
alreadyEncounteredUid . set ( uid , ( alreadyEncounteredUid . get ( uid ) ? ? 0 ) + 1 )
2023-01-16 01:54:51 +01:00
console . log ( "Selecting image" , randomBestImage . image , " by " , randomBestImage . changeset . properties . user + " with score " + bestImageScore )
2023-01-14 03:23:40 +01:00
}
return result
}
2023-01-16 01:54:51 +01:00
public async buildMessage ( date : string ) : Promise < void > {
2023-01-14 03:23:40 +01:00
const changesets = this . _changesetsMade
2023-01-18 01:08:02 +01:00
let lastPostId : string = undefined
2023-01-18 23:05:55 +01:00
if ( this . _config . report ) {
2023-01-18 01:08:02 +01:00
const report = this . _config . report
const overpass = new Overpass ( report )
const data = await overpass . query ( )
const total = data . elements . length
const date = data . osm3s . timestamp_osm_base . substring ( 0 , 10 )
lastPostId = ( await this . _poster . writeMessage (
2023-01-18 23:05:55 +01:00
report . post . replace ( /{total}/g , "" + total ) . replace ( /{date}/g , date ) ,
{ spoilerText : this._config.contentWarning }
2023-01-18 01:08:02 +01:00
) ) . id
}
2023-01-18 23:05:55 +01:00
2023-01-14 03:23:40 +01:00
const perContributor = new Histogram ( changesets , cs = > cs . properties . uid )
const topContributors = perContributor . sortedByCount ( {
countMethod : cs = > {
let sum = 0
for ( const metakey of Postbuilder . metakeys ) {
if ( cs . properties [ metakey ] ) {
sum += cs . properties [ metakey ]
}
}
return sum
}
} ) ;
const totalStats = this . getStatisticsFor ( )
2023-01-18 23:05:55 +01:00
const {
2023-01-16 01:54:51 +01:00
totalImagesCreated ,
2023-01-18 23:05:55 +01:00
randomImages ,
2023-01-16 01:54:51 +01:00
totalImageContributorCount
2023-01-18 23:05:55 +01:00
} = await this . prepareImages ( changesets )
const imageUploader = new ImageUploader ( randomImages , this . _poster , this . _globalConfig )
2023-01-14 03:23:40 +01:00
2023-01-16 03:11:44 +01:00
let timePeriod = "Yesterday"
2023-01-18 01:08:02 +01:00
if ( this . _config . numberOfDays > 1 ) {
timePeriod = "In the past " + this . _config . numberOfDays + " days"
2023-01-16 03:11:44 +01:00
}
2023-01-18 01:08:02 +01:00
const singleTheme = this . _config ? . themeWhitelist ? . length === 1 ? "/" + this . _config . themeWhitelist [ 0 ] : ""
2023-01-14 03:23:40 +01:00
let toSend : string [ ] = [
2023-01-20 00:51:48 +01:00
` ${ timePeriod } , ${ perContributor . keys ( ) . length } people made ${ totalStats . total } changes to #OpenStreetMap using https://mapcomplete.osm.be ${ singleTheme } .
2023-01-18 01:08:02 +01:00
` ,
2023-01-14 03:23:40 +01:00
]
2023-01-18 01:08:02 +01:00
if ( this . _config . showTopContributors && topContributors . length > 0 ) {
for ( const topContributor of topContributors ) {
const uid = topContributor . key
const changesetsMade = perContributor . get ( uid )
try {
2023-01-18 23:05:55 +01:00
const userInfo = new OsmUserInfo ( Number ( uid ) , this . _globalConfig )
2023-01-18 01:08:02 +01:00
const { nobot } = await userInfo . hasNoBotTag ( )
if ( nobot ) {
continue
}
const overview = await this . createOverviewForContributor ( uid , changesetsMade )
if ( overview . length + toSend . join ( "\n" ) . length > 500 ) {
break
}
toSend . push ( " - " + overview )
} catch ( e ) {
console . error ( "Could not add contributor " + uid , e )
2023-01-14 03:23:40 +01:00
}
}
2023-01-18 23:05:55 +01:00
lastPostId = ( await this . _poster . writeMessage ( toSend . join ( "\n" ) ,
{
2023-01-20 04:22:21 +01:00
inReplyToId : lastPostId ,
2023-01-18 23:05:55 +01:00
mediaIds : await imageUploader . attemptToUpload ( 4 ) ,
spoilerText : this._config.contentWarning
} ) ) . id
2023-01-18 01:08:02 +01:00
toSend = [ ]
2023-01-14 03:23:40 +01:00
}
const perTheme = new Histogram ( changesets , cs = > {
return cs . properties . theme ;
} )
const mostPopularThemes = perTheme . sortedByCount ( {
countMethod : cs = > this . getStatisticsFor ( [ cs ] ) . total ,
dropZeroValues : true
} )
2023-01-18 01:08:02 +01:00
if ( this . _config . showTopThemes && mostPopularThemes . length > 0 ) {
2023-01-14 03:23:40 +01:00
2023-01-18 01:08:02 +01:00
for ( const theme of mostPopularThemes ) {
const themeId = theme . key
const changesetsMade = perTheme . get ( themeId )
const overview = await this . createOverviewForTheme ( themeId , changesetsMade )
if ( overview . length + toSend . join ( "\n" ) . length > 500 ) {
break
}
toSend . push ( overview )
}
lastPostId = ( await this . _poster . writeMessage ( toSend . join ( "\n" ) , {
inReplyToId : lastPostId ,
2023-01-18 23:05:55 +01:00
mediaIds : await imageUploader . attemptToUpload ( 4 ) ,
spoilerText : this._config.contentWarning
2023-01-18 01:08:02 +01:00
} ) ) . id
toSend = [ ]
}
2023-01-16 01:54:51 +01:00
2023-01-18 23:05:55 +01:00
const images = await imageUploader . attemptToUpload ( 4 )
const authors = Array . from ( new Set ( imageUploader . getCurrentAuthors ( ) ) )
2023-01-20 04:35:52 +01:00
if ( authors . length > 0 ) {
await this . _poster . writeMessage ( [
"In total, " + totalImageContributorCount + " different contributors uploaded " + totalImagesCreated + " images.\n" ,
"Images in this thread are randomly selected from them and were made by: " ,
. . . authors . map ( auth = > "- " + auth ) ,
"" ,
"All changes were made on " + date + ( this . _config . numberOfDays > 1 ? ` or at most ${ this . _config . numberOfDays } days before ` : "" )
] . join ( "\n" ) , {
inReplyToId : lastPostId ,
mediaIds : images ,
spoilerText : this._config.contentWarning
}
)
}
2023-01-16 01:54:51 +01:00
2023-01-14 03:23:40 +01:00
}
2023-01-18 23:05:55 +01:00
private async prepareImages ( changesets : ChangeSetData [ ] ) : Promise < { randomImages : { image : string , changeset : ChangeSetData } [ ] , totalImagesCreated : number , totalImageContributorCount : number } > {
2023-01-14 03:23:40 +01:00
const withImage : ChangeSetData [ ] = changesets . filter ( cs = > cs . properties [ "add-image" ] > 0 )
const totalImagesCreated = Utils . Sum ( withImage . map ( cs = > cs . properties [ "add-image" ] ) )
const images : ImageInfo [ ] = [ ]
2023-01-16 03:11:44 +01:00
const seenURLS = new Set < string > ( )
2023-01-14 03:23:40 +01:00
for ( const changeset of withImage ) {
2023-01-16 01:54:51 +01:00
2023-01-18 23:05:55 +01:00
const userinfo = new OsmUserInfo ( Number ( changeset . properties . uid ) , this . _globalConfig )
2023-01-18 01:08:02 +01:00
const { nobot } = await userinfo . hasNoBotTag ( )
if ( nobot ) {
console . log ( "Not indexing images of user" , changeset . properties . user )
continue
}
2023-01-16 03:11:44 +01:00
const url = this . _globalConfig . osmBackend + "/api/0.6/changeset/" + changeset . id + "/download"
2023-01-16 01:54:51 +01:00
const osmChangeset = await Utils . DownloadXml ( url )
const osmChangesetTags : { k : string , v : string } [ ] = Array . from ( osmChangeset . getElementsByTagName ( "tag" ) )
. map ( tag = > ( { k : tag.getAttribute ( "k" ) , v : tag.getAttribute ( "v" ) } ) )
. filter ( kv = > kv . k . startsWith ( "image" ) )
for ( const kv of osmChangesetTags ) {
2023-01-18 01:08:02 +01:00
if ( seenURLS . has ( kv . v ) ) {
2023-01-16 03:11:44 +01:00
continue
}
seenURLS . add ( kv . v )
2023-01-16 01:54:51 +01:00
images . push ( { image : kv.v , changeset } )
2023-01-14 03:23:40 +01:00
}
}
2023-01-18 23:05:55 +01:00
const randomImages : ImageInfo [ ] = this . selectImages ( images )
2023-01-18 01:08:02 +01:00
2023-01-16 01:54:51 +01:00
return {
2023-01-18 23:05:55 +01:00
randomImages ,
2023-01-16 01:54:51 +01:00
totalImagesCreated ,
totalImageContributorCount : new Set ( withImage . map ( cs = > cs . properties . uid ) ) . size
2023-01-14 03:23:40 +01:00
}
}
}