From 5f04a695172934d4052ba7ee90ae378e66893d6a Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Wed, 20 Sep 2023 01:47:32 +0200 Subject: [PATCH 01/40] Refactoring: port PlantNet-detection to svelte, re-integrate wikipedia component --- langs/en.json | 6 +- package.json | 2 +- public/css/index-tailwind-output.css | 18 +-- src/Logic/Web/PlantNet.ts | 42 +++--- src/UI/Base/BackButton.svelte | 3 +- src/UI/Base/NextButton.svelte | 2 +- src/UI/BigComponents/PlantNetSpeciesSearch.ts | 127 ------------------ src/UI/PlantNet/PlantNet.svelte | 123 +++++++++++++++++ src/UI/PlantNet/PlantNetSpeciesList.svelte | 37 +++++ src/UI/PlantNet/SpeciesButton.svelte | 56 ++++++++ src/UI/Popup/PlantNetDetectionViz.ts | 68 ++++------ src/UI/SpecialVisualizations.ts | 2 +- src/UI/StylesheetTestGui.svelte | 4 + src/UI/Wikipedia/WikidataPreviewBox.ts | 2 +- src/UI/Wikipedia/WikidataSearchBox.ts | 2 +- src/UI/Wikipedia/WikipediaArticle.svelte | 6 +- src/UI/Wikipedia/WikipediaPanel.svelte | 2 +- src/index.css | 5 + 18 files changed, 297 insertions(+), 210 deletions(-) delete mode 100644 src/UI/BigComponents/PlantNetSpeciesSearch.ts create mode 100644 src/UI/PlantNet/PlantNet.svelte create mode 100644 src/UI/PlantNet/PlantNetSpeciesList.svelte create mode 100644 src/UI/PlantNet/SpeciesButton.svelte diff --git a/langs/en.json b/langs/en.json index cc9974402e..7d75d0a7bd 100644 --- a/langs/en.json +++ b/langs/en.json @@ -380,6 +380,7 @@ "born": "Born: {value}", "died": "Died: {value}" }, + "readMore": "Read the rest of the article", "searchToShort": "Your search query is too short, enter a longer text", "searchWikidata": "Search on Wikidata", "wikipediaboxTitle": "Wikipedia" @@ -498,7 +499,9 @@ }, "plantDetection": { "back": "Back to species overview", + "button": "Automatically detect the plant species using the AI of Plantnet.org", "confirm": "Select species", + "done": "The species has been applied", "error": "Something went wrong while detecting the tree species: {error}", "howTo": { "intro": "For optimal results,", @@ -515,7 +518,8 @@ "poweredByPlantnet": "Powered by plantnet.org", "querying": "Querying plantnet.org with {length} images", "seeInfo": "See more information about the species", - "takeImages": "Take images of the tree to automatically detect the tree type" + "takeImages": "Take images of the tree to automatically detect the tree type", + "tryAgain": "Select a different species" }, "privacy": { "editing": "When you make a change to the map, this change is recorded on OpenStreetMap and is publicly available to anyone. A changeset made with MapComplete includes the following data: Please refer to the privacy policy on OpenStreetMap.org for detailed information. We'd like to remind you that you can use a fictional name when signing up.", diff --git a/package.json b/package.json index fff429293f..534568b5de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapcomplete", - "version": "0.32.0", + "version": "0.33.0", "repository": "https://github.com/pietervdvn/MapComplete", "description": "A small website to edit OSM easily", "bugs": "https://github.com/pietervdvn/MapComplete/issues", diff --git a/public/css/index-tailwind-output.css b/public/css/index-tailwind-output.css index 91a0cd7e0e..e068f5f492 100644 --- a/public/css/index-tailwind-output.css +++ b/public/css/index-tailwind-output.css @@ -1096,6 +1096,10 @@ video { height: 2.75rem; } +.h-10 { + height: 2.5rem; +} + .h-48 { height: 12rem; } @@ -1104,10 +1108,6 @@ video { height: 10rem; } -.h-10 { - height: 2.5rem; -} - .h-80 { height: 20rem; } @@ -1709,11 +1709,6 @@ video { padding-right: 0.5rem; } -.py-2 { - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} - .pl-1 { padding-left: 0.25rem; } @@ -2209,6 +2204,11 @@ input[type=text] { border-radius: 0.5rem; } +.border-region { + border: 2px dashed var(--interactive-background); + border-radius: 0.5rem; +} + /******************* Styling of input elements **********************/ /** diff --git a/src/Logic/Web/PlantNet.ts b/src/Logic/Web/PlantNet.ts index d22cae7632..dab705ad0b 100644 --- a/src/Logic/Web/PlantNet.ts +++ b/src/Logic/Web/PlantNet.ts @@ -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 } diff --git a/src/UI/Base/BackButton.svelte b/src/UI/Base/BackButton.svelte index 40f4d1eeaa..5f48bf7e63 100644 --- a/src/UI/Base/BackButton.svelte +++ b/src/UI/Base/BackButton.svelte @@ -10,12 +10,13 @@ const dispatch = createEventDispatcher<{ click }>() export let clss: string | undefined = undefined + export let imageClass: string | undefined = undefined dispatch("click")} options={{ extraClasses: twMerge("flex items-center", clss) }} > - + diff --git a/src/UI/Base/NextButton.svelte b/src/UI/Base/NextButton.svelte index a546fd8c1c..6b4a64dd83 100644 --- a/src/UI/Base/NextButton.svelte +++ b/src/UI/Base/NextButton.svelte @@ -20,6 +20,6 @@
- +
diff --git a/src/UI/BigComponents/PlantNetSpeciesSearch.ts b/src/UI/BigComponents/PlantNetSpeciesSearch.ts deleted file mode 100644 index b7e503f6e0..0000000000 --- a/src/UI/BigComponents/PlantNetSpeciesSearch.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { VariableUiElement } from "../Base/VariableUIElement" -import { Store, UIEventSource } from "../../Logic/UIEventSource" -import PlantNet from "../../Logic/Web/PlantNet" -import Loading from "../Base/Loading" -import Wikidata from "../../Logic/Web/Wikidata" -import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox" -import { Button } from "../Base/Button" -import Combine from "../Base/Combine" -import Title from "../Base/Title" -import Translations from "../i18n/Translations" -import List from "../Base/List" -import Svg from "../../Svg" - -export default class PlantNetSpeciesSearch extends VariableUiElement { - /*** - * Given images, queries plantnet to search a species matching those images. - * A list of species will be presented to the user, after which they can confirm an item. - * The wikidata-url is returned in the callback when the user selects one - */ - constructor(images: Store, onConfirm: (wikidataUrl: string) => Promise) { - const t = Translations.t.plantDetection - super( - images - .bind((images) => { - if (images.length === 0) { - return null - } - return UIEventSource.FromPromiseWithErr(PlantNet.query(images.slice(0, 5))) - }) - .map((result) => { - if (images.data.length === 0) { - return new Combine([ - t.takeImages, - t.howTo.intro, - new List([t.howTo.li0, t.howTo.li1, t.howTo.li2, t.howTo.li3]), - ]).SetClass("flex flex-col") - } - if (result === undefined) { - return new Loading(t.querying.Subs(images.data)) - } - - if (result["error"] !== undefined) { - return t.error.Subs(result).SetClass("alert") - } - console.log(result) - const success = result["success"] - - const selectedSpecies = new UIEventSource(undefined) - const speciesInformation = success.results - .filter((species) => species.score >= 0.005) - .map((species) => { - const wikidata = UIEventSource.FromPromise( - Wikidata.Sparql<{ species }>( - ["?species", "?speciesLabel"], - ['?species wdt:P846 "' + species.gbif.id + '"'] - ) - ) - - const confirmButton = new Button(t.seeInfo, async () => { - await selectedSpecies.setData(wikidata.data[0].species?.value) - }).SetClass("btn") - - const match = t.matchPercentage - .Subs({ match: Math.round(species.score * 100) }) - .SetClass("font-bold") - - const extraItems = new Combine([match, confirmButton]).SetClass( - "flex flex-col" - ) - - return new WikidataPreviewBox( - wikidata.map((wd) => - wd == undefined ? undefined : wd[0]?.species?.value - ), - { - whileLoading: new Loading( - t.loadingWikidata.Subs({ - species: species.species.scientificNameWithoutAuthor, - }) - ), - extraItems: [new Combine([extraItems])], - - imageStyle: "max-width: 8rem; width: unset; height: 8rem", - } - ).SetClass("border-2 border-subtle rounded-xl block mb-2") - }) - const plantOverview = new Combine([ - new Title(t.overviewTitle), - t.overviewIntro, - t.overviewVerify.SetClass("font-bold"), - ...speciesInformation, - ]).SetClass("flex flex-col") - - return new VariableUiElement( - selectedSpecies.map((wikidataSpecies) => { - if (wikidataSpecies === undefined) { - return plantOverview - } - return new Combine([ - new Button( - new Combine([ - Svg.back_svg().SetClass( - "w-6 mr-1 bg-white rounded-full p-1" - ), - t.back, - ]).SetClass("flex"), - () => { - selectedSpecies.setData(undefined) - } - ).SetClass("btn btn-secondary"), - - new Button( - new Combine([ - Svg.confirm_svg().SetClass("w-6 mr-1"), - t.confirm, - ]).SetClass("flex"), - () => { - onConfirm(wikidataSpecies) - } - ).SetClass("btn"), - ]).SetClass("flex justify-between") - }) - ) - }) - ) - } -} diff --git a/src/UI/PlantNet/PlantNet.svelte b/src/UI/PlantNet/PlantNet.svelte new file mode 100644 index 0000000000..170c7fc182 --- /dev/null +++ b/src/UI/PlantNet/PlantNet.svelte @@ -0,0 +1,123 @@ + + +
+ + {#if collapsedMode} + + {:else if $error !== undefined} + + {:else if $imageUrls.length === 0} + +
+ {collapsedMode = true}}> + + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ {:else if selectedOption === undefined} + speciesSelected(species.detail)}> + {collapsedMode = true}}> + + + {:else if !done} +
+
+ + +
+
+ {selectedOption = undefined}}> + + + { done = true; onConfirm(selectedOption); }} > + + +
+
+ {:else} + + + {done = false; selectedOption = undefined}}> + + + {/if} +
+ + +
+ +
diff --git a/src/UI/PlantNet/PlantNetSpeciesList.svelte b/src/UI/PlantNet/PlantNetSpeciesList.svelte new file mode 100644 index 0000000000..5cb7341561 --- /dev/null +++ b/src/UI/PlantNet/PlantNetSpeciesList.svelte @@ -0,0 +1,37 @@ + + +{#if $options === undefined} + + + +{:else} +
+
+ + +
+

+ +

+ + + {#each $options as species} + + {/each} +
+{/if} diff --git a/src/UI/PlantNet/SpeciesButton.svelte b/src/UI/PlantNet/SpeciesButton.svelte new file mode 100644 index 0000000000..de7f873332 --- /dev/null +++ b/src/UI/PlantNet/SpeciesButton.svelte @@ -0,0 +1,56 @@ + + +{ + console.log("Dispatching: ", $wikidataId) + return dispatch("selected", $wikidataId); }}> + {#if $wikidata === undefined} + + + + {:else} + new WikidataPreviewBox(wikidataId, + { imageStyle: "max-width: 8rem; width: unset; height: 8rem", + extraItems: [t.matchPercentage + .Subs({ match: Math.round(species.score * 100) }) + .SetClass("thanks w-fit self-center")] + }).SetClass("w-full")}> + {/if} + diff --git a/src/UI/Popup/PlantNetDetectionViz.ts b/src/UI/Popup/PlantNetDetectionViz.ts index 198fc7a938..69397cabdf 100644 --- a/src/UI/Popup/PlantNetDetectionViz.ts +++ b/src/UI/Popup/PlantNetDetectionViz.ts @@ -1,18 +1,14 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource" -import Toggle from "../Input/Toggle" -import Lazy from "../Base/Lazy" import { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" import PlantNetSpeciesSearch from "../BigComponents/PlantNetSpeciesSearch" import Wikidata from "../../Logic/Web/Wikidata" import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction" import { And } from "../../Logic/Tags/And" import { Tag } from "../../Logic/Tags/Tag" -import { SubtleButton } from "../Base/SubtleButton" -import Combine from "../Base/Combine" -import Svg from "../../Svg" -import Translations from "../i18n/Translations" import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders" import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization" +import SvelteUIElement from "../Base/SvelteUIElement" +import PlantNet from "../PlantNet/PlantNet.svelte" export class PlantNetDetectionViz implements SpecialVisualization { funcName = "plantnet_detection" @@ -37,45 +33,29 @@ export class PlantNetDetectionViz implements SpecialVisualization { imagePrefixes = [].concat(...args.map((a) => a.split(","))) } - const detect = new UIEventSource(false) - const toggle = new Toggle( - new Lazy(() => { - const allProvidedImages: Store = AllImageProviders.LoadImagesFor( - tags, - imagePrefixes - ) - const allImages: Store = allProvidedImages.map((pi) => - pi.map((pi) => pi.url) - ) - return new PlantNetSpeciesSearch(allImages, async (selectedWikidata) => { - selectedWikidata = Wikidata.ExtractKey(selectedWikidata) - const change = new ChangeTagAction( - tags.data.id, - new And([ - new Tag("species:wikidata", selectedWikidata), - new Tag("source:species:wikidata", "PlantNet.org AI"), - ]), - tags.data, - { - theme: state.layout.id, - changeType: "plantnet-ai-detection", - } - ) - await state.changes.applyAction(change) - }) - }), - new SubtleButton(undefined, "Detect plant species with plantnet.org").onClick(() => - detect.setData(true) - ), - detect + const allProvidedImages: Store = AllImageProviders.LoadImagesFor( + tags, + imagePrefixes ) + const imageUrls: Store = allProvidedImages.map((pi) => pi.map((pi) => pi.url)) - return new Combine([ - toggle, - new Combine([ - Svg.plantnet_logo_svg().SetClass("w-10 h-10 p-1 mr-1 bg-white rounded-full"), - Translations.t.plantDetection.poweredByPlantnet, - ]).SetClass("flex p-2 bg-gray-200 rounded-xl self-end"), - ]).SetClass("flex flex-col") + async function applySpecies(selectedWikidata) { + selectedWikidata = Wikidata.ExtractKey(selectedWikidata) + const change = new ChangeTagAction( + tags.data.id, + new And([ + new Tag("species:wikidata", selectedWikidata), + new Tag("source:species:wikidata", "PlantNet.org AI"), + ]), + tags.data, + { + theme: state.layout.id, + changeType: "plantnet-ai-detection", + } + ) + await state.changes.applyAction(change) + } + + return new SvelteUIElement(PlantNet, { imageUrls, onConfirm: applySpecies }) } } diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 213451e971..26a94ec7f3 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -538,7 +538,7 @@ export default class SpecialVisualizations { const keys = args[0].split(";").map((k) => k.trim()) const wikiIds: Store = tagsSource.map((tags) => { const key = keys.find((k) => tags[k] !== undefined && tags[k] !== "") - return tags[key]?.split(";")?.map((id) => id.trim()) + return tags[key]?.split(";")?.map((id) => id.trim()) ?? [] }) return new SvelteUIElement(WikipediaPanel, { wikiIds, diff --git a/src/UI/StylesheetTestGui.svelte b/src/UI/StylesheetTestGui.svelte index 4ad1430061..7d40c735f8 100644 --- a/src/UI/StylesheetTestGui.svelte +++ b/src/UI/StylesheetTestGui.svelte @@ -29,6 +29,10 @@ areas, where some buttons might appear.

+
+ Highly interactive area (mostly: active question) +
+
-
+
{selectedOption = undefined}}> - { done = true; onConfirm(selectedOption); }} > + { done = true; onConfirm(selectedOption); }} >
@@ -115,8 +115,8 @@ {/if} -
- +
+
diff --git a/src/UI/PlantNet/SpeciesButton.svelte b/src/UI/PlantNet/SpeciesButton.svelte index de7f873332..9e3a44b91b 100644 --- a/src/UI/PlantNet/SpeciesButton.svelte +++ b/src/UI/PlantNet/SpeciesButton.svelte @@ -36,9 +36,7 @@ const wikidataId: Store = UIEventSource.FromPromise( ).mapD(wd => wd[0]?.species?.value); -{ - console.log("Dispatching: ", $wikidataId) - return dispatch("selected", $wikidataId); }}> + dispatch("selected", $wikidataId)}> {#if $wikidata === undefined} image.pictureUrl === v); const t = Translations.t.image.nearby; const c = [lon, lat]; @@ -35,6 +35,7 @@ date: new Date(image.date) }); let distance = Math.round(GeoOperations.distanceBetween([image.coordinates.lng, image.coordinates.lat], c)); + $: { const currentTags = tags.data; const key = Object.keys(image.osmTags)[0]; diff --git a/src/UI/Popup/PlantNetDetectionViz.ts b/src/UI/Popup/PlantNetDetectionViz.ts index 69397cabdf..ed5a7acc4f 100644 --- a/src/UI/Popup/PlantNetDetectionViz.ts +++ b/src/UI/Popup/PlantNetDetectionViz.ts @@ -1,6 +1,5 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource" import { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" -import PlantNetSpeciesSearch from "../BigComponents/PlantNetSpeciesSearch" import Wikidata from "../../Logic/Web/Wikidata" import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction" import { And } from "../../Logic/Tags/And" From c25e2787472d8b7e94bdfe6b6438272d9eecfb93 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 21 Sep 2023 12:14:02 +0200 Subject: [PATCH 15/40] Chore: translation sync --- assets/layers/bench/bench.json | 8 ++++++-- langs/layers/en.json | 23 +++++++++++++++++++++++ langs/layers/nl.json | 3 +++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/assets/layers/bench/bench.json b/assets/layers/bench/bench.json index f6e5650c0a..9bea58a071 100644 --- a/assets/layers/bench/bench.json +++ b/assets/layers/bench/bench.json @@ -890,7 +890,9 @@ "mappings": [ { "if": "tourism=artwork", - "addExtraTags": ["not:tourism:artwork="], + "addExtraTags": [ + "not:tourism:artwork=" + ], "then": { "en": "This bench has an integrated artwork", "nl": "Deze bank heeft een geïntegreerd kunstwerk", @@ -915,7 +917,9 @@ "he": "לספסל זה אין יצירת אמנות משולבת", "pl": "Ta ławka nie ma wbudowanego dzieła sztuki" }, - "addExtraTags": ["tourism="] + "addExtraTags": [ + "tourism=" + ] }, { "if": "tourism=", diff --git a/langs/layers/en.json b/langs/layers/en.json index 460b13f6c1..dba18196cc 100644 --- a/langs/layers/en.json +++ b/langs/layers/en.json @@ -672,6 +672,9 @@ }, "1": { "then": "This bench does not have an integrated artwork" + }, + "2": { + "then": "This bench probably doesn't have an integrated artwork" } }, "question": "Does this bench have an artistic element?", @@ -8765,6 +8768,14 @@ }, "1": { "title": "a surveillance camera mounted on a wall" + }, + "2": { + "description": "An ALPR typically has two lenses and an array of infrared lights.", + "title": "an ALPR camera (Automatic Number Plate Reader)" + }, + "3": { + "description": "An ALPR typically has two lenses and an array of infrared lights.", + "title": "an ALPR camera (Automatic Number Plate Reader) mounted on a wall" } }, "tagRenderings": { @@ -8858,6 +8869,18 @@ "question": "In which geographical direction does this camera film?", "render": "Films to a compass heading of {camera:direction}" }, + "has_alpr": { + "mappings": { + "0": { + "then": "This is a normal camera" + }, + "1": { + "then": "This is an ALPR (Automatic License Plate Reader)" + } + }, + "question": "Can this camera automatically detect license plates?", + "questionHint": "An ALPR (Automatic License Plate Reader) typically has two lenses and an array of infrared LEDS in between." + }, "is_indoor": { "mappings": { "0": { diff --git a/langs/layers/nl.json b/langs/layers/nl.json index 8256d43c2b..3961df739b 100644 --- a/langs/layers/nl.json +++ b/langs/layers/nl.json @@ -568,6 +568,9 @@ }, "1": { "then": "Deze bank heeft geen geïntegreerd kunstwerk" + }, + "2": { + "then": "Deze bank heeft waarschijnlijk geen geïntegreerd kunstwerk" } }, "question": "Heeft deze bank een geïntegreerd kunstwerk?", From 246b16317d93b8e2effe9b41f7ed8c362a61cff6 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 21 Sep 2023 14:50:35 +0200 Subject: [PATCH 16/40] Themes: add icons to question --- .../surveillance_camera.json | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/assets/layers/surveillance_camera/surveillance_camera.json b/assets/layers/surveillance_camera/surveillance_camera.json index a2a2404eb1..2e59fb3975 100644 --- a/assets/layers/surveillance_camera/surveillance_camera.json +++ b/assets/layers/surveillance_camera/surveillance_camera.json @@ -52,7 +52,7 @@ { "if": "surveillance:type=camera", "then": { - "en": "This is a normal camera" + "en": "This is a camera without number plate recognition." } }, { @@ -79,11 +79,7 @@ }, "mappings": [ { - "if": { - "and": [ - "camera:type=fixed" - ] - }, + "if": "camera:type=fixed", "then": { "en": "A fixed (non-moving) camera", "nl": "Een vaste camera", @@ -92,14 +88,11 @@ "de": "Eine fest montierte (nicht bewegliche) Kamera", "ca": "Una càmera fixa (no movible)", "es": "Cámara fija (no móvil)" - } + }, + "icon": "./assets/themes/surveillance/cam_right.svg" }, { - "if": { - "and": [ - "camera:type=dome" - ] - }, + "if": "camera:type=dome", "then": { "en": "A dome camera (which can turn)", "nl": "Een dome (bolvormige camera die kan draaien)", @@ -109,7 +102,8 @@ "de": "Eine Kuppelkamera (drehbar)", "ca": "Càmera de cúpula (que pot girar)", "es": "Cámara con domo (que se puede girar)" - } + }, + "icon": "./assets/themes/surveillance/dome.svg" }, { "if": { From c14cbc9fe95bf1d2432ea7290cae0d0f7a9b6ada Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 21 Sep 2023 14:53:52 +0200 Subject: [PATCH 17/40] Fix: upload flow deals better with point reuse: it actually opens the feature now --- .../Actors/FeaturePropertiesStore.ts | 79 +++--- .../NewGeometryFromChangesFeatureSource.ts | 237 ++++++++++-------- src/Logic/Osm/Actions/CreateNewNodeAction.ts | 8 +- src/Models/ThemeViewState.ts | 2 +- src/UI/Map/MapLibreAdaptor.ts | 7 - src/UI/Popup/AddNewPoint/AddNewPoint.svelte | 185 +++++++------- .../TagRendering/TagRenderingQuestion.svelte | 1 - 7 files changed, 282 insertions(+), 237 deletions(-) diff --git a/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts b/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts index a4f2be3e72..cd7522a29c 100644 --- a/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts +++ b/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts @@ -1,5 +1,6 @@ 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 @@ -13,40 +14,6 @@ export default class FeaturePropertiesStore { } } - public getStore(id: string): UIEventSource> { - 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(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,6 +50,48 @@ export default class FeaturePropertiesStore { return changeMade } + public getStore(id: string): UIEventSource> { + 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(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 = source.data + if (changeMade) { + source.ping() + } + } + + public trackFeatureSource(source: FeatureSource) { + const self = this + source.features.addCallbackAndRunD((features) => { + for (const feature of features) { + self.trackFeature(feature) + } + }) + } + // noinspection JSUnusedGlobalSymbols public addAlias(oldId: string, newId: string): void { if (newId === undefined) { diff --git a/src/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts b/src/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts index a9b8841671..a62070e174 100644 --- a/src/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts @@ -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 = new UIEventSource([]) + private readonly _seenChanges: Set + 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() - const features = this.features.data + constructor( + changes: Changes, + allElementStorage: IndexedFeatureSource, + featureProperties: FeaturePropertiesStore + ) { + this._allElementStorage = allElementStorage + this._featureProperties = featureProperties + this._seenChanges = new Set() + 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 = >{ + type: "Feature", + properties: { + id: 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: (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: (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() + } } } diff --git a/src/Logic/Osm/Actions/CreateNewNodeAction.ts b/src/Logic/Osm/Actions/CreateNewNodeAction.ts index 224f24aab3..251a9413a7 100644 --- a/src/Logic/Osm/Actions/CreateNewNodeAction.ts +++ b/src/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -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] diff --git a/src/Models/ThemeViewState.ts b/src/Models/ThemeViewState.ts index 4058b8c75c..77f7a5dd4c 100644 --- a/src/Models/ThemeViewState.ts +++ b/src/Models/ThemeViewState.ts @@ -244,7 +244,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.newFeatures = new NewGeometryFromChangesFeatureSource( this.changes, indexedElements, - this.osmConnection.Backend() + this.featureProperties ) layoutSource.addSource(this.newFeatures) diff --git a/src/UI/Map/MapLibreAdaptor.ts b/src/UI/Map/MapLibreAdaptor.ts index 75f2d54d4a..4af8ac9804 100644 --- a/src/UI/Map/MapLibreAdaptor.ts +++ b/src/UI/Map/MapLibreAdaptor.ts @@ -376,12 +376,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { } const background: RasterLayerProperties = this.rasterLayer?.data?.properties if (!background) { - console.error( - "Attempting to 'setBackground', but the background is", - background, - "for", - map.getCanvas() - ) return } if (this._currentRasterLayer === background.id) { @@ -457,7 +451,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap { if (!map) { return } - console.log("Rotation allowed:", allow) if (allow === false) { map.rotateTo(0, { duration: 0 }) map.setPitch(0) diff --git a/src/UI/Popup/AddNewPoint/AddNewPoint.svelte b/src/UI/Popup/AddNewPoint/AddNewPoint.svelte index b95fbb5a57..d89e94402e 100644 --- a/src/UI/Popup/AddNewPoint/AddNewPoint.svelte +++ b/src/UI/Popup/AddNewPoint/AddNewPoint.svelte @@ -3,109 +3,109 @@ * This component ties together all the steps that are needed to create a new point. * There are many subcomponents which help with that */ - import type { SpecialVisualizationState } from "../../SpecialVisualization" - import PresetList from "./PresetList.svelte" - import type PresetConfig from "../../../Models/ThemeConfig/PresetConfig" - import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" - import Tr from "../../Base/Tr.svelte" - import SubtleButton from "../../Base/SubtleButton.svelte" - import FromHtml from "../../Base/FromHtml.svelte" - import Translations from "../../i18n/Translations.js" - import TagHint from "../TagHint.svelte" - import { And } from "../../../Logic/Tags/And.js" - import LoginToggle from "../../Base/LoginToggle.svelte" - import Constants from "../../../Models/Constants.js" - import FilteredLayer from "../../../Models/FilteredLayer" - import { Store, UIEventSource } from "../../../Logic/UIEventSource" - import { EyeIcon, EyeOffIcon } from "@rgossiaux/svelte-heroicons/solid" - import LoginButton from "../../Base/LoginButton.svelte" - import NewPointLocationInput from "../../BigComponents/NewPointLocationInput.svelte" - import CreateNewNodeAction from "../../../Logic/Osm/Actions/CreateNewNodeAction" - import { OsmWay } from "../../../Logic/Osm/OsmObject" - import { Tag } from "../../../Logic/Tags/Tag" - import type { WayId } from "../../../Models/OsmFeature" - import Loading from "../../Base/Loading.svelte" - import type { GlobalFilter } from "../../../Models/GlobalFilter" - import { onDestroy } from "svelte" - import NextButton from "../../Base/NextButton.svelte" - import BackButton from "../../Base/BackButton.svelte" - import ToSvelte from "../../Base/ToSvelte.svelte" - import Svg from "../../../Svg" - import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte" - import { twJoin } from "tailwind-merge" + import type { SpecialVisualizationState } from "../../SpecialVisualization"; + import PresetList from "./PresetList.svelte"; + import type PresetConfig from "../../../Models/ThemeConfig/PresetConfig"; + import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; + import Tr from "../../Base/Tr.svelte"; + import SubtleButton from "../../Base/SubtleButton.svelte"; + import FromHtml from "../../Base/FromHtml.svelte"; + import Translations from "../../i18n/Translations.js"; + import TagHint from "../TagHint.svelte"; + import { And } from "../../../Logic/Tags/And.js"; + import LoginToggle from "../../Base/LoginToggle.svelte"; + import Constants from "../../../Models/Constants.js"; + import FilteredLayer from "../../../Models/FilteredLayer"; + import { Store, UIEventSource } from "../../../Logic/UIEventSource"; + import { EyeIcon, EyeOffIcon } from "@rgossiaux/svelte-heroicons/solid"; + import LoginButton from "../../Base/LoginButton.svelte"; + import NewPointLocationInput from "../../BigComponents/NewPointLocationInput.svelte"; + import CreateNewNodeAction from "../../../Logic/Osm/Actions/CreateNewNodeAction"; + import { OsmWay } from "../../../Logic/Osm/OsmObject"; + import { Tag } from "../../../Logic/Tags/Tag"; + import type { WayId } from "../../../Models/OsmFeature"; + import Loading from "../../Base/Loading.svelte"; + import type { GlobalFilter } from "../../../Models/GlobalFilter"; + import { onDestroy } from "svelte"; + import NextButton from "../../Base/NextButton.svelte"; + import BackButton from "../../Base/BackButton.svelte"; + import ToSvelte from "../../Base/ToSvelte.svelte"; + import Svg from "../../../Svg"; + import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte"; + import { twJoin } from "tailwind-merge"; - export let coordinate: { lon: number; lat: number } - export let state: SpecialVisualizationState + export let coordinate: { lon: number; lat: number }; + export let state: SpecialVisualizationState; let selectedPreset: { preset: PresetConfig layer: LayerConfig icon: string tags: Record - } = undefined - let checkedOfGlobalFilters: number = 0 - let confirmedCategory = false + } = undefined; + let checkedOfGlobalFilters: number = 0; + let confirmedCategory = false; $: if (selectedPreset === undefined) { - confirmedCategory = false - creating = false - checkedOfGlobalFilters = 0 + confirmedCategory = false; + creating = false; + checkedOfGlobalFilters = 0; } - let flayer: FilteredLayer = undefined - let layerIsDisplayed: UIEventSource | undefined = undefined - let layerHasFilters: Store | undefined = undefined - let globalFilter: UIEventSource = state.layerState.globalFilters - let _globalFilter: GlobalFilter[] = [] + let flayer: FilteredLayer = undefined; + let layerIsDisplayed: UIEventSource | undefined = undefined; + let layerHasFilters: Store | undefined = undefined; + let globalFilter: UIEventSource = state.layerState.globalFilters; + let _globalFilter: GlobalFilter[] = []; onDestroy( globalFilter.addCallbackAndRun((globalFilter) => { - console.log("Global filters are", globalFilter) - _globalFilter = globalFilter ?? [] + console.log("Global filters are", globalFilter); + _globalFilter = globalFilter ?? []; }) - ) + ); $: { - flayer = state.layerState.filteredLayers.get(selectedPreset?.layer?.id) - layerIsDisplayed = flayer?.isDisplayed - layerHasFilters = flayer?.hasFilter + flayer = state.layerState.filteredLayers.get(selectedPreset?.layer?.id); + layerIsDisplayed = flayer?.isDisplayed; + layerHasFilters = flayer?.hasFilter; } - const t = Translations.t.general.add + const t = Translations.t.general.add; - const zoom = state.mapProperties.zoom + const zoom = state.mapProperties.zoom; - const isLoading = state.dataIsLoading - let preciseCoordinate: UIEventSource<{ lon: number; lat: number }> = new UIEventSource(undefined) - let snappedToObject: UIEventSource = new UIEventSource(undefined) + const isLoading = state.dataIsLoading; + let preciseCoordinate: UIEventSource<{ lon: number; lat: number }> = new UIEventSource(undefined); + let snappedToObject: UIEventSource = new UIEventSource(undefined); // Small helper variable: if the map is tapped, we should let the 'Next'-button grab some attention as users have to click _that_ to continue, not the map - let preciseInputIsTapped = false + let preciseInputIsTapped = false; - let creating = false + let creating = false; /** * Call when the user should restart the flow by clicking on the map, e.g. because they disabled filters. * Will delete the lastclick-location */ function abort() { - state.selectedElement.setData(undefined) + state.selectedElement.setData(undefined); // When aborted, we force the contributors to place the pin _again_ // This is because there might be a nearby object that was disabled; this forces them to re-evaluate the map - state.lastClickObject.features.setData([]) - preciseInputIsTapped = false + state.lastClickObject.features.setData([]); + preciseInputIsTapped = false; } async function confirm() { - creating = true - const location: { lon: number; lat: number } = preciseCoordinate.data - const snapTo: WayId | undefined = snappedToObject.data + creating = true; + const location: { lon: number; lat: number } = preciseCoordinate.data; + const snapTo: WayId | undefined = snappedToObject.data; const tags: Tag[] = selectedPreset.preset.tags.concat( ..._globalFilter.map((f) => f?.onNewPoint?.tags ?? []) - ) - console.log("Creating new point at", location, "snapped to", snapTo, "with tags", tags) + ); + console.log("Creating new point at", location, "snapped to", snapTo, "with tags", tags); - let snapToWay: undefined | OsmWay = undefined + let snapToWay: undefined | OsmWay = undefined; if (snapTo !== undefined) { - const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0) + const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0); if (downloaded !== "deleted") { - snapToWay = downloaded + snapToWay = downloaded; } } @@ -113,33 +113,42 @@ theme: state.layout?.id ?? "unkown", changeType: "create", snapOnto: snapToWay, - }) - await state.changes.applyAction(newElementAction) - state.newFeatures.features.ping() + reusePointWithinMeters: 1 + }); + await state.changes.applyAction(newElementAction); + state.newFeatures.features.ping(); // The 'changes' should have created a new point, which added this into the 'featureProperties' - const newId = newElementAction.newElementId - console.log("Applied pending changes, fetching store for", newId) - const tagsStore = state.featureProperties.getStore(newId) + const newId = newElementAction.newElementId; + console.log("Applied pending changes, fetching store for", newId); + const tagsStore = state.featureProperties.getStore(newId); + if (!tagsStore) { + console.error("Bug: no tagsStore found for", newId); + } { // Set some metainfo - const properties = tagsStore.data + const properties = tagsStore.data; if (snapTo) { // metatags (starting with underscore) are not uploaded, so we can safely mark this - delete properties["_referencing_ways"] - properties["_referencing_ways"] = `["${snapTo}"]` + delete properties["_referencing_ways"]; + properties["_referencing_ways"] = `["${snapTo}"]`; } - properties["_backend"] = state.osmConnection.Backend() - properties["_last_edit:timestamp"] = new Date().toISOString() - const userdetails = state.osmConnection.userDetails.data - properties["_last_edit:contributor"] = userdetails.name - properties["_last_edit:uid"] = "" + userdetails.uid - tagsStore.ping() + properties["_backend"] = state.osmConnection.Backend(); + properties["_last_edit:timestamp"] = new Date().toISOString(); + const userdetails = state.osmConnection.userDetails.data; + properties["_last_edit:contributor"] = userdetails.name; + properties["_last_edit:uid"] = "" + userdetails.uid; + tagsStore.ping(); } - const feature = state.indexedFeatures.featuresById.data.get(newId) - abort() - state.selectedLayer.setData(selectedPreset.layer) - state.selectedElement.setData(feature) - tagsStore.ping() + const feature = state.indexedFeatures.featuresById.data.get(newId); + console.log("Selecting feature", feature, "and opening their popup"); + abort(); + state.selectedLayer.setData(selectedPreset.layer); + state.selectedElement.setData(feature); + tagsStore.ping(); + } + + function confirmSync() { + confirm().then(_ => console.debug("New point successfully handled")).catch(e => console.error("Handling the new point went wrong due to", e)); } @@ -328,7 +337,7 @@ "absolute top-0 flex w-full justify-center p-12" )} > - +
diff --git a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte index 0522baf0eb..878b33923e 100644 --- a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte @@ -88,7 +88,6 @@ } - console.log("Inited 'checkMappings' to", checkedMappings); if (confg.freeform?.key) { if (!confg.multiAnswer) { // Somehow, setting multi-answer freeform values is broken if this is not set From db1dbc0f07c3fdda08dbb0be229893be03cdf8ab Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 21 Sep 2023 15:28:40 +0200 Subject: [PATCH 18/40] Fix tests, version bump --- package.json | 2 +- src/Logic/Osm/OsmConnection.ts | 2 +- test/Logic/Actors/Actors.spec.ts | 2 +- .../OSM/Actions/ReplaceGeometryAction.spec.ts | 6 +++--- test/Logic/OSM/Actions/SplitAction.spec.ts | 16 ++++++++-------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 2216e5314c..443d54e3df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapcomplete", - "version": "0.33.1", + "version": "0.33.2", "repository": "https://github.com/pietervdvn/MapComplete", "description": "A small website to edit OSM easily", "bugs": "https://github.com/pietervdvn/MapComplete/issues", diff --git a/src/Logic/Osm/OsmConnection.ts b/src/Logic/Osm/OsmConnection.ts index 0ee8505d1e..e650250a92 100644 --- a/src/Logic/Osm/OsmConnection.ts +++ b/src/Logic/Osm/OsmConnection.ts @@ -179,7 +179,7 @@ export class OsmConnection { /** * The backend host, without path or trailing '/' * - * new OsmConnection().Backend() // => "https://api.openstreetmap.org" + * new OsmConnection().Backend() // => "https://www.openstreetmap.org" */ public Backend(): string { return this._oauth_config.url diff --git a/test/Logic/Actors/Actors.spec.ts b/test/Logic/Actors/Actors.spec.ts index 751964722e..ef31ee3193 100644 --- a/test/Logic/Actors/Actors.spec.ts +++ b/test/Logic/Actors/Actors.spec.ts @@ -21,7 +21,7 @@ const latestTags = { "public_bookcase:type": "reading_box", } -Utils.injectJsonDownloadForTests("https://api.openstreetmap.org/api/0.6/node/5568693115", { +Utils.injectJsonDownloadForTests("https://www.openstreetmap.org/api/0.6/node/5568693115", { version: "0.6", generator: "CGImap 0.8.5 (1815943 spike-06.openstreetmap.org)", copyright: "OpenStreetMap and contributors", diff --git a/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts b/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts index bcc6e69d7b..94098e525e 100644 --- a/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts +++ b/test/Logic/OSM/Actions/ReplaceGeometryAction.spec.ts @@ -327,7 +327,7 @@ describe("ReplaceGeometryAction", () => { const wayId = "way/160909312" Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/map.json?bbox=3.2166673243045807,51.21467321525788,3.217007964849472,51.21482442824023", + "https://www.openstreetmap.org/api/0.6/map.json?bbox=3.2166673243045807,51.21467321525788,3.217007964849472,51.21482442824023", { version: "0.6", generator: "CGImap 0.8.6 (1549677 spike-06.openstreetmap.org)", @@ -715,7 +715,7 @@ describe("ReplaceGeometryAction", () => { } ) - Utils.injectJsonDownloadForTests("https://api.openstreetmap.org/api/0.6/way/160909312/full", { + Utils.injectJsonDownloadForTests("https://www.openstreetmap.org/api/0.6/way/160909312/full", { version: "0.6", generator: "CGImap 0.8.6 (2407324 spike-06.openstreetmap.org)", copyright: "OpenStreetMap and contributors", @@ -880,7 +880,7 @@ describe("ReplaceGeometryAction", () => { [3.2166673243045807, 51.21467321525788], [3.217007964849472, 51.21482442824023], ]) - const url = `https://api.openstreetmap.org/api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}` + const url = `https://www.openstreetmap.org/api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}` const data = await Utils.downloadJson(url) const fullNodeDatabase = new FullNodeDatabaseSource() fullNodeDatabase.handleOsmJson(data, 0, 0, 0) diff --git a/test/Logic/OSM/Actions/SplitAction.spec.ts b/test/Logic/OSM/Actions/SplitAction.spec.ts index aee9a58600..53147d224c 100644 --- a/test/Logic/OSM/Actions/SplitAction.spec.ts +++ b/test/Logic/OSM/Actions/SplitAction.spec.ts @@ -9,7 +9,7 @@ describe("SplitAction", () => { { // Setup of download Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/941079939/full", + "https://www.openstreetmap.org/api/0.6/way/941079939/full", { version: "0.6", generator: "CGImap 0.8.5 (957273 spike-08.openstreetmap.org)", @@ -210,7 +210,7 @@ describe("SplitAction", () => { ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/941079939/relations", + "https://www.openstreetmap.org/api/0.6/way/941079939/relations", { version: "0.6", generator: "CGImap 0.8.5 (2419440 spike-07.openstreetmap.org)", @@ -222,7 +222,7 @@ describe("SplitAction", () => { ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/295132739/full", + "https://www.openstreetmap.org/api/0.6/way/295132739/full", { version: "0.6", generator: "CGImap 0.8.5 (3138407 spike-07.openstreetmap.org)", @@ -409,7 +409,7 @@ describe("SplitAction", () => { } ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/295132739/relations", + "https://www.openstreetmap.org/api/0.6/way/295132739/relations", // Mimick that there are no relations relation is missing { version: "0.6", @@ -422,7 +422,7 @@ describe("SplitAction", () => { ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/61435323/full", + "https://www.openstreetmap.org/api/0.6/way/61435323/full", { version: "0.6", generator: "CGImap 0.8.5 (53092 spike-08.openstreetmap.org)", @@ -488,7 +488,7 @@ describe("SplitAction", () => { } ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/61435323/relations", + "https://www.openstreetmap.org/api/0.6/way/61435323/relations", { version: "0.6", generator: "CGImap 0.8.5 (3622541 spike-06.openstreetmap.org)", @@ -2567,7 +2567,7 @@ describe("SplitAction", () => { } ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/61435332/full", + "https://www.openstreetmap.org/api/0.6/way/61435332/full", { version: "0.6", generator: "CGImap 0.8.5 (3819319 spike-06.openstreetmap.org)", @@ -2620,7 +2620,7 @@ describe("SplitAction", () => { } ) Utils.injectJsonDownloadForTests( - "https://api.openstreetmap.org/api/0.6/way/509668834/full", + "https://www.openstreetmap.org/api/0.6/way/509668834/full", { version: "0.6", generator: "CGImap 0.8.5 (3735280 spike-06.openstreetmap.org)", From e6d84019a579daed01a34cf67825689511fd9c0a Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 21 Sep 2023 15:38:28 +0200 Subject: [PATCH 19/40] Fix: correct merge conflict --- src/UI/Popup/LinkableImage.svelte | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/UI/Popup/LinkableImage.svelte b/src/UI/Popup/LinkableImage.svelte index c5d286ba39..ef04b98689 100644 --- a/src/UI/Popup/LinkableImage.svelte +++ b/src/UI/Popup/LinkableImage.svelte @@ -23,9 +23,6 @@ export let feature: Feature export let layer: LayerConfig - export let linkable = true - let isLinked = false - export let linkable = true; let isLinked = Object.values(tags.data).some(v => image.pictureUrl === v); From 865b0bc44f694ad9c2295466fbe4c8abea266386 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 21 Sep 2023 16:09:51 +0200 Subject: [PATCH 20/40] Security: pin external github actions --- .github/actions/setup-and-validate/action.yml | 2 +- .github/workflows/deploy_pietervdvn.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-and-validate/action.yml b/.github/actions/setup-and-validate/action.yml index 51201b9589..baa0af7a25 100644 --- a/.github/actions/setup-and-validate/action.yml +++ b/.github/actions/setup-and-validate/action.yml @@ -19,7 +19,7 @@ runs: shell: bash - name: REUSE compliance check - uses: fsfe/reuse-action@v2 + uses: fsfe/reuse-action@952281636420dd0b691786c93e9d3af06032f138 - name: create generated dir run: mkdir ./assets/generated diff --git a/.github/workflows/deploy_pietervdvn.yml b/.github/workflows/deploy_pietervdvn.yml index ec51d7ea70..817f25606b 100644 --- a/.github/workflows/deploy_pietervdvn.yml +++ b/.github/workflows/deploy_pietervdvn.yml @@ -89,7 +89,7 @@ jobs: env: TARGET_BRANCH: ${{ env.TARGET_BRANCH }} - - uses: mshick/add-pr-comment@v1 + - uses: mshick/add-pr-comment@a96c578acba98b60f16c6866d5f20478dc4ef68b name: Comment the PR with the review URL if: ${{ success() && github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/master' }} with: From 39944a01fb02635e88e2e498c4d3752bd95aa256 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Fri, 22 Sep 2023 11:20:22 +0200 Subject: [PATCH 21/40] Refactoring: automatically generate code files from layer/theme files to avoid using 'Eval' --- 404.html | 1 + index.html | 7 +- public/css/index-tailwind-output.css | 22 ++-- scripts/build.sh | 4 +- scripts/generateLayerOverview.ts | 124 ++++++++++++++++++ scripts/generateLayouts.ts | 28 +++- src/Logic/ExtraFunctions.ts | 6 +- src/Logic/MetaTagging.ts | 51 ++++++- src/Logic/State/UserRelatedState.ts | 8 +- src/Logic/State/UserSettingsMetaTagging.ts | 13 ++ .../ThemeConfig/Conversion/Validation.ts | 9 ++ src/Models/ThemeConfig/LayerConfig.ts | 6 - src/UI/SpecialVisualizations.ts | 2 +- src/Utils.ts | 7 +- src/assets/editor-layer-index.json | 6 +- src/index_theme.ts.template | 3 + theme.html | 3 +- 17 files changed, 269 insertions(+), 31 deletions(-) create mode 100644 src/Logic/State/UserSettingsMetaTagging.ts diff --git a/404.html b/404.html index 8d425e109f..9b667602b6 100644 --- a/404.html +++ b/404.html @@ -3,6 +3,7 @@ + diff --git a/index.html b/index.html index 4a29a70269..5d850ae9ef 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + @@ -16,8 +17,6 @@ MapComplete - -
Mastodon @@ -48,10 +47,12 @@ + +
- @@ -58,12 +62,24 @@
- p === "denied")}> + {#if $currentGPSLocation !== undefined || $geopermission === "prompt"} - + + {:else if $geopermission === "requested"} + + {:else if $geopermission !== "denied"} + + {/if}
From 90c44cc8bdfb7b2447f1974ed94430e5b2fa3c6f Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 02:09:19 +0200 Subject: [PATCH 32/40] Themes: enable deletion of bike_cafes --- assets/layers/bike_cafe/bike_cafe.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/layers/bike_cafe/bike_cafe.json b/assets/layers/bike_cafe/bike_cafe.json index d90821ded4..ea86ad10a7 100644 --- a/assets/layers/bike_cafe/bike_cafe.json +++ b/assets/layers/bike_cafe/bike_cafe.json @@ -363,5 +363,6 @@ "fr": "Un vélo café est un café à destination des cyclistes avec, par exemple, des services tels qu’une pompe, et de nombreuses décorations liées aux vélos, etc.", "cs": "Cyklokavárna je kavárna zaměřená na cyklisty, například se službami, jako je pumpa, se spoustou výzdoby související s jízdními koly, …", "ca": "Un cafè ciclista és un cafè enfocat a ciclistes, per exemple, amb serveis com una manxa, amb molta decoració relacionada amb el ciclisme, …" - } + }, + "deletion": true } From 57e0093e478f541c46145a72dbfb9fc70d467633 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 02:09:42 +0200 Subject: [PATCH 33/40] Fix: initialize user settings with a strict value to be able to override it --- assets/layers/usersettings/usersettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/layers/usersettings/usersettings.json b/assets/layers/usersettings/usersettings.json index 8054c27756..10d21c736b 100644 --- a/assets/layers/usersettings/usersettings.json +++ b/assets/layers/usersettings/usersettings.json @@ -24,7 +24,7 @@ "_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) ", "_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) ", "_mastodon_candidate=feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a", - "__current_background='initial_value'" + "__current_background:='initial_value'" ], "tagRenderings": [ { From 6f5b0622a5c8a319ab5c20d822f6a5b817fc1489 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 02:11:42 +0200 Subject: [PATCH 34/40] Chore: remove some obsolete console.logs --- .../ImageProviders/ImageUploadManager.ts | 0 src/Logic/ImageProviders/ImageUploader.ts | 0 src/Logic/ImageProviders/ImgurUploader.ts | 43 ------------------- .../{LinkPicture.ts => LinkImageAction.ts} | 0 src/Logic/Osm/ChangesetHandler.ts | 7 +-- src/Logic/State/GeoLocationState.ts | 1 - src/Logic/State/UserSettingsMetaTagging.ts | 2 +- src/Logic/UIEventSource.ts | 2 +- src/UI/Base/FileSelector.svelte | 0 src/UI/Image/UploadImage.svelte | 0 src/UI/Image/UploadingImageCounter.svelte | 31 +++++++++++++ src/UI/Popup/DeleteFlow/DeleteWizard.svelte | 2 - src/UI/Popup/LinkableImage.svelte | 4 +- 13 files changed, 39 insertions(+), 53 deletions(-) create mode 100644 src/Logic/ImageProviders/ImageUploadManager.ts create mode 100644 src/Logic/ImageProviders/ImageUploader.ts delete mode 100644 src/Logic/ImageProviders/ImgurUploader.ts rename src/Logic/Osm/Actions/{LinkPicture.ts => LinkImageAction.ts} (100%) create mode 100644 src/UI/Base/FileSelector.svelte create mode 100644 src/UI/Image/UploadImage.svelte create mode 100644 src/UI/Image/UploadingImageCounter.svelte diff --git a/src/Logic/ImageProviders/ImageUploadManager.ts b/src/Logic/ImageProviders/ImageUploadManager.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Logic/ImageProviders/ImageUploader.ts b/src/Logic/ImageProviders/ImageUploader.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Logic/ImageProviders/ImgurUploader.ts b/src/Logic/ImageProviders/ImgurUploader.ts deleted file mode 100644 index bb4fc6a9f8..0000000000 --- a/src/Logic/ImageProviders/ImgurUploader.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { UIEventSource } from "../UIEventSource" -import { Imgur } from "./Imgur" - -export default class ImgurUploader { - public readonly queue: UIEventSource = new UIEventSource([]) - public readonly failed: UIEventSource = new UIEventSource([]) - public readonly success: UIEventSource = new UIEventSource([]) - public maxFileSizeInMegabytes = 10 - private readonly _handleSuccessUrl: (string) => Promise - - constructor(handleSuccessUrl: (string) => Promise) { - 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]) - } - ) - } -} diff --git a/src/Logic/Osm/Actions/LinkPicture.ts b/src/Logic/Osm/Actions/LinkImageAction.ts similarity index 100% rename from src/Logic/Osm/Actions/LinkPicture.ts rename to src/Logic/Osm/Actions/LinkImageAction.ts diff --git a/src/Logic/Osm/ChangesetHandler.ts b/src/Logic/Osm/ChangesetHandler.ts index dcdb8a9361..4b2a70b322 100644 --- a/src/Logic/Osm/ChangesetHandler.ts +++ b/src/Logic/Osm/ChangesetHandler.ts @@ -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 @@ -29,11 +30,11 @@ export class ChangesetHandler { constructor( dryRun: Store, 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 = allElements this.changes = changes this._dryRun = dryRun this.userDetails = osmConnection.userDetails diff --git a/src/Logic/State/GeoLocationState.ts b/src/Logic/State/GeoLocationState.ts index ff7f3ac449..fe395fde04 100644 --- a/src/Logic/State/GeoLocationState.ts +++ b/src/Logic/State/GeoLocationState.ts @@ -61,7 +61,6 @@ export class GeoLocationState { const self = this; this.permission.addCallbackAndRunD(async (state) => { - console.trace("GEOPERMISSION", state) if (state === "granted") { self._previousLocationGrant.setData("true"); self._grantedThisSession.setData(true); diff --git a/src/Logic/State/UserSettingsMetaTagging.ts b/src/Logic/State/UserSettingsMetaTagging.ts index 74a74dae45..33a5ae85b5 100644 --- a/src/Logic/State/UserSettingsMetaTagging.ts +++ b/src/Logic/State/UserSettingsMetaTagging.ts @@ -9,6 +9,6 @@ export class ThemeMetaTagging { 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 ) - Utils.AddLazyProperty(feat.properties, '__current_background', () => 'initial_value' ) + feat.properties['__current_backgroun'] = 'initial_value' } } \ No newline at end of file diff --git a/src/Logic/UIEventSource.ts b/src/Logic/UIEventSource.ts index b726465391..bb22df11cc 100644 --- a/src/Logic/UIEventSource.ts +++ b/src/Logic/UIEventSource.ts @@ -515,7 +515,7 @@ class MappedStore extends Store { } 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()) diff --git a/src/UI/Base/FileSelector.svelte b/src/UI/Base/FileSelector.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/UI/Image/UploadImage.svelte b/src/UI/Image/UploadImage.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/UI/Image/UploadingImageCounter.svelte b/src/UI/Image/UploadingImageCounter.svelte new file mode 100644 index 0000000000..a3bfa02e56 --- /dev/null +++ b/src/UI/Image/UploadingImageCounter.svelte @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/src/UI/Popup/DeleteFlow/DeleteWizard.svelte b/src/UI/Popup/DeleteFlow/DeleteWizard.svelte index 8eada0a7fb..2cbfb0850a 100644 --- a/src/UI/Popup/DeleteFlow/DeleteWizard.svelte +++ b/src/UI/Popup/DeleteFlow/DeleteWizard.svelte @@ -38,7 +38,6 @@ const hasSoftDeletion = deleteConfig.softDeletionTags !== undefined let currentState: "start" | "confirm" | "applying" | "deleted" = "start" $: { - console.log("Current state is", currentState, $canBeDeleted, canBeDeletedReason) deleteAbility.CheckDeleteability(true) } @@ -55,7 +54,6 @@ let actionToTake: OsmChangeAction const changedProperties = TagUtils.changeAsProperties(selectedTags.asChange(tags?.data ?? {})) const deleteReason = changedProperties[DeleteConfig.deleteReasonKey] - console.log("Deleting! Hard?:", canBeDeleted.data, deleteReason) if (deleteReason) { // This is a proper, hard deletion actionToTake = new DeleteAction( diff --git a/src/UI/Popup/LinkableImage.svelte b/src/UI/Popup/LinkableImage.svelte index ef04b98689..2aa280f986 100644 --- a/src/UI/Popup/LinkableImage.svelte +++ b/src/UI/Popup/LinkableImage.svelte @@ -6,7 +6,7 @@ import ToSvelte from "../Base/ToSvelte.svelte" import { AttributedImage } from "../Image/AttributedImage" import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders" - import LinkPicture from "../../Logic/Osm/Actions/LinkPicture" + import LinkImageAction from "../../Logic/Osm/Actions/LinkImageAction" import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction" import { Tag } from "../../Logic/Tags/Tag" import { GeoOperations } from "../../Logic/GeoOperations" @@ -40,7 +40,7 @@ const key = Object.keys(image.osmTags)[0] const url = image.osmTags[key] if (isLinked) { - const action = new LinkPicture(currentTags.id, key, url, currentTags, { + const action = new LinkImageAction(currentTags.id, key, url, currentTags, { theme: state.layout.id, changeType: "link-image", }) From 94ba18785d2383b0ae384f8af2e6f66c7ad89e77 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 02:13:24 +0200 Subject: [PATCH 35/40] Chore: rework image uploading, should work better now --- langs/en.json | 20 +- public/css/index-tailwind-output.css | 20 -- .../Actors/FeaturePropertiesStore.ts | 4 +- .../ImageProviders/ImageUploadManager.ts | 150 ++++++++++++++ src/Logic/ImageProviders/ImageUploader.ts | 15 ++ src/Logic/ImageProviders/Imgur.ts | 79 ++------ src/Logic/Osm/Actions/LinkImageAction.ts | 40 +++- src/Logic/Osm/Actions/OsmChangeAction.ts | 3 + src/Logic/State/UserRelatedState.ts | 43 ++-- src/Models/ThemeViewState.ts | 6 + src/UI/Base/FileSelector.svelte | 40 ++++ src/UI/Base/Loading.svelte | 11 +- src/UI/Image/ImageUploadFlow.ts | 19 +- src/UI/Image/UploadImage.svelte | 73 +++++++ src/UI/Image/UploadingImageCounter.svelte | 68 +++++-- src/UI/SpecialVisualization.ts | 184 +++++++++--------- src/UI/SpecialVisualizations.ts | 11 +- 17 files changed, 548 insertions(+), 238 deletions(-) diff --git a/langs/en.json b/langs/en.json index f25b61951f..42432d12c9 100644 --- a/langs/en.json +++ b/langs/en.json @@ -344,8 +344,8 @@ }, "useSearch": "Use the search above to see presets", "useSearchForMore": "Use the search function to search within {total} more values…", - "waitingForGeopermission": "Waiting for your permission to use the geolocation...", - "waitingForLocation": "Searching your current location...", + "waitingForGeopermission": "Waiting for your permission to use the geolocation…", + "waitingForLocation": "Searching your current location…", "weekdays": { "abbreviations": { "friday": "Fri", @@ -416,6 +416,22 @@ "pleaseLogin": "Please log in to add a picture", "respectPrivacy": "Do not photograph people nor license plates. Do not upload Google Maps, Google Streetview or other copyrighted sources.", "toBig": "Your image is too large as it is {actual_size}. Please use images of at most {max_size}", + "upload": { + "failReasons": "You might have lost connection to the internet", + "failReasonsAdvanced": "Alternatively, make sure your browser and extensions do not block third-party API's.", + "multiple": { + "done": "{count} images are successfully uploaded. Thank you!", + "partiallyDone": "{count} images are getting uploaded, {done} images are done…", + "someFailed": "Sorry, we could not upload {count} images", + "uploading": "{count} images are getting uploaded…" + }, + "one": { + "done": "Your image was successfully uploaded. Thank you!", + "failed": "Sorry, we could not upload your image", + "retrying": "Your image is getting uploaded again…", + "uploading": "Your image is getting uploaded…" + } + }, "uploadDone": "Your picture has been added. Thanks for helping out!", "uploadFailed": "Could not upload your picture. Are you connected to the Internet, and allow third party API's? The Brave browser or the uMatrix plugin might block them.", "uploadMultipleDone": "{count} pictures have been added. Thanks for helping out!", diff --git a/public/css/index-tailwind-output.css b/public/css/index-tailwind-output.css index f38fd858e6..da51a2a7f0 100644 --- a/public/css/index-tailwind-output.css +++ b/public/css/index-tailwind-output.css @@ -2682,26 +2682,6 @@ a.link-underline { } } -@media (prefers-reduced-motion: reduce) { - @-webkit-keyframes spin { - to { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } - } - @keyframes spin { - to { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } - } - - .motion-reduce\:animate-spin { - -webkit-animation: spin 1s linear infinite; - animation: spin 1s linear infinite; - } -} - @media (max-width: 480px) { .max-\[480px\]\:w-full { width: 100%; diff --git a/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts b/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts index cd7522a29c..c8186a8bad 100644 --- a/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts +++ b/src/Logic/FeatureSource/Actors/FeaturePropertiesStore.ts @@ -7,7 +7,7 @@ import { OsmTags } from "../../../Models/OsmFeature" */ export default class FeaturePropertiesStore { private readonly _elements = new Map>>() - + public readonly aliases = new Map() constructor(...sources: FeatureSource[]) { for (const source of sources) { this.trackFeatureSource(source) @@ -92,7 +92,6 @@ export default class FeaturePropertiesStore { }) } - // noinspection JSUnusedGlobalSymbols public addAlias(oldId: string, newId: string): void { if (newId === undefined) { // We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap! @@ -112,6 +111,7 @@ export default class FeaturePropertiesStore { } element.data.id = newId this._elements.set(newId, element) + this.aliases.set(newId, oldId) element.ping() } diff --git a/src/Logic/ImageProviders/ImageUploadManager.ts b/src/Logic/ImageProviders/ImageUploadManager.ts index e69de29bb2..9bb2f9535e 100644 --- a/src/Logic/ImageProviders/ImageUploadManager.ts +++ b/src/Logic/ImageProviders/ImageUploadManager.ts @@ -0,0 +1,150 @@ +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"; + + +/** + * The ImageUploadManager has a + */ +export class ImageUploadManager { + + private readonly _uploader: ImageUploader; + private readonly _featureProperties: FeaturePropertiesStore; + private readonly _layout: LayoutConfig; + + private readonly _uploadStarted: Map> = new Map(); + private readonly _uploadFinished: Map> = new Map(); + private readonly _uploadFailed: Map> = new Map(); + private readonly _uploadRetried: Map> = new Map(); + private readonly _uploadRetriedSuccess: Map> = 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; + uploadStarted: Store; + retrySuccess: Store; + failed: Store; + uploadFinished: Store + } { + 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 + */ + public async uploadImageAndApply(file: File, tags: OsmTags) { + + const sizeInBytes = file.size + const featureId = tags.id + console.log(file.name + " has a size of " + sizeInBytes + " Bytes, attaching to", 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); + await this._changes.applyAction(action); + } + + private async uploadImageWithLicense( + featureId: OsmId, + title: string, description: string, blob: File + ): Promise { + 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>, key: string | "*") { + if (this._featureProperties.aliases.has(key)) { + key = this._featureProperties.aliases.get(key); + } + if (!collection.has(key)) { + collection.set(key, new UIEventSource(0)); + } + return collection.get(key); + } + + private increaseCountFor(collection: Map>, key: string | "*") { + const counter = this.getCounterFor(collection, key); + counter.setData(counter.data + 1); + const global = this.getCounterFor(collection, "*"); + global.setData(counter.data + 1); + } + +} diff --git a/src/Logic/ImageProviders/ImageUploader.ts b/src/Logic/ImageProviders/ImageUploader.ts index e69de29bb2..3efb8d2793 100644 --- a/src/Logic/ImageProviders/ImageUploader.ts +++ b/src/Logic/ImageProviders/ImageUploader.ts @@ -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 }>; +} diff --git a/src/Logic/ImageProviders/Imgur.ts b/src/Logic/ImageProviders/Imgur.ts index a7a1427331..4e4a1c5418 100644 --- a/src/Logic/ImageProviders/Imgur.ts +++ b/src/Logic/ImageProviders/Imgur.ts @@ -1,60 +1,30 @@ -import ImageProvider, { ProvidedImage } from "./ImageProvider" -import BaseUIElement from "../../UI/BaseUIElement" -import { Utils } from "../../Utils" -import Constants from "../../Models/Constants" -import { LicenseInfo } from "./LicenseInfo" +import ImageProvider, { ProvidedImage } from "./ImageProvider"; +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, - 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, - onFail: (reason: string) => void - ) { + blob: File + ): Promise<{ key: string, value: string }> { const apiUrl = "https://api.imgur.com/3/image" const apiKey = Constants.ImgurApiKey @@ -63,6 +33,7 @@ export class Imgur extends ImageProvider { formData.append("title", title) formData.append("description", description) + const settings: RequestInit = { method: "POST", body: formData, @@ -74,17 +45,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 { diff --git a/src/Logic/Osm/Actions/LinkImageAction.ts b/src/Logic/Osm/Actions/LinkImageAction.ts index 014a836a06..1b2b90d19f 100644 --- a/src/Logic/Osm/Actions/LinkImageAction.ts +++ b/src/Logic/Osm/Actions/LinkImageAction.ts @@ -1,11 +1,20 @@ -import ChangeTagAction from "./ChangeTagAction" -import { Tag } from "../../Tags/Tag" +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; + private readonly _url: string; + private readonly _currentTags: Store>; + private readonly _meta: { theme: string; changeType: "add-image" | "link-image" }; -export default class LinkPicture extends ChangeTagAction { /** - * Adds a link to an 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 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 @@ -15,18 +24,31 @@ export default class LinkPicture extends ChangeTagAction { elementId: string, proposedKey: "image" | "mapillary" | "wiki_commons" | string, url: string, - currentTags: Record, + currentTags: Store>, meta: { theme: string changeType: "add-image" | "link-image" } ) { - let key = proposedKey + super(elementId, true) + this._proposedKey = proposedKey; + this._url = url; + this._currentTags = currentTags; + this._meta = meta; + } + + protected CreateChangeDescriptions(): Promise { + let key = this._proposedKey let i = 0 + const currentTags = this._currentTags.data + const url = this._url while (currentTags[key] !== undefined && currentTags[key] !== url) { - key = proposedKey + ":" + i + key = this._proposedKey + ":" + i i++ } - super(elementId, new Tag(key, url), currentTags, meta) + const tagChangeAction = new ChangeTagAction ( this.mainObjectId, new Tag(key, url), currentTags, this._meta) + return tagChangeAction.CreateChangeDescriptions() } + + } diff --git a/src/Logic/Osm/Actions/OsmChangeAction.ts b/src/Logic/Osm/Actions/OsmChangeAction.ts index 4161dc9676..2bf31b02c2 100644 --- a/src/Logic/Osm/Actions/OsmChangeAction.ts +++ b/src/Logic/Osm/Actions/OsmChangeAction.ts @@ -19,6 +19,9 @@ export default abstract class OsmChangeAction { constructor(mainObjectId: string, trackStatistics: boolean = true) { this.trackStatistics = trackStatistics this.mainObjectId = mainObjectId + if(mainObjectId === undefined || mainObjectId === null){ + throw "OsmObject received '"+mainObjectId+"' as mainObjectId" + } } public async Perform(changes: Changes) { diff --git a/src/Logic/State/UserRelatedState.ts b/src/Logic/State/UserRelatedState.ts index 8c276be2c1..867ef45d51 100644 --- a/src/Logic/State/UserRelatedState.ts +++ b/src/Logic/State/UserRelatedState.ts @@ -1,22 +1,22 @@ -import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" -import { OsmConnection } from "../Osm/OsmConnection" -import { MangroveIdentity } from "../Web/MangroveReviews" -import { Store, Stores, UIEventSource } from "../UIEventSource" -import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource" -import { FeatureSource } from "../FeatureSource/FeatureSource" -import { Feature } from "geojson" -import { Utils } from "../../Utils" -import translators from "../../assets/translators.json" -import codeContributors from "../../assets/contributors.json" -import LayerConfig from "../../Models/ThemeConfig/LayerConfig" -import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" -import usersettings from "../../../src/assets/generated/layers/usersettings.json" -import Locale from "../../UI/i18n/Locale" -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 LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; +import { OsmConnection } from "../Osm/OsmConnection"; +import { MangroveIdentity } from "../Web/MangroveReviews"; +import { Store, Stores, UIEventSource } from "../UIEventSource"; +import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"; +import { FeatureSource } from "../FeatureSource/FeatureSource"; +import { Feature } from "geojson"; +import { Utils } from "../../Utils"; +import translators from "../../assets/translators.json"; +import codeContributors from "../../assets/contributors.json"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"; +import usersettings from "../../../src/assets/generated/layers/usersettings.json"; +import Locale from "../../UI/i18n/Locale"; +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"; /** @@ -43,7 +43,7 @@ export default class UserRelatedState { public readonly homeLocation: FeatureSource public readonly language: UIEventSource public readonly preferredBackgroundLayer: UIEventSource - public readonly preferredBackgroundLayerForTheme: UIEventSource + public readonly imageLicense : UIEventSource /** * The number of seconds that the GPS-locations are stored in memory. * Time in seconds @@ -108,6 +108,9 @@ export default class UserRelatedState { 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() diff --git a/src/Models/ThemeViewState.ts b/src/Models/ThemeViewState.ts index caa3b63571..b6f13012b6 100644 --- a/src/Models/ThemeViewState.ts +++ b/src/Models/ThemeViewState.ts @@ -51,6 +51,8 @@ import ThemeViewStateHashActor from "../Logic/Web/ThemeViewStateHashActor"; import NoElementsInViewDetector, { FeatureViewState } from "../Logic/Actors/NoElementsInViewDetector"; import FilteredLayer from "./FilteredLayer"; import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLayerSelector"; +import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"; +import { Imgur } from "../Logic/ImageProviders/Imgur"; /** * @@ -99,6 +101,8 @@ export default class ThemeViewState implements SpecialVisualizationState { readonly userRelatedState: UserRelatedState; readonly geolocation: GeoLocationHandler; + readonly imageUploadManager: ImageUploadManager + readonly lastClickObject: WritableFeatureSource; readonly overlayLayerStates: ReadonlyMap< string, @@ -168,6 +172,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location); + const self = this; this.layerState = new LayerState(this.osmConnection, layout.layers, layout.id); @@ -323,6 +328,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.perLayerFiltered = this.showNormalDataOn(this.map); this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView; + this.imageUploadManager = new ImageUploadManager(layout, Imgur.singleton, this.featureProperties, this.osmConnection, this.changes) this.initActors(); this.addLastClick(lastClick); diff --git a/src/UI/Base/FileSelector.svelte b/src/UI/Base/FileSelector.svelte index e69de29bb2..fa4dc2ad69 100644 --- a/src/UI/Base/FileSelector.svelte +++ b/src/UI/Base/FileSelector.svelte @@ -0,0 +1,40 @@ + + +
+ + { + drawAttention = false; + dispatcher("submit", inputElement.files)}} + + on:dragend={ () => {drawAttention = false}} + on:dragover|preventDefault|stopPropagation={(e) => { + console.log("Dragging over!") + drawAttention = true + e.dataTransfer.drop = "copy" + }} + on:dragstart={ () => {drawAttention = false}} + on:drop|preventDefault|stopPropagation={(e) => { + console.log("Got a 'drop'") + drawAttention = false + dispatcher("submit", e.dataTransfer.files) + }} + type="file" + > +
diff --git a/src/UI/Base/Loading.svelte b/src/UI/Base/Loading.svelte index 097bc4472c..ff8a622d7c 100644 --- a/src/UI/Base/Loading.svelte +++ b/src/UI/Base/Loading.svelte @@ -1,9 +1,12 @@ - -
+
diff --git a/src/UI/Image/ImageUploadFlow.ts b/src/UI/Image/ImageUploadFlow.ts index 5c3f6b5c68..2a90ab66a1 100644 --- a/src/UI/Image/ImageUploadFlow.ts +++ b/src/UI/Image/ImageUploadFlow.ts @@ -15,8 +15,9 @@ import Loading from "../Base/Loading" import { LoginToggle } from "../Popup/LoginButton" import Constants from "../../Models/Constants" import { SpecialVisualizationState } from "../SpecialVisualization" +import exp from "constants"; -export class ImageUploadFlow extends Toggle { +export class ImageUploadFlow extends Combine { private static readonly uploadCountsPerId = new Map>() constructor( @@ -129,7 +130,7 @@ export class ImageUploadFlow extends Toggle { uploader.uploadMany(title, description, filelist) }) - const uploadFlow: BaseUIElement = new Combine([ + super([ new VariableUiElement( uploader.queue .map((q) => q.length) @@ -183,17 +184,9 @@ export class ImageUploadFlow extends Toggle { }) .SetClass("underline"), ]).SetStyle("font-size:small;"), - ]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none") + ]) + this.SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none") + - super( - new LoginToggle( - /*We can show the actual upload button!*/ - uploadFlow, - /* User not logged in*/ t.pleaseLogin.Clone(), - state - ), - undefined /* Nothing as the user badge is disabled*/, - state?.featureSwitchUserbadge - ) } } diff --git a/src/UI/Image/UploadImage.svelte b/src/UI/Image/UploadImage.svelte index e69de29bb2..fa82ee34cf 100644 --- a/src/UI/Image/UploadImage.svelte +++ b/src/UI/Image/UploadImage.svelte @@ -0,0 +1,73 @@ + + + + + + +
+ + + handleFiles(e.detail)}> +
+ + {#if image !== undefined} + + {:else} + + {/if} + {#if labelText} + {labelText} + {:else} + + {/if} +
+
+ +
+ +
diff --git a/src/UI/Image/UploadingImageCounter.svelte b/src/UI/Image/UploadingImageCounter.svelte index a3bfa02e56..0c1b6f7776 100644 --- a/src/UI/Image/UploadingImageCounter.svelte +++ b/src/UI/Image/UploadingImageCounter.svelte @@ -1,31 +1,67 @@ +{#if $uploadStarted == 1} + {#if $uploadFinished == 1 } + + {:else if $failed == 1} +
+ + + +
+ {:else if $retried == 1} + + + + {:else } + + + + {/if} +{:else if $uploadStarted > 1} + {#if ($uploadFinished + $failed) == $uploadStarted && $uploadFinished > 0} + + {:else if $uploadFinished == 0} + + + + {:else if $uploadFinished > 0} + + + + {/if} + {#if $failed > 0} +
+ {#if failed === 1} + + {:else} + - - - - - + {/if} + + +
+ {/if} +{/if} diff --git a/src/UI/SpecialVisualization.ts b/src/UI/SpecialVisualization.ts index 4cb3aeb02b..1d3575b421 100644 --- a/src/UI/SpecialVisualization.ts +++ b/src/UI/SpecialVisualization.ts @@ -1,113 +1,117 @@ -import { Store, UIEventSource } from "../Logic/UIEventSource" -import BaseUIElement from "./BaseUIElement" -import LayoutConfig from "../Models/ThemeConfig/LayoutConfig" -import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource" -import { OsmConnection } from "../Logic/Osm/OsmConnection" -import { Changes } from "../Logic/Osm/Changes" -import { ExportableMap, MapProperties } from "../Models/MapProperties" -import LayerState from "../Logic/State/LayerState" -import { Feature, Geometry, Point } from "geojson" -import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource" -import { MangroveIdentity } from "../Logic/Web/MangroveReviews" -import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore" -import LayerConfig from "../Models/ThemeConfig/LayerConfig" -import FeatureSwitchState from "../Logic/State/FeatureSwitchState" -import { MenuState } from "../Models/MenuState" -import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader" -import { RasterLayerPolygon } from "../Models/RasterLayers" +import { Store, UIEventSource } from "../Logic/UIEventSource"; +import BaseUIElement from "./BaseUIElement"; +import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; +import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource"; +import { OsmConnection } from "../Logic/Osm/OsmConnection"; +import { Changes } from "../Logic/Osm/Changes"; +import { ExportableMap, MapProperties } from "../Models/MapProperties"; +import LayerState from "../Logic/State/LayerState"; +import { Feature, Geometry, Point } from "geojson"; +import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; +import { MangroveIdentity } from "../Logic/Web/MangroveReviews"; +import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore"; +import LayerConfig from "../Models/ThemeConfig/LayerConfig"; +import FeatureSwitchState from "../Logic/State/FeatureSwitchState"; +import { MenuState } from "../Models/MenuState"; +import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"; +import { RasterLayerPolygon } from "../Models/RasterLayers"; +import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"; /** * The state needed to render a special Visualisation. */ export interface SpecialVisualizationState { - readonly guistate: MenuState - readonly layout: LayoutConfig - readonly featureSwitches: FeatureSwitchState + readonly guistate: MenuState; + readonly layout: LayoutConfig; + readonly featureSwitches: FeatureSwitchState; - readonly layerState: LayerState - readonly featureProperties: { getStore(id: string): UIEventSource> } + readonly layerState: LayerState; + readonly featureProperties: { getStore(id: string): UIEventSource> }; - readonly indexedFeatures: IndexedFeatureSource + readonly indexedFeatures: IndexedFeatureSource; - /** - * Some features will create a new element that should be displayed. - * These can be injected by appending them to this featuresource (and pinging it) - */ - readonly newFeatures: WritableFeatureSource + /** + * Some features will create a new element that should be displayed. + * These can be injected by appending them to this featuresource (and pinging it) + */ + readonly newFeatures: WritableFeatureSource; - readonly historicalUserLocations: WritableFeatureSource> + readonly historicalUserLocations: WritableFeatureSource>; - readonly osmConnection: OsmConnection - readonly featureSwitchUserbadge: Store - readonly featureSwitchIsTesting: Store - readonly changes: Changes - readonly osmObjectDownloader: OsmObjectDownloader - /** - * State of the main map - */ - readonly mapProperties: MapProperties & ExportableMap + readonly osmConnection: OsmConnection; + readonly featureSwitchUserbadge: Store; + readonly featureSwitchIsTesting: Store; + readonly changes: Changes; + readonly osmObjectDownloader: OsmObjectDownloader; + /** + * State of the main map + */ + readonly mapProperties: MapProperties & ExportableMap; - readonly selectedElement: UIEventSource - /** - * Works together with 'selectedElement' to indicate what properties should be displayed - */ - readonly selectedLayer: UIEventSource - readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }> + readonly selectedElement: UIEventSource; + /** + * Works together with 'selectedElement' to indicate what properties should be displayed + */ + readonly selectedLayer: UIEventSource; + readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>; - /** - * If data is currently being fetched from external sources - */ - readonly dataIsLoading: Store - /** - * Only needed for 'ReplaceGeometryAction' - */ - readonly fullNodeDatabase?: FullNodeDatabaseSource + /** + * If data is currently being fetched from external sources + */ + readonly dataIsLoading: Store; + /** + * Only needed for 'ReplaceGeometryAction' + */ + readonly fullNodeDatabase?: FullNodeDatabaseSource; - readonly perLayer: ReadonlyMap - readonly userRelatedState: { - readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full"> - readonly mangroveIdentity: MangroveIdentity - readonly showAllQuestionsAtOnce: UIEventSource - readonly preferencesAsTags: Store> - readonly language: UIEventSource - } - readonly lastClickObject: WritableFeatureSource + readonly perLayer: ReadonlyMap; + readonly userRelatedState: { + readonly imageLicense: UIEventSource; + readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full"> + readonly mangroveIdentity: MangroveIdentity + readonly showAllQuestionsAtOnce: UIEventSource + readonly preferencesAsTags: Store> + readonly language: UIEventSource + }; + readonly lastClickObject: WritableFeatureSource; - readonly availableLayers: Store + readonly availableLayers: Store; + + readonly imageUploadManager: ImageUploadManager; } export interface SpecialVisualization { - readonly funcName: string - readonly docs: string | BaseUIElement - readonly example?: string + readonly funcName: string; + readonly docs: string | BaseUIElement; + readonly example?: string; - /** - * Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included - */ - readonly needsNodeDatabase?: boolean - readonly args: { - name: string - defaultValue?: string - doc: string - required?: false | boolean - }[] - readonly getLayerDependencies?: (argument: string[]) => string[] + /** + * Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included + */ + readonly needsNodeDatabase?: boolean; + readonly args: { + name: string + defaultValue?: string + doc: string + required?: false | boolean + }[]; + readonly getLayerDependencies?: (argument: string[]) => string[]; - structuredExamples?(): { feature: Feature>; args: string[] }[] + structuredExamples?(): { feature: Feature>; args: string[] }[]; - constr( - state: SpecialVisualizationState, - tagSource: UIEventSource>, - argument: string[], - feature: Feature, - layer: LayerConfig - ): BaseUIElement + constr( + state: SpecialVisualizationState, + tagSource: UIEventSource>, + argument: string[], + feature: Feature, + layer: LayerConfig + ): BaseUIElement; } export type RenderingSpecification = - | string - | { - func: SpecialVisualization - args: string[] - style: string - } + | string + | { + func: SpecialVisualization + args: string[] + style: string +} diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 0d9e1d66fe..24b712da27 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -35,7 +35,6 @@ import LiveQueryHandler from "../Logic/Web/LiveQueryHandler" import { SubtleButton } from "./Base/SubtleButton" import Svg from "../Svg" import NoteCommentElement from "./Popup/NoteCommentElement" -import ImgurUploader from "../Logic/ImageProviders/ImgurUploader" import FileSelectorButton from "./Input/FileSelectorButton" import { LoginToggle } from "./Popup/LoginButton" import Toggle from "./Input/Toggle" @@ -74,6 +73,7 @@ import FediverseValidator from "./InputElement/Validators/FediverseValidator" import SendEmail from "./Popup/SendEmail.svelte" import NearbyImages from "./Popup/NearbyImages.svelte" import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte" +import UploadImage from "./Image/UploadImage.svelte"; class NearbyImageVis implements SpecialVisualization { // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests @@ -616,16 +616,19 @@ export default class SpecialVisualizations { { name: "image-key", doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)", - defaultValue: "image", + required: false }, { name: "label", doc: "The text to show on the button", - defaultValue: "Add image", + required: false }, ], constr: (state, tags, args) => { - return new ImageUploadFlow(tags, state, args[0], args[1]) + return new SvelteUIElement(UploadImage, { + state,tags, labelText: args[1], image: args[0] + }) + // return new ImageUploadFlow(tags, state, args[0], args[1]) }, }, { From 9a5a2e9924f668af8666bc156a4788850dda429a Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 02:55:43 +0200 Subject: [PATCH 36/40] Refactoring: port add-image-to-note to new element as well, remove obsolete classes, fix note creation --- .../ImageProviders/ImageUploadManager.ts | 45 +- src/Logic/Osm/Actions/LinkImageAction.ts | 2 +- src/Logic/Osm/OsmConnection.ts | 1008 +++++++++-------- src/UI/Image/ImageUploadFlow.ts | 192 ---- src/UI/Image/UploadImage.svelte | 6 +- src/UI/Input/FileSelectorButton.ts | 111 -- src/UI/Input/Slider.ts | 62 - src/UI/Popup/CreateNewNote.svelte | 5 + src/UI/SpecialVisualization.ts | 3 +- src/UI/SpecialVisualizations.ts | 184 ++- 10 files changed, 617 insertions(+), 1001 deletions(-) delete mode 100644 src/UI/Image/ImageUploadFlow.ts delete mode 100644 src/UI/Input/FileSelectorButton.ts delete mode 100644 src/UI/Input/Slider.ts diff --git a/src/Logic/ImageProviders/ImageUploadManager.ts b/src/Logic/ImageProviders/ImageUploadManager.ts index 9bb2f9535e..85964a507e 100644 --- a/src/Logic/ImageProviders/ImageUploadManager.ts +++ b/src/Logic/ImageProviders/ImageUploadManager.ts @@ -7,6 +7,7 @@ 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"; /** @@ -58,24 +59,25 @@ export class ImageUploadManager { } /** - * Uploads the given image, applies the correct title and license for the known user + * 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, tags: OsmTags) { + public async uploadImageAndApply(file: File, tagsStore: UIEventSource) : Promise{ - const sizeInBytes = file.size - const featureId = tags.id - console.log(file.name + " has a size of " + sizeInBytes + " Bytes, attaching to", 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 sizeInBytes = file.size; + const tags= tagsStore.data + const featureId = 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"); @@ -93,8 +95,15 @@ export class ImageUploadManager { "osmid:" + tags.id ].join("\n"); - console.log("Upload done, creating ") + 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, > tagsStore, {osmConnection: this._osmConnection}) + return + } await this._changes.applyAction(action); } @@ -121,7 +130,7 @@ export class ImageUploadManager { } } - console.log("Uploading done, creating action for", featureId) + console.log("Uploading done, creating action for", featureId); const action = new LinkImageAction(featureId, key, value, properties, { theme: this._layout.id, changeType: "add-image" diff --git a/src/Logic/Osm/Actions/LinkImageAction.ts b/src/Logic/Osm/Actions/LinkImageAction.ts index 1b2b90d19f..7d4ec23c8c 100644 --- a/src/Logic/Osm/Actions/LinkImageAction.ts +++ b/src/Logic/Osm/Actions/LinkImageAction.ts @@ -7,7 +7,7 @@ import { Store } from "../../UIEventSource"; export default class LinkImageAction extends OsmChangeAction { private readonly _proposedKey: "image" | "mapillary" | "wiki_commons" | string; - private readonly _url: string; + public readonly _url: string; private readonly _currentTags: Store>; private readonly _meta: { theme: string; changeType: "add-image" | "link-image" }; diff --git a/src/Logic/Osm/OsmConnection.ts b/src/Logic/Osm/OsmConnection.ts index e650250a92..07028c35a1 100644 --- a/src/Logic/Osm/OsmConnection.ts +++ b/src/Logic/Osm/OsmConnection.ts @@ -1,551 +1,553 @@ // @ts-ignore -import { osmAuth } from "osm-auth" -import { Store, Stores, UIEventSource } from "../UIEventSource" -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" - public uid: number - public csCount = 0 - public img?: string - public unreadMessages = 0 - public totalMessages: number = 0 - public home: { lon: number; lat: number } - public backend: string - public account_created: string - public tracesCount: number = 0 - public description: string +import { osmAuth } from "osm-auth"; +import { Store, Stores, UIEventSource } from "../UIEventSource"; +import { OsmPreferences } from "./OsmPreferences"; +import { Utils } from "../../Utils"; +import { LocalStorageSource } from "../Web/LocalStorageSource"; +import * as config from "../../../package.json"; - constructor(backend: string) { - this.backend = backend - } +export default class UserDetails { + public loggedIn = false; + public name = "Not logged in"; + public uid: number; + public csCount = 0; + public img?: string; + public unreadMessages = 0; + public totalMessages: number = 0; + public home: { lon: number; lat: number }; + public backend: string; + public account_created: string; + public tracesCount: number = 0; + public description: string; + + constructor(backend: string) { + this.backend = backend; + } } export interface AuthConfig { - "#"?: string // optional comment - oauth_client_id: string - oauth_secret: string - url: string + "#"?: string; // optional comment + oauth_client_id: string; + oauth_secret: string; + url: string; } export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable" export class OsmConnection { - public static readonly oauth_configs: Record = - config.config.oauth_credentials - public auth - public userDetails: UIEventSource - public isLoggedIn: Store - public gpxServiceIsOnline: UIEventSource = new UIEventSource( - "unknown" - ) - public apiIsOnline: UIEventSource = new UIEventSource( - "unknown" - ) + public static readonly oauth_configs: Record = + config.config.oauth_credentials; + public auth; + public userDetails: UIEventSource; + public isLoggedIn: Store; + public gpxServiceIsOnline: UIEventSource = new UIEventSource( + "unknown" + ); + public apiIsOnline: UIEventSource = new UIEventSource( + "unknown" + ); - public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">( - "not-attempted" - ) - public preferencesHandler: OsmPreferences - public readonly _oauth_config: AuthConfig - private readonly _dryRun: Store - private fakeUser: boolean - private _onLoggedIn: ((userDetails: UserDetails) => void)[] = [] - private readonly _iframeMode: Boolean | boolean - private readonly _singlePage: boolean - private isChecking = false + public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">( + "not-attempted" + ); + public preferencesHandler: OsmPreferences; + public readonly _oauth_config: AuthConfig; + private readonly _dryRun: Store; + private fakeUser: boolean; + private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []; + private readonly _iframeMode: Boolean | boolean; + private readonly _singlePage: boolean; + private isChecking = false; - constructor(options?: { - dryRun?: Store - fakeUser?: false | boolean - oauth_token?: UIEventSource - // Used to keep multiple changesets open and to write to the correct changeset - singlePage?: boolean - osmConfiguration?: "osm" | "osm-test" - attemptLogin?: true | boolean - }) { - options = options ?? {} - this.fakeUser = options.fakeUser ?? false - this._singlePage = options.singlePage ?? true - this._oauth_config = - OsmConnection.oauth_configs[options.osmConfiguration ?? "osm"] ?? - OsmConnection.oauth_configs.osm - console.debug("Using backend", this._oauth_config.url) - this._iframeMode = Utils.runningFromConsole ? false : window !== window.top + constructor(options?: { + dryRun?: Store + fakeUser?: false | boolean + oauth_token?: UIEventSource + // Used to keep multiple changesets open and to write to the correct changeset + singlePage?: boolean + osmConfiguration?: "osm" | "osm-test" + attemptLogin?: true | boolean + }) { + options = options ?? {}; + this.fakeUser = options.fakeUser ?? false; + this._singlePage = options.singlePage ?? true; + this._oauth_config = + OsmConnection.oauth_configs[options.osmConfiguration ?? "osm"] ?? + OsmConnection.oauth_configs.osm; + console.debug("Using backend", this._oauth_config.url); + this._iframeMode = Utils.runningFromConsole ? false : window !== window.top; - // Check if there are settings available in environment variables, and if so, use those - if ( - import.meta.env.VITE_OSM_OAUTH_CLIENT_ID !== undefined && - import.meta.env.VITE_OSM_OAUTH_SECRET !== undefined - ) { - console.debug("Using environment variables for oauth config") - 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://api.openstreetmap.org", - } - } - - this.userDetails = new UIEventSource( - new UserDetails(this._oauth_config.url), - "userDetails" - ) - if (options.fakeUser) { - const ud = this.userDetails.data - ud.csCount = 5678 - ud.loggedIn = true - ud.unreadMessages = 0 - ud.name = "Fake user" - ud.totalMessages = 42 - } - const self = this - this.UpdateCapabilities() - this.isLoggedIn = this.userDetails.map( - (user) => - user.loggedIn && - (self.apiIsOnline.data === "unknown" || self.apiIsOnline.data === "online"), - [this.apiIsOnline] - ) - this.isLoggedIn.addCallback((isLoggedIn) => { - if (self.userDetails.data.loggedIn == false && isLoggedIn == true) { - // We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do - // This means someone attempted to toggle this; so we attempt to login! - self.AttemptLogin() - } - }) - - this._dryRun = options.dryRun ?? new UIEventSource(false) - - this.updateAuthObject() - - this.preferencesHandler = new OsmPreferences( - this.auth, - this - ) - - if (options.oauth_token?.data !== undefined) { - console.log(options.oauth_token.data) - const self = this - this.auth.bootstrapToken( - options.oauth_token.data, - (x) => { - console.log("Called back: ", x) - self.AttemptLogin() - }, - this.auth - ) - - options.oauth_token.setData(undefined) - } - if (this.auth.authenticated() && options.attemptLogin !== false) { - this.AttemptLogin() // Also updates the user badge - } else { - console.log("Not authenticated") - } + // Check if there are settings available in environment variables, and if so, use those + if ( + import.meta.env.VITE_OSM_OAUTH_CLIENT_ID !== undefined && + import.meta.env.VITE_OSM_OAUTH_SECRET !== undefined + ) { + console.debug("Using environment variables for oauth config"); + 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://api.openstreetmap.org" + }; } - public GetPreference( - key: string, - defaultValue: string = undefined, - options?: { - documentation?: string - prefix?: string - } - ): UIEventSource { - return this.preferencesHandler.GetPreference(key, defaultValue, options) + this.userDetails = new UIEventSource( + new UserDetails(this._oauth_config.url), + "userDetails" + ); + if (options.fakeUser) { + const ud = this.userDetails.data; + ud.csCount = 5678; + ud.loggedIn = true; + ud.unreadMessages = 0; + ud.name = "Fake user"; + ud.totalMessages = 42; } + const self = this; + this.UpdateCapabilities(); + this.isLoggedIn = this.userDetails.map( + (user) => + user.loggedIn && + (self.apiIsOnline.data === "unknown" || self.apiIsOnline.data === "online"), + [this.apiIsOnline] + ); + this.isLoggedIn.addCallback((isLoggedIn) => { + if (self.userDetails.data.loggedIn == false && isLoggedIn == true) { + // We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do + // This means someone attempted to toggle this; so we attempt to login! + self.AttemptLogin(); + } + }); - public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource { - return this.preferencesHandler.GetLongPreference(key, prefix) + this._dryRun = options.dryRun ?? new UIEventSource(false); + + this.updateAuthObject(); + + this.preferencesHandler = new OsmPreferences( + this.auth, + this + ); + + if (options.oauth_token?.data !== undefined) { + console.log(options.oauth_token.data); + const self = this; + this.auth.bootstrapToken( + options.oauth_token.data, + (x) => { + console.log("Called back: ", x); + self.AttemptLogin(); + }, + this.auth + ); + + options.oauth_token.setData(undefined); } - - public OnLoggedIn(action: (userDetails: UserDetails) => void) { - this._onLoggedIn.push(action) + if (this.auth.authenticated() && options.attemptLogin !== false) { + this.AttemptLogin(); // Also updates the user badge + } else { + console.log("Not authenticated"); } + } - public LogOut() { - this.auth.logout() - this.userDetails.data.loggedIn = false - this.userDetails.data.csCount = 0 - this.userDetails.data.name = "" - this.userDetails.ping() - console.log("Logged out") - this.loadingStatus.setData("not-attempted") + public GetPreference( + key: string, + defaultValue: string = undefined, + options?: { + documentation?: string + prefix?: string } + ): UIEventSource { + return this.preferencesHandler.GetPreference(key, defaultValue, options); + } - /** - * The backend host, without path or trailing '/' - * - * new OsmConnection().Backend() // => "https://www.openstreetmap.org" - */ - public Backend(): string { - return this._oauth_config.url + public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource { + return this.preferencesHandler.GetLongPreference(key, prefix); + } + + public OnLoggedIn(action: (userDetails: UserDetails) => void) { + this._onLoggedIn.push(action); + } + + public LogOut() { + this.auth.logout(); + this.userDetails.data.loggedIn = false; + this.userDetails.data.csCount = 0; + this.userDetails.data.name = ""; + this.userDetails.ping(); + console.log("Logged out"); + this.loadingStatus.setData("not-attempted"); + } + + /** + * The backend host, without path or trailing '/' + * + * new OsmConnection().Backend() // => "https://www.openstreetmap.org" + */ + public Backend(): string { + return this._oauth_config.url; + } + + public AttemptLogin() { + this.UpdateCapabilities(); + this.loadingStatus.setData("loading"); + if (this.fakeUser) { + this.loadingStatus.setData("logged-in"); + console.log("AttemptLogin called, but ignored as fakeUser is set"); + return; } - - public AttemptLogin() { - this.UpdateCapabilities() - this.loadingStatus.setData("loading") - if (this.fakeUser) { - this.loadingStatus.setData("logged-in") - console.log("AttemptLogin called, but ignored as fakeUser is set") - return - } - 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", - }, - function (err, details) { - if (err != null) { - console.log(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() - } - return - } - - if (details == null) { - self.loadingStatus.setData("error") - return - } - - self.CheckForMessagesContinuously() - - // details is an XML DOM of user details - let userInfo = details.getElementsByTagName("user")[0] - - let data = self.userDetails.data - data.loggedIn = true - console.log("Login completed, userinfo is ", userInfo) - data.name = userInfo.getAttribute("display_name") - data.account_created = userInfo.getAttribute("account_created") - data.uid = Number(userInfo.getAttribute("id")) - data.csCount = Number.parseInt( - userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? 0 - ) - data.tracesCount = Number.parseInt( - userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? 0 - ) - - data.img = undefined - const imgEl = userInfo.getElementsByTagName("img") - if (imgEl !== undefined && imgEl[0] !== undefined) { - data.img = imgEl[0].getAttribute("href") - } - - const description = userInfo.getElementsByTagName("description") - if (description !== undefined && description[0] !== undefined) { - data.description = description[0]?.innerHTML - } - const homeEl = userInfo.getElementsByTagName("home") - if (homeEl !== undefined && homeEl[0] !== undefined) { - const lat = parseFloat(homeEl[0].getAttribute("lat")) - const lon = parseFloat(homeEl[0].getAttribute("lon")) - data.home = { lat: lat, lon: lon } - } - - self.loadingStatus.setData("logged-in") - const messages = userInfo - .getElementsByTagName("messages")[0] - .getElementsByTagName("received")[0] - data.unreadMessages = parseInt(messages.getAttribute("unread")) - data.totalMessages = parseInt(messages.getAttribute("count")) - - self.userDetails.ping() - for (const action of self._onLoggedIn) { - action(self.userDetails.data) - } - self._onLoggedIn = [] - } - ) - } - - /** - * Interact with the API. - * - * @param path: the path to query, without host and without '/api/0.6'. Example 'notes/1234/close' - */ - public async interact( - path: string, - method: "GET" | "POST" | "PUT" | "DELETE", - header?: Record, - content?: string - ): Promise { - return new Promise((ok, error) => { - this.auth.xhr( - { - method, - options: { - header, - }, - content, - path: `/api/0.6/${path}`, - }, - function (err, response) { - if (err !== null) { - error(err) - } else { - ok(response) - } - } - ) - }) - } - - public async post( - path: string, - content?: string, - header?: Record - ): Promise { - return await this.interact(path, "POST", header, content) - } - - public async put( - path: string, - content?: string, - header?: Record - ): Promise { - return await this.interact(path, "PUT", header, content) - } - - public async get(path: string, header?: Record): Promise { - return await this.interact(path, "GET", header) - } - - public closeNote(id: number | string, text?: string): Promise { - let textSuffix = "" - if ((text ?? "") !== "") { - textSuffix = "?text=" + encodeURIComponent(text) - } - if (this._dryRun.data) { - console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text) - return new Promise((ok) => { - ok() - }) - } - return this.post(`notes/${id}/close${textSuffix}`) - } - - public reopenNote(id: number | string, text?: string): Promise { - if (this._dryRun.data) { - console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text) - return new Promise((ok) => { - ok() - }) - } - let textSuffix = "" - if ((text ?? "") !== "") { - textSuffix = "?text=" + encodeURIComponent(text) - } - return this.post(`notes/${id}/reopen${textSuffix}`) - } - - public async openNote(lat: number, lon: number, text: string): Promise<{ id: number }> { - if (this._dryRun.data) { - console.warn("Dryrun enabled - not actually opening note with text ", text) - return new Promise<{ id: number }>((ok) => { - window.setTimeout( - () => ok({ id: Math.floor(Math.random() * 1000) }), - Math.random() * 5000 - ) - }) - } - const content = { lat, lon, text } - const response = await this.post("notes.json", JSON.stringify(content), { - "Content-Type": "application/json", - }) - const parsed = JSON.parse(response) - const id = parsed.properties - console.log("OPENED NOTE", id) - return id - } - - public async uploadGpxTrack( - gpx: string, - options: { - description: string - visibility: "private" | "public" | "trackable" | "identifiable" - filename?: string - /** - * Some words to give some properties; - * - * Note: these are called 'tags' on the wiki, but I opted to name them 'labels' instead as they aren't "key=value" tags, but just words. - */ - labels: string[] - } - ): Promise<{ id: number }> { - if (this._dryRun.data) { - console.warn("Dryrun enabled - not actually uploading GPX ", gpx) - return new Promise<{ id: number }>((ok, error) => { - window.setTimeout( - () => ok({ id: Math.floor(Math.random() * 1000) }), - Math.random() * 5000 - ) - }) + 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" + }, + function(err, details) { + if (err != null) { + console.log(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(); + } + return; } - const contents = { - file: gpx, - description: options.description ?? "", - tags: options.labels?.join(",") ?? "", - visibility: options.visibility, + if (details == null) { + self.loadingStatus.setData("error"); + return; } - const extras = { - file: - '; filename="' + - (options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) + - '"\r\nContent-Type: application/gpx+xml', + self.CheckForMessagesContinuously(); + + // details is an XML DOM of user details + let userInfo = details.getElementsByTagName("user")[0]; + + let data = self.userDetails.data; + data.loggedIn = true; + console.log("Login completed, userinfo is ", userInfo); + data.name = userInfo.getAttribute("display_name"); + data.account_created = userInfo.getAttribute("account_created"); + data.uid = Number(userInfo.getAttribute("id")); + data.csCount = Number.parseInt( + userInfo.getElementsByTagName("changesets")[0].getAttribute("count") ?? 0 + ); + data.tracesCount = Number.parseInt( + userInfo.getElementsByTagName("traces")[0].getAttribute("count") ?? 0 + ); + + data.img = undefined; + const imgEl = userInfo.getElementsByTagName("img"); + if (imgEl !== undefined && imgEl[0] !== undefined) { + data.img = imgEl[0].getAttribute("href"); } - const boundary = "987654" - - let body = "" - for (const key in contents) { - body += "--" + boundary + "\r\n" - body += 'Content-Disposition: form-data; name="' + key + '"' - if (extras[key] !== undefined) { - body += extras[key] - } - body += "\r\n\r\n" - body += contents[key] + "\r\n" + const description = userInfo.getElementsByTagName("description"); + if (description !== undefined && description[0] !== undefined) { + data.description = description[0]?.innerHTML; + } + const homeEl = userInfo.getElementsByTagName("home"); + if (homeEl !== undefined && homeEl[0] !== undefined) { + const lat = parseFloat(homeEl[0].getAttribute("lat")); + const lon = parseFloat(homeEl[0].getAttribute("lon")); + data.home = { lat: lat, lon: lon }; } - body += "--" + boundary + "--\r\n" - const response = await this.post("gpx/create", body, { - "Content-Type": "multipart/form-data; boundary=" + boundary, - "Content-Length": body.length, - }) - const parsed = JSON.parse(response) - console.log("Uploaded GPX track", parsed) - return { id: parsed } + self.loadingStatus.setData("logged-in"); + const messages = userInfo + .getElementsByTagName("messages")[0] + .getElementsByTagName("received")[0]; + data.unreadMessages = parseInt(messages.getAttribute("unread")); + data.totalMessages = parseInt(messages.getAttribute("count")); + + self.userDetails.ping(); + for (const action of self._onLoggedIn) { + action(self.userDetails.data); + } + self._onLoggedIn = []; + } + ); + } + + /** + * Interact with the API. + * + * @param path: the path to query, without host and without '/api/0.6'. Example 'notes/1234/close' + */ + public async interact( + path: string, + method: "GET" | "POST" | "PUT" | "DELETE", + header?: Record, + content?: string + ): Promise { + return new Promise((ok, error) => { + this.auth.xhr( + { + method, + options: { + header + }, + content, + path: `/api/0.6/${path}` + }, + function(err, response) { + if (err !== null) { + error(err); + } else { + ok(response); + } + } + ); + }); + } + + public async post( + path: string, + content?: string, + header?: Record + ): Promise { + return await this.interact(path, "POST", header, content); + } + + public async put( + path: string, + content?: string, + header?: Record + ): Promise { + return await this.interact(path, "PUT", header, content); + } + + public async get(path: string, header?: Record): Promise { + return await this.interact(path, "GET", header); + } + + public closeNote(id: number | string, text?: string): Promise { + let textSuffix = ""; + if ((text ?? "") !== "") { + textSuffix = "?text=" + encodeURIComponent(text); + } + if (this._dryRun.data) { + console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text); + return new Promise((ok) => { + ok(); + }); + } + return this.post(`notes/${id}/close${textSuffix}`); + } + + public reopenNote(id: number | string, text?: string): Promise { + if (this._dryRun.data) { + console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text); + return new Promise((ok) => { + ok(); + }); + } + let textSuffix = ""; + if ((text ?? "") !== "") { + textSuffix = "?text=" + encodeURIComponent(text); + } + return this.post(`notes/${id}/reopen${textSuffix}`); + } + + public async openNote(lat: number, lon: number, text: string): Promise<{ id: number }> { + if (this._dryRun.data) { + console.warn("Dryrun enabled - not actually opening note with text ", text); + return new Promise<{ id: number }>((ok) => { + window.setTimeout( + () => ok({ id: Math.floor(Math.random() * 1000) }), + Math.random() * 5000 + ); + }); + } + // 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; + console.log("OPENED NOTE", id); + return id; + } + + public async uploadGpxTrack( + gpx: string, + options: { + description: string + visibility: "private" | "public" | "trackable" | "identifiable" + filename?: string + /** + * Some words to give some properties; + * + * Note: these are called 'tags' on the wiki, but I opted to name them 'labels' instead as they aren't "key=value" tags, but just words. + */ + labels: string[] + } + ): Promise<{ id: number }> { + if (this._dryRun.data) { + console.warn("Dryrun enabled - not actually uploading GPX ", gpx); + return new Promise<{ id: number }>((ok, error) => { + window.setTimeout( + () => ok({ id: Math.floor(Math.random() * 1000) }), + Math.random() * 5000 + ); + }); } - public addCommentToNote(id: number | string, text: string): Promise { - if (this._dryRun.data) { - console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id) - return new Promise((ok) => { - ok() - }) - } - if ((text ?? "") === "") { - throw "Invalid text!" - } + const contents = { + file: gpx, + description: options.description ?? "", + tags: options.labels?.join(",") ?? "", + visibility: options.visibility + }; - return new Promise((ok, error) => { - this.auth.xhr( - { - method: "POST", + const extras = { + file: + "; filename=\"" + + (options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) + + "\"\r\nContent-Type: application/gpx+xml" + }; - path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`, - }, - function (err, _) { - if (err !== null) { - error(err) - } else { - ok() - } - } - ) - }) + const boundary = "987654"; + + let body = ""; + for (const key in contents) { + body += "--" + boundary + "\r\n"; + body += "Content-Disposition: form-data; name=\"" + key + "\""; + if (extras[key] !== undefined) { + body += extras[key]; + } + body += "\r\n\r\n"; + body += contents[key] + "\r\n"; + } + body += "--" + boundary + "--\r\n"; + + const response = await this.post("gpx/create", body, { + "Content-Type": "multipart/form-data; boundary=" + boundary, + "Content-Length": body.length + }); + const parsed = JSON.parse(response); + console.log("Uploaded GPX track", parsed); + return { id: parsed }; + } + + public addCommentToNote(id: number | string, text: string): Promise { + if (this._dryRun.data) { + console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id); + return new Promise((ok) => { + ok(); + }); + } + if ((text ?? "") === "") { + throw "Invalid text!"; } - private updateAuthObject() { - let pwaStandAloneMode = false - try { - if (Utils.runningFromConsole) { - pwaStandAloneMode = true - } else if ( - window.matchMedia("(display-mode: standalone)").matches || - window.matchMedia("(display-mode: fullscreen)").matches - ) { - pwaStandAloneMode = true - } - } catch (e) { - console.warn( - "Detecting standalone mode failed", - e, - ". Assuming in browser and not worrying furhter" - ) + return new Promise((ok, error) => { + this.auth.xhr( + { + method: "POST", + + path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}` + }, + function(err, _) { + if (err !== null) { + error(err); + } else { + ok(); + } } - const standalone = this._iframeMode || pwaStandAloneMode || !this._singlePage + ); + }); + } - // In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway... - // Same for an iframe... + /** + * 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); + }); + } - this.auth = new osmAuth({ - client_id: this._oauth_config.oauth_client_id, - url: this._oauth_config.url, - scope: "read_prefs write_prefs write_api write_gpx write_notes", - redirect_uri: Utils.runningFromConsole - ? "https://mapcomplete.org/land.html" - : window.location.protocol + "//" + window.location.host + "/land.html", - singlepage: !standalone, - auto: true, - }) + private updateAuthObject() { + let pwaStandAloneMode = false; + try { + if (Utils.runningFromConsole) { + pwaStandAloneMode = true; + } else if ( + window.matchMedia("(display-mode: standalone)").matches || + window.matchMedia("(display-mode: fullscreen)").matches + ) { + pwaStandAloneMode = true; + } + } catch (e) { + console.warn( + "Detecting standalone mode failed", + e, + ". Assuming in browser and not worrying furhter" + ); } + const standalone = this._iframeMode || pwaStandAloneMode || !this._singlePage; - /** - * 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) - }) - } + // In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway... + // Same for an iframe... - private CheckForMessagesContinuously() { - const self = this - if (this.isChecking) { - return - } - this.isChecking = true - Stores.Chronic(5 * 60 * 1000).addCallback((_) => { - if (self.isLoggedIn.data) { - console.log("Checking for messages") - self.AttemptLogin() - } - }) - } + this.auth = new osmAuth({ + client_id: this._oauth_config.oauth_client_id, + url: this._oauth_config.url, + scope: "read_prefs write_prefs write_api write_gpx write_notes", + redirect_uri: Utils.runningFromConsole + ? "https://mapcomplete.org/land.html" + : window.location.protocol + "//" + window.location.host + "/land.html", + singlepage: !standalone, + auto: true + }); + } - private UpdateCapabilities(): void { - const self = this - this.FetchCapabilities().then(({ api, gpx }) => { - self.apiIsOnline.setData(api) - self.gpxServiceIsOnline.setData(gpx) - }) + private CheckForMessagesContinuously() { + const self = this; + if (this.isChecking) { + return; } + this.isChecking = true; + Stores.Chronic(5 * 60 * 1000).addCallback((_) => { + if (self.isLoggedIn.data) { + console.log("Checking for messages"); + self.AttemptLogin(); + } + }); + } - private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> { - if (Utils.runningFromConsole) { - return { api: "online", gpx: "online" } - } - const result = await Utils.downloadAdvanced(this.Backend() + "/api/0.6/capabilities") - if (result["content"] === undefined) { - console.log("Something went wrong:", result) - return { api: "unreachable", gpx: "unreachable" } - } - const xmlRaw = result["content"] - const parsed = new DOMParser().parseFromString(xmlRaw, "text/xml") - const statusEl = parsed.getElementsByTagName("status")[0] - const api = statusEl.getAttribute("api") - const gpx = statusEl.getAttribute("gpx") - return { api, gpx } + private UpdateCapabilities(): void { + const self = this; + this.FetchCapabilities().then(({ api, gpx }) => { + self.apiIsOnline.setData(api); + self.gpxServiceIsOnline.setData(gpx); + }); + } + + private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> { + if (Utils.runningFromConsole) { + return { api: "online", gpx: "online" }; } + const result = await Utils.downloadAdvanced(this.Backend() + "/api/0.6/capabilities"); + if (result["content"] === undefined) { + console.log("Something went wrong:", result); + return { api: "unreachable", gpx: "unreachable" }; + } + const xmlRaw = result["content"]; + const parsed = new DOMParser().parseFromString(xmlRaw, "text/xml"); + const statusEl = parsed.getElementsByTagName("status")[0]; + const api = statusEl.getAttribute("api"); + const gpx = statusEl.getAttribute("gpx"); + return { api, gpx }; + } } diff --git a/src/UI/Image/ImageUploadFlow.ts b/src/UI/Image/ImageUploadFlow.ts deleted file mode 100644 index 2a90ab66a1..0000000000 --- a/src/UI/Image/ImageUploadFlow.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Store, UIEventSource } from "../../Logic/UIEventSource" -import Combine from "../Base/Combine" -import Translations from "../i18n/Translations" -import Svg from "../../Svg" -import { Tag } from "../../Logic/Tags/Tag" -import BaseUIElement from "../BaseUIElement" -import Toggle from "../Input/Toggle" -import FileSelectorButton from "../Input/FileSelectorButton" -import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader" -import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction" -import LayerConfig from "../../Models/ThemeConfig/LayerConfig" -import { FixedUiElement } from "../Base/FixedUiElement" -import { VariableUiElement } from "../Base/VariableUIElement" -import Loading from "../Base/Loading" -import { LoginToggle } from "../Popup/LoginButton" -import Constants from "../../Models/Constants" -import { SpecialVisualizationState } from "../SpecialVisualization" -import exp from "constants"; - -export class ImageUploadFlow extends Combine { - private static readonly uploadCountsPerId = new Map>() - - constructor( - tagsSource: Store, - state: SpecialVisualizationState, - imagePrefix: string = "image", - text: string = undefined - ) { - const perId = ImageUploadFlow.uploadCountsPerId - const id = tagsSource.data.id - if (!perId.has(id)) { - perId.set(id, new UIEventSource(0)) - } - const uploadedCount = perId.get(id) - const uploader = new ImgurUploader(async (url) => { - // A file was uploaded - we add it to the tags of the object - - const tags = tagsSource.data - let key = imagePrefix - if (tags[imagePrefix] !== undefined) { - let freeIndex = 0 - while (tags[imagePrefix + ":" + freeIndex] !== undefined) { - freeIndex++ - } - key = imagePrefix + ":" + freeIndex - } - - await state.changes.applyAction( - new ChangeTagAction(tags.id, new Tag(key, url), tagsSource.data, { - changeType: "add-image", - theme: state.layout.id, - }) - ) - console.log("Adding image:" + key, url) - uploadedCount.data++ - uploadedCount.ping() - }) - - const t = Translations.t.image - - let labelContent: BaseUIElement - if (text === undefined) { - labelContent = Translations.t.image.addPicture - .Clone() - .SetClass("block align-middle mt-1 ml-3 text-4xl ") - } else { - labelContent = new FixedUiElement(text).SetClass( - "block align-middle mt-1 ml-3 text-2xl " - ) - } - const label = new Combine([ - Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl "), - labelContent, - ]).SetClass("w-full flex justify-center items-center") - - const licenseStore = state?.osmConnection?.GetPreference("pictures-license", "CC0") - - const fileSelector = new FileSelectorButton(label, { - acceptType: "image/*", - allowMultiple: true, - labelClasses: "rounded-full border-2 border-black font-bold", - }) - /* fileSelector.SetClass( - "p-2 border-4 border-detail rounded-full font-bold h-full align-middle w-full flex justify-center" - ) - .SetStyle(" border-color: var(--foreground-color);")*/ - fileSelector.GetValue().addCallback((filelist) => { - if (filelist === undefined || filelist.length === 0) { - return - } - - for (var i = 0; i < filelist.length; i++) { - const sizeInBytes = filelist[i].size - console.log(filelist[i].name + " has a size of " + sizeInBytes + " Bytes") - if (sizeInBytes > uploader.maxFileSizeInMegabytes * 1000000) { - alert( - Translations.t.image.toBig.Subs({ - actual_size: Math.floor(sizeInBytes / 1000000) + "MB", - max_size: uploader.maxFileSizeInMegabytes + "MB", - }).txt - ) - return - } - } - - const license = licenseStore?.data ?? "CC0" - - const tags = tagsSource.data - - const layout = state?.layout - let matchingLayer: LayerConfig = undefined - for (const layer of layout?.layers ?? []) { - if (layer.source.osmTags.matchesProperties(tags)) { - matchingLayer = layer - break - } - } - - const title = - matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.ConstructElement() - ?.textContent ?? - tags.name ?? - "https//osm.org/" + tags.id - const description = [ - "author:" + state.osmConnection.userDetails.data.name, - "license:" + license, - "osmid:" + tags.id, - ].join("\n") - - uploader.uploadMany(title, description, filelist) - }) - - super([ - new VariableUiElement( - uploader.queue - .map((q) => q.length) - .map((l) => { - if (l == 0) { - return undefined - } - if (l == 1) { - return new Loading(t.uploadingPicture).SetClass("alert") - } else { - return new Loading( - t.uploadingMultiple.Subs({ count: "" + l }) - ).SetClass("alert") - } - }) - ), - new VariableUiElement( - uploader.failed - .map((q) => q.length) - .map((l) => { - if (l == 0) { - return undefined - } - console.log(l) - return t.uploadFailed.SetClass("block alert") - }) - ), - new VariableUiElement( - uploadedCount.map((l) => { - if (l == 0) { - return undefined - } - if (l == 1) { - return t.uploadDone.Clone().SetClass("thanks block") - } - return t.uploadMultipleDone.Subs({ count: l }).SetClass("thanks block") - }) - ), - - fileSelector, - new Combine([ - Translations.t.image.respectPrivacy, - new VariableUiElement( - licenseStore.map((license) => - Translations.t.image.currentLicense.Subs({ license }) - ) - ) - .onClick(() => { - console.log("Opening the license settings... ") - state.guistate.openUsersettings("picture-license") - }) - .SetClass("underline"), - ]).SetStyle("font-size:small;"), - ]) - this.SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none") - - - } -} diff --git a/src/UI/Image/UploadImage.svelte b/src/UI/Image/UploadImage.svelte index fa82ee34cf..23408e778f 100644 --- a/src/UI/Image/UploadImage.svelte +++ b/src/UI/Image/UploadImage.svelte @@ -16,6 +16,10 @@ import Svg from "../../Svg"; export let state: SpecialVisualizationState; export let tags: Store; +/** + * Image to show in the button + * NOT the image to upload! + */ export let image: string = undefined; if (image === "") { image = undefined; @@ -30,7 +34,7 @@ function handleFiles(files: FileList) { const file = files.item(i); console.log("Got file", file.name) try { - state.imageUploadManager.uploadImageAndApply(file, tags.data); + state.imageUploadManager.uploadImageAndApply(file, tags); } catch (e) { alert(e); } diff --git a/src/UI/Input/FileSelectorButton.ts b/src/UI/Input/FileSelectorButton.ts deleted file mode 100644 index c3f56d297a..0000000000 --- a/src/UI/Input/FileSelectorButton.ts +++ /dev/null @@ -1,111 +0,0 @@ -import BaseUIElement from "../BaseUIElement" -import { InputElement } from "./InputElement" -import { UIEventSource } from "../../Logic/UIEventSource" - -/** - * @deprecated - */ -export default class FileSelectorButton extends InputElement { - private static _nextid = 0 - private readonly _value = new UIEventSource(undefined) - private readonly _label: BaseUIElement - private readonly _acceptType: string - private readonly allowMultiple: boolean - private readonly _labelClasses: string - - constructor( - label: BaseUIElement, - options?: { - acceptType: "image/*" | string - allowMultiple: true | boolean - labelClasses?: string - } - ) { - super() - this._label = label - this._acceptType = options?.acceptType ?? "image/*" - this._labelClasses = options?.labelClasses ?? "" - this.SetClass("block cursor-pointer") - label.SetClass("cursor-pointer") - this.allowMultiple = options?.allowMultiple ?? true - } - - GetValue(): UIEventSource { - return this._value - } - - IsValid(t: FileList): boolean { - return true - } - - protected InnerConstructElement(): HTMLElement { - const self = this - const el = document.createElement("form") - const label = document.createElement("label") - label.appendChild(this._label.ConstructElement()) - label.classList.add(...this._labelClasses.split(" ").filter((t) => t !== "")) - el.appendChild(label) - - const actualInputElement = document.createElement("input") - actualInputElement.style.cssText = "display:none" - actualInputElement.type = "file" - actualInputElement.accept = this._acceptType - actualInputElement.name = "picField" - actualInputElement.multiple = this.allowMultiple - actualInputElement.id = "fileselector" + FileSelectorButton._nextid - FileSelectorButton._nextid++ - - label.htmlFor = actualInputElement.id - - actualInputElement.onchange = () => { - if (actualInputElement.files !== null) { - self._value.setData(actualInputElement.files) - } - } - - el.addEventListener("submit", (e) => { - if (actualInputElement.files !== null) { - self._value.setData(actualInputElement.files) - } - actualInputElement.classList.remove("glowing-shadow") - - e.preventDefault() - }) - - el.appendChild(actualInputElement) - - function setDrawAttention(isOn: boolean) { - if (isOn) { - label.classList.add("glowing-shadow") - } else { - label.classList.remove("glowing-shadow") - } - } - - el.addEventListener("dragover", (event) => { - event.stopPropagation() - event.preventDefault() - setDrawAttention(true) - // Style the drag-and-drop as a "copy file" operation. - event.dataTransfer.dropEffect = "copy" - }) - - window.document.addEventListener("dragenter", () => { - setDrawAttention(true) - }) - - window.document.addEventListener("dragend", () => { - setDrawAttention(false) - }) - - el.addEventListener("drop", (event) => { - event.stopPropagation() - event.preventDefault() - label.classList.remove("glowing-shadow") - const fileList = event.dataTransfer.files - this._value.setData(fileList) - }) - - return el - } -} diff --git a/src/UI/Input/Slider.ts b/src/UI/Input/Slider.ts deleted file mode 100644 index 9fce626a7b..0000000000 --- a/src/UI/Input/Slider.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { InputElement } from "./InputElement" -import { UIEventSource } from "../../Logic/UIEventSource" - -/** - * @deprecated - */ -export default class Slider extends InputElement { - private readonly _value: UIEventSource - private readonly min: number - private readonly max: number - private readonly step: number - private readonly vertical: boolean - - /** - * Constructs a slider input element for natural numbers - * @param min: the minimum value that is allowed, inclusive - * @param max: the max value that is allowed, inclusive - * @param options: value: injectable value; step: the step size of the slider - */ - constructor( - min: number, - max: number, - options?: { - value?: UIEventSource - step?: 1 | number - vertical?: false | boolean - } - ) { - super() - this.max = max - this.min = min - this._value = options?.value ?? new UIEventSource(min) - this.step = options?.step ?? 1 - this.vertical = options?.vertical ?? false - } - - GetValue(): UIEventSource { - return this._value - } - - protected InnerConstructElement(): HTMLElement { - const el = document.createElement("input") - el.type = "range" - el.min = "" + this.min - el.max = "" + this.max - el.step = "" + this.step - const valuestore = this._value - el.oninput = () => { - valuestore.setData(Number(el.value)) - } - if (this.vertical) { - el.classList.add("vertical") - el.setAttribute("orient", "vertical") // firefox only workaround... - } - valuestore.addCallbackAndRunD((v) => (el.value = "" + valuestore.data)) - return el - } - - IsValid(t: number): boolean { - return Math.round(t) == t && t >= this.min && t <= this.max - } -} diff --git a/src/UI/Popup/CreateNewNote.svelte b/src/UI/Popup/CreateNewNote.svelte index 4bdb92b23c..10eda741a2 100644 --- a/src/UI/Popup/CreateNewNote.svelte +++ b/src/UI/Popup/CreateNewNote.svelte @@ -62,8 +62,13 @@ state.newFeatures.features.data.push(feature) state.newFeatures.features.ping() state.selectedElement?.setData(feature) + if(state.featureProperties.trackFeature){ + state.featureProperties.trackFeature(feature) + } comment.setData("") created = true + state.selectedElement.setData(feature) + state.selectedLayer.setData(state.layerState.filteredLayers.get("note")) } diff --git a/src/UI/SpecialVisualization.ts b/src/UI/SpecialVisualization.ts index 1d3575b421..a4e00100b8 100644 --- a/src/UI/SpecialVisualization.ts +++ b/src/UI/SpecialVisualization.ts @@ -16,6 +16,7 @@ import { MenuState } from "../Models/MenuState"; import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"; import { RasterLayerPolygon } from "../Models/RasterLayers"; import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"; +import { OsmTags } from "../Models/OsmFeature"; /** * The state needed to render a special Visualisation. @@ -26,7 +27,7 @@ export interface SpecialVisualizationState { readonly featureSwitches: FeatureSwitchState; readonly layerState: LayerState; - readonly featureProperties: { getStore(id: string): UIEventSource> }; + readonly featureProperties: { getStore(id: string): UIEventSource>, trackFeature?(feature: { properties: OsmTags }) }; readonly indexedFeatures: IndexedFeatureSource; diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 24b712da27..23d1b57c8b 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -1,78 +1,70 @@ -import Combine from "./Base/Combine" -import { FixedUiElement } from "./Base/FixedUiElement" -import BaseUIElement from "./BaseUIElement" -import Title from "./Base/Title" -import Table from "./Base/Table" -import { - RenderingSpecification, - SpecialVisualization, - SpecialVisualizationState, -} from "./SpecialVisualization" -import { HistogramViz } from "./Popup/HistogramViz" -import { MinimapViz } from "./Popup/MinimapViz" -import { ShareLinkViz } from "./Popup/ShareLinkViz" -import { UploadToOsmViz } from "./Popup/UploadToOsmViz" -import { MultiApplyViz } from "./Popup/MultiApplyViz" -import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz" -import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz" -import TagApplyButton from "./Popup/TagApplyButton" -import { CloseNoteButton } from "./Popup/CloseNoteButton" -import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis" -import { Store, Stores, UIEventSource } from "../Logic/UIEventSource" -import AllTagsPanel from "./Popup/AllTagsPanel.svelte" -import AllImageProviders from "../Logic/ImageProviders/AllImageProviders" -import { ImageCarousel } from "./Image/ImageCarousel" -import { ImageUploadFlow } from "./Image/ImageUploadFlow" -import { VariableUiElement } from "./Base/VariableUIElement" -import { Utils } from "../Utils" -import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata" -import { Translation } from "./i18n/Translation" -import Translations from "./i18n/Translations" -import ReviewForm from "./Reviews/ReviewForm" -import ReviewElement from "./Reviews/ReviewElement" -import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization" -import LiveQueryHandler from "../Logic/Web/LiveQueryHandler" -import { SubtleButton } from "./Base/SubtleButton" -import Svg from "../Svg" -import NoteCommentElement from "./Popup/NoteCommentElement" -import FileSelectorButton from "./Input/FileSelectorButton" -import { LoginToggle } from "./Popup/LoginButton" -import Toggle from "./Input/Toggle" -import { SubstitutedTranslation } from "./SubstitutedTranslation" -import List from "./Base/List" -import StatisticsPanel from "./BigComponents/StatisticsPanel" -import AutoApplyButton from "./Popup/AutoApplyButton" -import { LanguageElement } from "./Popup/LanguageElement" -import FeatureReviews from "../Logic/Web/MangroveReviews" -import Maproulette from "../Logic/Maproulette" -import SvelteUIElement from "./Base/SvelteUIElement" -import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource" -import QuestionViz from "./Popup/QuestionViz" -import { Feature, Point } from "geojson" -import { GeoOperations } from "../Logic/GeoOperations" -import CreateNewNote from "./Popup/CreateNewNote.svelte" -import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte" -import UserProfile from "./BigComponents/UserProfile.svelte" -import LanguagePicker from "./LanguagePicker" -import Link from "./Base/Link" -import LayerConfig from "../Models/ThemeConfig/LayerConfig" -import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig" -import { OsmTags, WayId } from "../Models/OsmFeature" -import MoveWizard from "./Popup/MoveWizard" -import SplitRoadWizard from "./Popup/SplitRoadWizard" -import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz" -import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte" -import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte" -import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz" -import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz" -import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz" -import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte" -import { OpenJosm } from "./BigComponents/OpenJosm" -import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte" -import FediverseValidator from "./InputElement/Validators/FediverseValidator" -import SendEmail from "./Popup/SendEmail.svelte" -import NearbyImages from "./Popup/NearbyImages.svelte" -import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte" +import Combine from "./Base/Combine"; +import { FixedUiElement } from "./Base/FixedUiElement"; +import BaseUIElement from "./BaseUIElement"; +import Title from "./Base/Title"; +import Table from "./Base/Table"; +import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization"; +import { HistogramViz } from "./Popup/HistogramViz"; +import { MinimapViz } from "./Popup/MinimapViz"; +import { ShareLinkViz } from "./Popup/ShareLinkViz"; +import { UploadToOsmViz } from "./Popup/UploadToOsmViz"; +import { MultiApplyViz } from "./Popup/MultiApplyViz"; +import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz"; +import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz"; +import TagApplyButton from "./Popup/TagApplyButton"; +import { CloseNoteButton } from "./Popup/CloseNoteButton"; +import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis"; +import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"; +import AllTagsPanel from "./Popup/AllTagsPanel.svelte"; +import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"; +import { ImageCarousel } from "./Image/ImageCarousel"; +import { VariableUiElement } from "./Base/VariableUIElement"; +import { Utils } from "../Utils"; +import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata"; +import { Translation } from "./i18n/Translation"; +import Translations from "./i18n/Translations"; +import ReviewForm from "./Reviews/ReviewForm"; +import ReviewElement from "./Reviews/ReviewElement"; +import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"; +import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"; +import { SubtleButton } from "./Base/SubtleButton"; +import Svg from "../Svg"; +import NoteCommentElement from "./Popup/NoteCommentElement"; +import { SubstitutedTranslation } from "./SubstitutedTranslation"; +import List from "./Base/List"; +import StatisticsPanel from "./BigComponents/StatisticsPanel"; +import AutoApplyButton from "./Popup/AutoApplyButton"; +import { LanguageElement } from "./Popup/LanguageElement"; +import FeatureReviews from "../Logic/Web/MangroveReviews"; +import Maproulette from "../Logic/Maproulette"; +import SvelteUIElement from "./Base/SvelteUIElement"; +import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource"; +import QuestionViz from "./Popup/QuestionViz"; +import { Feature, Point } from "geojson"; +import { GeoOperations } from "../Logic/GeoOperations"; +import CreateNewNote from "./Popup/CreateNewNote.svelte"; +import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte"; +import UserProfile from "./BigComponents/UserProfile.svelte"; +import LanguagePicker from "./LanguagePicker"; +import Link from "./Base/Link"; +import LayerConfig from "../Models/ThemeConfig/LayerConfig"; +import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"; +import { OsmTags, WayId } from "../Models/OsmFeature"; +import MoveWizard from "./Popup/MoveWizard"; +import SplitRoadWizard from "./Popup/SplitRoadWizard"; +import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz"; +import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte"; +import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte"; +import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz"; +import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz"; +import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz"; +import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte"; +import { OpenJosm } from "./BigComponents/OpenJosm"; +import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"; +import FediverseValidator from "./InputElement/Validators/FediverseValidator"; +import SendEmail from "./Popup/SendEmail.svelte"; +import NearbyImages from "./Popup/NearbyImages.svelte"; +import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"; import UploadImage from "./Image/UploadImage.svelte"; class NearbyImageVis implements SpecialVisualization { @@ -272,6 +264,7 @@ export default class SpecialVisualizations { SpecialVisualizations.specialVisualizations .map((sp) => sp.funcName + "()") .join(", ") + } } @@ -628,7 +621,6 @@ export default class SpecialVisualizations { return new SvelteUIElement(UploadImage, { state,tags, labelText: args[1], image: args[0] }) - // return new ImageUploadFlow(tags, state, args[0], args[1]) }, }, { @@ -867,43 +859,11 @@ export default class SpecialVisualizations { }, ], constr: (state, tags, args) => { - const isUploading = new UIEventSource(false) - const t = Translations.t.notes const id = tags.data[args[0] ?? "id"] - - const uploader = new ImgurUploader(async (url) => { - isUploading.setData(false) - await state.osmConnection.addCommentToNote(id, url) - NoteCommentElement.addCommentTo(url, tags, state) - }) - - const label = new Combine([ - Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl "), - Translations.t.image.addPicture, - ]).SetClass( - "p-2 border-4 border-black rounded-full font-bold h-full align-center w-full flex justify-center" - ) - - const fileSelector = new FileSelectorButton(label) - fileSelector.GetValue().addCallback((filelist) => { - isUploading.setData(true) - uploader.uploadMany("Image for osm.org/note/" + id, "CC0", filelist) - }) - const ti = Translations.t.image - const uploadPanel = new Combine([ - fileSelector, - ti.respectPrivacy.SetClass("text-sm"), - ]).SetClass("flex flex-col") - return new LoginToggle( - new Toggle( - Translations.t.image.uploadingPicture.SetClass("alert"), - uploadPanel, - isUploading - ), - t.loginToAddPicture, - state - ) - }, + tags = state.featureProperties.getStore(id) + console.log("Id is", id) + return new SvelteUIElement(UploadImage, {state, tags}) + } }, { funcName: "title", From 9d149cae308a95ed5b78b684b35c6dd0c3da58d6 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 02:56:02 +0200 Subject: [PATCH 37/40] Version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f3f0aecdb4..cbc5623816 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapcomplete", - "version": "0.33.3", + "version": "0.33.4", "repository": "https://github.com/pietervdvn/MapComplete", "description": "A small website to edit OSM easily", "bugs": "https://github.com/pietervdvn/MapComplete/issues", From fe3ccd1074431f83c57fa092787b17bb0d47d62b Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 03:13:17 +0200 Subject: [PATCH 38/40] CI: add extra config file for easier per-deployment veriations, add hetzner deploy script --- config.json | 3 +++ package.json | 3 ++- scripts/hetzner/config.json | 4 ++++ scripts/hetzner/deployHetzner.sh | 20 ++++++++++++++++++++ src/Models/Constants.ts | 22 +++++++++++----------- 5 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 config.json create mode 100644 scripts/hetzner/config.json create mode 100755 scripts/hetzner/deployHetzner.sh diff --git a/config.json b/config.json new file mode 100644 index 0000000000..16f8d4541e --- /dev/null +++ b/config.json @@ -0,0 +1,3 @@ +{ + "#": "Settings in this file override the `config`-section of `package.json`" +} diff --git a/package.json b/package.json index cbc5623816..66bf5bccb8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "main": "index.ts", "type": "module", "config": { - "#": "Various endpoints that are instance-specific", + "#": "Various endpoints that are instance-specific. This is the default configuration, which is re-exported in 'Constants.ts'.", + "#": "Use MAPCOMPLETE_CONFIGURATION to use an additional configuration, e.g. `MAPCOMPLETE_CONFIGURATION=config_hetzner`", "#oauth_credentials:comment": [ "`oauth_credentials` are the OAuth-2 credentials for the production-OSM server and the test-server.", "Are you deploying your own instance? Register your application too.", diff --git a/scripts/hetzner/config.json b/scripts/hetzner/config.json new file mode 100644 index 0000000000..c3ad60fdd5 --- /dev/null +++ b/scripts/hetzner/config.json @@ -0,0 +1,4 @@ +{ + "#":"Some configuration tweaks specifically for hetzner", + "country_coder_host": "https://countrycoder.mapcomplete.org/" +} diff --git a/scripts/hetzner/deployHetzner.sh b/scripts/hetzner/deployHetzner.sh new file mode 100755 index 0000000000..70aec756ff --- /dev/null +++ b/scripts/hetzner/deployHetzner.sh @@ -0,0 +1,20 @@ +#! /bin/bash +### To be run from the root of the repository + +# Some pointers to get started: +# apt install npm +# apt install unzip +# npm i -g csp-logger + +# wget https://github.com/pietervdvn/latlon2country/raw/main/tiles.zip +# unzip tiles.zip + +MAPCOMPLETE_CONFIGURATION="config_hetzner" +cp config.json config.json.bu && +cp ./scripts/hetzner/config.json . && +npm run prepare-deploy && +mv config.json.bu config.json && +zip dist.zip -r dist/* && +scp -r dist.zip hetzner:/root/ && +scp ./scripts/hetzner/config/* hetzner:/root/ +ssh hetzner -t "unzip dist.zip && rm dist.zip && rm -rf public/ mv dist public && caddy stop && caddy start" diff --git a/src/Models/Constants.ts b/src/Models/Constants.ts index 3edeebc876..75ea6f73f5 100644 --- a/src/Models/Constants.ts +++ b/src/Models/Constants.ts @@ -1,14 +1,11 @@ -import * as meta from "../../package.json" +import * as packagefile from "../../package.json" +import * as extraconfig from "../../config.json" import { Utils } from "../Utils" export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number] export default class Constants { - public static vNumber = meta.version - - public static ImgurApiKey = meta.config.api_keys.imgur - public static readonly mapillary_client_token_v4 = meta.config.api_keys.mapillary_v4 - + public static vNumber = packagefile.version /** * API key for Maproulette * @@ -17,9 +14,6 @@ export default class Constants { * Using an empty string however does work for most actions, but will attribute all actions to the Superuser. */ public static readonly MaprouletteApiKey = "" - - public static defaultOverpassUrls = meta.config.default_overpass_urls - public static readonly added_by_default = [ "selected_element", "gps_location", @@ -47,7 +41,6 @@ export default class Constants { ...Constants.added_by_default, ...Constants.no_include, ] as const - // The user journey states thresholds when a new feature gets unlocked public static userJourney = { moreScreenUnlock: 1, @@ -104,7 +97,14 @@ export default class Constants { * In seconds */ static zoomToLocationTimeout = 15 - static countryCoderEndpoint: string = meta.config.country_coder_host + private static readonly config = (() => { + const defaultConfig = packagefile.config + return { ...defaultConfig, ...extraconfig } + })() + public static ImgurApiKey = Constants.config.api_keys.imgur + public static readonly mapillary_client_token_v4 = Constants.config.api_keys.mapillary_v4 + public static defaultOverpassUrls = Constants.config.default_overpass_urls + static countryCoderEndpoint: string = Constants.config.country_coder_host /** * These are the values that are allowed to use as 'backdrop' icon for a map pin From 1c0c44f2f8446f088918b0b6cc8e282d95d47a37 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 03:14:29 +0200 Subject: [PATCH 39/40] Deploy: add hetzner config files --- scripts/hetzner/config/Caddyfile | 21 +++++++++++++++++++ scripts/hetzner/config/csp-logger-config.json | 7 +++++++ 2 files changed, 28 insertions(+) create mode 100644 scripts/hetzner/config/Caddyfile create mode 100644 scripts/hetzner/config/csp-logger-config.json diff --git a/scripts/hetzner/config/Caddyfile b/scripts/hetzner/config/Caddyfile new file mode 100644 index 0000000000..a417808d2f --- /dev/null +++ b/scripts/hetzner/config/Caddyfile @@ -0,0 +1,21 @@ +hosted.mapcomplete.org { + root * public/ + file_server + header { + +Permissions-Policy "interest-cohort=()" + +Report-To `\{"group":"csp-endpoint", "max_age": 86400,"endpoints": [\{"url": "https://report.mapcomplete.org/csp"}], "include_subdomains": true}` + +Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://gc.zgo.at ; img-src * ; report-uri https://report.mapcomplete.org/csp ; report-to csp-endpoint ;" + } +} + +countrycoder.mapcomplete.org { + root * tiles/ + file_server +} + + +report.mapcomplete.org { + reverse_proxy { + to http://127.0.0.1:2600 + } +} diff --git a/scripts/hetzner/config/csp-logger-config.json b/scripts/hetzner/config/csp-logger-config.json new file mode 100644 index 0000000000..0c2bfd7a21 --- /dev/null +++ b/scripts/hetzner/config/csp-logger-config.json @@ -0,0 +1,7 @@ +{ + "store": "console", + "allowedOrigin": null, + "port": 2600, + "domainWhitelist": ["localhost:10179", "localhost:2600","hosted.mapcomplete.org", "dev.mapcomplete.org", "mapcomplete.org","*"], + "sourceBlacklist": ["chrome-extension://gighmmpiobklfepjocnamgkkbiglidom"] +} From df88fd2f7188084a733804401638f65692f8a24e Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Mon, 25 Sep 2023 11:30:50 +0200 Subject: [PATCH 40/40] Deployment: improve deployment script --- scripts/hetzner/deployHetzner.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/hetzner/deployHetzner.sh b/scripts/hetzner/deployHetzner.sh index 70aec756ff..8d692a40c3 100755 --- a/scripts/hetzner/deployHetzner.sh +++ b/scripts/hetzner/deployHetzner.sh @@ -10,6 +10,8 @@ # unzip tiles.zip MAPCOMPLETE_CONFIGURATION="config_hetzner" +npm run reset:layeroverview +npm run test cp config.json config.json.bu && cp ./scripts/hetzner/config.json . && npm run prepare-deploy && @@ -17,4 +19,5 @@ mv config.json.bu config.json && zip dist.zip -r dist/* && scp -r dist.zip hetzner:/root/ && scp ./scripts/hetzner/config/* hetzner:/root/ -ssh hetzner -t "unzip dist.zip && rm dist.zip && rm -rf public/ mv dist public && caddy stop && caddy start" +ssh hetzner -t "unzip dist.zip && rm dist.zip && rm -rf public/ && mv dist public && caddy stop && caddy start" +rm dist.zip