Merge develop

This commit is contained in:
Pieter Vander Vennet 2023-10-11 04:34:57 +02:00
commit 08ffe4b7c0
146 changed files with 4380 additions and 1435 deletions

View file

@ -55,8 +55,13 @@ export class NewGeometryFromChangesFeatureSource implements WritableFeatureSourc
* @private
*/
private handleChange(change: ChangeDescription): boolean {
const allElementStorage = this._allElementStorage
if (change.changes === undefined) {
// The geometry is not described - not a new point or geometry change, but probably a tagchange to a newly created point
// Not something that should be handled here
return false
}
const allElementStorage = this._allElementStorage
console.log("Handling pending change", change)
if (change.id > 0) {
// This is an already existing object
@ -85,10 +90,6 @@ export class NewGeometryFromChangesFeatureSource implements WritableFeatureSourc
this._featureProperties.trackFeature(feature)
this.addNewFeature(feature)
return true
} else if (change.changes === undefined) {
// The geometry is not described - not a new point or geometry change, but probably a tagchange to a newly created point
// Not something that should be handled here
return false
}
try {
@ -150,7 +151,7 @@ export class NewGeometryFromChangesFeatureSource implements WritableFeatureSourc
continue
}
somethingChanged ||= this.handleChange(change)
somethingChanged = this.handleChange(change) || somethingChanged // important: _first_ evaluate the method, to avoid shortcutting
}
if (somethingChanged) {
this.features.ping()

View file

@ -23,27 +23,27 @@ export default class AllImageProviders {
)
),
]
public static apiUrls: string[] = [].concat(
...AllImageProviders.ImageAttributionSource.map((src) => src.apiUrls())
)
public static defaultKeys = [].concat(
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes)
)
private static providersByName = {
imgur: Imgur.singleton,
mapillary: Mapillary.singleton,
wikidata: WikidataImageProvider.singleton,
wikimedia: WikimediaImageProvider.singleton,
}
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
}
public static defaultKeys = [].concat(
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes)
)
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
string,
UIEventSource<ProvidedImage[]>
>()
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
}
public static LoadImagesFor(
tags: Store<Record<string, string>>,
tagKey?: string[]

View file

@ -3,6 +3,10 @@ import ImageProvider, { ProvidedImage } from "./ImageProvider"
export default class GenericImageProvider extends ImageProvider {
public defaultKeyPrefixes: string[] = ["image"]
public apiUrls(): string[] {
return []
}
private readonly _valuePrefixBlacklist: string[]
public constructor(valuePrefixBlacklist: string[]) {

View file

@ -65,4 +65,6 @@ export default abstract class ImageProvider {
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>
public abstract DownloadAttribution(url: string): Promise<LicenseInfo>
public abstract apiUrls(): string[]
}

View file

@ -10,10 +10,16 @@ export class Imgur extends ImageProvider implements ImageUploader {
public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
public static readonly apiUrl = "https://api.imgur.com/3/image"
private constructor() {
super()
}
apiUrls(): string[] {
return [Imgur.apiUrl]
}
/**
* Uploads an image, returns the URL where to find the image
* @param title
@ -25,7 +31,7 @@ export class Imgur extends ImageProvider implements ImageUploader {
description: string,
blob: File
): Promise<{ key: string; value: string }> {
const apiUrl = "https://api.imgur.com/3/image"
const apiUrl = Imgur.apiUrl
const apiKey = Constants.ImgurApiKey
const formData = new FormData()

View file

@ -17,6 +17,10 @@ export class Mapillary extends ImageProvider {
]
defaultKeyPrefixes = ["mapillary", "image"]
apiUrls(): string[] {
return ["https://mapillary.com", "https://www.mapillary.com", "https://graph.mapillary.com"]
}
/**
* Indicates that this is the same URL
* Ignores 'stp' parameter

View file

@ -5,6 +5,9 @@ import { WikimediaImageProvider } from "./WikimediaImageProvider"
import Wikidata from "../Web/Wikidata"
export class WikidataImageProvider extends ImageProvider {
public apiUrls(): string[] {
return Wikidata.neededUrls
}
public static readonly singleton = new WikidataImageProvider()
public readonly defaultKeyPrefixes = ["wikidata"]

View file

@ -11,11 +11,11 @@ import Wikimedia from "../Web/Wikimedia"
*/
export class WikimediaImageProvider extends ImageProvider {
public static readonly singleton = new WikimediaImageProvider()
public static readonly commonsPrefixes = [
public static readonly apiUrls = [
"https://commons.wikimedia.org/wiki/",
"https://upload.wikimedia.org",
"File:",
]
public static readonly commonsPrefixes = [...WikimediaImageProvider.apiUrls, "File:"]
private readonly commons_key = "wikimedia_commons"
public readonly defaultKeyPrefixes = [this.commons_key, "image"]
@ -66,6 +66,10 @@ export class WikimediaImageProvider extends ImageProvider {
return value
}
apiUrls(): string[] {
return WikimediaImageProvider.apiUrls
}
SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg().SetStyle("width:2em;height: 2em")
if (backlink === undefined) {

View file

@ -1,6 +1,8 @@
import Constants from "../Models/Constants"
export default class Maproulette {
public static readonly defaultEndpoint = "https://maproulette.org/api/v2"
public static readonly STATUS_OPEN = 0
public static readonly STATUS_FIXED = 1
public static readonly STATUS_FALSE_POSITIVE = 2
@ -20,59 +22,28 @@ export default class Maproulette {
6: "Too hard",
9: "Disabled",
}
public static singleton = new Maproulette()
/*
* The API endpoint to use
*/
endpoint: string
/**
* The API key to use for all requests
*/
private readonly apiKey: string
public static singleton = new Maproulette()
/**
* Creates a new Maproulette instance
* @param endpoint The API endpoint to use
*/
constructor(endpoint: string = "https://maproulette.org/api/v2") {
this.endpoint = endpoint
constructor(endpoint?: string) {
this.endpoint = endpoint ?? Maproulette.defaultEndpoint
if (!this.endpoint) {
throw "MapRoulette endpoint is undefined. Make sure that `Maproulette.defaultEndpoint` is defined on top of the class"
}
this.apiKey = Constants.MaprouletteApiKey
}
/**
* Close a task; might throw an error
*
* Also see:https://maproulette.org/docs/swagger-ui/index.html?url=/assets/swagger.json&docExpansion=none#/Task/setTaskStatus
* @param taskId The task to close
* @param status A number indicating the status. Use MapRoulette.STATUS_*
* @param options Additional settings to pass. Refer to the API-docs for more information
*/
async closeTask(
taskId: number,
status = Maproulette.STATUS_FIXED,
options?: {
comment?: string
tags?: string
requestReview?: boolean
completionResponses?: Record<string, string>
}
): Promise<void> {
const response = await fetch(`${this.endpoint}/task/${taskId}/${status}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
apiKey: this.apiKey,
},
body: options !== undefined ? JSON.stringify(options) : undefined,
})
if (response.status !== 204) {
console.log(`Failed to close task: ${response.status}`)
throw `Failed to close task: ${response.status}`
}
}
/**
* Converts a status text into the corresponding number
*
@ -91,4 +62,37 @@ export default class Maproulette {
}
return undefined
}
/**
* Close a task; might throw an error
*
* Also see:https://maproulette.org/docs/swagger-ui/index.html?url=/assets/swagger.json&docExpansion=none#/Task/setTaskStatus
* @param taskId The task to close
* @param status A number indicating the status. Use MapRoulette.STATUS_*
* @param options Additional settings to pass. Refer to the API-docs for more information
*/
async closeTask(
taskId: number,
status = Maproulette.STATUS_FIXED,
options?: {
comment?: string
tags?: string
requestReview?: boolean
completionResponses?: Record<string, string>
}
): Promise<void> {
console.log("Maproulette: setting", `${this.endpoint}/task/${taskId}/${status}`, options)
const response = await fetch(`${this.endpoint}/task/${taskId}/${status}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
apiKey: this.apiKey,
},
body: options !== undefined ? JSON.stringify(options) : undefined,
})
if (response.status !== 204) {
console.log(`Failed to close task: ${response.status}`)
throw `Failed to close task: ${response.status}`
}
}
}

View file

@ -0,0 +1,6 @@
export interface AuthConfig {
"#"?: string // optional comment
oauth_client_id: string
oauth_secret: string
url: string
}

View file

@ -525,11 +525,13 @@ export class Changes {
pending = pending.map((ch) => ChangeDescriptionTools.rewriteIds(ch, remappings))
console.log("Result is", pending)
}
const changes: {
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, objects)
return Changes.createChangesetFor("" + csId, changes)
},
metatags,
@ -558,19 +560,11 @@ export class Changes {
const successes = await Promise.all(
Array.from(pendingPerTheme, async ([theme, pendingChanges]) => {
try {
const openChangeset = this.state.osmConnection
.GetPreference("current-open-changeset-" + theme)
.sync(
(str) => {
const n = Number(str)
if (isNaN(n)) {
return undefined
}
return n
},
[],
(n) => "" + n
const openChangeset = UIEventSource.asInt(
this.state.osmConnection.GetPreference(
"current-open-changeset-" + theme
)
)
console.log(
"Using current-open-changeset-" +
theme +

View file

@ -1,5 +1,6 @@
import { Utils } from "../../Utils"
import { BBox } from "../BBox"
import Constants from "../../Models/Constants"
export interface GeoCodeResult {
display_name: string
@ -15,7 +16,7 @@ export interface GeoCodeResult {
}
export class Geocoding {
private static readonly host = "https://nominatim.openstreetmap.org/search?"
public static readonly host = Constants.nominatimEndpoint
static async Search(query: string, bbox: BBox): Promise<GeoCodeResult[]> {
const b = bbox ?? BBox.global

View file

@ -4,7 +4,8 @@ import { Store, Stores, UIEventSource } from "../UIEventSource"
import { OsmPreferences } from "./OsmPreferences"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import * as config from "../../../package.json"
import { AuthConfig } from "./AuthConfig"
import Constants from "../../Models/Constants"
export default class UserDetails {
public loggedIn = false
@ -25,18 +26,9 @@ export default class UserDetails {
}
}
export interface AuthConfig {
"#"?: string // optional comment
oauth_client_id: string
oauth_secret: string
url: string
}
export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable"
export class OsmConnection {
public static readonly oauth_configs: Record<string, AuthConfig> =
config.config.oauth_credentials
public auth
public userDetails: UIEventSource<UserDetails>
public isLoggedIn: Store<boolean>
@ -53,7 +45,7 @@ export class OsmConnection {
public preferencesHandler: OsmPreferences
public readonly _oauth_config: AuthConfig
private readonly _dryRun: Store<boolean>
private fakeUser: boolean
private readonly fakeUser: boolean
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []
private readonly _iframeMode: Boolean | boolean
private readonly _singlePage: boolean
@ -65,15 +57,12 @@ export class OsmConnection {
oauth_token?: UIEventSource<string>
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean
osmConfiguration?: "osm" | "osm-test"
attemptLogin?: true | boolean
}) {
options = options ?? {}
this.fakeUser = options.fakeUser ?? false
this._singlePage = options.singlePage ?? true
this._oauth_config =
OsmConnection.oauth_configs[options.osmConfiguration ?? "osm"] ??
OsmConnection.oauth_configs.osm
options ??= {}
this.fakeUser = options?.fakeUser ?? false
this._singlePage = options?.singlePage ?? true
this._oauth_config = Constants.osmAuthConfig
console.debug("Using backend", this._oauth_config.url)
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top
@ -83,11 +72,8 @@ export class OsmConnection {
import.meta.env.VITE_OSM_OAUTH_SECRET !== undefined
) {
console.debug("Using environment variables for oauth config")
this._oauth_config = {
oauth_client_id: import.meta.env.VITE_OSM_OAUTH_CLIENT_ID,
oauth_secret: import.meta.env.VITE_OSM_OAUTH_SECRET,
url: "https://api.openstreetmap.org",
}
this._oauth_config.oauth_client_id = import.meta.env.VITE_OSM_OAUTH_CLIENT_ID
this._oauth_config.oauth_secret = import.meta.env.VITE_OSM_OAUTH_SECRET
}
this.userDetails = new UIEventSource<UserDetails>(

View file

@ -1,9 +1,17 @@
import { UIEventSource } from "../UIEventSource"
import UserDetails, { OsmConnection } from "./OsmConnection"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
export class OsmPreferences {
public preferences = new UIEventSource<Record<string, string>>({}, "all-osm-preferences")
/**
* A dictionary containing all the preferences. The 'preferenceSources' will be initialized from this
* We keep a local copy of them, to init mapcomplete with the previous choices and to be able to get the open changesets right away
*/
public preferences = LocalStorageSource.GetParsed<Record<string, string>>(
"all-osm-preferences",
{}
)
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any
private userDetails: UIEventSource<UserDetails>

View file

@ -28,15 +28,8 @@ class FeatureSwitchUtils {
export class OsmConnectionFeatureSwitches {
public readonly featureSwitchFakeUser: UIEventSource<boolean>
public readonly featureSwitchApiURL: UIEventSource<string>
constructor() {
this.featureSwitchApiURL = QueryParameters.GetQueryParameter(
"backend",
"osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'"
)
this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter(
"fake-user",
false,
@ -143,7 +136,6 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
let testingDefaultValue = false
if (
this.featureSwitchApiURL.data !== "osm-test" &&
!Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1")
) {
@ -172,7 +164,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
(urls) => urls?.join(",")
)
this.overpassTimeout = UIEventSource.asFloat(
this.overpassTimeout = UIEventSource.asInt(
QueryParameters.GetQueryParameter(
"overpassTimeout",
"" + layoutToUse?.overpassTimeout,
@ -188,7 +180,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
)
)
this.osmApiTileSize = UIEventSource.asFloat(
this.osmApiTileSize = UIEventSource.asInt(
QueryParameters.GetQueryParameter(
"osmApiTileSize",
"" + layoutToUse?.osmApiTileSize,

View file

@ -612,6 +612,48 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return src
}
/**
*
* @param source
* UIEventSource.asInt(new UIEventSource("123")).data // => 123
* UIEventSource.asInt(new UIEventSource("123456789")).data // => 123456789
*
* const srcStr = new UIEventSource("123456789"))
* const srcInt = UIEventSource.asInt(srcStr)
* srcInt.setData(987654321)
* srcStr.data // => "987654321"
*/
public static asInt(source: UIEventSource<string>): UIEventSource<number> {
return source.sync(
(str) => {
let parsed = parseInt(str)
return isNaN(parsed) ? undefined : parsed
},
[],
(fl) => {
if (fl === undefined || isNaN(fl)) {
return undefined
}
return "" + fl
}
)
}
/**
* UIEventSource.asFloat(new UIEventSource("123")).data // => 123
* UIEventSource.asFloat(new UIEventSource("123456789")).data // => 123456789
* UIEventSource.asFloat(new UIEventSource("0.5")).data // => 0.5
* UIEventSource.asFloat(new UIEventSource("0.125")).data // => 0.125
* UIEventSource.asFloat(new UIEventSource("0.0000000001")).data // => 0.0000000001
*
*
* const srcStr = new UIEventSource("123456789"))
* const srcInt = UIEventSource.asFloat(srcStr)
* srcInt.setData(987654321)
* srcStr.data // => "987654321"
* @param source
*/
public static asFloat(source: UIEventSource<string>): UIEventSource<number> {
return source.sync(
(str) => {
@ -623,7 +665,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
if (fl === undefined || isNaN(fl)) {
return undefined
}
return ("" + fl).substr(0, 8)
return "" + fl
}
)
}

View file

@ -1,9 +1,9 @@
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoOperations } from "../GeoOperations"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
import { Mapillary } from "../ImageProviders/Mapillary"
import P4C from "pic4carto"
import { Utils } from "../../Utils"
export interface NearbyImageOptions {
lon: number
lat: number
@ -35,17 +35,12 @@ export interface P4CPicture {
}
/**
* Uses Pic4wCarto to fetch nearby images from various providers
* Uses Pic4Carto to fetch nearby images from various providers
*/
export default class NearbyImagesSearch {
private static readonly services = [
"mapillary",
"flickr",
"openstreetcam",
"wikicommons",
] as const
private individualStores
public static readonly services = ["mapillary", "flickr", "kartaview", "wikicommons"] as const
public static readonly apiUrls = ["https://api.flickr.com"]
private readonly individualStores: Store<{ images: P4CPicture[]; beforeFilter: number }>[]
private readonly _store: UIEventSource<P4CPicture[]> = new UIEventSource<P4CPicture[]>([])
public readonly store: Store<P4CPicture[]> = this._store
private readonly _options: NearbyImageOptions
@ -71,16 +66,16 @@ export default class NearbyImagesSearch {
this.update()
}
private static buildPictureFetcher(
private static async fetchImages(
options: NearbyImageOptions,
fetcher: "mapillary" | "flickr" | "openstreetcam" | "wikicommons"
): Store<{ images: P4CPicture[]; beforeFilter: number }> {
fetcher: P4CService
): Promise<P4CPicture[]> {
const picManager = new P4C.PicturesManager({ usefetchers: [fetcher] })
const searchRadius = options.searchRadius ?? 100
const maxAgeSeconds = (options.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000
const searchRadius = options.searchRadius ?? 100
const p4cStore = Stores.FromPromise<P4CPicture[]>(
picManager.startPicsRetrievalAround(
try {
const pics: P4CPicture[] = await picManager.startPicsRetrievalAround(
new P4C.LatLng(options.lat, options.lon),
searchRadius,
{
@ -88,7 +83,21 @@ export default class NearbyImagesSearch {
towardscenter: false,
}
)
return pics
} catch (e) {
console.error("Could not fetch images from service", fetcher, e)
return []
}
}
private static buildPictureFetcher(
options: NearbyImageOptions,
fetcher: P4CService
): Store<{ images: P4CPicture[]; beforeFilter: number }> {
const p4cStore = Stores.FromPromise<P4CPicture[]>(
NearbyImagesSearch.fetchImages(options, fetcher)
)
const searchRadius = options.searchRadius ?? 100
return p4cStore.map(
(images) => {
if (images === undefined) {
@ -220,3 +229,5 @@ class ImagesInLoadedDataFetcher {
return foundImages
}
}
type P4CService = (typeof NearbyImagesSearch.services)[number]

View file

@ -1,7 +1,7 @@
import { Utils } from "../../Utils"
export default class PlantNet {
private static baseUrl =
public static baseUrl =
"https://my-api.plantnet.org/v2/identify/all?api-key=2b10AAsjzwzJvucA5Ncm5qxe"
public static query(imageUrls: string[]): Promise<PlantNetResult> {

View file

@ -123,6 +123,11 @@ export interface WikidataAdvancedSearchoptions extends WikidataSearchoptions {
* Utility functions around wikidata
*/
export default class Wikidata {
public static readonly neededUrls = [
"https://www.wikidata.org/",
"https://wikidata.org/",
"https://query.wikidata.org",
]
private static readonly _identifierPrefixes = ["Q", "L"].map((str) => str.toLowerCase())
private static readonly _prefixesToRemove = [
"https://www.wikidata.org/wiki/Lexeme:",
@ -130,11 +135,11 @@ export default class Wikidata {
"http://www.wikidata.org/entity/",
"Lexeme:",
].map((str) => str.toLowerCase())
private static readonly _storeCache = new Map<
string,
Store<{ success: WikidataResponse } | { error: any }>
>()
/**
* Same as LoadWikidataEntry, but wrapped into a UIEventSource
* @param value
@ -388,6 +393,7 @@ export default class Wikidata {
}
private static _cache = new Map<string, Promise<WikidataResponse>>()
public static async LoadWikidataEntryAsync(value: string | number): Promise<WikidataResponse> {
const key = "" + value
const cached = Wikidata._cache.get(key)
@ -398,6 +404,7 @@ export default class Wikidata {
Wikidata._cache.set(key, uncached)
return uncached
}
/**
* Loads a wikidata page
* @returns the entity of the given value

View file

@ -34,6 +34,8 @@ export default class Wikipedia {
private static readonly idsToRemove = ["sjabloon_zie"]
public static readonly neededUrls = ["*.wikipedia.org"]
private static readonly _cache = new Map<string, Promise<string>>()
private static _fullDetailsCache = new Map<string, Store<FullWikipediaDetails>>()
public readonly backend: string