Feat: Use panoramax to upload to. Will contain bugs
This commit is contained in:
parent
cf74296d23
commit
0bdc1aec61
19 changed files with 325 additions and 193 deletions
|
@ -5,6 +5,7 @@ import GenericImageProvider from "./GenericImageProvider"
|
|||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import ImageProvider, { ProvidedImage } from "./ImageProvider"
|
||||
import { WikidataImageProvider } from "./WikidataImageProvider"
|
||||
import Panoramax from "./Panoramax"
|
||||
|
||||
/**
|
||||
* A generic 'from the interwebz' image picker, without attribution
|
||||
|
@ -28,6 +29,7 @@ export default class AllImageProviders {
|
|||
Mapillary.singleton,
|
||||
WikidataImageProvider.singleton,
|
||||
WikimediaImageProvider.singleton,
|
||||
Panoramax.singleton,
|
||||
AllImageProviders.genericImageProvider,
|
||||
]
|
||||
public static apiUrls: string[] = [].concat(
|
||||
|
@ -41,6 +43,7 @@ export default class AllImageProviders {
|
|||
mapillary: Mapillary.singleton,
|
||||
wikidata: WikidataImageProvider.singleton,
|
||||
wikimedia: WikimediaImageProvider.singleton,
|
||||
panoramax: Panoramax.singleton
|
||||
}
|
||||
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
|
||||
string,
|
||||
|
@ -66,6 +69,9 @@ export default class AllImageProviders {
|
|||
return AllImageProviders.genericImageProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to extract all image data for this image
|
||||
*/
|
||||
public static LoadImagesFor(
|
||||
tags: Store<Record<string, string>>,
|
||||
tagKey?: string[]
|
||||
|
|
|
@ -36,7 +36,7 @@ export default abstract class ImageProvider {
|
|||
prefixes?: string[]
|
||||
}
|
||||
): UIEventSource<ProvidedImage[]> {
|
||||
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
|
||||
const prefixes = Utils.Dedup(options?.prefixes ?? this.defaultKeyPrefixes)
|
||||
if (prefixes === undefined) {
|
||||
throw "No `defaultKeyPrefixes` defined by this image provider"
|
||||
}
|
||||
|
@ -46,6 +46,9 @@ export default abstract class ImageProvider {
|
|||
const seenValues = new Set<string>()
|
||||
allTags.addCallbackAndRunD((tags) => {
|
||||
for (const key in tags) {
|
||||
if(key === "panoramax"){
|
||||
console.log("Inspecting", key,"against", prefixes)
|
||||
}
|
||||
if (!prefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Changes } from "../Osm/Changes"
|
|||
import Translations from "../../UI/i18n/Translations"
|
||||
import NoteCommentElement from "../../UI/Popup/Notes/NoteCommentElement"
|
||||
import { Translation } from "../../UI/i18n/Translation"
|
||||
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
|
||||
import { GeoOperations } from "../GeoOperations"
|
||||
|
||||
/**
|
||||
* The ImageUploadManager has a
|
||||
|
@ -17,7 +19,8 @@ export class ImageUploadManager {
|
|||
private readonly _uploader: ImageUploader
|
||||
private readonly _featureProperties: FeaturePropertiesStore
|
||||
private readonly _layout: LayoutConfig
|
||||
|
||||
private readonly _indexedFeatures: IndexedFeatureSource
|
||||
private readonly _gps: Store<GeolocationCoordinates | undefined>
|
||||
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()
|
||||
|
@ -32,13 +35,17 @@ export class ImageUploadManager {
|
|||
uploader: ImageUploader,
|
||||
featureProperties: FeaturePropertiesStore,
|
||||
osmConnection: OsmConnection,
|
||||
changes: Changes
|
||||
changes: Changes,
|
||||
gpsLocation: Store<GeolocationCoordinates | undefined>,
|
||||
allFeatures: IndexedFeatureSource,
|
||||
) {
|
||||
this._uploader = uploader
|
||||
this._featureProperties = featureProperties
|
||||
this._layout = layout
|
||||
this._osmConnection = osmConnection
|
||||
this._changes = changes
|
||||
this._indexedFeatures = allFeatures
|
||||
this._gps = gpsLocation
|
||||
|
||||
const failed = this.getCounterFor(this._uploadFailed, "*")
|
||||
const done = this.getCounterFor(this._uploadFinished, "*")
|
||||
|
@ -47,7 +54,7 @@ export class ImageUploadManager {
|
|||
(startedCount) => {
|
||||
return startedCount > failed.data + done.data
|
||||
},
|
||||
[failed, done]
|
||||
[failed, done],
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -96,7 +103,7 @@ export class ImageUploadManager {
|
|||
public async uploadImageAndApply(
|
||||
file: File,
|
||||
tagsStore: UIEventSource<OsmTags>,
|
||||
targetKey?: string
|
||||
targetKey?: string,
|
||||
): Promise<void> {
|
||||
const canBeUploaded = this.canBeUploaded(file)
|
||||
if (canBeUploaded !== true) {
|
||||
|
@ -105,28 +112,15 @@ export class ImageUploadManager {
|
|||
|
||||
const tags = tagsStore.data
|
||||
const featureId = <OsmId>tags.id
|
||||
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")
|
||||
const author = this._osmConnection.userDetails.data.name
|
||||
|
||||
const action = await this.uploadImageWithLicense(
|
||||
featureId,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
file,
|
||||
targetKey,
|
||||
tags?.data?.["_orig_theme"]
|
||||
tags?.data?.["_orig_theme"],
|
||||
)
|
||||
|
||||
if (!action) {
|
||||
|
@ -146,23 +140,30 @@ export class ImageUploadManager {
|
|||
|
||||
private async uploadImageWithLicense(
|
||||
featureId: OsmId,
|
||||
title: string,
|
||||
description: string,
|
||||
author: string,
|
||||
blob: File,
|
||||
targetKey: string | undefined,
|
||||
theme?: string
|
||||
theme?: string,
|
||||
): Promise<LinkImageAction> {
|
||||
this.increaseCountFor(this._uploadStarted, featureId)
|
||||
const properties = this._featureProperties.getStore(featureId)
|
||||
let key: string
|
||||
let value: string
|
||||
let location: [number, number] = undefined
|
||||
if (this._gps.data) {
|
||||
location = [this._gps.data.longitude, this._gps.data.latitude]
|
||||
}
|
||||
if (location === undefined || location?.some(l => l === undefined)) {
|
||||
const feature = this._indexedFeatures.featuresById.data.get(featureId)
|
||||
location = GeoOperations.centerpointCoordinates(feature)
|
||||
}
|
||||
try {
|
||||
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
|
||||
;({ key, value } = await this._uploader.uploadImage(blob, location, author))
|
||||
} 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))
|
||||
;({ key, value } = await this._uploader.uploadImage(blob, location, author))
|
||||
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
|
||||
} catch (e) {
|
||||
console.error("Could again not upload image due to", e)
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import { Feature } from "geojson"
|
||||
|
||||
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
|
||||
blob: File,
|
||||
currentGps: [number,number],
|
||||
author: string
|
||||
): Promise<{ key: string; value: string }>
|
||||
}
|
||||
|
|
|
@ -3,14 +3,12 @@ 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 implements ImageUploader {
|
||||
export class Imgur extends ImageProvider {
|
||||
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
|
||||
public static readonly singleton = new Imgur()
|
||||
public readonly name = "Imgur"
|
||||
public readonly defaultKeyPrefixes: string[] = ["image"]
|
||||
public readonly maxFileSizeInMegabytes = 10
|
||||
public static readonly apiUrl = "https://api.imgur.com/3/image"
|
||||
public static readonly supportingUrls = ["https://i.imgur.com"]
|
||||
private constructor() {
|
||||
|
@ -21,40 +19,6 @@ export class Imgur extends ImageProvider implements ImageUploader {
|
|||
return [Imgur.apiUrl]
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image, returns the URL where to find the image
|
||||
* @param title
|
||||
* @param description
|
||||
* @param blob
|
||||
*/
|
||||
public async uploadImage(
|
||||
title: string,
|
||||
description: string,
|
||||
blob: File
|
||||
): Promise<{ key: string; value: string }> {
|
||||
const apiUrl = Imgur.apiUrl
|
||||
const apiKey = Constants.ImgurApiKey
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("image", blob)
|
||||
formData.append("title", title)
|
||||
formData.append("description", description)
|
||||
|
||||
const settings: RequestInit = {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "follow",
|
||||
headers: new Headers({
|
||||
Authorization: `Client-ID ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
}),
|
||||
}
|
||||
|
||||
// Response contains stringified JSON
|
||||
const response = await fetch(apiUrl, settings)
|
||||
const content = await response.json()
|
||||
return { key: "image", value: content.data.link }
|
||||
}
|
||||
|
||||
SourceIcon(): BaseUIElement {
|
||||
return undefined
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
export class LicenseInfo {
|
||||
title: string = ""
|
||||
title?: string = ""
|
||||
artist: string = ""
|
||||
license: string = undefined
|
||||
licenseShortName: string = ""
|
||||
usageTerms: string = ""
|
||||
attributionRequired: boolean = false
|
||||
copyrighted: boolean = false
|
||||
credit: string = ""
|
||||
description: string = ""
|
||||
informationLocation: URL = undefined
|
||||
license?: string = undefined
|
||||
licenseShortName?: string = ""
|
||||
usageTerms?: string = ""
|
||||
attributionRequired?: boolean = false
|
||||
copyrighted?: boolean = false
|
||||
credit?: string = ""
|
||||
description?: string = ""
|
||||
informationLocation?: URL = undefined
|
||||
date?: Date
|
||||
views?: number
|
||||
}
|
||||
|
|
158
src/Logic/ImageProviders/Panoramax.ts
Normal file
158
src/Logic/ImageProviders/Panoramax.ts
Normal file
|
@ -0,0 +1,158 @@
|
|||
import { ImageUploader } from "./ImageUploader"
|
||||
import { AuthorizedPanoramax } from "panoramax-js/dist"
|
||||
import ExifReader from "exifreader"
|
||||
import ImageProvider, { ProvidedImage } from "./ImageProvider"
|
||||
import BaseUIElement from "../../UI/BaseUIElement"
|
||||
import { LicenseInfo } from "./LicenseInfo"
|
||||
import { Utils } from "../../Utils"
|
||||
import { Feature, FeatureCollection, Point } from "geojson"
|
||||
import { GeoOperations } from "../GeoOperations"
|
||||
|
||||
type ImageData = Feature<Point, { "geovisio:producer": string, "geovisio:license": string, "datetime": string }> & {
|
||||
id: string,
|
||||
assets: { hd: { href: string }, sd: { href: string } },
|
||||
providers: {name: string}[]
|
||||
}
|
||||
|
||||
export default class PanoramaxImageProvider extends ImageProvider {
|
||||
|
||||
public static readonly singleton = new PanoramaxImageProvider()
|
||||
|
||||
public defaultKeyPrefixes: string[] = ["panoramax", "image"]
|
||||
public readonly name: string = "panoramax"
|
||||
|
||||
private static knownMeta: Record<string, ImageData> = {}
|
||||
|
||||
public SourceIcon(id?: string, location?: { lon: number; lat: number; }): BaseUIElement {
|
||||
return undefined
|
||||
}
|
||||
|
||||
public addKnownMeta(meta: ImageData){
|
||||
console.log("Adding known meta for", meta.id)
|
||||
PanoramaxImageProvider.knownMeta[meta.id] = meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to get the entry from the mapcomplete-panoramax instance. Might return undefined
|
||||
* @param id
|
||||
* @private
|
||||
*/
|
||||
private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData, url: string }> {
|
||||
const sequence = "6e702976-580b-419c-8fb3-cf7bd364e6f8" // We always reuse this sequence
|
||||
const url = `https://panoramax.mapcomplete.org/api/collections/${sequence}/items/${id}`
|
||||
const data = <any> await Utils.downloadJsonCached(url, 60 * 60 * 1000)
|
||||
return {url, data}
|
||||
}
|
||||
|
||||
private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData, url: string }> {
|
||||
const url = "https://api.panoramax.xyz/api/search?limit=1&ids=" + imageId
|
||||
const metaAll = await Utils.downloadJsonCached<FeatureCollection<Point>>(url, 1000 * 60 * 60)
|
||||
const data= <any>metaAll.features[0]
|
||||
return {data, url}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reads a geovisio-somewhat-looking-like-geojson object and converts it to a provided image
|
||||
* @param meta
|
||||
* @private
|
||||
*/
|
||||
private featureToImage(info: {data: ImageData, url: string}) {
|
||||
const meta = info.data
|
||||
const url = info.url
|
||||
if (!meta) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function makeAbsolute(s: string){
|
||||
if(!s.startsWith("https://") && !s.startsWith("http://")){
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol+"//"+parsed.host+s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
const [lon, lat] = GeoOperations.centerpointCoordinates(meta)
|
||||
return <ProvidedImage>{
|
||||
id: meta.id,
|
||||
url: makeAbsolute(meta.assets.sd.href),
|
||||
url_hd: makeAbsolute(meta.assets.hd.href),
|
||||
lon, lat,
|
||||
key: "panoramax",
|
||||
provider: this,
|
||||
rotation: Number(meta.properties["view:azimuth"]),
|
||||
date: new Date(meta.properties.datetime),
|
||||
}
|
||||
}
|
||||
|
||||
private async getInfoFor(id: string): Promise<{ data: ImageData, url: string }> {
|
||||
const cached= PanoramaxImageProvider.knownMeta[id]
|
||||
console.log("Cached version", id, cached)
|
||||
if(cached){
|
||||
return {data: cached, url: undefined}
|
||||
}
|
||||
try {
|
||||
return await this.getInfoFromMapComplete(id)
|
||||
} catch (e) {
|
||||
return await this.getInfoFromXYZ(id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
|
||||
return [this.getInfoFor(value).then(r => this.featureToImage(<any>r))]
|
||||
}
|
||||
|
||||
public async DownloadAttribution(providedImage: { url: string; id: string; }): Promise<LicenseInfo> {
|
||||
const meta = await this.getInfoFor(providedImage.id)
|
||||
|
||||
return {
|
||||
artist: meta.data.providers.at(-1).name, // We take the last provider, as that one probably contain the username of the uploader
|
||||
date: new Date(meta.data.properties["datetime"]),
|
||||
licenseShortName: meta.data.properties["geovisio:license"],
|
||||
}
|
||||
}
|
||||
|
||||
public apiUrls(): string[] {
|
||||
return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"]
|
||||
}
|
||||
}
|
||||
|
||||
export class PanoramaxUploader implements ImageUploader {
|
||||
private readonly _panoramax: AuthorizedPanoramax
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this._panoramax = new AuthorizedPanoramax(url, token)
|
||||
}
|
||||
|
||||
async uploadImage(blob: File, currentGps: [number, number], author: string): Promise<{
|
||||
key: string;
|
||||
value: string;
|
||||
}> {
|
||||
|
||||
const tags = await ExifReader.load(blob)
|
||||
const hasDate = tags.DateTime !== undefined
|
||||
const hasGPS = tags.GPSLatitude !== undefined && tags.GPSLongitude !== undefined
|
||||
|
||||
const [lon, lat] = currentGps
|
||||
|
||||
const p = this._panoramax
|
||||
const defaultSequence = (await p.mySequences())[0]
|
||||
const img = <ImageData> await p.addImage(blob, defaultSequence, {
|
||||
lat: !hasGPS ? lat : undefined,
|
||||
lon: !hasGPS ? lon : undefined,
|
||||
datetime: !hasDate ? new Date().toISOString() : undefined,
|
||||
exifOverride: {
|
||||
Artist: author,
|
||||
},
|
||||
|
||||
})
|
||||
PanoramaxImageProvider.singleton.addKnownMeta(img)
|
||||
await Utils.waitFor(1250)
|
||||
return {
|
||||
key: "panoramax",
|
||||
value: img.id,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject"
|
||||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { UIEventSource } from "../UIEventSource"
|
||||
import Constants from "../../Models/Constants"
|
||||
import OsmChangeAction from "./Actions/OsmChangeAction"
|
||||
import { ChangeDescription, ChangeDescriptionTools } from "./Actions/ChangeDescription"
|
||||
|
@ -11,7 +11,6 @@ import { GeoLocationPointProperties } from "../State/GeoLocationState"
|
|||
import { GeoOperations } from "../GeoOperations"
|
||||
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
|
||||
import { OsmConnection } from "./OsmConnection"
|
||||
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
|
||||
import OsmObjectDownloader from "./OsmObjectDownloader"
|
||||
import ChangeLocationAction from "./Actions/ChangeLocationAction"
|
||||
import ChangeTagAction from "./Actions/ChangeTagAction"
|
||||
|
@ -62,9 +61,9 @@ export class Changes {
|
|||
this.backend = state.osmConnection.Backend()
|
||||
this._reportError = reportError
|
||||
this._changesetHandler = new ChangesetHandler(
|
||||
state.dryRun,
|
||||
state.featureSwitches.featureSwitchIsTesting,
|
||||
state.osmConnection,
|
||||
state.featurePropertiesStore,
|
||||
state.featureProperties,
|
||||
this,
|
||||
(e, extramessage: string) => this._reportError(e, extramessage)
|
||||
)
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import { GeoOperations } from "./GeoOperations"
|
||||
import { Utils } from "../Utils"
|
||||
import opening_hours from "opening_hours"
|
||||
import Combine from "../UI/Base/Combine"
|
||||
import BaseUIElement from "../UI/BaseUIElement"
|
||||
import Title from "../UI/Base/Title"
|
||||
import { FixedUiElement } from "../UI/Base/FixedUiElement"
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
||||
import { CountryCoder } from "latlon2country"
|
||||
import Constants from "../Models/Constants"
|
||||
|
|
|
@ -291,6 +291,8 @@ export default class NameSuggestionIndex {
|
|||
if (location === undefined) {
|
||||
return true
|
||||
}
|
||||
console.log("Resolving location", i.locationSet)
|
||||
/*
|
||||
const resolvedSet = NameSuggestionIndex.loco.resolveLocationSet(i.locationSet)
|
||||
|
||||
if (resolvedSet) {
|
||||
|
@ -299,7 +301,7 @@ export default class NameSuggestionIndex {
|
|||
const setFeature: Feature<MultiPolygon> = resolvedSet.feature
|
||||
return turf.booleanPointInPolygon(location, setFeature.geometry)
|
||||
}
|
||||
|
||||
*/
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue