import { Store, UIEventSource } from "../UIEventSource" import { OsmConnection } from "./OsmConnection" import { LocalStorageSource } from "../Web/LocalStorageSource" import OSMAuthInstance = OSMAuth.osmAuth import { Utils } from "../../Utils" export class OsmPreferences { /** * A 'cache' of all the preference stores * @private */ private readonly preferences: Record> = {} private localStorageInited: Set = new Set() /** * Contains all the keys as returned by the OSM-preferences. * Used to clean up old preferences */ private seenKeys: string[] = [] /** * Contains a dictionary which has all preferences * @private */ private readonly _allPreferences: UIEventSource> = new UIEventSource({}) public readonly allPreferences: Store>> = this._allPreferences private readonly _fakeUser: boolean private readonly auth: OSMAuthInstance private readonly osmConnection: OsmConnection constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) { this.auth = auth this._fakeUser = fakeUser this.osmConnection = osmConnection osmConnection.OnLoggedIn(() => { this.loadBulkPreferences() return true }) } /** * Sets a new preferenceValue in 'allPreferences' * @param key * @param value * @param deferping: the end user will ping '_allPreferences' * @private */ private setPreferencesAll(key: string, value: string, deferping = false) { if (this._allPreferences.data[key] !== value) { this._allPreferences.data[key] = value if(!deferping){ this._allPreferences.ping() } } } private initPreference(key: string, value: string = undefined, deferPing = false): UIEventSource { if (this.preferences[key] !== undefined) { if (value !== undefined) { this.preferences[key].set(value) } return this.preferences[key] } const pref = (this.preferences[key] = new UIEventSource(value, "preference: " + key)) if (value) { this.setPreferencesAll(key, value, deferPing) } pref.addCallback((v) => { console.log("Got an update:", key, "--->", v) this.uploadKvSplit(key, v) this.setPreferencesAll(key, v, deferPing) }) return pref } private async loadBulkPreferences() { const prefs = await this.getPreferencesDictDirectly() this.seenKeys = Object.keys(prefs) const legacy = OsmPreferences.getLegacyCombinedItems(prefs) const merged = OsmPreferences.mergeDict(prefs) if (Object.keys(legacy).length > 0) { await this.removeLegacy(legacy) } for (const key in merged) { this.initPreference(key, prefs[key], true) } for (const key in legacy) { this.initPreference(key, legacy[key], true) } this._allPreferences.ping() } public getPreference(key: string, defaultValue: string = undefined, prefix?: string) { return this.getPreferenceSeedFromlocal(key, defaultValue, { prefix }) } /** * Gets a OSM-preference. * The OSM-preference is cached in local storage and updated from the OSM.org as soon as those values come in. * THis means that values written before being logged in might be erased by the cloud settings */ private getPreferenceSeedFromlocal( key: string, defaultValue: string = undefined, options?: { prefix?: string saveToLocalStorage?: true | boolean } ): UIEventSource { if (options?.prefix) { key = options.prefix + key } key = key.replace(/[:/"' {}.%\\]/g, "") const localStorage = LocalStorageSource.get(key) // cached if (localStorage.data === "null" || localStorage.data === "undefined") { localStorage.set(undefined) } const pref: UIEventSource = this.initPreference( key, localStorage.data ?? defaultValue ) // cached if (this.localStorageInited.has(key)) { return pref } if (options?.saveToLocalStorage ?? true) { pref.addCallback((v) => localStorage.set(v)) // Keep a local copy } this.localStorageInited.add(key) return pref } public ClearPreferences() { console.log("Starting to remove all preferences") this.removeAllWithPrefix("") } public async removeLegacy(legacyDict: Record) { for (const k in legacyDict) { const v = legacyDict[k] console.log("Upgrading legacy preference", k) await this.removeAllWithPrefix(k) this.osmConnection.getPreference(k).set(v) } } /** * * OsmPreferences.mergeDict({abc: "123", def: "123", "def:0": "456", "def:1":"789"}) // => {abc: "123", def: "123456789"} */ private static mergeDict(dict: Record): Record { const newDict = {} const allKeys: string[] = Object.keys(dict) const normalKeys = allKeys.filter((k) => !k.match(/[a-z-_0-9A-Z]*:[0-9]+/)) for (const normalKey of normalKeys) { if (normalKey.match(/-combined-[0-9]*$/) || normalKey.match(/-combined-length$/)) { // Ignore legacy keys continue } const partKeys = OsmPreferences.keysStartingWith(allKeys, normalKey) const parts = partKeys.map((k) => dict[k]) newDict[normalKey] = parts.join("") } return newDict } /** * Gets all items which have a 'combined'-string, the legacy long preferences * * const input = { * "extra-noncombined-key":"xyz", * "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-0": * "{\"id\":\"https://raw.githubusercontent.com/osm-catalan/wikidataimg/main/wikidataimg.json\",\"icon\":\"https://upload.wikimedia.org/wikipedia/commons/5/50/Yes_Check_Circle.svg\",\"title\":{\"ca\":\"wikidataimg\",\"_context\":\"themes:wikidataimg.title\"},\"shortDescription\"", * "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-1": * ":{\"ca\":\"Afegeix imatges d'articles de wikimedia\",\"_context\":\"themes:wikidataimg\"}}", * } * const merged = OsmPreferences.getLegacyCombinedItems(input) * const data = merged["mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson"] * JSON.parse(data) // => {"id": "https://raw.githubusercontent.com/osm-catalan/wikidataimg/main/wikidataimg.json", "icon": "https://upload.wikimedia.org/wikipedia/commons/5/50/Yes_Check_Circle.svg","title": { "ca": "wikidataimg", "_context": "themes:wikidataimg.title" }, "shortDescription": {"ca": "Afegeix imatges d'articles de wikimedia","_context": "themes:wikidataimg"}} * merged["extra-noncombined-key"] // => undefined */ public static getLegacyCombinedItems(dict: Record): Record { const merged: Record = {} const keys = Object.keys(dict) const toCheck = Utils.NoNullInplace( Utils.Dedup(keys.map((k) => k.match(/(.*)-combined-[0-9]+$/)?.[1])) ) for (const key of toCheck) { let i = 0 let str = "" let v: string do { v = dict[key + "-combined-" + i] str += v ?? "" i++ } while (v !== undefined) merged[key] = str } return merged } /** * Bulk-downloads all preferences * @private */ private getPreferencesDictDirectly(): Promise> { return new Promise>((resolve, reject) => { this.auth.xhr( { method: "GET", path: "/api/0.6/user/preferences", }, (error, value: XMLDocument) => { if (error) { console.log("Could not load preferences", error) reject(error) return } const prefs = value.getElementsByTagName("preference") const dict: Record = {} for (let i = 0; i < prefs.length; i++) { const pref = prefs[i] const k = pref.getAttribute("k") dict[k] = pref.getAttribute("v") } resolve(dict) } ) }) } /** * Returns all keys matching `k:[number]` * Split separately for test * * const keys = ["abc", "def", "ghi", "ghi:0", "ghi:1"] * OsmPreferences.keysStartingWith(keys, "xyz") // => [] * OsmPreferences.keysStartingWith(keys, "abc") // => ["abc"] * OsmPreferences.keysStartingWith(keys, "ghi") // => ["ghi", "ghi:0", "ghi:1"] * */ private static keysStartingWith(allKeys: string[], key: string): string[] { const keys = allKeys.filter((k) => k === key || k.match(new RegExp(key + ":[0-9]+"))) keys.sort() return keys } /** * Smart 'upload', which splits the value into `k`, `k:0`, `k:1` if needed. * If `v` is null, undefined, empty, "undefined" (literal string) or "null" (literal string), will delete `k` and `k:[number]` * */ private async uploadKvSplit(k: string, v: string) { if (v === null || v === undefined || v === "" || v === "undefined" || v === "null") { const keysToDelete = OsmPreferences.keysStartingWith(this.seenKeys, k) await Promise.all(keysToDelete.map((k) => this.deleteKeyDirectly(k))) return } await this.uploadKeyDirectly(k, v.slice(0, 255)) v = v.slice(255) let i = 0 while (v.length > 0) { await this.uploadKeyDirectly(`${k}:${i}`, v.slice(0, 255)) v = v.slice(255) i++ } } /** * Directly deletes this key * @param k * @private */ private deleteKeyDirectly(k: string) { if (!this.osmConnection.userDetails.data.loggedIn) { console.debug(`Not saving preference ${k}: user not logged in`) return } if (this._fakeUser) { return } return new Promise((resolve, reject) => { this.auth.xhr( { method: "DELETE", path: "/api/0.6/user/preferences/" + encodeURIComponent(k), headers: { "Content-Type": "text/plain" }, }, (error) => { if (error) { console.warn("Could not remove preference", error) reject(error) return } console.debug("Preference ", k, "removed!") resolve() } ) }) } /** * Uploads the given k=v to the OSM-server * Deletes it if 'v' is undefined, null or empty */ private async uploadKeyDirectly(k: string, v: string) { if (!this.osmConnection.userDetails.data.loggedIn) { console.debug(`Not saving preference ${k}: user not logged in`) return } if (this._fakeUser) { return } if (v === undefined || v === "" || v === null) { await this.deleteKeyDirectly(k) return } if (v.length > 255) { console.error("Preference too long, max 255 chars", { k, v }) throw "Preference too long, at most 255 characters are supported" } return new Promise((resolve, reject) => { this.auth.xhr( { method: "PUT", path: "/api/0.6/user/preferences/" + encodeURIComponent(k), headers: { "Content-Type": "text/plain" }, content: v, }, (error) => { if (error) { console.warn(`Could not set preference "${k}"'`, error) reject(error) return } resolve() } ) }) } async removeAllWithPrefix(prefix: string) { const keys = this.seenKeys for (const key of keys) { if (!key.startsWith(prefix)) { continue } await this.deleteKeyDirectly(key) } } }