Merge develop

This commit is contained in:
Pieter Vander Vennet 2023-10-01 13:13:07 +02:00
commit 55c6442cac
388 changed files with 16178 additions and 17860 deletions

View file

@ -0,0 +1,73 @@
import { Store, UIEventSource } from "../UIEventSource"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
/**
* Selects the appropriate raster layer as background for the given query parameter, theme setting, user preference or default value.
*
* It the requested layer is not available, a layer of the same type will be selected.
*/
export class PreferredRasterLayerSelector {
private readonly _rasterLayerSetting: UIEventSource<RasterLayerPolygon>
private readonly _availableLayers: Store<RasterLayerPolygon[]>
private readonly _preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
private readonly _queryParameter: UIEventSource<string>
constructor(
rasterLayerSetting: UIEventSource<RasterLayerPolygon>,
availableLayers: Store<RasterLayerPolygon[]>,
queryParameter: UIEventSource<string>,
preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
) {
this._rasterLayerSetting = rasterLayerSetting
this._availableLayers = availableLayers
this._queryParameter = queryParameter
this._preferredBackgroundLayer = preferredBackgroundLayer
const self = this
this._rasterLayerSetting.addCallbackD((layer) => {
if (layer.properties.id !== this._queryParameter.data) {
this._queryParameter.setData(undefined)
return true
}
})
this._queryParameter.addCallbackAndRunD((_) => {
const isApplied = self.updateLayer()
if (!isApplied) {
// A different layer was set as background
// We remove this queryParameter instead
self._queryParameter.setData(undefined)
return true // Unregister
}
})
this._preferredBackgroundLayer.addCallbackD((_) => self.updateLayer())
this._availableLayers.addCallbackD((_) => self.updateLayer())
}
/**
* Returns 'true' if the target layer is set or is the current layer
* @private
*/
private updateLayer() {
// What is the ID of the layer we have to (try to) load?
const targetLayerId = this._queryParameter.data ?? this._preferredBackgroundLayer.data
const available = this._availableLayers.data
const isCategory =
targetLayerId === "photo" || targetLayerId === "osmbasedmap" || targetLayerId === "map"
const foundLayer = isCategory
? available.find((l) => l.properties.category === targetLayerId)
: available.find((l) => l.properties.id === targetLayerId)
if (foundLayer) {
this._rasterLayerSetting.setData(foundLayer)
return true
}
// The current layer is not in view
}
}

View file

@ -454,12 +454,16 @@ export class ExtraFunctions {
"To enable this feature, add a field `calculatedTags` in the layer object, e.g.:",
"````",
'"calculatedTags": [',
' "_someKey=javascript-expression",',
' "_someKey=javascript-expression (lazy execution)",',
' "_some_other_key:=javascript expression (strict execution)',
' "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator",',
" \"_distanceCloserThen3Km=distanceTo(feat)( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ",
" ]",
"````",
"",
"By using `:=` as separator, the attribute will be calculated as soone as the data is loaded (strict evaluation)",
"The default behaviour, using `=` as separator, is lazy loading",
"",
"The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:",
new List([

View file

@ -1,52 +1,19 @@
import { FeatureSource } from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import { OsmTags } from "../../../Models/OsmFeature"
/**
* Constructs a UIEventStore for the properties of every Feature, indexed by id
*/
export default class FeaturePropertiesStore {
private readonly _elements = new Map<string, UIEventSource<Record<string, string>>>()
public readonly aliases = new Map<string, string>()
constructor(...sources: FeatureSource[]) {
for (const source of sources) {
this.trackFeatureSource(source)
}
}
public getStore(id: string): UIEventSource<Record<string, string>> {
return this._elements.get(id)
}
public trackFeatureSource(source: FeatureSource) {
const self = this
source.features.addCallbackAndRunD((features) => {
for (const feature of features) {
const id = feature.properties.id
if (id === undefined) {
console.trace("Error: feature without ID:", feature)
throw "Error: feature without ID"
}
const source = self._elements.get(id)
if (source === undefined) {
self._elements.set(id, new UIEventSource<any>(feature.properties))
continue
}
if (source.data === feature.properties) {
continue
}
// Update the tags in the old store and link them
const changeMade = FeaturePropertiesStore.mergeTags(source.data, feature.properties)
feature.properties = source.data
if (changeMade) {
source.ping()
}
}
})
}
/**
* Overwrites the tags of the old properties object, returns true if a change was made.
* Metatags are overriden if they are in the new properties, but not removed
@ -67,7 +34,7 @@ export default class FeaturePropertiesStore {
}
if (newProperties[oldPropertiesKey] === undefined) {
changeMade = true
delete oldProperties[oldPropertiesKey]
// delete oldProperties[oldPropertiesKey]
}
}
@ -83,7 +50,48 @@ export default class FeaturePropertiesStore {
return changeMade
}
// noinspection JSUnusedGlobalSymbols
public getStore(id: string): UIEventSource<Record<string, string>> {
const store = this._elements.get(id)
if (store === undefined) {
console.error("PANIC: no store for", id)
}
return store
}
public trackFeature(feature: { properties: OsmTags }) {
const id = feature.properties.id
if (id === undefined) {
console.trace("Error: feature without ID:", feature)
throw "Error: feature without ID"
}
const source = this._elements.get(id)
if (source === undefined) {
this._elements.set(id, new UIEventSource<any>(feature.properties))
return
}
if (source.data === feature.properties) {
return
}
// Update the tags in the old store and link them
const changeMade = FeaturePropertiesStore.mergeTags(source.data, feature.properties)
feature.properties = <any>source.data
if (changeMade) {
source.ping()
}
}
public trackFeatureSource(source: FeatureSource) {
const self = this
source.features.addCallbackAndRunD((features) => {
for (const feature of features) {
self.trackFeature(<any>feature)
}
})
}
public addAlias(oldId: string, newId: string): void {
if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
@ -103,6 +111,7 @@ export default class FeaturePropertiesStore {
}
element.data.id = newId
this._elements.set(newId, element)
this.aliases.set(newId, oldId)
element.ping()
}

View file

@ -82,7 +82,7 @@ export default class FeatureSourceMerger implements IndexedFeatureSource {
}
const newList = []
all.forEach((value, key) => {
all.forEach((value) => {
newList.push(value)
})
this.features.setData(newList)

View file

@ -4,8 +4,9 @@ import { IndexedFeatureSource, WritableFeatureSource } from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import { ChangeDescription } from "../../Osm/Actions/ChangeDescription"
import { OsmId, OsmTags } from "../../../Models/OsmFeature"
import { Feature } from "geojson"
import OsmObjectDownloader from "../../Osm/OsmObjectDownloader"
import { Feature, Point } from "geojson"
import { TagUtils } from "../../Tags/TagUtils"
import FeaturePropertiesStore from "../Actors/FeaturePropertiesStore"
export class NewGeometryFromChangesFeatureSource implements WritableFeatureSource {
// This class name truly puts the 'Java' into 'Javascript'
@ -15,115 +16,145 @@ export class NewGeometryFromChangesFeatureSource implements WritableFeatureSourc
*
* These elements are probably created by the 'SimpleAddUi' which generates a new point, but the import functionality might create a line or polygon too.
* Other sources of new points are e.g. imports from nodes
*
* Alternatively, an already existing point might suddenly match the layer, especially if a point in a wall is reused
*
* Note that the FeaturePropertiesStore will track a featuresource, such as this one
*/
public readonly features: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([])
private readonly _seenChanges: Set<ChangeDescription>
private readonly _features: Feature[]
private readonly _backend: string
private readonly _allElementStorage: IndexedFeatureSource
private _featureProperties: FeaturePropertiesStore
constructor(changes: Changes, allElementStorage: IndexedFeatureSource, backendUrl: string) {
const seenChanges = new Set<ChangeDescription>()
const features = this.features.data
constructor(
changes: Changes,
allElementStorage: IndexedFeatureSource,
featureProperties: FeaturePropertiesStore
) {
this._allElementStorage = allElementStorage
this._featureProperties = featureProperties
this._seenChanges = new Set<ChangeDescription>()
this._features = this.features.data
this._backend = changes.backend
const self = this
const backend = changes.backend
changes.pendingChanges.addCallbackAndRunD((changes) => {
if (changes.length === 0) {
return
changes.pendingChanges.addCallbackAndRunD((changes) => self.handleChanges(changes))
}
private addNewFeature(feature: Feature) {
const features = this._features
feature.id = feature.properties.id
features.push(feature)
}
/**
* Handles a single pending change
* @returns true if something changed
* @param change
* @private
*/
private handleChange(change: ChangeDescription): boolean {
const backend = this._backend
const allElementStorage = this._allElementStorage
console.log("Handling pending change")
if (change.id > 0) {
// This is an already existing object
// In _most_ of the cases, this means that this _isn't_ a new object
// However, when a point is snapped to an already existing point, we have to create a representation for this point!
// For this, we introspect the change
if (allElementStorage.featuresById.data.has(change.type + "/" + change.id)) {
// The current point already exists, we don't have to do anything here
return false
}
console.debug("Detected a reused point, for", change)
// The 'allElementsStore' does _not_ have this point yet, so we have to create it
// However, we already create a store for it
const { lon, lat } = <{ lon: number; lat: number }>change.changes
const feature = <Feature<Point, OsmTags>>{
type: "Feature",
properties: {
id: <OsmId>change.type + "/" + change.id,
...TagUtils.changeAsProperties(change.tags),
},
geometry: {
type: "Point",
coordinates: [lon, lat],
},
}
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 {
const tags: OsmTags & { id: OsmId & string } = {
id: <OsmId & string>(change.type + "/" + change.id),
}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
let somethingChanged = false
tags["_backend"] = this._backend
function add(feature) {
feature.id = feature.properties.id
features.push(feature)
somethingChanged = true
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.tags = tags
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
this.addNewFeature(geojson)
break
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [lat, lon])
this.addNewFeature(w.asGeoJson())
break
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
this.addNewFeature(r.asGeoJson())
break
}
return true
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
private handleChanges(changes: ChangeDescription[]) {
const seenChanges = this._seenChanges
if (changes.length === 0) {
return
}
let somethingChanged = false
for (const change of changes) {
if (seenChanges.has(change)) {
// Already handled
continue
}
seenChanges.add(change)
if (change.tags === undefined) {
// If tags is undefined, this is probably a new point that is part of a split road
continue
}
for (const change of changes) {
if (seenChanges.has(change)) {
// Already handled
continue
}
seenChanges.add(change)
if (change.tags === undefined) {
// If tags is undefined, this is probably a new point that is part of a split road
continue
}
console.log("Handling pending change")
if (change.id > 0) {
// This is an already existing object
// In _most_ of the cases, this means that this _isn't_ a new object
// However, when a point is snapped to an already existing point, we have to create a representation for this point!
// For this, we introspect the change
if (allElementStorage.featuresById.data.has(change.type + "/" + change.id)) {
// The current point already exists, we don't have to do anything here
continue
}
console.debug("Detected a reused point")
// The 'allElementsStore' does _not_ have this point yet, so we have to create it
new OsmObjectDownloader(backend)
.DownloadObjectAsync(change.type + "/" + change.id)
.then((feat) => {
console.log("Got the reused point:", feat)
if (feat === "deleted") {
throw "Panic: snapping to a point, but this point has been deleted in the meantime"
}
for (const kv of change.tags) {
feat.tags[kv.k] = kv.v
}
const geojson = feat.asGeoJson()
self.features.data.push(geojson)
self.features.ping()
})
continue
} 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
continue
}
try {
const tags: OsmTags & { id: OsmId & string } = {
id: <OsmId & string>(change.type + "/" + change.id),
}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
tags["_backend"] = backendUrl
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.tags = tags
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [
lat,
lon,
])
add(w.asGeoJson())
break
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
add(r.asGeoJson())
break
}
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
if (somethingChanged) {
self.features.ping()
}
})
somethingChanged ||= this.handleChange(change)
}
if (somethingChanged) {
this.features.ping()
}
}
}

View file

@ -20,7 +20,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
private options: {
bounds: Store<BBox>
readonly allowedFeatures: TagsFilter
backend?: "https://openstreetmap.org/" | string
backend?: "https://api.openstreetmap.org/" | string
/**
* If given: this featureSwitch will not update if the store contains 'false'
*/
@ -41,7 +41,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
constructor(options: {
bounds: Store<BBox>
readonly allowedFeatures: TagsFilter
backend?: "https://openstreetmap.org/" | string
backend?: "https://api.openstreetmap.org/" | string
/**
* If given: this featureSwitch will not update if the store contains 'false'
*/
@ -54,7 +54,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
this._bounds = options.bounds
this.allowedTags = options.allowedFeatures
this.isActive = options.isActive ?? new ImmutableStore(true)
this._backend = options.backend ?? "https://www.openstreetmap.org"
this._backend = options.backend ?? "https://api.openstreetmap.org"
this._bounds.addCallbackAndRunD((bbox) => this.loadData(bbox))
this._patchRelations = options?.patchRelations ?? true
}

View file

@ -1,7 +1,6 @@
import { OsmNode, OsmObject, OsmWay } from "../../Osm/OsmObject"
import { UIEventSource } from "../../UIEventSource"
import { BBox } from "../../BBox"
import StaticFeatureSource from "../Sources/StaticFeatureSource"
import { Tiles } from "../../../Models/TileRange"
export default class FullNodeDatabaseSource {
@ -48,11 +47,7 @@ export default class FullNodeDatabaseSource {
src.ping()
}
}
const asGeojsonFeatures = Array.from(nodesById.values()).map((osmNode) =>
osmNode.asGeoJson()
)
const featureSource = new StaticFeatureSource(asGeojsonFeatures)
const tileId = Tiles.tile_index(z, x, y)
this.loadedTiles.set(tileId, nodesById)
}

View file

@ -771,7 +771,6 @@ export class GeoOperations {
const splitup = turf.lineSplit(<Feature<LineString>>toSplit, boundary)
const kept = []
for (const f of splitup.features) {
const ls = <Feature<LineString>>f
if (!GeoOperations.inside(GeoOperations.centerpointCoordinates(f), boundary)) {
continue
}

View file

@ -0,0 +1,159 @@
import { ImageUploader } from "./ImageUploader"
import LinkImageAction from "../Osm/Actions/LinkImageAction"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { OsmId, OsmTags } from "../../Models/OsmFeature"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Store, UIEventSource } from "../UIEventSource"
import { OsmConnection } from "../Osm/OsmConnection"
import { Changes } from "../Osm/Changes"
import Translations from "../../UI/i18n/Translations"
import NoteCommentElement from "../../UI/Popup/NoteCommentElement"
/**
* The ImageUploadManager has a
*/
export class ImageUploadManager {
private readonly _uploader: ImageUploader
private readonly _featureProperties: FeaturePropertiesStore
private readonly _layout: LayoutConfig
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFinished: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFailed: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadRetried: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map()
private readonly _osmConnection: OsmConnection
private readonly _changes: Changes
constructor(
layout: LayoutConfig,
uploader: ImageUploader,
featureProperties: FeaturePropertiesStore,
osmConnection: OsmConnection,
changes: Changes
) {
this._uploader = uploader
this._featureProperties = featureProperties
this._layout = layout
this._osmConnection = osmConnection
this._changes = changes
}
/**
* Gets various counters.
* Note that counters can only increase
* If a retry was a success, both 'retrySuccess' _and_ 'uploadFinished' will be increased
* @param featureId: the id of the feature you want information for. '*' has a global counter
*/
public getCountsFor(featureId: string | "*"): {
retried: Store<number>
uploadStarted: Store<number>
retrySuccess: Store<number>
failed: Store<number>
uploadFinished: Store<number>
} {
return {
uploadStarted: this.getCounterFor(this._uploadStarted, featureId),
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
retried: this.getCounterFor(this._uploadRetried, featureId),
failed: this.getCounterFor(this._uploadFailed, featureId),
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId),
}
}
/**
* Uploads the given image, applies the correct title and license for the known user.
* Will then add this image to the OSM-feature or the OSM-note
*/
public async uploadImageAndApply(file: File, tagsStore: UIEventSource<OsmTags>): Promise<void> {
const sizeInBytes = file.size
const tags = tagsStore.data
const featureId = <OsmId>tags.id
const self = this
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
this.increaseCountFor(this._uploadStarted, featureId)
this.increaseCountFor(this._uploadFailed, featureId)
throw Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: self._uploader.maxFileSizeInMegabytes + "MB",
}).txt
}
const licenseStore = this._osmConnection?.GetPreference("pictures-license", "CC0")
const license = licenseStore?.data ?? "CC0"
const matchingLayer = this._layout?.getMatchingLayer(tags)
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.textFor("en") ??
tags.name ??
"https//osm.org/" + tags.id
const description = [
"author:" + this._osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n")
console.log("Upload done, creating ")
const action = await this.uploadImageWithLicense(featureId, title, description, file)
if (!isNaN(Number(featureId))) {
// THis is a map note
const url = action._url
await this._osmConnection.addCommentToNote(featureId, url)
NoteCommentElement.addCommentTo(url, <UIEventSource<any>>tagsStore, {
osmConnection: this._osmConnection,
})
return
}
await this._changes.applyAction(action)
}
private async uploadImageWithLicense(
featureId: OsmId,
title: string,
description: string,
blob: File
): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId)
const properties = this._featureProperties.getStore(featureId)
let key: string
let value: string
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
} catch (e) {
this.increaseCountFor(this._uploadRetried, featureId)
console.error("Could not upload image, trying again:", e)
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
} catch (e) {
console.error("Could again not upload image due to", e)
this.increaseCountFor(this._uploadFailed, featureId)
}
}
console.log("Uploading done, creating action for", featureId)
const action = new LinkImageAction(featureId, key, value, properties, {
theme: this._layout.id,
changeType: "add-image",
})
this.increaseCountFor(this._uploadFinished, featureId)
return action
}
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
if (this._featureProperties.aliases.has(key)) {
key = this._featureProperties.aliases.get(key)
}
if (!collection.has(key)) {
collection.set(key, new UIEventSource<number>(0))
}
return collection.get(key)
}
private increaseCountFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
const counter = this.getCounterFor(collection, key)
counter.setData(counter.data + 1)
const global = this.getCounterFor(collection, "*")
global.setData(counter.data + 1)
}
}

View file

@ -0,0 +1,15 @@
export interface ImageUploader {
maxFileSizeInMegabytes?: number
/**
* Uploads the 'blob' as image, with some metadata.
* Returns the URL to be linked + the appropriate key to add this to OSM
* @param title
* @param description
* @param blob
*/
uploadImage(
title: string,
description: string,
blob: File
): Promise<{ key: string; value: string }>
}

View file

@ -3,58 +3,28 @@ import BaseUIElement from "../../UI/BaseUIElement"
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
import { LicenseInfo } from "./LicenseInfo"
import { ImageUploader } from "./ImageUploader"
export class Imgur extends ImageProvider {
export class Imgur extends ImageProvider implements ImageUploader {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
private constructor() {
super()
}
static uploadMultiple(
/**
* Uploads an image, returns the URL where to find the image
* @param title
* @param description
* @param blob
*/
public async uploadImage(
title: string,
description: string,
blobs: FileList,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
allDone: () => void,
onFail: (reason: string) => void,
offset: number = 0
) {
if (blobs.length == offset) {
allDone()
return
}
const blob = blobs.item(offset)
const self = this
this.uploadImage(
title,
description,
blob,
async (imageUrl) => {
await handleSuccessfullUpload(imageUrl)
self.uploadMultiple(
title,
description,
blobs,
handleSuccessfullUpload,
allDone,
onFail,
offset + 1
)
},
onFail
)
}
static uploadImage(
title: string,
description: string,
blob: File,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
onFail: (reason: string) => void
) {
blob: File
): Promise<{ key: string; value: string }> {
const apiUrl = "https://api.imgur.com/3/image"
const apiKey = Constants.ImgurApiKey
@ -74,17 +44,9 @@ export class Imgur extends ImageProvider {
}
// Response contains stringified JSON
// Image URL available at response.data.link
fetch(apiUrl, settings)
.then(async function (response) {
const content = await response.json()
await handleSuccessfullUpload(content.data.link)
})
.catch((reason) => {
console.log("Uploading to IMGUR failed", reason)
// @ts-ignore
onFail(reason)
})
const response = await fetch(apiUrl, settings)
const content = await response.json()
return { key: "image", value: content.data.link }
}
SourceIcon(): BaseUIElement {

View file

@ -1,43 +0,0 @@
import { UIEventSource } from "../UIEventSource"
import { Imgur } from "./Imgur"
export default class ImgurUploader {
public readonly queue: UIEventSource<string[]> = new UIEventSource<string[]>([])
public readonly failed: UIEventSource<string[]> = new UIEventSource<string[]>([])
public readonly success: UIEventSource<string[]> = new UIEventSource<string[]>([])
public maxFileSizeInMegabytes = 10
private readonly _handleSuccessUrl: (string) => Promise<void>
constructor(handleSuccessUrl: (string) => Promise<void>) {
this._handleSuccessUrl = handleSuccessUrl
}
public uploadMany(title: string, description: string, files: FileList): void {
for (let i = 0; i < files.length; i++) {
this.queue.data.push(files.item(i).name)
}
this.queue.ping()
const self = this
this.queue.setData([...self.queue.data])
Imgur.uploadMultiple(
title,
description,
files,
async function (url) {
console.log("File saved at", url)
self.success.data.push(url)
self.success.ping()
await self._handleSuccessUrl(url)
},
function () {
console.log("All uploads completed")
},
function (failReason) {
console.log("Upload failed due to ", failReason)
self.failed.setData([...self.failed.data, failReason])
}
)
}
}

View file

@ -9,7 +9,6 @@ import { IndexedFeatureSource } from "./FeatureSource/FeatureSource"
import OsmObjectDownloader from "./Osm/OsmObjectDownloader"
import { Utils } from "../Utils"
import { Store, UIEventSource } from "./UIEventSource"
import { SpecialVisualizationState } from "../UI/SpecialVisualization"
/**
* Metatagging adds various tags to the elements, e.g. lat, lon, surface area, ...
@ -19,6 +18,7 @@ import { SpecialVisualizationState } from "../UI/SpecialVisualization"
export default class MetaTagging {
private static errorPrintCount = 0
private static readonly stopErrorOutputAt = 10
private static metataggingObject: any = undefined
private static retaggingFuncCache = new Map<
string,
((feature: Feature, propertiesStore: UIEventSource<any>) => void)[]
@ -77,6 +77,23 @@ export default class MetaTagging {
})
}
// noinspection JSUnusedGlobalSymbols
/**
* The 'metaTagging'-object is an object which contains some functions.
* Those functions are named `metaTaggging_for_<layer_name>` and are constructed based on the 'calculatedField' for this layer.
*
* If they are set, those functions will be used instead of parsing them at runtime.
*
* This means that we can avoid using eval, resulting in faster and safer code (at the cost of more complexity) - at least for official themes.
*
* Note: this function might appear unused while developing, it is used in the generated `index_<themename>.ts` files.
*
* @param metatagging
*/
public static setThemeMetatagging(metatagging: any) {
MetaTagging.metataggingObject = metatagging
}
/**
* This method (re)calculates all metatags and calculated tags on every given feature.
* The given features should be part of the given layer
@ -298,6 +315,40 @@ export default class MetaTagging {
layer: LayerConfig,
helpers: Record<ExtraFuncType, (feature: Feature) => Function>
): (feature: Feature, tags: UIEventSource<Record<string, any>>) => boolean {
if (MetaTagging.metataggingObject) {
const id = layer.id.replace(/[^a-zA-Z0-9_]/g, "_")
const funcName = "metaTaggging_for_" + id
if (typeof MetaTagging.metataggingObject[funcName] !== "function") {
console.log(MetaTagging.metataggingObject)
throw (
"Error: metatagging-object for this theme does not have an entry at " +
funcName +
" (or it is not a function)"
)
}
// public metaTaggging_for_walls_and_buildings(feat: Feature, helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>) {
//
const func: (feat: Feature, helperFunctions: Record<string, any>) => void =
MetaTagging.metataggingObject[funcName]
return (feature: Feature) => {
const tags = feature.properties
if (tags === undefined) {
return
}
try {
func(feature, helpers)
} catch (e) {
console.error("Could not calculate calculated tags in exported class: ", e)
}
return true // Something changed
}
}
console.warn(
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
)
const calculatedTags: [string, string, boolean][] = layer.calculatedTags
if (calculatedTags === undefined || calculatedTags.length === 0) {
return undefined

View file

@ -97,7 +97,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
},
meta: this.meta,
}
if (this._snapOnto === undefined) {
if (this._snapOnto?.coordinates === undefined) {
return [newPointChange]
}
@ -113,6 +113,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
console.log("Attempting to snap:", { geojson, projected, projectedCoor, index })
// We check that it isn't close to an already existing point
let reusedPointId = undefined
let reusedPointCoordinates: [number, number] = undefined
let outerring: [number, number][]
if (geojson.geometry.type === "LineString") {
@ -125,11 +126,13 @@ export default class CreateNewNodeAction extends OsmCreateAction {
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._reusePointDistance) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index]
reusedPointCoordinates = this._snapOnto.coordinates[index]
}
const next = outerring[index + 1]
if (GeoOperations.distanceBetween(next, projectedCoor) < this._reusePointDistance) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index + 1]
reusedPointCoordinates = this._snapOnto.coordinates[index + 1]
}
if (reusedPointId !== undefined) {
this.setElementId(reusedPointId)
@ -139,12 +142,13 @@ export default class CreateNewNodeAction extends OsmCreateAction {
type: "node",
id: reusedPointId,
meta: this.meta,
changes: { lat: reusedPointCoordinates[0], lon: reusedPointCoordinates[1] },
},
]
}
const locations = [
...this._snapOnto.coordinates.map(([lat, lon]) => <[number, number]>[lon, lat]),
...this._snapOnto.coordinates?.map(([lat, lon]) => <[number, number]>[lon, lat]),
]
const ids = [...this._snapOnto.nodes]

View file

@ -0,0 +1,57 @@
import ChangeTagAction from "./ChangeTagAction"
import { Tag } from "../../Tags/Tag"
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import { Store } from "../../UIEventSource"
export default class LinkImageAction extends OsmChangeAction {
private readonly _proposedKey: "image" | "mapillary" | "wiki_commons" | string
public readonly _url: string
private readonly _currentTags: Store<Record<string, string>>
private readonly _meta: { theme: string; changeType: "add-image" | "link-image" }
/**
* Adds an image-link to a feature
* @param elementId
* @param proposedKey a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed)
* @param url
* @param currentTags
* @param meta
*
*/
constructor(
elementId: string,
proposedKey: "image" | "mapillary" | "wiki_commons" | string,
url: string,
currentTags: Store<Record<string, string>>,
meta: {
theme: string
changeType: "add-image" | "link-image"
}
) {
super(elementId, true)
this._proposedKey = proposedKey
this._url = url
this._currentTags = currentTags
this._meta = meta
}
protected CreateChangeDescriptions(): Promise<ChangeDescription[]> {
let key = this._proposedKey
let i = 0
const currentTags = this._currentTags.data
const url = this._url
while (currentTags[key] !== undefined && currentTags[key] !== url) {
key = this._proposedKey + ":" + i
i++
}
const tagChangeAction = new ChangeTagAction(
this.mainObjectId,
new Tag(key, url),
currentTags,
this._meta
)
return tagChangeAction.CreateChangeDescriptions()
}
}

View file

@ -1,32 +0,0 @@
import ChangeTagAction from "./ChangeTagAction"
import { Tag } from "../../Tags/Tag"
export default class LinkPicture extends ChangeTagAction {
/**
* Adds a link to an image
* @param elementId
* @param proposedKey: a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed)
* @param url
* @param currentTags
* @param meta
*
*/
constructor(
elementId: string,
proposedKey: "image" | "mapillary" | "wiki_commons" | string,
url: string,
currentTags: Record<string, string>,
meta: {
theme: string
changeType: "add-image" | "link-image"
}
) {
let key = proposedKey
let i = 0
while (currentTags[key] !== undefined && currentTags[key] !== url) {
key = proposedKey + ":" + i
i++
}
super(elementId, new Tag(key, url), currentTags, meta)
}
}

View file

@ -215,7 +215,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction implements Pr
throw "Invalid ID to conflate: " + this.wayToReplaceId
}
const url = `${
this.state.osmConnection?._oauth_config?.url ?? "https://openstreetmap.org"
this.state.osmConnection?._oauth_config?.url ?? "https://api.openstreetmap.org"
}/api/0.6/${this.wayToReplaceId}/full`
const rawData = await Utils.downloadJsonCached(url, 1000)
parsed = OsmObject.ParseObjects(rawData.elements)

View file

@ -5,6 +5,7 @@ import Locale from "../../UI/i18n/Locale"
import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
export interface ChangesetTag {
key: string
@ -13,7 +14,7 @@ export interface ChangesetTag {
}
export class ChangesetHandler {
private readonly allElements: { addAlias: (id0: String, id1: string) => void }
private readonly allElements: FeaturePropertiesStore
private osmConnection: OsmConnection
private readonly changes: Changes
private readonly _dryRun: Store<boolean>
@ -29,11 +30,14 @@ export class ChangesetHandler {
constructor(
dryRun: Store<boolean>,
osmConnection: OsmConnection,
allElements: { addAlias: (id0: string, id1: string) => void } | undefined,
allElements:
| FeaturePropertiesStore
| { addAlias: (id0: string, id1: string) => void }
| undefined,
changes: Changes
) {
this.osmConnection = osmConnection
this.allElements = allElements
this.allElements = <FeaturePropertiesStore>allElements
this.changes = changes
this._dryRun = dryRun
this.userDetails = osmConnection.userDetails

View file

@ -5,6 +5,7 @@ import { OsmPreferences } from "./OsmPreferences"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import * as config from "../../../package.json"
export default class UserDetails {
public loggedIn = false
public name = "Not logged in"
@ -85,7 +86,7 @@ export class OsmConnection {
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://www.openstreetmap.org",
url: "https://api.openstreetmap.org",
}
}
@ -227,8 +228,6 @@ export class OsmConnection {
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0]
// let moreDetails = new DOMParser().parseFromString(userInfo.innerHTML, "text/xml");
let data = self.userDetails.data
data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo)
@ -365,10 +364,10 @@ export class OsmConnection {
)
})
}
const auth = this.auth
const content = { lat, lon, text }
const response = await this.post("notes.json", JSON.stringify(content), {
"Content-Type": "application/json",
// Lat and lon must be strings for the API to accept it
const content = `lat=${lat}&lon=${lon}&text=${encodeURIComponent(text)}`
const response = await this.post("notes.json", content, {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
})
const parsed = JSON.parse(response)
const id = parsed.properties
@ -414,7 +413,6 @@ export class OsmConnection {
'"\r\nContent-Type: application/gpx+xml',
}
const auth = this.auth
const boundary = "987654"
let body = ""
@ -467,6 +465,18 @@ export class OsmConnection {
})
}
/**
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
callback(previousLocation.data)
})
}
private updateAuthObject() {
let pwaStandAloneMode = false
try {
@ -502,18 +512,6 @@ export class OsmConnection {
})
}
/**
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
callback(previousLocation.data)
})
}
private CheckForMessagesContinuously() {
const self = this
if (this.isChecking) {

View file

@ -5,7 +5,7 @@ import OsmToGeoJson from "osmtogeojson"
import { Feature, LineString, Polygon } from "geojson"
export abstract class OsmObject {
private static defaultBackend = "https://www.openstreetmap.org/"
private static defaultBackend = "https://api.openstreetmap.org/"
protected static backendURL = OsmObject.defaultBackend
private static polygonFeatures = OsmObject.constructPolygonFeatures()
type: "node" | "way" | "relation"

View file

@ -17,7 +17,7 @@ export default class OsmObjectDownloader {
private historyCache = new Map<string, UIEventSource<OsmObject[]>>()
constructor(
backend: string = "https://www.openstreetmap.org",
backend: string = "https://api.openstreetmap.org",
changes?: {
readonly pendingChanges: UIEventSource<ChangeDescription[]>
readonly isUploading: Store<boolean>

View file

@ -219,7 +219,7 @@ class RewriteMetaInfoTags extends SimpleMetaTagger {
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
feature.properties._backend = feature.properties._backend ?? "https://openstreetmap.org"
feature.properties._backend = feature.properties._backend ?? "https://api.openstreetmap.org"
return movedSomething
}
}
@ -339,21 +339,37 @@ export default class SimpleMetaTaggers {
)
private static levels = new InlineMetaTagger(
{
doc: "Extract the 'level'-tag into a normalized, ';'-separated value",
doc: "Extract the 'level'-tag into a normalized, ';'-separated value called '_level' (which also includes 'repeat_on'). The `level` tag (without underscore) will be normalized with only the value of `level`.",
keys: ["_level"],
},
(feature) => {
if (feature.properties["level"] === undefined) {
return false
let somethingChanged = false
if (feature.properties["level"] !== undefined) {
const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l !== newValue) {
feature.properties["level"] = newValue
somethingChanged = true
}
}
const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l === newValue) {
return false
if (feature.properties["repeat_on"] !== undefined) {
const l = feature.properties["repeat_on"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l !== newValue) {
feature.properties["repeat_on"] = newValue
somethingChanged = true
}
}
feature.properties["level"] = newValue
return true
const combined = TagUtils.LevelsParser(
(feature.properties.repeat_on ?? "") + ";" + (feature.properties.level ?? "")
).join(";")
if (feature.properties["_level"] !== combined) {
feature.properties["_level"] = combined
somethingChanged = true
}
return somethingChanged
}
)
private static canonicalize = new InlineMetaTagger(

View file

@ -198,7 +198,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.backgroundLayerId = QueryParameters.GetQueryParameter(
"background",
layoutToUse?.defaultBackgroundId ?? "osm",
layoutToUse?.defaultBackgroundId,
"The id of the background layer to start with"
)
}

View file

@ -56,6 +56,7 @@ export class GeoLocationState {
* Used to detect a permission retraction
*/
private readonly _grantedThisSession: UIEventSource<boolean> = new UIEventSource<boolean>(false)
constructor() {
const self = this
@ -67,7 +68,6 @@ export class GeoLocationState {
if (state === "prompt" && self._grantedThisSession.data) {
// This is _really_ weird: we had a grant earlier, but it's 'prompt' now?
// This means that the rights have been revoked again!
// self.permission.setData("denied")
self._previousLocationGrant.setData("false")
self.permission.setData("denied")
self.currentGPSLocation.setData(undefined)
@ -93,6 +93,54 @@ export class GeoLocationState {
}
}
/**
* Requests the user to allow access to their position.
* When granted, will be written to the 'geolocationState'.
* This class will start watching
*/
public requestPermission() {
this.requestPermissionAsync()
}
/**
* Requests the user to allow access to their position.
* When granted, will be written to the 'geolocationState'.
* This class will start watching
*/
public async requestPermissionAsync() {
if (typeof navigator === "undefined") {
// Not compatible with this browser
this.permission.setData("denied")
return
}
if (this.permission.data !== "prompt" && this.permission.data !== "requested") {
// If the user denies the first prompt, revokes the deny and then tries again, we have to run the flow as well
// Hence that we continue the flow if it is "requested"
return
}
this.permission.setData("requested")
try {
const status = await navigator?.permissions?.query({ name: "geolocation" })
const self = this
console.log("Got geolocation state", status.state)
if (status.state === "granted" || status.state === "denied") {
self.permission.setData(status.state)
self.startWatching()
return
}
status.addEventListener("change", (e) => {
self.permission.setData(status.state)
})
// The code above might have reset it to 'prompt', but we _did_ request permission!
this.permission.setData("requested")
// We _must_ call 'startWatching', as that is the actual trigger for the popup...
self.startWatching()
} catch (e) {
console.error("Could not get permission:", e)
}
}
/**
* Installs the listener for updates
* @private
@ -112,42 +160,4 @@ export class GeoLocationState {
}
)
}
/**
* Requests the user to allow access to their position.
* When granted, will be written to the 'geolocationState'.
* This class will start watching
*/
public requestPermission() {
if (typeof navigator === "undefined") {
// Not compatible with this browser
this.permission.setData("denied")
return
}
if (this.permission.data !== "prompt" && this.permission.data !== "requested") {
// If the user denies the first prompt, revokes the deny and then tries again, we have to run the flow as well
// Hence that we continue the flow if it is "requested"
return
}
this.permission.setData("requested")
try {
navigator?.permissions
?.query({ name: "geolocation" })
.then((status) => {
console.log("Status update: received geolocation permission is ", status.state)
this.permission.setData(status.state)
const self = this
status.onchange = function () {
self.permission.setData(status.state)
}
this.permission.setData("requested")
// We _must_ call 'startWatching', as that is the actual trigger for the popup...
self.startWatching()
})
.catch((e) => console.error("Could not get geopermission", e))
} catch (e) {
console.error("Could not get permission:", e)
}
}
}

View file

@ -66,11 +66,11 @@ export default class LayerState {
}
const t = Translations.t.general.levelSelection
const conditionsOrred = [
new Tag("level", "" + level),
new RegexTag("level", new RegExp("(.*;)?" + level + "(;.*)?")),
new Tag("_level", "" + level),
new RegexTag("_level", new RegExp("(.*;)?" + level + "(;.*)?")),
]
if (level === "0") {
conditionsOrred.push(new Tag("level", "")) // No level tag is the same as level '0'
conditionsOrred.push(new Tag("_level", "")) // No level tag is the same as level '0'
}
console.log("Setting levels filter to", conditionsOrred)
this.globalFilters.data.push({

View file

@ -16,6 +16,8 @@ import LinkToWeblate from "../../UI/Base/LinkToWeblate"
import FeatureSwitchState from "./FeatureSwitchState"
import Constants from "../../Models/Constants"
import { QueryParameters } from "../Web/QueryParameters"
import { ThemeMetaTagging } from "./UserSettingsMetaTagging"
import { MapProperties } from "../../Models/MapProperties"
/**
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
@ -40,6 +42,10 @@ export default class UserRelatedState {
public readonly fixateNorth: UIEventSource<undefined | "yes">
public readonly homeLocation: FeatureSource
public readonly language: UIEventSource<string>
public readonly preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
public readonly imageLicense: UIEventSource<string>
/**
* The number of seconds that the GPS-locations are stored in memory.
* Time in seconds
@ -51,16 +57,23 @@ export default class UserRelatedState {
/**
* Preferences as tags exposes many preferences and state properties as record.
* This is used to bridge the internal state with the usersettings.json layerconfig file
*
* Some metainformation that should not be edited starts with a single underscore
* Constants and query parameters start with two underscores
* Note: these are linked via OsmConnection.preferences which exports all preferences as UIEventSource
*/
public readonly preferencesAsTags: UIEventSource<Record<string, string>>
private readonly _mapProperties: MapProperties
constructor(
osmConnection: OsmConnection,
availableLanguages?: string[],
layout?: LayoutConfig,
featureSwitches?: FeatureSwitchState
featureSwitches?: FeatureSwitchState,
mapProperties?: MapProperties
) {
this.osmConnection = osmConnection
this._mapProperties = mapProperties
{
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
this.osmConnection.GetPreference("translation-mode", "false")
@ -89,11 +102,22 @@ export default class UserRelatedState {
)
this.language = this.osmConnection.GetPreference("language")
this.showTags = <UIEventSource<any>>this.osmConnection.GetPreference("show_tags")
this.fixateNorth = <any>this.osmConnection.GetPreference("fixate-north")
this.fixateNorth = <UIEventSource<"yes">>this.osmConnection.GetPreference("fixate-north")
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove")
)
this.preferredBackgroundLayer = this.osmConnection.GetPreference(
"preferred-background-layer",
undefined,
{
documentation:
"The ID of a layer or layer category that MapComplete uses by default",
}
)
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
documentation: "The license under which new images are uploaded",
})
this.installedUserThemes = this.InitInstalledUserThemes()
this.homeLocation = this.initHomeLocation()
@ -245,6 +269,7 @@ export default class UserRelatedState {
): UIEventSource<Record<string, string>> {
const amendedPrefs = new UIEventSource<Record<string, string>>({
_theme: layout?.id,
"_theme:backgroundLayer": layout?.defaultBackgroundId,
_backend: this.osmConnection.Backend(),
_applicationOpened: new Date().toISOString(),
_supports_sharing:
@ -279,7 +304,6 @@ export default class UserRelatedState {
amendedPrefs.ping()
console.log("Amended prefs are:", amendedPrefs.data)
})
const usersettingsConfig = UserRelatedState.usersettingsConfig
const translationMode = osmConnection.GetPreference("translation-mode")
Locale.language.mapD(
@ -326,30 +350,14 @@ export default class UserRelatedState {
},
[translationMode]
)
const usersettingMetaTagging = new ThemeMetaTagging()
osmConnection.userDetails.addCallback((userDetails) => {
for (const k in userDetails) {
amendedPrefs.data["_" + k] = "" + userDetails[k]
}
for (const [name, code, _] of usersettingsConfig.calculatedTags) {
try {
let result = new Function("feat", "return " + code + ";")({
properties: amendedPrefs.data,
})
if (result !== undefined && result !== "" && result !== null) {
if (typeof result !== "string") {
result = JSON.stringify(result)
}
amendedPrefs.data[name] = result
}
} catch (e) {
console.error(
"Calculating a tag for userprofile-settings failed for variable",
name,
e
)
}
}
usersettingMetaTagging.metaTaggging_for_usersettings({ properties: amendedPrefs.data })
const simplifiedName = userDetails.name.toLowerCase().replace(/\s+/g, "")
const isTranslator = translators.contributors.find(
@ -403,6 +411,11 @@ export default class UserRelatedState {
}
}
this._mapProperties?.rasterLayer?.addCallbackAndRun((l) => {
amendedPrefs.data["__current_background"] = l?.properties?.id
amendedPrefs.ping()
})
return amendedPrefs
}
}

View file

@ -0,0 +1,42 @@
import { Utils } from "../../Utils"
/** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */
export class ThemeMetaTagging {
public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () =>
feat.properties._description
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)
?.at(1)
)
Utils.AddLazyProperty(
feat.properties,
"_d",
() => feat.properties._description?.replace(/&lt;/g, "<")?.replace(/&gt;/g, ">") ?? ""
)
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.href.match(/mastodon|en.osm.town/) !== null
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(
feat.properties,
"_mastodon_candidate",
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
)
feat.properties["__current_backgroun"] = "initial_value"
}
}

View file

@ -286,7 +286,7 @@ export class TagUtils {
"Invalid type to flatten the multiAnswer: key is a regex too",
tagsFilter
)
throw "Invalid type to FlattenMultiAnswer"
throw "Invalid type to FlattenMultiAnswer: key is a regex too"
}
const keystr = <string>key
if (keyValues[keystr] === undefined) {
@ -297,7 +297,10 @@ export class TagUtils {
}
console.error("Invalid type to flatten the multiAnswer", tagsFilter)
throw "Invalid type to FlattenMultiAnswer"
throw (
"Invalid type to FlattenMultiAnswer, not one of RegexTag, Tag or And: " +
tagsFilter.asHumanString(false, false, {})
)
}
return keyValues
}
@ -320,6 +323,9 @@ export class TagUtils {
/**
* Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set.
*
* @see MatchesMultiAnswer to do the reverse
*
* E.g:
*
* const tag = TagUtils.ParseUploadableTag({"and": [
@ -665,13 +671,22 @@ export class TagUtils {
* TagUtils.LevelsParser("-1") // => ["-1"]
* TagUtils.LevelsParser("0;-1") // => ["0", "-1"]
* TagUtils.LevelsParser(undefined) // => []
* TagUtils.LevelsParser("") // => []
* TagUtils.LevelsParser(";") // => []
*
*/
public static LevelsParser(level: string): string[] {
if (level === undefined || level === null) {
return []
}
let spec = Utils.NoNull([level])
spec = [].concat(...spec.map((s) => s?.split(";")))
spec = [].concat(
...spec.map((s) => {
s = s.trim()
if (s === "") {
return undefined
}
if (s.indexOf("-") < 0 || s.startsWith("-")) {
return s
}

View file

@ -357,14 +357,18 @@ class ListenerTracker<T> {
let toDelete = undefined
let startTime = new Date().getTime() / 1000
for (const callback of this._callbacks) {
if (callback(data) === true) {
// This callback wants to be deleted
// Note: it has to return precisely true in order to avoid accidental deletions
if (toDelete === undefined) {
toDelete = [callback]
} else {
toDelete.push(callback)
try {
if (callback(data) === true) {
// This callback wants to be deleted
// Note: it has to return precisely true in order to avoid accidental deletions
if (toDelete === undefined) {
toDelete = [callback]
} else {
toDelete.push(callback)
}
}
} catch (e) {
console.error("Got an error while running a callback:", e)
}
}
let endTime = new Date().getTime() / 1000
@ -511,7 +515,7 @@ class MappedStore<TIn, T> extends Store<T> {
}
private unregisterFromUpstream() {
console.log("Unregistering callbacks for", this.tag)
console.debug("Unregistering callbacks for", this.tag)
this._callbacksAreRegistered = false
this._unregisterFromUpstream()
this._unregisterFromExtraStores?.forEach((unr) => unr())

View file

@ -54,6 +54,7 @@ export class MangroveIdentity {
export default class FeatureReviews {
private static readonly _featureReviewsCache: Record<string, FeatureReviews> = {}
public readonly subjectUri: Store<string>
public readonly average: Store<number | null>
private readonly _reviews: UIEventSource<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
new UIEventSource([])
public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
@ -124,6 +125,23 @@ export default class FeatureReviews {
console.log("Could not fetch reviews for partially incorrect query ", sub)
}
})
this.average = this._reviews.map((reviews) => {
if (!reviews) {
return null
}
if (reviews.length === 0) {
return null
}
let sum = 0
let count = 0
for (const review of reviews) {
if (review.rating !== undefined) {
count++
sum += review.rating
}
}
return Math.round(sum / count)
})
}
/**
@ -211,6 +229,8 @@ export default class FeatureReviews {
hasNew = true
}
if (hasNew) {
self._reviews.data.sort((a, b) => b.iat - a.iat) // Sort with most recent first
self._reviews.ping()
}
}

View file

@ -985,6 +985,27 @@ export default class PlantNet {
}
}
export interface PlantNetSpeciesMatch {
score: number
gbif: { id: string /*Actually a number*/ }
species: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
genus: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
family: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
commonNames: string[]
scientificName: string
}
}
export interface PlantNetResult {
query: {
project: string
@ -995,26 +1016,7 @@ export interface PlantNetResult {
language: string
preferedReferential: string
bestMatch: string
results: {
score: number
gbif: { id: string /*Actually a number*/ }
species: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
genus: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
family: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
commonNames: string[]
scientificName: string
}
}[]
results: PlantNetSpeciesMatch[]
version: string
remainingIdentificationRequests: number
}

View file

@ -159,7 +159,7 @@ export default class Wikidata {
*/
public static async searchAdvanced(
text: string,
options: WikidataAdvancedSearchoptions
options?: WikidataAdvancedSearchoptions
): Promise<
{
id: string
@ -185,7 +185,7 @@ export default class Wikidata {
?num wikibase:apiOrdinal true .
bd:serviceParam wikibase:limit ${
Math.round(
(options.maxCount ?? 20) * 1.5
(options?.maxCount ?? 20) * 1.5
) /*Some padding for disambiguation pages */
} .
?label wikibase:apiOutput mwapi:label .
@ -193,7 +193,7 @@ export default class Wikidata {
}
${instanceOf}
${minusPhrases.join("\n ")}
} ORDER BY ASC(?num) LIMIT ${options.maxCount ?? 20}`
} ORDER BY ASC(?num) LIMIT ${options?.maxCount ?? 20}`
const url = wds.sparqlQuery(sparql)
const result = await Utils.downloadJson(url)

View file

@ -73,7 +73,6 @@ export default class Wikipedia {
if (cached) {
return cached
}
console.log("Constructing store for", cachekey)
const store = new UIEventSource<FullWikipediaDetails>({}, cachekey)
Wikipedia._fullDetailsCache.set(cachekey, store)
@ -123,12 +122,15 @@ export default class Wikipedia {
}
const wikipedia = new Wikipedia({ language: data.language })
wikipedia.GetArticleHtml(data.pagename).then((article) => {
article = Utils.purify(article)
data.fullArticle = article
const content = document.createElement("div")
content.innerHTML = article
const firstParagraph = content.getElementsByTagName("p").item(0)
data.firstParagraph = firstParagraph.innerHTML
content.removeChild(firstParagraph)
if (firstParagraph) {
data.firstParagraph = firstParagraph.innerHTML
content.removeChild(firstParagraph)
}
data.restOfArticle = content.innerHTML
store.ping()
})
@ -194,53 +196,6 @@ export default class Wikipedia {
encodeURIComponent(searchTerm)
return (await Utils.downloadJson(url))["query"]["search"]
}
/**
* Searches via 'index.php' and scrapes the result.
* This gives better results then via the API
* @param searchTerm
*/
public async searchViaIndex(
searchTerm: string
): Promise<{ title: string; snippet: string; url: string }[]> {
const url = `${this.backend}/w/index.php?search=${encodeURIComponent(searchTerm)}&ns0=1`
const result = await Utils.downloadAdvanced(url)
if (result["redirect"]) {
const targetUrl = result["redirect"]
// This is an exact match
return [
{
title: this.extractPageName(targetUrl)?.trim(),
url: targetUrl,
snippet: "",
},
]
}
if (result["error"]) {
throw "Could not download: " + JSON.stringify(result)
}
const el = document.createElement("html")
el.innerHTML = result["content"].replace(/href="\//g, 'href="' + this.backend + "/")
const searchResults = el.getElementsByClassName("mw-search-results")
const individualResults = Array.from(
searchResults[0]?.getElementsByClassName("mw-search-result") ?? []
)
return individualResults.map((result) => {
const toRemove = Array.from(result.getElementsByClassName("searchalttitle"))
for (const toRm of toRemove) {
toRm.parentElement.removeChild(toRm)
}
return {
title: result
.getElementsByClassName("mw-search-result-heading")[0]
.textContent.trim(),
url: result.getElementsByTagName("a")[0].href,
snippet: result.getElementsByClassName("searchresult")[0].textContent,
}
})
}
/**
* Returns the innerHTML for the given article as string.
* Some cleanup is applied to this.
@ -262,7 +217,7 @@ export default class Wikipedia {
if (response?.parse?.text === undefined) {
return undefined
}
const html = response["parse"]["text"]["*"]
const html = Utils.purify(response["parse"]["text"]["*"])
if (html === undefined) {
return undefined
}