Merge master

This commit is contained in:
Pieter Vander Vennet 2024-04-02 19:07:11 +02:00
commit 890816d2dd
424 changed files with 40595 additions and 3354 deletions

View file

@ -3,9 +3,10 @@ import Constants from "../../Models/Constants"
import { UIEventSource } from "../UIEventSource"
import { Utils } from "../../Utils"
import { Feature } from "geojson"
import { ImageUploadManager } from "../ImageProviders/ImageUploadManager"
export default class PendingChangesUploader {
constructor(changes: Changes, selectedFeature: UIEventSource<Feature>) {
constructor(changes: Changes, selectedFeature: UIEventSource<Feature>, uploader : ImageUploadManager) {
changes.pendingChanges
.stabilized(Constants.updateTimeoutSec * 1000)
.addCallback(() => changes.flushChanges("Flushing changes due to timeout"))
@ -48,7 +49,9 @@ export default class PendingChangesUploader {
}
function onunload(e) {
if (changes.pendingChanges.data.length == 0) {
const pendingChanges = changes.pendingChanges.data.length
const uploadingImages = uploader.isUploading.data
if (pendingChanges == 0 && !uploadingImages) {
return
}
changes.flushChanges("onbeforeunload - probably closing or something similar")

View file

@ -96,6 +96,9 @@ export default class FeaturePropertiesStore {
if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
const element = this._elements.get(oldId)
if(!element || element.data === undefined){
return
}
element.data._deleted = "yes"
element.ping()
return

View file

@ -40,7 +40,7 @@ export default class GenericImageProvider extends ImageProvider {
return undefined
}
public DownloadAttribution(url: string) {
public DownloadAttribution(_) {
return undefined
}
}

View file

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

View file

@ -24,6 +24,7 @@ export class ImageUploadManager {
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map()
private readonly _osmConnection: OsmConnection
private readonly _changes: Changes
public readonly isUploading: Store<boolean>
constructor(
layout: LayoutConfig,
@ -37,6 +38,13 @@ export class ImageUploadManager {
this._layout = layout
this._osmConnection = osmConnection
this._changes = changes
const failed = this.getCounterFor(this._uploadFailed, "*")
const done = this.getCounterFor(this._uploadFinished, "*")
this.isUploading = this.getCounterFor(this._uploadStarted, "*").map(startedCount => {
return startedCount > failed.data + done.data
}, [failed, done])
}
/**
@ -101,7 +109,6 @@ export class ImageUploadManager {
"osmid:" + tags.id,
].join("\n")
console.log("Upload done, creating ")
const action = await this.uploadImageWithLicense(
featureId,
title,
@ -110,6 +117,9 @@ export class ImageUploadManager {
targetKey,
tags?.data?.["_orig_theme"]
)
if (!action) {
return
}
if (!isNaN(Number(featureId))) {
// This is a map note
const url = action._url
@ -145,6 +155,7 @@ export class ImageUploadManager {
} catch (e) {
console.error("Could again not upload image due to", e)
this.increaseCountFor(this._uploadFailed, featureId)
return undefined
}
}
console.log("Uploading done, creating action for", featureId)

View file

@ -78,16 +78,23 @@ export class Imgur extends ImageProvider implements ImageUploader {
*
* const data = {"data":{"id":"I9t6B7B","title":"Station Knokke","description":"author:Pieter Vander Vennet\r\nlicense:CC-BY 4.0\r\nosmid:node\/9812712386","datetime":1655052078,"type":"image\/jpeg","animated":false,"width":2400,"height":1795,"size":910872,"views":2,"bandwidth":1821744,"vote":null,"favorite":false,"nsfw":false,"section":null,"account_url":null,"account_id":null,"is_ad":false,"in_most_viral":false,"has_sound":false,"tags":[],"ad_type":0,"ad_url":"","edited":"0","in_gallery":false,"link":"https:\/\/i.imgur.com\/I9t6B7B.jpg","ad_config":{"safeFlags":["not_in_gallery","share"],"highRiskFlags":[],"unsafeFlags":["sixth_mod_unsafe"],"wallUnsafeFlags":[],"showsAds":false,"showAdLevel":1}},"success":true,"status":200}
* Utils.injectJsonDownloadForTests("https://api.imgur.com/3/image/E0RuAK3", data)
* const licenseInfo = await Imgur.singleton.DownloadAttribution("https://i.imgur.com/E0RuAK3.jpg")
* const licenseInfo = await Imgur.singleton.DownloadAttribution({url: "https://i.imgur.com/E0RuAK3.jpg"})
* const expected = new LicenseInfo()
* expected.licenseShortName = "CC-BY 4.0"
* expected.artist = "Pieter Vander Vennet"
* expected.date = new Date(1655052078000)
* expected.views = 2
* licenseInfo // => expected
* const licenseInfoJpeg = await Imgur.singleton.DownloadAttribution({url:"https://i.imgur.com/E0RuAK3.jpeg"})
* licenseInfoJpeg // => expected
* const licenseInfoUpperCase = await Imgur.singleton.DownloadAttribution({url: "https://i.imgur.com/E0RuAK3.JPEG"})
* licenseInfoUpperCase // => expected
*
*
*/
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]
public async DownloadAttribution(providedImage: {url: string}): Promise<LicenseInfo> {
const url = providedImage.url
const hash = url.substr("https://i.imgur.com/".length).split(/\.jpe?g/i)[0]
const apiUrl = "https://api.imgur.com/3/image/" + hash
const response = await Utils.downloadJsonCached(apiUrl, 365 * 24 * 60 * 60, {

View file

@ -133,12 +133,21 @@ export class Mapillary extends ImageProvider {
return [this.PrepareUrlAsync(key, value)]
}
public async DownloadAttribution(_: string): Promise<LicenseInfo> {
public async DownloadAttribution(providedImage: ProvidedImage): Promise<LicenseInfo> {
const mapillaryId = providedImage.id
const metadataUrl =
"https://graph.mapillary.com/" +
mapillaryId +
"?fields=thumb_1024_url,thumb_original_url,captured_at,creator&access_token=" +
Constants.mapillary_client_token_v4
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
const license = new LicenseInfo()
license.artist = undefined
license.artist = response["creator"]["username"]
license.license = "CC BY-SA 4.0"
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true
license.date = new Date(response["captured_at"])
return license
}
@ -151,16 +160,19 @@ export class Mapillary extends ImageProvider {
const metadataUrl =
"https://graph.mapillary.com/" +
mapillaryId +
"?fields=thumb_1024_url,thumb_original_url&access_token=" +
"?fields=thumb_1024_url,thumb_original_url,captured_at,creator&access_token=" +
Constants.mapillary_client_token_v4
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
const url = <string>response["thumb_1024_url"]
const url_hd = <string>response["thumb_original_url"]
return {
const date = new Date()
date.setTime(response["captured_at"])
return <ProvidedImage> {
id: "" + mapillaryId,
url,
url_hd,
provider: this,
date,
key,
}
}

View file

@ -53,7 +53,7 @@ export class WikidataImageProvider extends ImageProvider {
return allImages
}
public DownloadAttribution(_: string): Promise<any> {
public DownloadAttribution(_): Promise<any> {
throw new Error("Method not implemented; shouldn't be needed!")
}
}

View file

@ -13,7 +13,7 @@ export class WikimediaImageProvider extends ImageProvider {
public static readonly singleton = new WikimediaImageProvider()
public static readonly apiUrls = [
"https://commons.wikimedia.org/wiki/",
"https://upload.wikimedia.org",
"https://upload.wikimedia.org"
]
public static readonly commonsPrefixes = [...WikimediaImageProvider.apiUrls, "File:"]
private readonly commons_key = "wikimedia_commons"
@ -31,13 +31,18 @@ export class WikimediaImageProvider extends ImageProvider {
return path.substring(path.lastIndexOf("/") + 1)
}
private static PrepareUrl(value: string): string {
private static PrepareUrl(value: string, useHd = false): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
return value
}
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
const baseUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
value
)}?width=500&height=400`
)}`
if (useHd) {
return baseUrl
}
return baseUrl + `?width=500&height=400`
}
private static startsWithCommonsPrefix(value: string): boolean {
@ -109,8 +114,8 @@ export class WikimediaImageProvider extends ImageProvider {
return [Promise.resolve(this.UrlForImage("File:" + value))]
}
public async DownloadAttribution(filename: string): Promise<LicenseInfo> {
filename = WikimediaImageProvider.ExtractFileName(filename)
public async DownloadAttribution(img: ProvidedImage): Promise<LicenseInfo> {
const filename = WikimediaImageProvider.ExtractFileName(img.url)
if (filename === "") {
return undefined
@ -166,9 +171,10 @@ export class WikimediaImageProvider extends ImageProvider {
}
return {
url: WikimediaImageProvider.PrepareUrl(image),
url_hd: WikimediaImageProvider.PrepareUrl(image, true),
key: undefined,
provider: this,
id: image,
id: image
}
}
}

View file

@ -5,6 +5,20 @@ export default class ChangeLocationAction extends OsmChangeAction {
private readonly _id: number
private readonly _newLonLat: [number, number]
private readonly _meta: { theme: string; reason: string }
static metatags: {
readonly key?: string
readonly value?: string
readonly docs: string
readonly changeType: string[]
readonly specialMotivation?: boolean
}[] = [
{
value: "relocated|improve_accuraccy|...",
docs: "Will appear if the ",
changeType: ["move"],
specialMotivation: true,
},
]
constructor(
id: string,

View file

@ -4,6 +4,27 @@ import { TagsFilter } from "../../Tags/TagsFilter"
import { OsmTags } from "../../../Models/OsmFeature"
export default class ChangeTagAction extends OsmChangeAction {
static metatags: {
readonly key?: string
readonly value?: string
readonly docs: string
readonly changeType: string[]
readonly specialMotivation?: boolean
}[] = [
{
changeType: ["answer"],
docs: "Indicates the number of questions that have been answered",
},
{ changeType: ["soft-delete"], docs: "Indicates the number of soft-deleted items" },
{
changeType: ["add-image"],
docs: "Indicates the number of images that have been added in this changeset",
},
{
changeType: ["link-image"],
docs: "Indicates the number of images that have been linked in this changeset",
},
]
private readonly _elementId: string
/**
* The tags to apply onto the object

View file

@ -13,6 +13,12 @@ import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
import { OsmConnection } from "./OsmConnection"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import OsmObjectDownloader from "./OsmObjectDownloader"
import Combine from "../../UI/Base/Combine"
import BaseUIElement from "../../UI/BaseUIElement"
import Title from "../../UI/Base/Title"
import Table from "../../UI/Base/Table"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
/**
* Handles all changes made to OSM.
@ -99,6 +105,97 @@ export class Changes {
return changes
}
public static getDocs(): BaseUIElement {
function addSource(items: any[], src: string) {
items.forEach((i) => {
i["source"] = src
})
return items
}
const metatagsDocs: {
key?: string
value?: string
docs: string
changeType?: string[]
specialMotivation?: boolean
source?: string
}[] = [
...addSource(
[
{
key: "comment",
docs: "The changeset comment. Will be a fixed string, mentioning the theme",
},
{
key: "theme",
docs: "The name of the theme that was used to create this change. ",
},
{
key: "source",
value: "survey",
docs: "The contributor had their geolocation enabled while making changes",
},
{
key: "change_within_{distance}",
docs: "If the contributor enabled their geolocation, this will hint how far away they were from the objects they edited. This gives an indication of proximity and if they truly surveyed or were armchair-mapping",
},
{
key: "change_over_{distance}",
docs: "If the contributor enabled their geolocation, this will hint how far away they were from the objects they edited. If they were over 5000m away, the might have been armchair-mapping",
},
{
key: "created_by",
value: "MapComplete <version>",
docs: "The piece of software used to create this changeset; will always start with MapComplete, followed by the version number",
},
{
key: "locale",
value: "en|nl|de|...",
docs: "The code of the language that the contributor used MapComplete in. Hints what language the user speaks.",
},
{
key: "host",
value: "https://mapcomplete.org/<theme>",
docs: "The URL that the contributor used to make changes. One can see the used instance with this",
},
{
key: "imagery",
docs: "The identifier of the used background layer, this will probably be an identifier from the [editor layer index](https://github.com/osmlab/editor-layer-index)",
},
],
"default"
),
...addSource(ChangeTagAction.metatags, "ChangeTag"),
...addSource(ChangeLocationAction.metatags, "ChangeLocation"),
// TODO
/*
...DeleteAction.metatags,
...LinkImageAction.metatags,
...OsmChangeAction.metatags,
...RelationSplitHandler.metatags,
...ReplaceGeometryAction.metatags,
...SplitAction.metatags,*/
]
return new Combine([
new Title("Metatags on a changeset", 1),
"You might encounter the following metatags on a changeset:",
new Table(
["key", "value", "explanation", "source"],
metatagsDocs.map(({ key, value, docs, source, changeType, specialMotivation }) => [
key ?? changeType?.join(", ") ?? "",
value,
new Combine([
docs,
specialMotivation
? "This might give a reason per modified node or way"
: "",
]),
source,
])
),
])
}
private static GetNeededIds(changes: ChangeDescription[]) {
return Utils.Dedup(changes.filter((c) => c.id >= 0).map((c) => c.type + "/" + c.id))
}

View file

@ -6,6 +6,14 @@ import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
import DeleteAction from "./Actions/DeleteAction"
import LinkImageAction from "./Actions/LinkImageAction"
import OsmChangeAction from "./Actions/OsmChangeAction"
import RelationSplitHandler from "./Actions/RelationSplitHandler"
import ReplaceGeometryAction from "./Actions/ReplaceGeometryAction"
import SplitAction from "./Actions/SplitAction"
export interface ChangesetTag {
key: string

View file

@ -52,6 +52,7 @@ export class OsmConnection {
private readonly _iframeMode: Boolean | boolean
private readonly _singlePage: boolean
private isChecking = false
private readonly _doCheckRegularly
constructor(options?: {
dryRun?: Store<boolean>
@ -59,12 +60,17 @@ export class OsmConnection {
oauth_token?: UIEventSource<string>
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean
attemptLogin?: true | boolean
attemptLogin?: true | boolean,
/**
* If true: automatically check if we're still online every 5 minutes + fetch messages
*/
checkOnlineRegularly?: true | boolean
}) {
options ??= {}
this.fakeUser = options?.fakeUser ?? false
this._singlePage = options?.singlePage ?? true
this._oauth_config = Constants.osmAuthConfig
this._doCheckRegularly = options?.checkOnlineRegularly ?? true
console.debug("Using backend", this._oauth_config.url)
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top
@ -91,9 +97,11 @@ export class OsmConnection {
ud.name = "Fake user"
ud.totalMessages = 42
ud.languages = ["en"]
this.loadingStatus.setData("logged-in")
}
const self = this
this.UpdateCapabilities()
this.isLoggedIn = this.userDetails.map(
(user) =>
user.loggedIn &&
@ -111,11 +119,11 @@ export class OsmConnection {
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.updateAuthObject()
if(!this.fakeUser){
self.CheckForMessagesContinuously()
}
this.preferencesHandler = new OsmPreferences(
this.auth,
<any /*This is needed to make the tests work*/>this
)
this.preferencesHandler = new OsmPreferences(this.auth, this, this.fakeUser)
if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data)
@ -187,23 +195,27 @@ export class OsmConnection {
const self = this
console.log("Trying to log in...")
this.updateAuthObject()
LocalStorageSource.Get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href
)
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/details",
path: "/api/0.6/user/details"
},
function (err, details: XMLDocument) {
function(err, details: XMLDocument) {
if (err != null) {
console.log(err)
console.log("Could not login due to:", err)
self.loadingStatus.setData("error")
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
self.auth.logout()
self.LogOut()
} else {
console.log("Other error. Status:", err.status)
self.apiIsOnline.setData("unreachable")
}
return
}
@ -213,8 +225,6 @@ export class OsmConnection {
return
}
self.CheckForMessagesContinuously()
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0]
@ -303,12 +313,12 @@ export class OsmConnection {
<any>{
method,
options: {
header,
header
},
content,
path: `/api/0.6/${path}`,
path: `/api/0.6/${path}`
},
function (err, response) {
function(err, response) {
if (err !== null) {
error(err)
} else {
@ -388,7 +398,7 @@ export class OsmConnection {
"notes.json",
content,
{
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
},
true
)
@ -400,6 +410,7 @@ export class OsmConnection {
}
public static GpxTrackVisibility = ["private", "public", "trackable", "identifiable"] as const
public async uploadGpxTrack(
gpx: string,
options: {
@ -428,7 +439,7 @@ export class OsmConnection {
file: gpx,
description: options.description,
tags: options.labels?.join(",") ?? "",
visibility: options.visibility,
visibility: options.visibility
}
if (!contents.description) {
@ -436,9 +447,9 @@ export class OsmConnection {
}
const extras = {
file:
'; filename="' +
"; filename=\"" +
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
'"\r\nContent-Type: application/gpx+xml',
"\"\r\nContent-Type: application/gpx+xml"
}
const boundary = "987654"
@ -446,7 +457,7 @@ export class OsmConnection {
let body = ""
for (const key in contents) {
body += "--" + boundary + "\r\n"
body += 'Content-Disposition: form-data; name="' + key + '"'
body += "Content-Disposition: form-data; name=\"" + key + "\""
if (extras[key] !== undefined) {
body += extras[key]
}
@ -457,7 +468,7 @@ export class OsmConnection {
const response = await this.post("gpx/create", body, {
"Content-Type": "multipart/form-data; boundary=" + boundary,
"Content-Length": body.length,
"Content-Length": body.length
})
const parsed = JSON.parse(response)
console.log("Uploaded GPX track", parsed)
@ -480,9 +491,9 @@ export class OsmConnection {
{
method: "POST",
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`,
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`
},
function (err, _) {
function(err, _) {
if (err !== null) {
error(err)
} else {
@ -497,7 +508,7 @@ export class OsmConnection {
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () {
this.auth.authenticate(function() {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
@ -536,7 +547,7 @@ export class OsmConnection {
? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html",
singlepage: !standalone,
auto: true,
auto: true
})
}
@ -545,26 +556,44 @@ export class OsmConnection {
if (this.isChecking) {
return
}
this.isChecking = true
Stores.Chronic(5 * 60 * 1000).addCallback((_) => {
if (self.isLoggedIn.data) {
Stores.Chronic(3 * 1000).addCallback((_) => {
if (!(self.apiIsOnline.data === "unreachable" || self.apiIsOnline.data === "offline")) {
return
}
try {
console.log("Api is offline - trying to reconnect...")
self.AttemptLogin()
} catch (e) {
console.log("Could not login due to", e)
}
})
this.isChecking = true
if (!this._doCheckRegularly) {
return
}
Stores.Chronic(60 * 5 * 1000).addCallback((_) => {
if (self.isLoggedIn.data) {
try {
self.AttemptLogin()
} catch (e) {
console.log("Could not login due to", e)
}
}
})
}
private UpdateCapabilities(): void {
const self = this
if (this.fakeUser) {
return
}
this.FetchCapabilities().then(({ api, gpx }) => {
self.apiIsOnline.setData(api)
self.gpxServiceIsOnline.setData(gpx)
this.apiIsOnline.setData(api)
this.gpxServiceIsOnline.setData(gpx)
})
}
private readonly _userInfoCache: Record<number, any> = {}
public async getInformationAboutUser(id: number): Promise<{
id: number
display_name: string
@ -587,6 +616,7 @@ export class OsmConnection {
this._userInfoCache[id] = parsed
return parsed
}
private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> {
if (Utils.runningFromConsole) {
return { api: "online", gpx: "online" }

View file

@ -2,6 +2,9 @@ import { UIEventSource } from "../UIEventSource"
import UserDetails, { OsmConnection } from "./OsmConnection"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
// @ts-ignore
import { osmAuth } from "osm-auth"
import OSMAuthInstance = OSMAuth.OSMAuthInstance
export class OsmPreferences {
/**
@ -17,16 +20,17 @@ export class OsmPreferences {
* @private
*/
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any
private readonly auth: OSMAuthInstance
private userDetails: UIEventSource<UserDetails>
private longPreferences = {}
private readonly _fakeUser: boolean
constructor(auth, osmConnection: OsmConnection) {
constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) {
this.auth = auth
this._fakeUser = fakeUser
this.userDetails = osmConnection.userDetails
const self = this
osmConnection.OnLoggedIn(() => {
self.UpdatePreferences(true)
this.UpdatePreferences(true)
return true
})
}
@ -212,8 +216,21 @@ export class OsmPreferences {
})
}
removeAllWithPrefix(prefix: string) {
for (const key in this.preferences.data) {
if (key.startsWith(prefix)) {
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
console.log("Clearing preference", key)
}
}
this.preferences.ping()
}
private UpdatePreferences(forceUpdate?: boolean) {
const self = this
if (this._fakeUser) {
return
}
this.auth.xhr(
{
method: "GET",
@ -272,13 +289,15 @@ export class OsmPreferences {
}
const self = this
console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15))
if (this._fakeUser) {
return
}
if (v === undefined || v === "") {
this.auth.xhr(
{
method: "DELETE",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
options: { header: { "Content-Type": "text/plain" } },
headers: { "Content-Type": "text/plain" },
},
function (error) {
if (error) {
@ -297,7 +316,7 @@ export class OsmPreferences {
{
method: "PUT",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
options: { header: { "Content-Type": "text/plain" } },
headers: { "Content-Type": "text/plain" },
content: v,
},
function (error) {
@ -311,14 +330,4 @@ export class OsmPreferences {
}
)
}
removeAllWithPrefix(prefix: string) {
for (const key in this.preferences.data) {
if (key.startsWith(prefix)) {
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
console.log("Clearing preference", key)
}
}
this.preferences.ping()
}
}

View file

@ -78,7 +78,7 @@ export class And extends TagsFilter {
return { and: this.and.map((a) => a.asJson()) }
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
asHumanString(linkToWiki?: boolean, shorten?: boolean, properties?: Record<string, string>) {
return this.and
.map((t) => {
let e = t.asHumanString(linkToWiki, shorten, properties)
@ -159,7 +159,7 @@ export class And extends TagsFilter {
return [].concat(...this.and.map((subkeys) => subkeys.usedTags()))
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[] {
const result = []
for (const tagsFilter of this.and) {
result.push(...tagsFilter.asChange(properties))

View file

@ -3,7 +3,7 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { Tag } from "./Tag"
import { ExpressionSpecification } from "maplibre-gl"
export default class ComparingTag implements TagsFilter {
export default class ComparingTag extends TagsFilter {
private readonly _key: string
private readonly _predicate: (value: string) => boolean
private readonly _representation: "<" | ">" | "<=" | ">="
@ -15,13 +15,14 @@ export default class ComparingTag implements TagsFilter {
representation: "<" | ">" | "<=" | ">=",
boundary: string
) {
super()
this._key = key
this._predicate = predicate
this._representation = representation
this._boundary = boundary
}
asChange(_: Record<string, string>): { k: string; v: string }[] {
asChange(_: Readonly<Record<string, string>>): { k: string; v: string }[] {
throw "A comparable tag can not be used to be uploaded to OSM"
}

View file

@ -96,7 +96,7 @@ export class Or extends TagsFilter {
return [].concat(...this.or.map((subkeys) => subkeys.usedTags()))
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[] {
const result = []
for (const tagsFilter of this.or) {
result.push(...tagsFilter.asChange(properties))

View file

@ -13,12 +13,13 @@ import { ExpressionSpecification } from "maplibre-gl"
* The 'key' is always fixed and should not contain substitutions.
* This cannot be used to query features
*/
export default class SubstitutingTag implements TagsFilter {
export default class SubstitutingTag extends TagsFilter {
private readonly _key: string
private readonly _value: string
private readonly _invert: boolean
constructor(key: string, value: string, invert = false) {
super()
this._key = key
this._value = value
this._invert = invert
@ -42,7 +43,7 @@ export default class SubstitutingTag implements TagsFilter {
return new Tag(this._key, Utils.SubstituteKeys(this._value, currentProperties))
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties) {
asHumanString(linkToWiki?: boolean, shorten?: boolean, properties?: Record<string, string>) {
return (
this._key +
(this._invert ? "!" : "") +
@ -99,7 +100,7 @@ export default class SubstitutingTag implements TagsFilter {
return []
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[] {
if (this._invert) {
throw "An inverted substituting tag can not be used to create a change"
}

View file

@ -729,7 +729,7 @@ export class TagUtils {
}
if (typeof json != "string") {
if (json["and"] !== undefined && json["or"] !== undefined) {
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of \"parent": {...}\` to trigger a replacement and not a fuse of values`
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of \"parent": {...}\` to trigger a replacement and not a fuse of values. The value is ${JSON.stringify(json)}`
}
if (json["and"] !== undefined) {
return new And(json["and"].map((t) => TagUtils.Tag(t, context)))

View file

@ -15,9 +15,9 @@ export abstract class TagsFilter {
abstract matchesProperties(properties: Record<string, string>): boolean
abstract asHumanString(
linkToWiki: boolean,
shorten: boolean,
properties: Record<string, string>
linkToWiki?: boolean,
shorten?: boolean,
properties?: Record<string, string>
): string
abstract asJson(): TagConfigJson
@ -34,9 +34,18 @@ export abstract class TagsFilter {
* Converts the tagsFilter into a list of key-values that should be uploaded to OSM.
* Throws an error if not applicable.
*
* Note: properties are the already existing tags-object. It is only used in the substituting tag
* @param properties are the already existing tags-object. It is only used in the substituting tag and will not be changed
*/
abstract asChange(properties: Record<string, string>): { k: string; v: string }[]
abstract asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[]
public applyOn(properties: Readonly<Record<string, string>>): Record<string, string> {
const copy = { ...properties }
const changes = this.asChange(properties)
for (const { k, v } of changes) {
copy[k] = v
}
return copy
}
/**
* Returns an optimized version (or self) of this tagsFilter

View file

@ -3,34 +3,33 @@ import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations"
import ScriptUtils from "../../../scripts/ScriptUtils"
export class MangroveIdentity {
private readonly keypair: Store<CryptoKeyPair>
private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>(undefined)
/**
* Same as the one in the user settings
*/
public readonly mangroveIdentity: UIEventSource<string>
private readonly key_id: Store<string>
private readonly key_id: UIEventSource<string> = new UIEventSource<string>(undefined)
private readonly _mangroveIdentityCreationDate: UIEventSource<string>
constructor(mangroveIdentity: UIEventSource<string>, mangroveIdentityCreationDate: UIEventSource<string>) {
this.mangroveIdentity = mangroveIdentity
this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
const key_id = new UIEventSource<string>(undefined)
this.key_id = key_id
const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined)
this.keypair = keypairEventSource
mangroveIdentity.addCallbackAndRunD(async (data) => {
if (!data) {
return
}
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))
keypairEventSource.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
key_id.setData(pem)
await this.setKeypair(data)
})
}
private async setKeypair(data: string){
console.log("Setting keypair from",data)
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))
this.keypair.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
this.key_id.setData(pem)
}
/**
* Creates an identity if none exists already.
* Is written into the UIEventsource, which was passed into the constructor
@ -43,7 +42,9 @@ export class MangroveIdentity {
// Identity has been loaded via osmPreferences by now - we don't overwrite
return
}
console.log("Creating a new Mangrove identity!")
this.keypair.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
this.key_id.setData(pem)
this.mangroveIdentity.setData(JSON.stringify(jwk))
this._mangroveIdentityCreationDate.setData(new Date().toISOString())
}
@ -52,7 +53,7 @@ export class MangroveIdentity {
* Only called to create a review.
*/
async getKeypair(): Promise<CryptoKeyPair> {
if (this.keypair.data ?? "" === "") {
if (this.keypair.data === undefined) {
// We want to create a review, but it seems like no key has been setup at this moment
// We create the key
try {
@ -70,31 +71,51 @@ export class MangroveIdentity {
return this.key_id
}
private geoReviewsById: Store<(Review & { kid: string; signature: string })[]> =
undefined
public getGeoReviews(): Store<(Review & { kid: string, signature: string })[] | undefined> {
if (!this.geoReviewsById) {
const all = this.getAllReviews()
this.geoReviewsById = this.getAllReviews().mapD(reviews => reviews.filter(
review => {
try {
const subjectUrl = new URL(review.sub)
return subjectUrl.protocol === "geo:"
} catch (e) {
return false
}
}
))
}
return this.geoReviewsById
}
private allReviewsById: UIEventSource<(Review & { kid: string; signature: string })[]> =
undefined
/**
* Gets all reviews that are made for the current identity.
* The returned store will contain `undefined` if still loading
*/
public getAllReviews(): Store<(Review & { kid: string; signature: string })[]> {
public getAllReviews(): Store<(Review & { kid: string; signature: string })[] | undefined> {
if (this.allReviewsById !== undefined) {
return this.allReviewsById
}
this.allReviewsById = new UIEventSource([])
this.key_id.map((pem) => {
this.allReviewsById = new UIEventSource(undefined)
this.key_id.map(async (pem) => {
if (pem === undefined) {
return []
}
MangroveReviews.getReviews({
kid: pem,
}).then((allReviews) => {
this.allReviewsById.setData(
allReviews.reviews.map((r) => ({
...r,
...r.payload,
}))
)
const allReviews = await MangroveReviews.getReviews({
kid: pem
})
this.allReviewsById.setData(
allReviews.reviews.map((r) => ({
...r,
...r.payload
}))
)
})
return this.allReviewsById
}
@ -125,6 +146,7 @@ export default class FeatureReviews {
private readonly _uncertainty: number
private readonly _name: Store<string>
private readonly _identity: MangroveIdentity
private readonly _testmode: Store<boolean>
private constructor(
feature: Feature,
@ -134,11 +156,13 @@ export default class FeatureReviews {
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
}
},
testmode?: Store<boolean>
) {
const centerLonLat = GeoOperations.centerpointCoordinates(feature)
;[this._lon, this._lat] = centerLonLat
this._identity = mangroveIdentity
this._testmode = testmode ?? new ImmutableStore(false)
const nameKey = options?.nameKey ?? "name"
if (feature.geometry.type === "Point") {
@ -210,19 +234,20 @@ export default class FeatureReviews {
public static construct(
feature: Feature,
tagsSource: UIEventSource<Record<string, string>>,
mangroveIdentity?: MangroveIdentity,
options?: {
mangroveIdentity: MangroveIdentity,
options: {
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
}
) {
},
testmode: Store<boolean>
): FeatureReviews {
const key = feature.properties.id
const cached = FeatureReviews._featureReviewsCache[key]
if (cached !== undefined) {
return cached
}
const featureReviews = new FeatureReviews(feature, tagsSource, mangroveIdentity, options)
const featureReviews = new FeatureReviews(feature, tagsSource, mangroveIdentity, options,testmode )
FeatureReviews._featureReviewsCache[key] = featureReviews
return featureReviews
}
@ -243,17 +268,22 @@ export default class FeatureReviews {
}
const r: Review = {
sub: this.subjectUri.data,
...review,
...review
}
const keypair: CryptoKeyPair = await this._identity.getKeypair()
const jwt = await MangroveReviews.signReview(keypair, r)
const kid = await MangroveReviews.publicToPem(keypair.publicKey)
await MangroveReviews.submitReview(jwt)
if (!this._testmode.data) {
await MangroveReviews.submitReview(jwt)
} else {
console.log("Testmode enabled - not uploading review")
await Utils.waitFor(1000)
}
const reviewWithKid = {
...r,
kid,
signature: jwt,
madeByLoggedInUser: new ImmutableStore(true),
madeByLoggedInUser: new ImmutableStore(true)
}
this._reviews.data.push(reviewWithKid)
this._reviews.ping()
@ -301,7 +331,7 @@ export default class FeatureReviews {
signature: reviewData.signature,
madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
return reviewData.kid === user_key_id
}),
})
})
hasNew = true
}
@ -322,7 +352,7 @@ export default class FeatureReviews {
// https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
// `u` stands for `uncertainty`, https://www.rfc-editor.org/rfc/rfc5870#section-3.4.3
const self = this
return this._name.map(function (name) {
return this._name.map(function(name) {
let uri = `geo:${self._lat},${self._lon}?u=${Math.round(self._uncertainty)}`
if (name) {
uri += "&q=" + (dontEncodeName ? name : encodeURIComponent(name))