2023-01-14 03:23:40 +01:00
import Histogram from "./Histogram" ;
import Utils from "./Utils" ;
import { ChangeSetData } from "./OsmCha" ;
import OsmUserInfo from "./OsmUserInfo" ;
import Config from "./Config" ;
import MastodonPoster from "./Mastodon" ;
import ImgurAttribution from "./ImgurAttribution" ;
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" ,
"add-image" ,
"move" ,
"delete" ,
"plantnet-ai-detection" ,
"link-image"
]
private readonly _config : Config ;
private readonly _poster : MastodonPoster ;
private readonly _changesetsMade : ChangeSetData [ ] ;
constructor ( config : Config , poster : MastodonPoster , changesetsMade : ChangeSetData [ ] ) {
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://" ) )
;
}
getStatisticsFor ( changesetsMade? : ChangeSetData [ ] ) : { total : number , addImage? : number , deleted : number , answered? : number , moved? : number , summaryText? : string } {
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 [ ] = [ ]
const { answer , move } = stats
const deleted = stats . delete
const images = stats [ "add-image" ]
const plantnetDetected = stats [ "plantnet-ai-detection" ]
const linkedImages = stats [ "link-image" ]
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 ) {
overview . push ( "moved one point" )
} else {
overview . push ( "moved " + move + " points" )
}
}
if ( deleted ) {
if ( deleted == 1 ) {
overview . push ( "delted one deleted" )
} else {
overview . push ( "deleted " + deleted + " points" )
}
}
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 > {
const userinfo = new OsmUserInfo ( Number ( uid ) , this . _config )
const inf = await userinfo . getUserInfo ( )
const themes = new Histogram ( changesetsMade , cs = > cs . properties . theme )
2023-01-16 01:54:51 +01:00
let username = await userinfo . GetMastodonLink ( ) ? ? inf . display_name
2023-01-14 03:23:40 +01:00
const statistics = this . getStatisticsFor ( changesetsMade )
let thematicMaps = "maps " + Utils . commasAnd ( themes . keys ( ) )
if ( themes . keys ( ) . length === 1 ) {
thematicMaps = "map " + Utils . commasAnd ( themes . keys ( ) )
}
return username + " " + statistics . summaryText + " with the thematic " + thematicMaps
}
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 } `
}
public selectImages ( images : ImageInfo [ ] , targetCount : number = 4 ) :
ImageInfo [ ] {
if ( images . length <= targetCount ) {
return images
}
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 [ ] = [ ]
for ( let i = 0 ; i < targetCount ; i ++ ) {
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
if ( result . indexOf ( image ) >= 0 ) {
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
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-16 01:54:51 +01:00
const {
totalImagesCreated ,
attachmentIds ,
imgAuthors ,
totalImageContributorCount
} = await this . prepareImages ( changesets , 12 )
2023-01-14 03:23:40 +01:00
let toSend : string [ ] = [
2023-01-15 01:25:15 +01:00
"Yesterday, " + perContributor . keys ( ) . length + " different persons made " + totalStats . total + " changes to #OpenStreetMap using https://mapcomplete.osm.be .\n" ,
2023-01-14 03:23:40 +01:00
]
for ( let i = 0 ; i < this . _config . postSettings . topContributorsNumberToShow - 1 && i < topContributors . length ; i ++ ) {
const uid = topContributors [ i ] . key
const changesetsMade = perContributor . get ( uid )
try {
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 )
}
}
const firstPost = await this . _poster . writeMessage ( toSend . join ( "\n" ) , { mediaIds : attachmentIds.slice ( 0 , 4 ) } )
toSend = [ ]
const perTheme = new Histogram ( changesets , cs = > {
return cs . properties . theme ;
} )
const mostPopularThemes = perTheme . sortedByCount ( {
countMethod : cs = > this . getStatisticsFor ( [ cs ] ) . total ,
dropZeroValues : true
} )
toSend . push ( "" )
for ( let i = 0 ; i < this . _config . postSettings . topThemesNumberToShow && i < mostPopularThemes . length ; i ++ ) {
const theme = mostPopularThemes [ i ] . key
const changesetsMade = perTheme . get ( theme )
toSend . push ( await this . createOverviewForTheme ( theme , changesetsMade ) )
}
const secondPost = await this . _poster . writeMessage ( toSend . join ( "\n" ) , {
inReplyToId : firstPost [ "id" ] ,
mediaIds : attachmentIds.slice ( 4 , 8 )
} )
2023-01-16 01:54:51 +01:00
const authorNames = Array . from ( new Set < string > ( imgAuthors ) )
2023-01-14 03:23:40 +01:00
await this . _poster . writeMessage ( [
2023-01-16 01:54:51 +01:00
"In total, " + totalImageContributorCount + " different contributors uploaded " + totalImagesCreated + " images.\n" ,
"Images in this thread are randomly selected from them and were made by: " ,
. . . authorNames . map ( auth = > "- " + auth ) ,
"" ,
"All changes were made on " + date
] . join ( "\n" ) , {
inReplyToId : secondPost [ "id" ] ,
mediaIds : attachmentIds.slice ( 8 , 12 )
}
)
2023-01-14 03:23:40 +01:00
}
private async prepareImages ( changesets : ChangeSetData [ ] , targetCount : number = 4 ) : Promise < { imgAuthors : string [ ] , attachmentIds : string [ ] , totalImagesCreated : number , totalImageContributorCount : number } > {
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 [ ] = [ ]
for ( const changeset of withImage ) {
2023-01-16 01:54:51 +01:00
const url = this . _config . osmBackend + "/api/0.6/changeset/" + changeset . id + "/download"
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 ) {
images . push ( { image : kv.v , changeset } )
2023-01-14 03:23:40 +01:00
}
}
const randomImages : ImageInfo [ ] = this . selectImages ( images , targetCount )
const attachmentIds : string [ ] = [ ]
const imgAuthors : string [ ] = [ ]
for ( const randomImage of randomImages ) {
2023-01-16 01:54:51 +01:00
const cs = randomImage . changeset . properties
let authorName = cs . user
try {
const authorInfo = new OsmUserInfo ( Number ( cs . uid ) , this . _config )
authorName = ( await authorInfo . GetMastodonLink ( ) ) ? ? cs . user
} catch ( e ) {
console . log ( "Could not fetch more info about contributor" , authorName , cs . uid , "due to" , e )
}
imgAuthors . push ( authorName )
if ( this . _config . mastodonAuth . dryrun ) {
console . log ( "Not uploading/downloading image:" + randomImage . image + " dryrun" )
2023-01-15 01:25:15 +01:00
continue
}
2023-01-16 01:54:51 +01:00
const attribution = await ImgurAttribution . DownloadAttribution ( randomImage . image )
2023-01-14 03:23:40 +01:00
const id = randomImage . image . substring ( randomImage . image . lastIndexOf ( "/" ) + 1 )
const path = this . _config . cacheDir + "/image_" + id
await Utils . DownloadBlob ( randomImage . image , path )
2023-01-16 01:54:51 +01:00
const mediaId = await this . _poster . uploadImage ( path , "Image taken by " + authorName + ", available under " + attribution . license + ". It is made with the thematic map " + randomImage . changeset . properties . theme + " in changeset https://openstreetmap.org/changeset/" + randomImage . changeset . id )
2023-01-14 03:23:40 +01:00
attachmentIds . push ( mediaId )
2023-01-16 01:54:51 +01:00
}
return {
attachmentIds ,
imgAuthors ,
totalImagesCreated ,
totalImageContributorCount : new Set ( withImage . map ( cs = > cs . properties . uid ) ) . size
2023-01-14 03:23:40 +01:00
}
}
}