forked from MapComplete/MapComplete
Merge develop
This commit is contained in:
commit
55c6442cac
388 changed files with 16178 additions and 17860 deletions
73
src/Logic/Actors/PreferredRasterLayerSelector.ts
Normal file
73
src/Logic/Actors/PreferredRasterLayerSelector.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
159
src/Logic/ImageProviders/ImageUploadManager.ts
Normal file
159
src/Logic/ImageProviders/ImageUploadManager.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
15
src/Logic/ImageProviders/ImageUploader.ts
Normal file
15
src/Logic/ImageProviders/ImageUploader.ts
Normal 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 }>
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
57
src/Logic/Osm/Actions/LinkImageAction.ts
Normal file
57
src/Logic/Osm/Actions/LinkImageAction.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
src/Logic/State/UserSettingsMetaTagging.ts
Normal file
42
src/Logic/State/UserSettingsMetaTagging.ts
Normal 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(/</g, "<")?.replace(/>/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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue