Better osm.org

Several improvements for advanced users of openstreetmap.org

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Advertisement:

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

Advertisement:

// ==UserScript==
// @name            Better osm.org
// @name:ru         Better osm.org
// @version         1.6.6
// @changelog       v1.6.6: Under experimental flag: clickable POIs, full history for relations, level0 reborn links
// @changelog       v1.6.6: Links to regional Taginfo on taginfo.osm.org and Overpass links on #combinations page
// @changelog       v1.6.6: Download visible notes as .kml, Nodes restorer, parameters for vector styles
// @changelog       v1.6.5: Warn about tracking params in website=* ans similar tags
// @changelog       v1.6.5: Add translations for Russian, Ukrainian, German, French, Croatian, Turkish
// @changelog       v1.6.3: F1 hotkey for hotkeys list, preview Panoramax photos in Overpass search results, CSV reader
// @changelog       v1.6.3: Validate phone=*, email=*, short keys and keys with the first capital letter
// @changelog       v1.6.0: OpenGeoFiction support under debug flag in settings, add OSM2World 3D viewer, type=* validator
// @changelog       v1.6.0: Note marker in Overpass Turbo, more stable Overpass search
// @changelog       v1.5.9: memorizing the last satellite layer, simple vector style editor
// @changelog       v1.5.7: filter notes by creation date, Panoramax uploader (you need to enable it in the settings)
// @changelog       v1.5.5: render child relations on relation page by hover
// @changelog       v1.5.0: Shift + S: custom map layers, Shift + V: custom vector map styles, date for ESRI layer
// @changelog       v1.5.0: KeyV: switch between raster and vector styles, render light:direction=* and direction=12-34
// @changelog       v1.5.0: Initial OpenHistoricalMap support: changeset viewer
// @changelog       v1.4.8: Highlight changesets with review_requested=yes
// @changelog       v1.4.6: Copy coordinates button in map context menu, copy coordinates button for relations
// @changelog       v1.4.0: More links in Edit menu, the ability to add custom links (like OSM Smart Menu)
// @changelog       Changelog archive: https://github.com/deevroman/better-osm-org/blob/master/misc/CHANGELOG.md
// @changelog       Changelog with illustrations: https://c.osm.org/t/121670/207
// @changelog       OSM Wiki: https://osm.wiki/Better-osm-org
// @description     Several improvements for advanced users of openstreetmap.org
// @description:ru  Скрипт, добавляющий на openstreetmap.org полезные картографам функции
// @author       deevroman
// @match        https://www.openstreetmap.org/*
// @exclude      https://www.openstreetmap.org/api*
// @exclude      https://www.openstreetmap.org/message/new/*
// @exclude      https://www.openstreetmap.org/reports/new/*
// @exclude      https://www.openstreetmap.org/profile/*
// @exclude      https://www.openstreetmap.org/messages/*
// @exclude      https://www.openstreetmap.org/diary/*
// @exclude      https://www.openstreetmap.org/account*
// @exclude      https://www.openstreetmap.org/oauth2/*
// @exclude      https://www.openstreetmap.org/login*
// @match        https://www.openhistoricalmap.org/*
// @match        https://opengeofiction.net/*
// @match        https://master.apis.dev.openstreetmap.org/*
// @exclude      https://master.apis.dev.openstreetmap.org/api/*
// @exclude      https://master.apis.dev.openstreetmap.org/account*
// @exclude      https://master.apis.dev.openstreetmap.org/messages/*
// @exclude      https://master.apis.dev.openstreetmap.org/diary/*
// @exclude      https://master.apis.dev.openstreetmap.org/oauth2/*
// @match        https://taginfo.openstreetmap.org/*
// @match        https://taginfo.geofabrik.de/*
// @match        https://www.hdyc.neis-one.org/*
// @match        https://hdyc.neis-one.org/*
// @match        https://osmcha.org/*
// @match        https://osmcha.openhistoricalmap.org/*
// @match        https://overpass-turbo.eu/*
// @exclude      https://www.openhistoricalmap.org/api*
// @exclude      https://www.openhistoricalmap.org/account*
// @exclude      https://www.openhistoricalmap.org/messages/*
// @exclude      https://www.openhistoricalmap.org/diary/*
// @exclude      https://www.openhistoricalmap.org/oauth2/*
// @exclude      https://opengeofiction.net/api*
// @exclude      https://opengeofiction.net/account*
// @exclude      https://opengeofiction.net/messages/*
// @exclude      https://opengeofiction.net/diary/*
// @exclude      https://opengeofiction.net/oauth2/*
// @match        https://wiki.openstreetmap.org/wiki/Proposal:*
// @exclude      https://taginfo.openstreetmap.org/embed/*
// @match        https://github.com/openstreetmap/openstreetmap-website/issues/new*
// @match        https://github.com/deevroman/better-osm-org/issues/new*
// @license      WTFPL
// @namespace    https://github.com/deevroman/better-osm-org
// @supportURL   https://github.com/deevroman/better-osm-org/issues
// @icon         https://www.openstreetmap.org/favicon.ico
// @require      https://raw.githubusercontent.com/deevroman/GM_config/fixed-for-chromium/gm_config.js#sha256=ea04cb4254619543f8bca102756beee3e45e861077a75a5e74d72a5c131c580b
// @require      https://raw.githubusercontent.com/deevroman/osm-auth/d83736efcbec64a87d2c31ffdca3e3efc255f823/dist/osm-auth.iife.js#sha256=f9f85ed6209aa413097a5a4e1a4b6870d3a9ee94f267ac7c3ec35cee99b7dec9
// @require      https://raw.githubusercontent.com/deevroman/exif-js/aad22d0e24726efd1440a008ad08ab704731fcfd/exif.js#sha256=79c449a11e9e485318ca8eff108e1cbcc0ec8b8aa3f0a40294021a2898319147
// @require      https://raw.githubusercontent.com/deevroman/osmtogeojson/c97381a0c86c0a021641dd47d7bea01fb5514716/osmtogeojson.js#sha256=663bb5bbae47d5d12bff9cf1c87b8f973e85fab4b1f83453810aae99add54592
// @require      https://raw.githubusercontent.com/deevroman/better-osm-org/5a949d7b1b0897472396758dd5c1aaa514bba6c6/misc/assets/snow-animation.js#sha256=3b6cd76818c5575ea49aceb7bf4dc528eb8a7cb228b701329a41bb50f0800a5d
// @require      https://raw.githubusercontent.com/deevroman/opening_hours.js/03d9af5c0b3c8dcec6b6ecadc3ef419bee4b52c1/opening_hours+deps.min.js#sha256=b0a9be43fe081d13db2aff69b36417ef18a9e0707629741e627a9b30f948cab9
// @require      https://raw.githubusercontent.com/deevroman/unzipit/refs/heads/master/dist/unzipit.min.js#sha256=13a41f257bc1fd90adeaf6731e02838cf740299235ece90634f12e117e22e2ff
// @require      https://raw.githubusercontent.com/deevroman/bz2/342be4403bf5ba835bb2c9ba54ad008bba428d60/index.js#sha256=8c19861a31e7fb403824e513e1afd23f75c5024f610671490ebc86f2eca61845
// @require      https://raw.githubusercontent.com/deevroman/better-osm-org/9a020e54eef1aefef15f4fa97c31296b57cad1b1/misc/assets/libphonenumber-min.js#sha256=4fa4cc15faf634acd0f4f858842f6866405dd65e4be41f1aa286967e85b8956e
// @incompatible safari https://github.com/deevroman/better-osm-org/issues/13
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.listValues
// @grant        GM.deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_getResourceURL
// @grant        GM_getResourceText
// @grant        GM_addElement
// @grant        GM.xmlHttpRequest
// @grant        GM.fetch
// @grant        GM_info
// @note         for get diffs for finding deleted users
// @connect      planet.openstreetmap.org
// @connect      planet.maps.mail.ru
// @note         overpass instances
// @connect      maps.mail.ru
// @connect      overpass.private.coffee
// @connect      turbo.overpass.private.coffee
// @connect      overpass-api.openhistoricalmap.org
// @connect      overpass.opengeofiction.net
// @connect      overpass-api.de
// @connect      www.hdyc.neis-one.org
// @connect      hdyc.neis-one.org
// @connect      resultmaps.neis-one.org
// @connect      www.openstreetmap.org
// @connect      osmcha.org
// @connect      raw.githubusercontent.com
// @note         for images preview from Wikimedia Commons, Panoramax, Mapillary, StreetComplete
// @connect      en.wikipedia.org
// @connect      commons.wikimedia.org
// @connect      graph.mapillary.com
// @connect      fbcdn.net
// @connect      api.panoramax.xyz
// @connect      panoramax.openstreetmap.fr
// @connect      panoramax.ign.fr
// @connect      panoramax.mapcomplete.org
// @connect      panoramax.multimob.be
// @connect      panoramax.liswu.me
// @connect      panoramax.osm-hr.org
// @connect      panoramax.osm.tw
// @connect      panoramax.libre.net.ar
// @connect      panoramax.koenhabets.nl
// @connect      panoramax.basi.re
// @connect      westnordost.de
// @connect      streetcomplete.app
// @note         for downloading gps-tracks — osm stores tracks in AWS
// @connect      amazonaws.com
// @note         for satellite images and custom tiles
// @connect      server.arcgisonline.com
// @connect      services.arcgisonline.com
// @connect      clarity.maptiles.arcgis.com
// @connect      wayback.maptiles.arcgis.com
// @connect      apps.kontur.io
// @connect      vector.openstreetmap.org
// @connect      vtiles.openhistoricalmap.org
// @connect      openhistoricalmap.org
// @connect      api.maptiler.com
// @connect      api.jawg.io
// @connect      tile.jawg.io
// @connect      map.atownsend.org.uk
// @connect      tiles.openfreemap.org
// @connect      tiles.openrailwaymap.org
// @connect      frexosm.ru
// @connect      static-tiles-lclu.s3.us-west-1.amazonaws.com
// @connect      demotiles.maplibre.org
// @connect      router.project-osrm.org
// @connect      tiles.indoorequal.org
// @connect      tile.openstreetmap.org
// @connect      a.tile-cyclosm.openstreetmap.fr
// @connect      b.tile-cyclosm.openstreetmap.fr
// @connect      c.tile-cyclosm.openstreetmap.fr
// @connect      tile-a.openstreetmap.fr
// @connect      tile-b.openstreetmap.fr
// @connect      tile-c.openstreetmap.fr
// @connect      api.thunderforest.com
// @connect      tile.tracestrack.com
// @note         * for custom layers. ViolentMonkey ignores @connect by default,
// @note         Tampermonkey will show a warning before connecting to a host that is not listed above
// @connect      *
// @connect      geoscribble.osmz.ru
// @connect      geoportal.dgu.hr
// @note         geocoder
// @connect      photon.komoot.io
// @connect      whosthat.osmz.ru
// @connect      content-a.strava.com
// @connect      strava-heatmap.tiles.freemap.sk
// @note         for downloading routers data age
// @connect      map.project-osrm.org
// @connect      valhalla1.openstreetmap.de
// @sandbox      JavaScript
// @resource     OAUTH_HTML https://raw.githubusercontent.com/deevroman/better-osm-org/master/misc/assets/finish-oauth.html?bypass_cache
// @resource     DARK_THEME_FOR_ID_CSS https://gist.githubusercontent.com/deevroman/55f35da68ab1efb57b7ba4636bdf013d/raw/1e91d589ca8cb51c693a119424a45d9f773c265e/dark.css
// @resource     REGIONAL_TAGINFOS https://raw.githubusercontent.com/deevroman/better-osm-org/refs/heads/dev/misc/assets/regional-taginfo-list.html
// @run-at       document-start
// ==/UserScript==

//<editor-fold desc="globals" defaultstate="collapsed">
/*global osmAuth*/
/*global GM*/
/*global GM_info*/
/*global GM_config*/
/*global GM_addElement*/
/*global GM_getResourceURL*/
/*global GM_getResourceText*/
/*global GM_registerMenuCommand*/
/*global unsafeWindow*/
/*global exportFunction*/
/*global cloneInto*/
/*global EXIF*/
/*global osmtogeojson*/
/*global opening_hours*/
/*global libphonenumber*/
/*global runSnowAnimation*/
/*global unzipit*/
/*global bz2*/
performance.mark("BETTER_OSM_START")
//</editor-fold>

//<editor-fold desc="i18n-begin" defaultstate="collapsed">

const localeMap = {
    ru: ["ru-RU", "ru"],
    uk: ["uk-UA", "uk"],
    fr: ["fr-FR", "fr"],
    de: ["de-DE", "de"],
    hr: ["hr-HR", "hr"],
    tr: ["tr-TR", "tr"],
}

/** @typedef {"ru"|"en"|"uk"|"fr"|"de"|"hr"|"tr"} Langs */
/** @type {Langs} */
const currentLocale = Object.entries(localeMap).find(([, locales]) => locales.includes(navigator.language))?.[0] ?? "en"

/** @typedef {string | ((params: Object) => string)} Translation */
/** @typedef {Record<string, Record<string, Translation>>} Translations */
const _translations = /** @type {Record<Langs, Translations>} */ {}

_translations["en"] = {
    init: {
        disableBetterOsmOrg: "Disable better-osm-org",
        scriptVersion: "Script version: ",
        reportingFeaturesWarning: " before reporting bugs or asking questions about features ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org will not work in GreasyMonkey :(\n\n" +
            "It does not support important APIs without which most of the script's functions will not work.\n\n" +
            "Use ViolentMonkey or TamperMonkey\n\n" +
            "Discussion: https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "version was created",
        noteWasCreated: "note was created",
        changesetWasClosed: "changeset was closed",
        clickToChangeTimeFormat: "Click for change time format",
        clickWithCtrlOpenMapState: "Click with ctrl for open the map state at the time of {time}",
        clickWithAltViewAdiff: "Click with Alt for view adiff",
        openMapStateAtTime: "Open the map state at the time of {time}",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Viewing edits",
        sectionWorkingWithNotes: "Working with notes",
        sectionNewActions: "New actions",
        sectionOther: "Other",
        darkModeForMap: "Invert map colors in dark mode",
        colorblindFriendlyPalette: "Colorblind-friendly palette β",
        betterTagsPaste: "Add = when pasting tags separated by spaces or tabs",
        darkModeForID:
            'Dark mode for iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Thanks AlexPS</a>)',
        compactChangesetsHistory: "Compact changesets history",
        versionsDiff: "Add tags diff in history",
        showPreviousTagValue: "Show previous tag value",
        fullVersionsDiff: "Add diff with intermediate versions in way history",
        changesetQuickLook: "Add QuickLook for changesets",
        showChangesetGeometry: "Show geometry of objects in changeset",
        massChangesetsActions: "Add actions for changesets list (mass revert, filtering, ...)",
        imagesAndLinksInTags: "Make some tags clickable, shorter and display photos",
        hideNoteHighlight: "Hide note highlight",
        resolveNotesButton: "Addition resolve buttons:",
        revertButton: "Revert&Osmcha changeset button",
        deletor: "Button for node deletion",
        oneClickDeletor: "Delete node without confirmation",
        changesetsTemplates: 'Changesets comments templates <a id="last-comments-link" target="_blank">(your last comments)</a>',
        hdycInProfile: "Add HDYC to user profile",
        betterProfileStat: "Add filters to profile statistics",
        navigationViaHotkeys: 'Add hotkeys <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(List)</a>',
        newEditorsLinks: "Add new editors into Edit menu",
        resetSearchFormFocus: "Reset search form focus",
        satelliteLayers: "Add satellite layers switches",
        swipes: "Add swipes between user changesets",
        resizableSidebar: "Slider for sidebar width",
        clickableAvatar: "Click by avatar for open changesets",
        overzoomForDataLayer: "Allow overzoom when data/satellite layer enabled β",
        dragAndDropViewers: "Drag&Drop for .geojson, .jpg, .gpx, .osm",
        viewer3DInNewTab: "Open buildings 3D viewers always in new tab",
        betterTaginfo: "Add new buttons in Taginfo",
        defaultZoomKeysBehaviour: "Do not double the zoom step of the buttons +/-",
        addLocationFromNominatim: "Add location from Nominatim for changesets and notes",
        overpassInstance: '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Overpass API server</a>',
        panoramaxUploader: "Add form for uploading photos into Panoramax",
        routersTimestamps: "Add routing data date",
        clickableMap: "Clickable map β",
        debugMode: "Enable debug and experimental features",
    },
    objectEditor: {
        delete: "Delete",
        restore: "Restore",
    },
    historyDiff: {
        intermediateWayVersion: "Intermediate version",
        intermediateWayVersionTitle:
            "There have been changes to the tags or coordinates of nodes in the way that have not increased the way version",
        intermediateRelationVersion: "Intermediate version",
        intermediateRelationVersionTitle:
            "There have been changes to the tags or coordinates of nodes in the relation that have not increased the relation version",
        allVersions: "All versions",
        withGeometryChanges: "With geometry changes",
        withoutIntermediate: "Without intermediate",
        viewUnredactedHistory: "View Unredacted History β",
        errorPleaseReport: "Error :( Please report this page in better-osm-org GitHub repo",
        someNodesHidden: "Some nodes was hidden by moderators",
        reloadAndReport: "Please try reload page.\nIf the error persists, a message about it in the better-osm-org repository",
        someNodesHiddenSad: "Some nodes was hidden by moderators :\\",
        filterIntermediateChanges: "Filter for intermediate changes",
        downloadAllVersionsWithIntermediate: "Download all versions (with intermediate versions)",
        someMembersHidden: "Some members was hidden by moderators",
        downloadAllVersions: "Download all versions",
        toggleCompactTagsDiff: "Toggle between full and compact tags diff.\nYou can also use the T key.",
        hidePreviousValue: "Click for hide previous value",
        tagsCount: ({ count }) => `${count} tag${count === 1 ? "" : "s"}`,
    },
    changesetQuicklook: {
        lineWasReversed: "ⓘ The line has been reversed",
        objectDeletedByAuthor: " ⓘ The object is now deleted by author",
        objectDeleted: " ⓘ The object is now deleted",
        objectDeletedTitle: "{user} deleted this object",
        objectRestored: " ⓘ The object is now restored",
        objectRestoredByAuthor: " ⓘ The object is now restored by author",
        objectRestoredTitle: "{user} restored this object",
        listWayNodesChanged: "List of way nodes has been changed",
        nodesReversed: "Nodes of the way were reversed",
        relationMembersChanged: "List of relation members has been changed.\nClick to see more details",
        showRelationMembers: "Show list of relation members",
        relationMembersReversed: "Members of the relation were reversed",
        pinRelationOnMap: "Pin relation on map",
        unpinRelationFromMap: "Unpin relation from map",
        nodeCoordinatesChanged: "Coordinates of node has been changed",
        downloadThisRelation: "Download this relation",
        shiftClickZoomVia: 'Click with Shift for zoom to "via" members',
        unableDisplaySomeData: "better-osm-org was unable to display some data",
        editsWarTitle: "Edits war. {details}\nClick for details",
        tagRestored: "The tag is now restored",
        nowIsTag: "Now is {key}={value}",
        tagDeleted: "The tag is now deleted",
        wasValue: "was: {value}",
        tagReverted: "The tag is now reverted",
        nodesCountChanged: "Nodes count: {prevCount} → {nextCount}",
        nodesCount: "Nodes count: {count}",
        intermediateNodeChangesTitle:
            "The tags and coordinates of the way nodes were changed several times during the changeset.\n" +
            "This may be intentional or it may occur when uploading a batch of edits using JOSM.\n" +
            "The final state is shown. Click to open full way history",
        sendBugReport: "⚠️ Send Bug Report",
        debugAlert: "⚠ read logs in browser console (F12).\nOnly the script developer should see this message",
    },
    editMenuLinks: {
        moreLinks: "more links / edit list",
        editLinksList: "edit links list",
        editLink: "edit link",
        pinThisLink: "pin this link",
        moveUpLink: "move up link",
        moveDownLink: "move down link",
        removeLink: "remove link",
        saveLink: "save link",
        openPlaceExternalWebsite: "Open place in external website",
        linkNamePlaceholder: "Link name",
        urlExamplePrefix: "URL example: ",
    },
    routers: {
        routingDataTimeFor: "Routing data time for {name}: {time}",
        openDebugMap: "Open Debug Map",
    },
    satellite: {
        setupCustomStyleJson: "Setup custom style.json for MapLibre.js",
        setupCustomMapLayers: "Setup custom map layers",
        duplicateStyleAndEdit: "duplicate style and edit it",
        openMapStyleHomePage: "Open map style home page",
        openMapLayerHomePage: "Open map layer home page",
        switchMapAndSatellite: "Switch between map and satellite images",
        setCustomLayer: "Set custom layer (Shift + S)\nbetter-osm-org feature",
        setCustomVectorStyle: "Set custom vector style (Shift + V)",
        stravaLoginRequiredAlert: "Need login in Strava for access to heatmap.\nAnd after that, reload the tab.",
        vectorStyleUrlPlaceholder: "example: https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "Paste style.json",
        tileUrlPlaceholder: "example: https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Try reload page page without cache Ctrl + F5.\n" +
            "Or:\n" +
            "1. In TamperMonkey settings enable Advanced Config Mode\n" +
            '2. In TamperMonkey settings change "Content Script API" to "UserScript API Dynamic"\n' +
            "More info: https://c.osm.org/t/121670/208\n\n" +
            "Or close this tab and open new tab\n" +
            "Or switch on/off script\n" +
            "Or try to use Firefox with ViolentMonkey",
    },
    geojson: {
        metainfo: "metainfo",
        needUpdateBetterOsmOrg: "Need update better-osm-org",
        switchTableAndRawEditor: "Switch between table and raw editor",
        conflict: "Conflict",
    },
    overpassSearch: {
        httpError: "Error. HTTP Code: {status}",
        httpErrorWithQuery: "Error. HTTP Code: {status}\nSubmitted request:\n\n{query}",
        invalidQuery: "invalid query",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Available commands",
        recent: "Recent",
        noHotkeysMatchSearch: "No hotkeys match this search.",
        noCatalogedCommandsMatchPage: "No cataloged commands match this page.",
        searchByNamePlaceholder: "Search by hotkey name",
    },
    objectVersionPage: {
        length: "Length: {value}",
        area: "Area: {value}",
        pinRestrictionSign: "Pin restriction sign on map.\nYou can hide all the objects that better-osm-org adds by pressing ` or ~",
        hideRestrictionSign: "Hide restriction sign",
        selectCoordinatesFormat: "Select coordinates format for copy.\nTo copy just click by coordinates",
        setDefaultCopyFormat: "Set as default for copy, when you click by coordinates",
        defaultCopyFormat: "It's default format, when your click by coordinates",
        openExternalRelationViewer: "Click for open external relation viewer.\nOr use key O",
        clickToCopyTopLeft: "Click to copy TopLeft: {value}",
        clickToCopyCenter: "Click to copy center: {value}",
        clickToCopyRightBottom: "Click to copy RightBottom: {value}",
        clickToCopyBbox: "Click to copy bbox",
        clickToCopyIdOrUrl: `Click to copy ID\n{modifier} + click to copy URL`,
    },
    userProfile: {
        allEditors: "All editors",
        editorContributions: ({ editor, count }) => ` ${editor} (${count} contribution${count === 1 ? "" : "s"})`,
        osmcha: " [OSMCha] ",
        usernames: "Usernames: ",
        findingBlocks: " Finding blocks... ",
        copyIds: "Copy IDs",
        copyIdsCount: "Copy {count} IDs",
        noComment: "No comment",
        pastUsernames: "Past usernames: ",
        userIdLabel: "ID: ",
        disableTrackingProtection: "Please disable tracking protection so that the HDYC account login works",
        goToHdyc: "Go to https://www.hdyc.neis-one.org/",
        pleaseWaitUserChangesetsLoading: "Please wait while user changesets loading",
        openSelectedChangesetsOnePage: "Alt + O for open selected changesets on one page",
        regexSearchNotCaseSensitive: "Not case-sensitive regex search",
        viaWhosthat: "via whosthat.osmz.ru",
        viaOverpassApi: "via Overpass API",
        regexSearchByCommentsPlaceholder: "Regex search by comments",
        filteredChangesets: "{count} changesets filtered",
        singleUserIdLabel: "User ID: ",
        foundUserIds: "⚠️ Found {count} user IDs",
        userIdBadge: "🆔: ",
        scannedBlocksAfter: "Scanned all blocks after #{id}",
        lastChangesets: "Last {count} changesets:",
    },
    changesetsHistory: {
        copyIds: "Copy IDs",
        copyIdsCount: "Copy {count} IDs",
        displayedCount: " Displayed {displayed}/{total}",
        hideBigChangesets: "Hide big changesets",
        hideChangesetsFrom: "🔄Hide changesets from ",
        showChangesetsFrom: "🔄Show changesets from ",
        hideChangesetsWith: "🔄Hide changesets with ",
        showChangesetsWith: "🔄Show changesets with ",
        loadMore: "Load {count}",
        displayOnOneMapAll: "Display on one map\nif nothing is checked, all uploaded non hidden changesets will open",
        displayOnOneMap: "Display on one map",
        addCheckboxesMassActions: "Add checkboxes for mass actions with changesets",
        clickHideUserChangesets: "Click for hide this user changesets",
        filterViaBetterOsmOrg: "Changesets filter via better-osm-org",
        filterBySubstringInComments: "Filter by substring in changesets comments",
        clickCopyChangesetId: "Click for copy changeset id",
        usersFilterPlaceholder: "user1,user2,... and press Enter",
        commentsFilterPlaceholder: "words1,words2,... and press Enter",
        revertViaOsmRevert: "revert via {name}",
    },
    measurer: {
        measureFromHere: "Measure from here",
        endMeasure: "End measure",
        cleanMeasurements: "Clean measurements",
        clickToSwitchUnits: "Click to switch units of measurement",
        orPressEscapeTwice: "Or press Escape twice",
        hotkeysTitle: "Alt + Click: start new line\nEsc: stop measuring\n{modifier} + Z: remove last node",
    },
    panoramax: {
        blurFaces: "Blur faces",
        loginRequiredAlert: "Please, login to Panoramax",
        uploadPhoto: "Upload photo to Panoramax",
        selectFileAlert: "Select file",
        uploadError: "Error: {message}",
        exifInfo: "Info from EXIF:\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Find offsets",
        focusIframeAlert: "Please switch the focus to the Iframe iD.\nJust click anywhere in the editor.",
    },
    changesetPage: {
        selectObjects: "Select objects",
        revertViaOsmRevert: "Revert via osm-revert",
        openInEditor: "Open in {editor}",
        openInLevel0: "Open in Level0",
        openInLevel0WithWaysGeometry: "Open in Level0 with ways geometry",
        openChangesetInOsmcha: "Open changeset in OSMCha (or press O)\n(shift + O for open Achavi)",
        partialRevertOrEdit: "to partial revert or edit in JOSM/Level0",
        hotkeyR: "Hotkey: R",
        hotkeyJ: "Hotkey: J",
        hotkeyAltJ: "Hotkey: Alt + J",
        hotkeyShiftAltJ: "Hotkey: Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Move node to here",
    },
    scriptMenu: {
        settings: "Settings",
        checkScriptUpdates: "Check script updates",
        checkDevScriptUpdates: "Check dev script updates",
        listOfHotkeys: "List of hotkeys",
    },
    actions: {
        remove: "remove",
        level0WorksOnlyOnOsmOrg: "level0 works only with osm.org",
    },
    copying: {
        clickForCopy: "Click for copy",
        failedToCopy: "failed to copy:\n{text}",
        changeCoordinatesFormat: "Change coordinates format\nCurrent: {format}",
        clickToCopyValue: "Click to copy {value}",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "added by better-osm-org",
        experimentalFeature: "better-osm-org experimental feature",
    },
    osmcha: {
        openProfileInOsmcha: "Open profile in OSMCha",
    },
    selection: {
        shiftClickSelectRange: "Shift + click for select a range of empty checkboxes",
        clickForInvert: "Click for invert",
    },
    deletedUsers: {
        tryFindDeletedUser: "Try find deleted user",
    },
    findUserInDiff: {
        clickForCopyUserId: "Click for copy user ID",
        notFoundAlert: "User not found",
        userIdLabel: "ID: ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "OSMCha review tag. Right click to change\n",
        totalUserChangesets: "how many changesets does the user have in total",
        showHiddenTags: "Show hidden tags",
        clickForViewMore: "Click for view more",
        mapperRequestedReview: "Mapper requested changeset review\n\nClick to filter changesets with review_requested=yes",
        josmNotRunningAlert: "JOSM is not running",
        customOsmApiInJosmAlert: "You are using custom OSM API server in JOSM.\n\nChange JOSM settings or open other website",
        otherOsmInstanceInJosmAlert:
            "You are using other OSM instance, but JOSM uses default server.\n\nChange JOSM settings or open other website",
        osmchaReviewLike: "OSMCha review like\n\nRight click to add review tags",
        osmchaReviewDislike: "OSMCha review dislike\n\nRight click to add review tags",
        osmchaLoginAlert: "Please, login into OSMCha",
        osmchaChangesetNotFoundTitle:
            "Changeset not found in OSMCha database.\nEither OSMCha did not have time to process this changeset, or it is too old.",
        osmchaChangesetNotFoundAlert: "Changeset not found in OSMCha database",
        selectRangeHotkeys:
            "Click with Shift for select range\nPress R for revert via {name}\nPress J for open objects in JOSM\nPress alt + J for open objects in Level0",
        reverterTitle: "Open {name}\nShift + click for revert via JOSM\nPress R for partial revert",
        josmDoesNotSupportPartialRevert: "JOSM doesn't support partial revert",
        osmRevertOnlyMainServers: "{name} works only for www.OpenStreetMap.org and www.OpenHistoricalMap.org",
        osmRevertOnlyMainServersWithJosm:
            "{name} works only for www.OpenStreetMap.org and www.OpenHistoricalMap.org\n\n" +
            "But you can install reverter plugin in JOSM and use shift+click for other OSM servers.\n\n" +
            "⚠️Change the osm server in the josm settings!",
        commentTemplateTitle: '"{text}" will be added to the comment.\nYou can change this text in the script settings',
    },
    hashtags: {
        searchInOsmcha: "Search this hashtags in OSMCha",
        filterNotesByHashtag: "Click for filter notes by this hashtag.\nClick with CTLR or Shift for search this hashtags in osm-note-viewer",
        clickToCopyName: "Click to copy name",
        clickToCopyType: "Click to copy type",
    },
    userBadges: {
        userModerator: "This user is a moderator",
        userImporter: "This user is a importer",
        userBanned: "The user was banned",
        userNewbie: "At the time of creating the changeset/note, the user had been editing OpenStreetMap for less than a month",
        followingUser: "You are following this user",
        corporateMapper: "{names} corporate mapper\n\nClick to open wiki page\nClick with Alt to open data source",
    },
    notes: {
        openMapStateSnapshot: "Open the map state at the time of map snapshot",
        openMapStateNoteCreation: "Open the map state at the time of note creation",
        commaSeparatedSubstrings: "Comma-separated substrings\nFilter also works by comments",
        commaSeparatedUsernames: 'Comma-separated usernames.\n\nUse "anon" for anonymous notes',
        wordInNotesPlaceholder: "word in notes",
        usernamePlaceholder: "username",
        wordInCommentsPlaceholder: "word in comments",
        downloadVisibleNotesAsKmlTitle: "Download visible notes as .kml file",
        addNewObjectOnMap: "Add new object on map\nPaste tags in textarea\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "Textarea not contains any tag",
        resolveButtonTitle: 'Add to the comment "{text}" and close the note.\nYou can change emoji in userscript settings',
    },
    spyGlass: {
        activateSpyGlass: "Activate SpyGlass imitation mode (better-osm-org experiment)",
    },
    gpxFilter: {
        clickToZoomTrack: "click to zoom\nTip: press 8-9 to navigate between previous/next map position",
        showGpxTracks: "Show GPX tracks in current map view",
        downloadFailed: "download failed: {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "Too many lines in CSV file. Only object in bbox will be displayed",
    },
    objectPage: {
        gpsTrackerPosition: "Blue — position from GPS tracker\nOrange — estimated real position",
        roofOrientationAcrossAlong: 'roof:orientation must be either "across" or "along"',
        conditionalMustContainAt: ":conditional tag value must be contain @",
        emptyPartAfterAt: "empty part after @",
        emptyPartBeforeAt: "empty part before @",
        emptyPartBetweenAt: "empty part between @",
        observationNotFound: "Observation not found in iNaturalist API",
        noErrorsOpeningHours: "no errors were found by opening_hours.js 👍",
        phoneMustStartPlus: "phone number must start with +",
        trackingParamInUrl: 'Tracking "{param}=" parameter in URL',
        urlShouldStartWithHttp: "URL should start with https:// or http://",
        easterEgg: "better-osm-org easter egg",
        needRoofOrientation: "it seems to need to be changed to roof:orientation",
        clickShowEmbedded3d:
            "Click to show the embedded 3D Viewer.\nIn the script settings, you can set it to open on the OSM page by default",
        setAsDefaultForClick: "Set as default for click",
        defaultViewer: "It's default viewer",
        keyTooShort: "The key is too short",
        suspiciousCapitalKey: "Suspicious key starting with a capital letter",
        openObjectHistory: "Click for open object history page\nOr press key H",
        photoBy: "Photo by {author}",
        observedBy: "Observed by {user}",
        invalidPhone: "{value} invalid phone according to libphonenumber.js",
        invalidEmail: "{value} invalid email",
        typeOnlyForRelations: "type=* only for relations",
        suspiciousRelationType: "type={value} used < 50 times. This is probably a mistake.",
        openWebsite: "Open {host}",
        editWithWebsite: "Edit with {host}",
    },
    taginfo: {
        searchWithOverpass: "search with Overpass",
    },
    wiki: {
        needMoreVotes: "need {count} more votes up to 75 %",
        interimResults: "Interim results calculated by better-osm-org:",
    },
    relationVersionPage: {
        loadViaOverpass: "Load relation version via Overpass API",
    },
}

_translations["tr"] = {
    init: {
        disableBetterOsmOrg: "better-osm-org'u devre dışı bırak",
        scriptVersion: "Betik sürümü: ",
        reportingFeaturesWarning: " hata bildirmeden veya özellikler hakkında soru sormadan önce ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org GreasyMonkey'de çalışmaz :(\n\n" +
            "Betik işlevlerinin çoğu için gerekli önemli API'leri desteklemiyor.\n\n" +
            "ViolentMonkey veya TamperMonkey kullanın\n\n" +
            "Tartışma: https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "sürüm oluşturuldu",
        noteWasCreated: "not oluşturuldu",
        changesetWasClosed: "değişiklik seti kapatıldı",
        clickToChangeTimeFormat: "Zaman biçimini değiştirmek için tıklayın",
        clickWithCtrlOpenMapState: "{time} zamanındaki harita durumunu açmak için Ctrl ile tıklayın",
        clickWithAltViewAdiff: "adiff'i görüntülemek için Alt ile tıklayın",
        openMapStateAtTime: "{time} zamanındaki harita durumunu aç",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Düzenlemeleri görüntüleme",
        sectionWorkingWithNotes: "Notlarla çalışma",
        sectionNewActions: "Yeni eylemler",
        sectionOther: "Diğer",
        darkModeForMap: "Koyu modda harita renklerini ters çevir",
        colorblindFriendlyPalette: "Renk körlüğüne uygun palet β",
        betterTagsPaste: "Boşluk veya sekmeyle ayrılmış etiketleri yapıştırırken = ekle",
        darkModeForID:
            'iD için koyu mod (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Teşekkürler AlexPS</a>)',
        compactChangesetsHistory: "Kompakt değişiklik setleri geçmişi",
        versionsDiff: "Geçmişe etiket farkı ekle",
        showPreviousTagValue: "Önceki etiket değerini göster",
        fullVersionsDiff: "Yol geçmişine ara sürümlerle fark ekle",
        changesetQuickLook: "Değişiklik setleri için HızlıÖnizleme(QuickLook) ekle",
        showChangesetGeometry: "Değişiklik setindeki nesnelerin geometrisini göster",
        massChangesetsActions: "Değişiklik setleri listesi için eylemler ekle (toplu geri alma, filtreleme, ...)",
        imagesAndLinksInTags: "Bazı etiketleri tıklanabilir ve daha kısa yap, fotoğrafları göster",
        hideNoteHighlight: "Not vurgusunu gizle",
        resolveNotesButton: "Ek çözümleme düğmeleri:",
        revertButton: "Değişiklik seti için geri al ve OSMCha düğmesi",
        deletor: "Düğüm silme düğmesi",
        oneClickDeletor: "Düğümü onaysız sil",
        changesetsTemplates: 'Değişiklik seti yorum şablonları <a id="last-comments-link" target="_blank">(son yorumlarınız)</a>',
        hdycInProfile: "Kullanıcı profiline HDYC ekle",
        betterProfileStat: "Profil istatistiklerine filtreler ekle",
        navigationViaHotkeys:
            'Kısayol tuşları ekle <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(Liste)</a>',
        newEditorsLinks: "Düzenle menüsüne yeni düzenleyiciler ekle",
        resetSearchFormFocus: "Arama formu odağını sıfırla",
        satelliteLayers: "Uydu katmanlarını ayarlama düğmesi ekle",
        swipes: "Kullanıcı değişiklik setleri arasında geçiş tuşu ekle",
        resizableSidebar: "Kenar çubuğu genişliğini ayarlamak için kaydırıcı ekle",
        clickableAvatar: "Değişiklik setlerini açmak için avatara tıkla",
        overzoomForDataLayer: "Veri/uydu katmanı etkinken fazla yakınlaştırmaya izin ver β",
        dragAndDropViewers: ".geojson, .jpg, .gpx, .osm için sürükle bırak",
        viewer3DInNewTab: "Bina 3B görüntüleyicilerini her zaman yeni sekmede aç",
        betterTaginfo: "Taginfo'ya yeni düğmeler ekle",
        defaultZoomKeysBehaviour: "+/- düğmelerinin yakınlaştırma adımını ikiye katlamaz",
        addLocationFromNominatim: "Değişiklik setleri ve notlar için Nominatim'den konum ekle",
        overpassInstance:
            '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Overpass API sunucusu</a>',
        panoramaxUploader: "Panoramax'a fotoğraf yükleme formu ekle",
        routersTimestamps: "Rota verisi tarihini ekle",
        clickableMap: "Haritayı tıklanabilir yap β",
        debugMode: "Hata ayıklama ve deneysel özellikleri etkinleştir",
    },
    objectEditor: {
        delete: "Sil",
        restore: "Geri yükle",
    },
    historyDiff: {
        intermediateWayVersion: "Ara sürüm",
        intermediateWayVersionTitle: "Yoldaki düğümlerin etiketlerinde veya koordinatlarında yol sürümünü artırmayan değişiklikler oldu",
        intermediateRelationVersion: "Ara sürüm",
        intermediateRelationVersionTitle:
            "İlişkideki düğümlerin etiketlerinde veya koordinatlarında ilişki sürümünü artırmayan değişiklikler oldu",
        allVersions: "Tüm sürümler",
        withGeometryChanges: "Geometri değişiklikleriyle",
        withoutIntermediate: "Ara sürümler olmadan",
        viewUnredactedHistory: "Gizlenmemiş geçmişi görüntüle β",
        errorPleaseReport: "Hata :( Lütfen bu sayfayı better-osm-org GitHub deposunda bildirin",
        someNodesHidden: "Bazı düğümler moderatörler tarafından gizlendi",
        reloadAndReport: "Lütfen sayfayı yeniden yüklemeyi deneyin.\nHata devam ederse better-osm-org deposunda bildirin",
        someNodesHiddenSad: "Bazı düğümler moderatörler tarafından gizlendi :\\",
        filterIntermediateChanges: "Ara değişiklikler için filtre",
        downloadAllVersionsWithIntermediate: "Tüm sürümleri indir (ara sürümlerle birlikte)",
        someMembersHidden: "Bazı üyeler moderatörler tarafından gizlendi",
        downloadAllVersions: "Tüm sürümleri indir",
        toggleCompactTagsDiff: "Tam ve kompakt etiket farkı arasında geçiş yap.\nT tuşunu da kullanabilirsiniz.",
        hidePreviousValue: "Önceki değeri gizlemek için tıklayın",
        tagsCount: ({ count }) => `${count} etiket`,
    },
    changesetQuicklook: {
        lineWasReversed: "ⓘ Çizgi ters çevrilmiş",
        objectDeletedByAuthor: " ⓘ Nesneyi ekleyen kişi, bu nesneyi silmiş",
        objectDeleted: " ⓘ Nesne artık silinmiş",
        objectDeletedTitle: "{user} bu nesneyi silmiş",
        objectRestored: " ⓘ Nesne artık geri yüklenmiş",
        objectRestoredByAuthor: " ⓘ Nesneyi ekleyen kişi, bu nesneyi geri yüklemiş",
        objectRestoredTitle: "{user} bu nesneyi geri yüklemiş",
        listWayNodesChanged: "Yol düğümleri listesi değiştirilmiş",
        nodesReversed: "Yolun düğümleri ters çevrilmiş",
        relationMembersChanged: "İlişki üyeleri listesi değiştirilmiş.\nDaha fazla ayrıntı için tıklayın",
        showRelationMembers: "İlişki üyeleri listesini göster",
        relationMembersReversed: "İlişkinin üyeleri ters çevrilmiş",
        pinRelationOnMap: "İlişkiyi haritaya sabitle",
        unpinRelationFromMap: "İlişkinin harita sabitlemesini kaldır",
        nodeCoordinatesChanged: "Düğüm koordinatları değiştirildi",
        downloadThisRelation: "Bu ilişkiyi indir",
        shiftClickZoomVia: '"via" üyelerine yakınlaştırmak için Shift ile tıklayın',
        unableDisplaySomeData: "better-osm-org bazı verileri görüntüleyemedi",
        editsWarTitle: "Düzenleme savaşı. {details}\nAyrıntılar için tıklayın",
        tagRestored: "Etiket artık geri yüklenmiş",
        nowIsTag: "Şimdi {key}={value}",
        tagDeleted: "Etiket artık silinmiş",
        wasValue: "önceki: {value}",
        tagReverted: "Etiket artık geri alınmış",
        nodesCountChanged: "Düğüm sayısı: {prevCount} → {nextCount}",
        nodesCount: "Düğüm sayısı: {count}",
        intermediateNodeChangesTitle:
            "Yol düğümlerinin etiketleri ve koordinatları değişiklik seti sırasında birkaç kez değiştirildi.\n" +
            "Bu kasıtlı olabilir veya JOSM ile toplu düzenleme yüklenirken ortaya çıkmış olabilir.\n" +
            "Son durum gösteriliyor. Tam yol geçmişini açmak için tıklayın",
        sendBugReport: "⚠️ Hata Raporu Gönder",
        debugAlert: "⚠ tarayıcı konsolundaki günlükleri okuyun (F12).\nBu mesajı yalnızca betik geliştiricisi görmelidir",
    },
    editMenuLinks: {
        moreLinks: "daha fazla bağlantı / listeyi düzenle",
        editLinksList: "bağlantı listesini düzenle",
        editLink: "bağlantıyı düzenle",
        pinThisLink: "bu bağlantıyı sabitle",
        moveUpLink: "bağlantıyı yukarı taşı",
        moveDownLink: "bağlantıyı aşağı taşı",
        removeLink: "bağlantıyı kaldır",
        saveLink: "bağlantıyı kaydet",
        openPlaceExternalWebsite: "Yeri harici internet sitesinde aç",
        linkNamePlaceholder: "Bağlantı adı",
        urlExamplePrefix: "URL örneği: ",
    },
    routers: {
        routingDataTimeFor: "{name} için rota verisi zamanı: {time}",
        openDebugMap: "Hata Ayıklama Haritasını Aç",
    },
    satellite: {
        setupCustomStyleJson: "MapLibre.js için özel style.json ayarla",
        setupCustomMapLayers: "Özel harita katmanları ayarla",
        duplicateStyleAndEdit: "stili çoğalt ve düzenle",
        openMapStyleHomePage: "Harita stili ana sayfasını aç",
        openMapLayerHomePage: "Harita katmanı ana sayfasını aç",
        switchMapAndSatellite: "Harita ile uydu görüntüleri arasında geçiş yap",
        setCustomLayer: "Özel katman ayarla (Shift + S)\nbetter-osm-org özelliği",
        setCustomVectorStyle: "Özel vektör stili ayarla (Shift + V)",
        stravaLoginRequiredAlert: "Isı haritasına erişmek için Strava'da oturum açmanız gerekir.\nSonra sekmeyi yenileyin.",
        vectorStyleUrlPlaceholder: "örnek: https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "style.json yapıştır",
        tileUrlPlaceholder: "örnek: https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Sayfayı önbelleksiz yeniden yüklemeyi deneyin: Ctrl + F5.\n" +
            "Veya:\n" +
            "1. TamperMonkey ayarlarında Advanced Config Mode'u etkinleştirin\n" +
            '2. TamperMonkey ayarlarında "Content Script API" değerini "UserScript API Dynamic" olarak değiştirin\n' +
            "Daha fazla bilgi: https://c.osm.org/t/121670/208\n\n" +
            "Veya bu sekmeyi kapatıp yeni bir sekme açın\n" +
            "Veya betiği kapatıp açın\n" +
            "Veya Firefox'u ViolentMonkey ile kullanmayı deneyin",
    },
    geojson: {
        metainfo: "meta bilgi",
        needUpdateBetterOsmOrg: "better-osm-org güncellenmeli",
        switchTableAndRawEditor: "Tablo ile ham düzenleyici arasında geçiş yap",
        conflict: "Çakışma",
    },
    overpassSearch: {
        httpError: "Hata. HTTP kodu: {status}",
        httpErrorWithQuery: "Hata. HTTP kodu: {status}\nGönderilen istek:\n\n{query}",
        invalidQuery: "geçersiz sorgu",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Kullanılabilir komutlar",
        recent: "Son kullanılanlar",
        noHotkeysMatchSearch: "Bu aramayla eşleşen kısayol yok.",
        noCatalogedCommandsMatchPage: "Bu sayfayla eşleşen kataloglanmış komut yok.",
        searchByNamePlaceholder: "Kısayol adına göre ara",
    },
    objectVersionPage: {
        length: "Uzunluk: {value}",
        area: "Alan: {value}",
        pinRestrictionSign:
            "Kısıtlama işaretini haritaya sabitle.\n` veya ~ tuşuna basarak better-osm-org'un eklediği tüm nesneleri gizleyebilirsiniz",
        hideRestrictionSign: "Kısıtlama işaretini gizle",
        selectCoordinatesFormat: "Kopyalama için koordinat biçimini seç.\nKopyalamak için koordinatlara tıklamanız yeterli",
        setDefaultCopyFormat: "Koordinatlara tıkladığınızda kopyalama için varsayılan yap",
        defaultCopyFormat: "Koordinatlara tıkladığınızda varsayılan biçim budur",
        openExternalRelationViewer: "Harici ilişki görüntüleyicisini açmak için tıklayın.\nVeya O tuşunu kullanın",
        clickToCopyTopLeft: "Sol üstü kopyalamak için tıklayın: {value}",
        clickToCopyCenter: "Merkezi kopyalamak için tıklayın: {value}",
        clickToCopyRightBottom: "Sağ altı kopyalamak için tıklayın: {value}",
        clickToCopyBbox: "bbox'u kopyalamak için tıklayın",
        clickToCopyIdOrUrl: `ID'yi kopyalamak için tıklayın\nURL'yi kopyalamak için {modifier} + tıklayın`,
    },
    userProfile: {
        allEditors: "Tüm düzenleyiciler",
        editorContributions: ({ editor, count }) => `${editor} (${count} katkı)`,
        osmcha: " [OSMCha] ",
        usernames: "Kullanıcı adları: ",
        findingBlocks: " Engellemeler aranıyor... ",
        copyIds: "ID'leri kopyala",
        copyIdsCount: "{count} ID kopyala",
        noComment: "Yorum yok",
        pastUsernames: "Geçmiş kullanıcı adları: ",
        userIdLabel: "ID: ",
        disableTrackingProtection: "HDYC hesabı girişinin çalışması için lütfen izleme korumasını devre dışı bırakın",
        goToHdyc: "https://www.hdyc.neis-one.org/ adresine git",
        pleaseWaitUserChangesetsLoading: "Kullanıcının değişiklik setleri yüklenirken lütfen bekleyin",
        openSelectedChangesetsOnePage: "Seçili değişiklik setlerini tek sayfada açmak için Alt + O",
        regexSearchNotCaseSensitive: "Büyük/küçük harfe duyarsız regex araması",
        viaWhosthat: "whosthat.osmz.ru üzerinden",
        viaOverpassApi: "Overpass API üzerinden",
        regexSearchByCommentsPlaceholder: "Yorumlarda regex araması",
        filteredChangesets: "{count} değişiklik seti filtrelendi",
        singleUserIdLabel: "Kullanıcı ID'si: ",
        foundUserIds: "⚠️ {count} kullanıcı ID'si bulundu",
        userIdBadge: "🆔: ",
        scannedBlocksAfter: "#{id} sonrasındaki tüm engellemeler tarandı",
        lastChangesets: "Son {count} değişiklik seti:",
    },
    changesetsHistory: {
        copyIds: "ID'leri kopyala",
        copyIdsCount: "{count} ID kopyala",
        displayedCount: " Gösterilen {displayed}/{total}",
        hideBigChangesets: "Büyük değişiklik setlerini gizle",
        hideChangesetsFrom: "🔄Şu kullanıcının değişikliklerini gizle: ",
        showChangesetsFrom: "🔄Şu kullanıcının değişikliklerini göster: ",
        hideChangesetsWith: "🔄Şunu içeren değişiklikleri gizle: ",
        showChangesetsWith: "🔄Şunu içeren değişiklikleri göster: ",
        loadMore: "{count} yükle",
        displayOnOneMapAll: "Tümünü tek haritada göster\nhiçbir şey işaretlenmezse yüklenen ve gizli olmayan tüm değişiklik setleri açılır",
        displayOnOneMap: "Tek haritada göster",
        addCheckboxesMassActions: "Değişiklik setleriyle ilgili toplu işlemler yapabilmek için onay kutuları ekle",
        clickHideUserChangesets: "Bu kullanıcının değişiklik setlerini gizlemek için tıklayın",
        filterViaBetterOsmOrg: "better-osm-org ile değişiklik seti filtresi",
        filterBySubstringInComments: "Değişiklik seti yorumlarında alt dizeye göre filtrele",
        clickCopyChangesetId: "Değişiklik seti ID'sini kopyalamak için tıklayın",
        usersFilterPlaceholder: "kullanıcı1,kullanıcı2,... ve Enter'a basın",
        commentsFilterPlaceholder: "kelime1,kelime2,... ve Enter'a basın",
        revertViaOsmRevert: "{name} ile geri al",
    },
    measurer: {
        measureFromHere: "Buradan ölç",
        endMeasure: "Ölçümü bitir",
        cleanMeasurements: "Ölçümleri temizle",
        clickToSwitchUnits: "Ölçü birimlerini değiştirmek için tıklayın",
        orPressEscapeTwice: "Veya Escape'e iki kez basın",
        hotkeysTitle: "Alt + Tık: yeni çizgi başlat\nEsc: ölçümü durdur\n{modifier} + Z: son düğümü kaldır",
    },
    panoramax: {
        blurFaces: "Yüzleri bulanıklaştır",
        loginRequiredAlert: "Lütfen Panoramax'a giriş yapın",
        uploadPhoto: "Panoramax'a fotoğraf yükle",
        selectFileAlert: "Dosya seçin",
        uploadError: "Hata: {message}",
        exifInfo: "EXIF bilgisi:\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Ofsetleri bul",
        focusIframeAlert: "Lütfen odağı iD Iframe'ine geçirin.\nDüzenleyicide herhangi bir yere tıklamanız yeterli.",
    },
    changesetPage: {
        selectObjects: "Nesneleri seç",
        revertViaOsmRevert: "osm-revert ile geri al",
        openInEditor: "{editor} içinde aç",
        openInLevel0: "Level0'da aç",
        openInLevel0WithWaysGeometry: "Yol geometrisiyle Level0'da aç",
        openChangesetInOsmcha: "Değişiklik setini OSMCha'da aç (veya O'a bas)\n(Achavi'yi açmak için Shift + O)",
        partialRevertOrEdit: "JOSM/Level0'da kısmi geri alma veya düzenleme için",
        hotkeyR: "Kısayol: R",
        hotkeyJ: "Kısayol: J",
        hotkeyAltJ: "Kısayol: Alt + J",
        hotkeyShiftAltJ: "Kısayol: Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Düğümü buraya taşı",
    },
    scriptMenu: {
        settings: "Ayarlar",
        checkScriptUpdates: "Betik güncellemelerini kontrol et",
        checkDevScriptUpdates: "Geliştirici betik güncellemelerini kontrol et",
        listOfHotkeys: "Kısayol listesi",
    },
    actions: {
        remove: "kaldır",
        level0WorksOnlyOnOsmOrg: "level0 yalnızca osm.org ile çalışır",
    },
    copying: {
        clickForCopy: "Kopyalamak için tıklayın",
        failedToCopy: "kopyalanamadı:\n{text}",
        changeCoordinatesFormat: "Koordinat biçimini değiştir\nGeçerli: {format}",
        clickToCopyValue: "{value} kopyalamak için tıklayın",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "better-osm-org tarafından eklendi",
        experimentalFeature: "better-osm-org deneysel özelliği",
    },
    osmcha: {
        openProfileInOsmcha: "Profili OSMCha'da aç",
    },
    selection: {
        shiftClickSelectRange: "Boş onay kutuları aralığını seçmek için Shift + tık",
        clickForInvert: "Tersine çevirmek için tıklayın",
    },
    deletedUsers: {
        tryFindDeletedUser: "Silinmiş kullanıcıyı bulmayı dene",
    },
    findUserInDiff: {
        clickForCopyUserId: "Kullanıcı ID'sini kopyalamak için tıklayın",
        notFoundAlert: "Kullanıcı bulunamadı",
        userIdLabel: "ID: ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "OSMCha inceleme etiketi. Değiştirmek için sağ tıklayın\n",
        totalUserChangesets: "kullanıcının toplam kaç değişiklik seti var",
        showHiddenTags: "Gizli etiketleri göster",
        clickForViewMore: "Daha fazlasını görüntülemek için tıklayın",
        mapperRequestedReview:
            "Haritacı değişiklik setinin incelenmesini istedi\n\nreview_requested=yes olan değişiklik setlerini filtrelemek için tıklayın",
        josmNotRunningAlert: "JOSM çalışmıyor",
        customOsmApiInJosmAlert:
            "JOSM'da özel OSM API sunucusu kullanıyorsunuz.\n\nJOSM ayarlarını değiştirin veya başka internet sitesi açın",
        otherOsmInstanceInJosmAlert:
            "Başka bir OSM örneği kullanıyorsunuz, ancak JOSM varsayılan sunucuyu kullanıyor.\n\nJOSM ayarlarını değiştirin veya başka internet sitesi açın",
        osmchaReviewLike: "OSMCha olumlu inceleme\n\nİnceleme etiketleri eklemek için sağ tıklayın",
        osmchaReviewDislike: "OSMCha olumsuz inceleme\n\nİnceleme etiketleri eklemek için sağ tıklayın",
        osmchaLoginAlert: "Lütfen OSMCha'ya giriş yapın",
        osmchaChangesetNotFoundTitle:
            "Değişiklik seti OSMCha veritabanında bulunamadı.\nOSMCha bu değişiklik setini henüz işlememiş olabilir veya değişiklik seti çok eski olabilir.",
        osmchaChangesetNotFoundAlert: "Değişiklik seti OSMCha veritabanında bulunamadı",
        selectRangeHotkeys:
            "Aralık seçmek için Shift ile tıklayın\n{name} ile geri almak için R'ye basın\nNesneleri JOSM'da açmak için J'ye basın\nNesneleri Level0'da açmak için Alt + J'ye basın",
        reverterTitle: "{name} aç\nJOSM ile geri almak için Shift + tık\nKısmi geri alma için R'ye basın",
        josmDoesNotSupportPartialRevert: "JOSM kısmi geri almayı desteklemiyor",
        osmRevertOnlyMainServers: "{name} yalnızca www.OpenStreetMap.org ve www.OpenHistoricalMap.org için çalışır",
        osmRevertOnlyMainServersWithJosm:
            "{name} yalnızca www.OpenStreetMap.org ve www.OpenHistoricalMap.org için çalışır\n\n" +
            "Ancak JOSM'da reverter eklentisini kurabilir ve diğer OSM sunucuları için shift+tık kullanabilirsiniz.\n\n" +
            "⚠️JOSM ayarlarında osm sunucusunu değiştirin!",
        commentTemplateTitle: '"{text}" yoruma eklenecek.\nBu metni script ayarlarında değiştirebilirsiniz',
    },
    hashtags: {
        searchInOsmcha: "Bu hashtag'leri OSMCha'da ara",
        filterNotesByHashtag:
            "Notları bu hashtag'e göre filtrelemek için tıklayın.\nBu hashtag'leri osm-note-viewer'da aramak için CTLR veya Shift ile tıklayın",
        clickToCopyName: "Adı kopyalamak için tıklayın",
        clickToCopyType: "Türü kopyalamak için tıklayın",
    },
    userBadges: {
        userModerator: "Bu kullanıcı bir moderatör",
        userImporter: "Bu kullanıcı bir içe aktarıcı",
        userBanned: "Kullanıcı yasaklandı",
        userNewbie: "Değişiklik seti/not oluşturulduğu sırada kullanıcı OpenStreetMap'i bir aydan kısa süredir düzenliyordu",
        followingUser: "Bu kullanıcıyı takip ediyorsunuz",
        corporateMapper: "{names} kurumsal haritacı\n\nWiki sayfasını açmak için tıklayın\nVeri kaynağını açmak için Alt ile tıklayın",
    },
    notes: {
        openMapStateSnapshot: "Harita anlık görüntüsü zamanındaki harita durumunu aç",
        openMapStateNoteCreation: "Not oluşturma zamanındaki harita durumunu aç",
        commaSeparatedSubstrings: "Virgülle ayrılmış alt dizeler\nFiltre yorumlarda da çalışır",
        commaSeparatedUsernames: 'Virgülle ayrılmış kullanıcı adları.\n\nAnonim notlar için "anon" kullanın',
        wordInNotesPlaceholder: "notlardaki kelime",
        usernamePlaceholder: "kullanıcı adı",
        wordInCommentsPlaceholder: "yorumlardaki kelime",
        downloadVisibleNotesAsKmlTitle: "Görünen notları .kml dosyası olarak indir",
        addNewObjectOnMap: "Haritaya yeni nesne ekle\nEtiketleri metin alanına yapıştır\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "Metin alanı hiçbir etiket içermiyor",
        resolveButtonTitle: 'Yoruma "{text}" ekle ve notu kapat.\nEmoji userscript ayarlarında değiştirilebilir',
    },
    spyGlass: {
        activateSpyGlass: "SpyGlass taklit modunu etkinleştir (better-osm-org deneyi)",
    },
    gpxFilter: {
        clickToZoomTrack: "yakınlaştırmak için tıklayın\nİpucu: önceki/sonraki harita konumu arasında gezinmek için 8-9'a basın",
        showGpxTracks: "Geçerli harita görünümünde GPX izlerini göster",
        downloadFailed: "indirme başarısız: {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "CSV dosyasında çok fazla satır var. Yalnızca bbox içindeki nesne gösterilecek",
    },
    objectPage: {
        gpsTrackerPosition: "Mavi — GPS izleyiciden gelen konum\nTuruncu — tahmini gerçek konum",
        roofOrientationAcrossAlong: 'roof:orientation değeri "across" veya "along" olmalıdır',
        conditionalMustContainAt: ":conditional etiket değeri @ içermelidir",
        emptyPartAfterAt: "@ sonrasında boş bölüm",
        emptyPartBeforeAt: "@ öncesinde boş bölüm",
        emptyPartBetweenAt: "@ arasında boş bölüm",
        observationNotFound: "Gözlem iNaturalist API'sinde bulunamadı",
        noErrorsOpeningHours: "opening_hours.js hata bulamadı 👍",
        phoneMustStartPlus: "telefon numarası + ile başlamalıdır",
        trackingParamInUrl: 'URL\'de izleme parametresi "{param}="',
        urlShouldStartWithHttp: "URL https:// veya http:// ile başlamalıdır",
        easterEgg: "better-osm-org paskalya yumurtası",
        needRoofOrientation: "bunun roof:orientation olarak değiştirilmesi gerekiyor gibi görünüyor",
        clickShowEmbedded3d:
            "Gömülü 3B Görüntüleyiciyi göstermek için tıklayın.\nScript ayarlarında varsayılan olarak OSM sayfasında açılmasını ayarlayabilirsiniz",
        setAsDefaultForClick: "Tıklama için varsayılan yap",
        defaultViewer: "Bu varsayılan görüntüleyici",
        keyTooShort: "Anahtar çok kısa",
        suspiciousCapitalKey: "Büyük harfle başlayan şüpheli anahtar",
        openObjectHistory: "Nesne geçmişi sayfasını açmak için tıklayın\nVeya H tuşuna basın",
        photoBy: "Fotoğraf: {author}",
        observedBy: "Gözlemleyen: {user}",
        invalidPhone: "{value} libphonenumber.js'e göre geçersiz telefon numarası",
        invalidEmail: "{value} geçersiz e-posta",
        typeOnlyForRelations: "type=* yalnızca ilişkiler için kullanılır",
        suspiciousRelationType: "type={value} < 50 kez kullanılmış. Bu muhtemelen bir hata.",
        openWebsite: "{host} aç",
        editWithWebsite: "{host} ile düzenle",
    },
    taginfo: {
        searchWithOverpass: "Overpass ile ara",
    },
    wiki: {
        needMoreVotes: "75 % için {count} oy daha gerekli",
        interimResults: "better-osm-org tarafından hesaplanan ara sonuçlar:",
    },
    relationVersionPage: {
        loadViaOverpass: "İlişki sürümünü Overpass API üzerinden yükle",
    },
}

_translations["ru"] = {
    init: {
        disableBetterOsmOrg: "Отключите better-osm-org",
        scriptVersion: "Версия скрипта: ",
        reportingFeaturesWarning: " перед сообщением об ошибках или вопросами о функциях ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org не будет работать в GreasyMonkey :(\n\n" +
            "Он не поддерживает важные API, без которых большинство функций скрипта не будет работать.\n\n" +
            "Используйте ViolentMonkey или TamperMonkey\n\n" +
            "Обсуждение: https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "версия была создана",
        noteWasCreated: "заметка была создана",
        changesetWasClosed: "пакет правок был закрыт",
        clickToChangeTimeFormat: "Нажмите, чтобы сменить формат времени",
        clickWithCtrlOpenMapState: "Нажмите с Ctrl, чтобы открыть состояние карты на момент, когда {time}",
        clickWithAltViewAdiff: "Нажмите с Alt, чтобы посмотреть adiff",
        openMapStateAtTime: "Открыть состояние карты на момент, когда {time}",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Просмотр правок",
        sectionWorkingWithNotes: "Работа с заметками",
        sectionNewActions: "Новые действия",
        sectionOther: "Прочее",
        darkModeForMap: "Инвертировать цвета карты в тёмной теме",
        colorblindFriendlyPalette: "Палитра для дальтоников β",
        betterTagsPaste: "Добавлять = при вставке тегов, разделённых пробелами или табами",
        darkModeForID:
            'Тёмная тема для iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Спасибо AlexPS</a>)',
        compactChangesetsHistory: "Компактная история пакетов правок",
        versionsDiff: "Цветной дифф тегов в истории",
        showPreviousTagValue: "Показывать предыдущее значение тега",
        fullVersionsDiff: "Показывать промежуточные версии в истории линий",
        changesetQuickLook: "QuickLook для пакетов правок",
        showChangesetGeometry: "Показывать геометрию объектов в пакете правок",
        massChangesetsActions: "Добавить действия для списка пакетов правок (массовый откат, фильтрация, ...)",
        imagesAndLinksInTags: "Сделать некоторые теги кликабельными, короче и показывать фотографии",
        hideNoteHighlight: "Скрывать подсветку заметки",
        resolveNotesButton: "Дополнительные кнопки закрытия заметок:",
        revertButton: "Кнопки отката и открытия OSMCha для пакета правок",
        deletor: "Кнопка удаления узла",
        oneClickDeletor: "Удалять узел без подтверждения",
        changesetsTemplates: 'Шаблоны комментариев к правкам <a id="last-comments-link" target="_blank">(ваши последние комментарии)</a>',
        hdycInProfile: "Добавить HDYC в профиль пользователя",
        betterProfileStat: "Добавить фильтры в статистику профиля",
        navigationViaHotkeys:
            'Включить горячие клавиши <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(список)</a>',
        newEditorsLinks: "Больше ссылок в меню Правка",
        resetSearchFormFocus: "Сбрасывать фокус с формы поиска",
        satelliteLayers: "Добавить переключатели спутникового слоя",
        swipes: "Добавить свайпы между страницами правок пользователя",
        resizableSidebar: "Разрешить изменять ширину боковой панели",
        clickableAvatar: "Открывать пакеты правок по клику на аватар",
        overzoomForDataLayer: "Разрешить оверзум при включённом слое данные или подложке β",
        dragAndDropViewers: "Drag&Drop для .geojson, .jpg, .gpx, .osm",
        viewer3DInNewTab: "Открывать 3D-просмотрщики зданий в новой вкладке",
        betterTaginfo: "Добавить новые кнопки в Taginfo",
        defaultZoomKeysBehaviour: "Не удваивать шаг зума для кнопок +/-",
        addLocationFromNominatim: "Отображать адрес из Nominatim для правок и заметок",
        overpassInstance: '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Сервер Overpass API</a>',
        panoramaxUploader: "Добавить форму загрузки фотографий в Panoramax",
        routersTimestamps: "Показывать дату данных для GraphHopper, OSRM, Valhalla",
        clickableMap: "Сделать карту кликабельной β",
        debugMode: "Включить отладочные и экспериментальные фичи",
    },
    objectEditor: {
        delete: "Выпилить!",
        restore: "Восстановить",
    },
    historyDiff: {
        intermediateWayVersion: "Промежуточная версия",
        intermediateWayVersionTitle: "Произошли изменения тегов или координат точек в линии,\nкоторые не увеличили версию линии",
        intermediateRelationVersion: "Промежуточная версия",
        intermediateRelationVersionTitle: "Произошли изменения тегов или координат точек в отношении,\nкоторые не увеличили версию отношении",
        allVersions: "Все версии",
        withGeometryChanges: "Все изменения геометрии",
        withoutIntermediate: "Без промежуточных",
        viewUnredactedHistory: "Просмотр неотредактированной истории β",
        errorPleaseReport: "Ошибка :( Пожалуйста, сообщите об этом в GitHub-репозитории better-osm-org",
        someNodesHidden: "Некоторые точки были скрыты модераторами",
        reloadAndReport: "Попробуйте перезагрузить страницу.\nЕсли ошибка сохраняется, напишите об этом в репозитории better-osm-org",
        someNodesHiddenSad: "Некоторые точки были скрыты модераторами :\\",
        filterIntermediateChanges: "Фильтр промежуточных изменений",
        downloadAllVersionsWithIntermediate: "Скачать все версии (включая промежуточные)",
        someMembersHidden: "Некоторые участники были скрыты модераторами",
        downloadAllVersions: "Скачать все версии",
        toggleCompactTagsDiff: "Переключить между полным и компактным diff тегов.\nТакже можно использовать клавишу T.",
        hidePreviousValue: "Нажмите, чтобы скрыть предыдущее значение",
        tagsCount: ({ count }) => {
            if (count === 1) {
                return `${count} тег`
            }
            if ((count < 10 || count > 20) && [2, 3, 4].includes(count % 10)) {
                return `${count} тега`
            }
            return `${count} тегов`
        },
    },
    changesetQuicklook: {
        lineWasReversed: " ⓘ Линию перевернули",
        objectDeletedByAuthor: " ⓘ Автор уже удалил объект",
        objectDeleted: " ⓘ Объект уже удалён",
        objectDeletedTitle: "{user} удалил этот объект",
        objectRestored: " ⓘ Объект сейчас восстановлен",
        objectRestoredByAuthor: " ⓘ Автор уже восстановил объект",
        objectRestoredTitle: "{user} восстановил этот объект",
        listWayNodesChanged: "Список точек линии был изменён",
        nodesReversed: "Точки линии были развернуты",
        relationMembersChanged: "Список участников отношения был изменён.\nНажмите, чтобы увидеть подробности",
        showRelationMembers: "Показать список участников отношения",
        relationMembersReversed: "Участники отношения были развернуты",
        pinRelationOnMap: "Закрепить отношение на карте",
        unpinRelationFromMap: "Убрать отношение с карты",
        nodeCoordinatesChanged: "Координаты точки были изменены",
        downloadThisRelation: "Скачать это отношение",
        shiftClickZoomVia: "Нажмите с Shift, чтобы приблизить участников с ролью «via»",
        unableDisplaySomeData: "better-osm-org не смог отобразить некоторые данные",
        editsWarTitle: "Война правок. {details}\nНажмите для подробностей",
        tagRestored: " Тег теперь восстановлен",
        nowIsTag: "Сейчас {key}={value}",
        tagDeleted: "Тег теперь удалён",
        wasValue: "было: {value}",
        tagReverted: "Тег теперь откачен",
        nodesCountChanged: "Количество точек: {prevCount} → {nextCount}",
        nodesCount: "Количество точек: {count}",
        intermediateNodeChangesTitle:
            "Теги и координаты точек линии изменялись несколько раз в течение пакета правок.\n" +
            "Это может быть намеренно или происходить при пакетной загрузке правок через JOSM.\n" +
            "Показано итоговое состояние. Нажмите, чтобы открыть полную историю линии",
        sendBugReport: "⚠️ Отправить баг-репорт",
        debugAlert: "⚠ прочитайте логи в консоли браузера (F12).\nЭто сообщение должен видеть только разработчик скрипта",
    },
    editMenuLinks: {
        moreLinks: "больше ссылок / изменить список",
        editLinksList: "редактировать список",
        editLink: "редактировать ссылку",
        pinThisLink: "закрепить эту ссылку",
        moveUpLink: "переместить ссылку вверх",
        moveDownLink: "переместить ссылку вниз",
        removeLink: "удалить ссылку",
        saveLink: "сохранить ссылку",
        openPlaceExternalWebsite: "Открыть место на внешнем сайте",
        linkNamePlaceholder: "Название ссылки",
        urlExamplePrefix: "Пример URL: ",
    },
    routers: {
        routingDataTimeFor: "Время данных роутинга для {name}: {time}",
        openDebugMap: "Открыть Debug Map",
    },
    satellite: {
        setupCustomStyleJson: "Настройка своего стиля векторной карты",
        setupCustomMapLayers: "Настройка подложки карты",
        duplicateStyleAndEdit: "дублировать стиль и отредактировать его",
        openMapStyleHomePage: "Открыть домашнюю страницу стиля карты",
        openMapLayerHomePage: "Открыть домашнюю страницу слоя карты",
        switchMapAndSatellite: "Переключение между картой и спутниковыми снимками",
        setCustomLayer: "Задать свой слой (Shift + S)\nфича better-osm-org",
        setCustomVectorStyle: "Задать свой векторный стиль (Shift + V)",
        stravaLoginRequiredAlert: "Нужно войти в Strava для доступа к тепловой карте.\nПосле этого перезагрузите вкладку.",
        vectorStyleUrlPlaceholder: "пример: https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "Вставьте style.json",
        tileUrlPlaceholder: "пример: https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Попробуйте перезагрузить страницу без кеша Ctrl + F5.\n" +
            "Или:\n" +
            "1. В настройках TamperMonkey включите Advanced Config Mode\n" +
            '2. В настройках TamperMonkey переключите "Content Script API" на "UserScript API Dynamic"\n' +
            "Подробнее: https://c.osm.org/t/121670/208\n\n" +
            "Или закройте эту вкладку и откройте новую\n" +
            "Или выключите и включите скрипт\n" +
            "Или попробуйте Firefox с ViolentMonkey",
    },
    geojson: {
        metainfo: "метаинформация",
        needUpdateBetterOsmOrg: "Нужно обновить better-osm-org",
        switchTableAndRawEditor: "Переключить между таблицей и сырым редактором",
        conflict: "Конфликт",
    },
    overpassSearch: {
        httpError: "Ошибка. HTTP код: {status}",
        httpErrorWithQuery: "Ошибка. HTTP код: {status}\nОтправленный запрос:\n\n{query}",
        invalidQuery: "неверный запрос",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Доступные команды",
        recent: "Недавние",
        noHotkeysMatchSearch: "Нет подходящих горячих клавиш.",
        noCatalogedCommandsMatchPage: "Для этой страницы нет подходящих команд.",
        searchByNamePlaceholder: "Поиск по названию хоткея",
    },
    objectVersionPage: {
        length: "Длина: {value}",
        area: "Площадь: {value}",
        pinRestrictionSign:
            "Закрепить знак restriction на карте.\nВы можете скрыть все объекты, которые добавляет better-osm-org, нажав ` или ~",
        hideRestrictionSign: "Скрыть знак restriction",
        selectCoordinatesFormat: "Выберите формат координат для копирования.\nЧтобы скопировать, просто нажмите по координатам",
        setDefaultCopyFormat: "Сделать форматом копирования по умолчанию при клике по координатам",
        defaultCopyFormat: "Это формат по умолчанию при клике по координатам",
        openExternalRelationViewer: "Нажмите, чтобы открыть внешний просмотрщик отношений.\nИли используйте клавишу O",
        clickToCopyTopLeft: "Нажмите, чтобы скопировать TopLeft: {value}",
        clickToCopyCenter: "Нажмите, чтобы скопировать центр: {value}",
        clickToCopyRightBottom: "Нажмите, чтобы скопировать RightBottom: {value}",
        clickToCopyBbox: "Нажмите, чтобы скопировать bbox",
        clickToCopyIdOrUrl: `Нажмите, чтобы скопировать ID\n{modifier} + click, чтобы скопировать URL`,
    },
    userProfile: {
        allEditors: "Все редакторы",
        editorContributions: ({ editor, count }) => {
            if (count === 1) {
                return `${editor} (1 правка)`
            }
            if ((count < 10 || count > 20) && [2, 3, 4].includes(count % 10)) {
                return `${count} правок`
            }
            return `${editor} (${count} правка)`
        },
        osmcha: " [OSMCha] ",
        usernames: "Имена пользователей: ",
        findingBlocks: " Ищем блокировки... ",
        copyIds: "Скопировать ID",
        copyIdsCount: "Скопировать {count} ID",
        noComment: "Без комментария",
        pastUsernames: "Прошлые имена: ",
        userIdLabel: "ID: ",
        disableTrackingProtection: "Отключите защиту от отслеживания, чтобы заработал показ информации с сайта HDYC",
        goToHdyc: "Открыть https://www.hdyc.neis-one.org/",
        pleaseWaitUserChangesetsLoading: "Подождите, пока загружаются пакеты правок пользователя",
        openSelectedChangesetsOnePage: "Alt + O для открытия выбранных пакетов правок на одной странице",
        regexSearchNotCaseSensitive: "Регулярное выражение без учёта регистра",
        viaWhosthat: "через whosthat.osmz.ru",
        viaOverpassApi: "через Overpass API",
        regexSearchByCommentsPlaceholder: "Regex-поиск по комментариям",
        filteredChangesets: "Отфильтровано пакетов правок: {count}",
        singleUserIdLabel: "ID пользователя: ",
        foundUserIds: "⚠️ Найдено ID пользователей: {count}",
        userIdBadge: "🆔: ",
        scannedBlocksAfter: "Просканированы все блокировки после #{id}",
        lastChangesets: "Последние пакеты правок: {count}",
    },
    changesetsHistory: {
        copyIds: "Скопировать ID",
        copyIdsCount: "Скопировать {count} ID",
        displayedCount: " Показано {displayed}/{total}",
        hideBigChangesets: "Скрывать большие пакеты правок",
        hideChangesetsFrom: "🔄Скрывать правки от ",
        showChangesetsFrom: "🔄Показывать правки от ",
        hideChangesetsWith: "🔄Скрывать правки с ",
        showChangesetsWith: "🔄Только правки с ",
        loadMore: "Загрузить {count}",
        displayOnOneMapAll: "Показать на одной карте\nесли ничего не отмечено, откроются все загруженные и не скрытые пакеты правок",
        displayOnOneMap: "Показать на одной карте",
        addCheckboxesMassActions: "Добавить чекбоксы для массовых действий с пакетами правок",
        clickHideUserChangesets: "Нажмите, чтобы скрыть пакеты правок этого пользователя",
        filterViaBetterOsmOrg: "Фильтр пакетов правок через better-osm-org",
        filterBySubstringInComments: "Фильтр по подстроке в комментариях пакетов правок",
        clickCopyChangesetId: "Нажмите, чтобы скопировать ID пакета правок",
        usersFilterPlaceholder: "user1,user2,... и нажмите Enter",
        commentsFilterPlaceholder: "words1,words2,... и нажмите Enter",
        revertViaOsmRevert: "откатить через {name}",
    },
    measurer: {
        measureFromHere: "Измерить отсюда",
        endMeasure: "Закончить измерение",
        cleanMeasurements: "Очистить измерения",
        clickToSwitchUnits: "Нажмите, чтобы переключить единицы измерения",
        orPressEscapeTwice: "Или нажмите Escape дважды",
        hotkeysTitle: "Alt + Click: начать новую линию\nEsc: остановить измерение\n{modifier} + Z: удалить последнюю точку",
    },
    panoramax: {
        blurFaces: "Размыть лица",
        loginRequiredAlert: "Пожалуйста, войдите в Panoramax",
        uploadPhoto: "Загрузить фото в Panoramax",
        selectFileAlert: "Выберите файл",
        uploadError: "Ошибка: {message}",
        exifInfo: "Информация из EXIF:\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Найти смещения",
        focusIframeAlert: "Пожалуйста, переключите фокус на iframe iD.\nПросто кликните в любом месте редактора.",
    },
    changesetPage: {
        selectObjects: "Выбрать объекты",
        revertViaOsmRevert: "Откатить через osm-revert",
        openInEditor: "Открыть в {editor}",
        openInLevel0: "Открыть в Level0",
        openInLevel0WithWaysGeometry: "Открыть в Level0 с геометрией линий",
        openChangesetInOsmcha: "Открыть пакет правок в OSMCha (или нажмите O)\n(Shift + O для открытия Achavi)",
        partialRevertOrEdit: "для частичного отката или редактирования в JOSM/Level0",
        hotkeyR: "Горячая клавиша: R",
        hotkeyJ: "Горячая клавиша: J",
        hotkeyAltJ: "Горячая клавиша: Alt + J",
        hotkeyShiftAltJ: "Горячая клавиша: Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Переместить точку сюда",
    },
    scriptMenu: {
        settings: "Настройки",
        checkScriptUpdates: "Проверить обновления скрипта",
        checkDevScriptUpdates: "Проверить обновления dev-версии скрипта",
        listOfHotkeys: "Список горячих клавиш",
    },
    actions: {
        remove: "удалить",
        level0WorksOnlyOnOsmOrg: "level0 работает только с osm.org",
    },
    copying: {
        clickForCopy: "Нажмите, чтобы скопировать",
        failedToCopy: "не удалось скопировать:\n{text}",
        changeCoordinatesFormat: "Изменить формат координат\nТекущий: {format}",
        clickToCopyValue: "Нажмите, чтобы скопировать {value}",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "добавлено better-osm-org",
        experimentalFeature: "экспериментальная фича better-osm-org",
    },
    osmcha: {
        openProfileInOsmcha: "Открыть профиль в OSMCha",
    },
    selection: {
        shiftClickSelectRange: "Shift + click для выбора диапазона пустых чекбоксов",
        clickForInvert: "Нажмите, чтобы инвертировать",
    },
    deletedUsers: {
        tryFindDeletedUser: "Попробовать найти удалённого пользователя",
    },
    findUserInDiff: {
        clickForCopyUserId: "Нажмите, чтобы скопировать ID пользователя",
        notFoundAlert: "Пользователь не найден",
        userIdLabel: "ID: ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "Тег проверки OSMCha. Правая кнопка мыши для изменения\n",
        totalUserChangesets: "сколько всего пакетов правок у пользователя",
        showHiddenTags: "Показать скрытые теги",
        clickForViewMore: "Нажмите, чтобы показать больше",
        mapperRequestedReview: "Маппер запросил проверку пакета правок\n\nНажмите, чтобы отфильтровать пакеты правок с review_requested=yes",
        josmNotRunningAlert: "JOSM не запущен",
        customOsmApiInJosmAlert: "В JOSM используется кастомный OSM API сервер.\n\nИзмените настройки JOSM или откройте другой сайт",
        otherOsmInstanceInJosmAlert:
            "Вы используете другой экземпляр OSM, но JOSM использует сервер по умолчанию.\n\nИзмените настройки JOSM или откройте другой сайт",
        osmchaReviewLike: "Положительная оценка OSMCha\n\nПравая кнопка мыши для добавления тегов",
        osmchaReviewDislike: "Отрицательная оценка OSMCha\n\nПравая кнопка мыши для добавления тегов",
        osmchaLoginAlert: "Пожалуйста, войдите в OSMCha",
        osmchaChangesetNotFoundTitle:
            "Пакет правок не найден в базе OSMCha.\nВозможно, OSMCha ещё не успел обработать этот пакет правок или он слишком старый.",
        osmchaChangesetNotFoundAlert: "Пакет правок не найден в базе OSMCha",
        selectRangeHotkeys:
            "Нажмите с Shift для выбора диапазона\nНажмите R для отката через {name}\nНажмите J для открытия объектов в JOSM\nНажмите Alt + J для открытия объектов в Level0",
        reverterTitle: "Открыть {name}\nShift + click для отката через JOSM\nНажмите R для частичного отката",
        josmDoesNotSupportPartialRevert: "JOSM не поддерживает частичный откат",
        osmRevertOnlyMainServers: "{name} работает только для www.OpenStreetMap.org и www.OpenHistoricalMap.org",
        osmRevertOnlyMainServersWithJosm:
            "{name} работает только для www.OpenStreetMap.org и www.OpenHistoricalMap.org\n\n" +
            "Но вы можете установить reverter plugin в JOSM и использовать shift+click для других OSM серверов.\n\n" +
            "⚠️ Измените OSM сервер в настройках JOSM!",
        commentTemplateTitle: 'Текст "{text}" будет добавлен в комментарий.\nВы можете изменить этот текст в настройках скрипта',
    },
    hashtags: {
        searchInOsmcha: "Искать этот хэштег в OSMCha",
        filterNotesByHashtag:
            "Нажмите, чтобы фильтровать заметки по этому хэштегу.\nНажмите с CTLR или Shift, чтобы искать этот хэштег в osm-note-viewer",
        clickToCopyName: "Нажмите, чтобы скопировать имя",
        clickToCopyType: "Нажмите, чтобы скопировать тип",
    },
    userBadges: {
        userModerator: "Этот пользователь является модератором",
        userImporter: "Этот пользователь является импортёром",
        userBanned: "Пользователь был заблокирован",
        userNewbie: "На момент создания пакета правок или заметки пользователь редактировал OpenStreetMap меньше месяца",
        followingUser: "Вы подписаны на этого пользователя",
        corporateMapper: "{names} корпоративный маппер\n\nНажмите, чтобы открыть страницу вики\nНажмите с Alt, чтобы открыть источник данных",
    },
    notes: {
        openMapStateSnapshot: "Открыть состояние карты на момент снимка",
        openMapStateNoteCreation: "Открыть состояние карты на момент создания заметки",
        commaSeparatedSubstrings: "Подстроки через запятую\nФильтр также работает по комментариям",
        commaSeparatedUsernames: 'Имена пользователей через запятую.\n\nИспользуйте "anon" для анонимных заметок',
        wordInNotesPlaceholder: "слово в заметках",
        usernamePlaceholder: "имя пользователя",
        wordInCommentsPlaceholder: "слово в комментариях",
        downloadVisibleNotesAsKmlTitle: "Скачать видимые заметки как .kml файл",
        addNewObjectOnMap: "Добавить новый объект на карту\nВставьте теги в textarea\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "В textarea нет ни одного тега",
        resolveButtonTitle: 'Добавить в комментарий "{text}" и закрыть заметку.\nВы можете изменить эмодзи в настройках скрипта',
    },
    spyGlass: {
        activateSpyGlass: "Включить режим SpyGlass (эксперимент better-osm-org)",
    },
    gpxFilter: {
        clickToZoomTrack: "нажмите для приближения\nПодсказка: нажимайте 8-9 для перехода между предыдущим и следующим положением карты",
        showGpxTracks: "Показать GPX-треки в текущем окне карты",
        downloadFailed: "ошибка загрузки: {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "Слишком много строк в CSV-файле. Будут показаны только объекты в текущем bbox",
    },
    objectPage: {
        gpsTrackerPosition: "Синий — позиция по GPS-трекеру\nОранжевый — предполагаемая реальная позиция",
        roofOrientationAcrossAlong: "roof:orientation должен быть либо «across», либо «along»",
        conditionalMustContainAt: "значение тега :conditional должно содержать @",
        emptyPartAfterAt: "пустая часть после @",
        emptyPartBeforeAt: "пустая часть перед @",
        emptyPartBetweenAt: "пустая часть между @",
        observationNotFound: "Наблюдение не найдено в API iNaturalist",
        noErrorsOpeningHours: "opening_hours.js не нашёл ошибок 👍",
        phoneMustStartPlus: "номер телефона должен начинаться с +",
        trackingParamInUrl: 'В URL есть трекинговый параметр "{param}="',
        urlShouldStartWithHttp: "URL должен начинаться с https:// или http://",
        easterEgg: "пасхалка от better-osm-org",
        needRoofOrientation: "похоже, это нужно заменить на roof:orientation",
        clickShowEmbedded3d:
            "Нажмите, чтобы показать встроенный 3D Viewer.\nВ настройках скрипта можно задать открытие на странице OSM по умолчанию",
        setAsDefaultForClick: "Установить по умолчанию для клика",
        defaultViewer: "Это просмотрщик по умолчанию",
        keyTooShort: "Ключ слишком короткий",
        suspiciousCapitalKey: "Подозрительный ключ, начинающийся с заглавной буквы",
        openObjectHistory: "Нажмите, чтобы открыть страницу истории объекта\nИли нажмите клавишу H",
        photoBy: "Фото: {author}",
        observedBy: "Наблюдал: {user}",
        invalidPhone: "{value} некорректный номер телефона по версии libphonenumber.js",
        invalidEmail: "{value} некорректный email",
        typeOnlyForRelations: "type=* используется только для отношений",
        suspiciousRelationType: "type={value} используется в базе меньше 50 раз. Скорее всего, это ошибка.",
        openWebsite: "Открыть {host}",
        editWithWebsite: "Редактировать через {host}",
    },
    taginfo: {
        searchWithOverpass: "Поиск через Overpass",
    },
    wiki: {
        needMoreVotes: "нужно ещё {count} голосов до 75 %",
        interimResults: "Промежуточные результаты, рассчитанные better-osm-org:",
    },
    relationVersionPage: {
        loadViaOverpass: "Загрузить версию отношения через Overpass API",
    },
}

_translations["de"] = {
    init: {
        disableBetterOsmOrg: "better-osm-org deaktivieren",
        scriptVersion: "Skriptversion: ",
        reportingFeaturesWarning: " bevor du Fehler meldest oder Fragen zu Funktionen stellst ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org funktioniert in GreasyMonkey nicht :(\n\n" +
            "Es unterstützt keine wichtigen APIs, ohne die die meisten Funktionen des Skripts nicht funktionieren.\n\n" +
            "Verwende ViolentMonkey oder TamperMonkey\n\n" +
            "Diskussion: https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "Version wurde erstellt",
        noteWasCreated: "Hinweis wurde erstellt",
        changesetWasClosed: "Changeset wurde geschlossen",
        clickToChangeTimeFormat: "Klicken, um das Zeitformat zu ändern",
        clickWithCtrlOpenMapState: "Mit Strg klicken, um den Kartenstand zum Zeitpunkt {time} zu öffnen",
        clickWithAltViewAdiff: "Mit Alt klicken, um adiff anzuzeigen",
        openMapStateAtTime: "Kartenstand zum Zeitpunkt {time} öffnen",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Bearbeitungen ansehen",
        sectionWorkingWithNotes: "Mit Hinweisen arbeiten",
        sectionNewActions: "Neue Aktionen",
        sectionOther: "Sonstiges",
        darkModeForMap: "Kartenfarben im Dunkelmodus invertieren",
        colorblindFriendlyPalette: "Farbenblind-freundliche Palette β",
        betterTagsPaste: "Beim Einfügen von Tags, die durch Leerzeichen oder Tabs getrennt sind, = hinzufügen",
        darkModeForID:
            'Dunkelmodus für iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Danke AlexPS</a>)',
        compactChangesetsHistory: "Kompakter Changeset-Verlauf",
        versionsDiff: "Tag-Diff im Verlauf hinzufügen",
        showPreviousTagValue: "Vorherigen Tag-Wert anzeigen",
        fullVersionsDiff: "Diff mit Zwischenversionen im Wegeverlauf hinzufügen",
        changesetQuickLook: "QuickLook für Changesets hinzufügen",
        showChangesetGeometry: "Geometrie der Objekte im Changeset anzeigen",
        massChangesetsActions: "Aktionen für die Changeset-Liste hinzufügen (Massen-Revert, Filterung, ...)",
        imagesAndLinksInTags: "Einige Tags anklickbar und kürzer machen sowie Fotos anzeigen",
        hideNoteHighlight: "Hervorhebung der Notiz ausblenden",
        resolveNotesButton: "Zusätzliche Schließen-Schaltflächen:",
        revertButton: "Revert&Osmcha-Changeset-Schaltfläche",
        deletor: "Schaltfläche zum Löschen von Knoten",
        oneClickDeletor: "Knoten ohne Bestätigung löschen",
        changesetsTemplates: 'Vorlagen für Changeset-Kommentare <a id="last-comments-link" target="_blank">(deine letzten Kommentare)</a>',
        hdycInProfile: "HDYC zum Benutzerprofil hinzufügen",
        betterProfileStat: "Filter zur Profilstatistik hinzufügen",
        navigationViaHotkeys:
            'Tastenkürzel hinzufügen <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(Liste)</a>',
        newEditorsLinks: "Neue Editoren zum Edit-Menü hinzufügen",
        resetSearchFormFocus: "Fokus des Suchformulars zurücksetzen",
        satelliteLayers: "Schalter für Satellitenebenen hinzufügen",
        swipes: "Wischgesten zwischen Benutzer-Changesets hinzufügen",
        resizableSidebar: "Schieberegler für die Breite der Seitenleiste",
        clickableAvatar: "Avatar anklicken, um Changesets zu öffnen",
        overzoomForDataLayer: "Overzoom zulassen, wenn Daten-/Satellitenebene aktiviert ist β",
        dragAndDropViewers: "Drag&Drop für .geojson, .jpg, .gpx, .osm",
        viewer3DInNewTab: "3D-Gebäude-Viewer immer in einem neuen Tab öffnen",
        betterTaginfo: "Neue Schaltflächen in Taginfo hinzufügen",
        defaultZoomKeysBehaviour: "Zoomschritt der +/- Schaltflächen nicht verdoppeln",
        addLocationFromNominatim: "Standort aus Nominatim für Changesets und Hinweise hinzufügen",
        overpassInstance: '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Overpass-API-Server</a>',
        panoramaxUploader: "Formular zum Hochladen von Fotos nach Panoramax hinzufügen",
        routersTimestamps: "Datum der Routing-Daten hinzufügen",
        clickableMap: "Karte anklickbar machen β",
        debugMode: "Debug- und experimentelle Funktionen aktivieren",
    },
    objectEditor: {
        delete: "Löschen",
        restore: "Wiederherstellen",
    },
    historyDiff: {
        intermediateWayVersion: "Zwischenversion",
        intermediateWayVersionTitle: "Es gab Änderungen an Tags oder Knotenkoordinaten im Weg, die die Wegversion nicht erhöht haben",
        intermediateRelationVersion: "Zwischenversion",
        intermediateRelationVersionTitle:
            "Es gab Änderungen an Tags oder Knotenkoordinaten in der Relation, die die Relationsversion nicht erhöht haben",
        allVersions: "Alle Versionen",
        withGeometryChanges: "Mit Geometrieänderungen",
        withoutIntermediate: "Ohne Zwischenversionen",
        viewUnredactedHistory: "Ungeschwärzten Verlauf anzeigen β",
        errorPleaseReport: "Fehler :( Bitte melde diese Seite im better-osm-org-GitHub-Repository",
        someNodesHidden: "Einige Knoten wurden von Moderatoren verborgen",
        reloadAndReport: "Bitte lade die Seite neu.\nWenn der Fehler bestehen bleibt, melde ihn im better-osm-org-Repository",
        someNodesHiddenSad: "Einige Knoten wurden von Moderatoren verborgen :\\",
        filterIntermediateChanges: "Zwischenänderungen filtern",
        downloadAllVersionsWithIntermediate: "Alle Versionen herunterladen (mit Zwischenversionen)",
        someMembersHidden: "Einige Mitglieder wurden von Moderatoren verborgen",
        downloadAllVersions: "Alle Versionen herunterladen",
        toggleCompactTagsDiff: "Zwischen vollständigem und kompaktem Tag-Diff umschalten.\nDu kannst auch die Taste T verwenden.",
        hidePreviousValue: "Klicken, um den vorherigen Wert auszublenden",
        tagsCount: ({ count }) => `${count} Tag${count === 1 ? "" : "s"}`,
    },
    changesetQuicklook: {
        lineWasReversed: "ⓘ Die Linie wurde umgedreht",
        objectDeletedByAuthor: " ⓘ Das Objekt wurde jetzt vom Autor gelöscht",
        objectDeleted: " ⓘ Das Objekt wurde jetzt gelöscht",
        objectDeletedTitle: "{user} hat dieses Objekt gelöscht",
        objectRestored: " ⓘ Das Objekt wurde jetzt wiederhergestellt",
        objectRestoredByAuthor: " ⓘ Das Objekt wurde jetzt vom Autor wiederhergestellt",
        objectRestoredTitle: "{user} hat dieses Objekt wiederhergestellt",
        listWayNodesChanged: "Die Liste der Wegknoten wurde geändert",
        nodesReversed: "Die Knoten des Weges wurden umgedreht",
        relationMembersChanged: "Die Liste der Relationsmitglieder wurde geändert.\nKlicken, um mehr Details zu sehen",
        showRelationMembers: "Liste der Relationsmitglieder anzeigen",
        relationMembersReversed: "Die Mitglieder der Relation wurden umgedreht",
        pinRelationOnMap: "Relation auf der Karte anheften",
        unpinRelationFromMap: "Relation von der Karte lösen",
        nodeCoordinatesChanged: "Die Koordinaten des Knotens wurden geändert",
        downloadThisRelation: "Diese Relation herunterladen",
        shiftClickZoomVia: 'Mit Shift klicken, um auf "via"-Mitglieder zu zoomen',
        unableDisplaySomeData: "better-osm-org konnte einige Daten nicht anzeigen",
        editsWarTitle: "Edit-War. {details}\nKlicken für Details",
        tagRestored: "Das Tag ist jetzt wiederhergestellt",
        nowIsTag: "Jetzt ist {key}={value}",
        tagDeleted: "Das Tag ist jetzt gelöscht",
        wasValue: "vorher: {value}",
        tagReverted: "Das Tag ist jetzt zurückgesetzt",
        nodesCountChanged: "Anzahl der Knoten: {prevCount} → {nextCount}",
        nodesCount: "Anzahl der Knoten: {count}",
        intermediateNodeChangesTitle:
            "Die Tags und Koordinaten der Wegknoten wurden während des Changesets mehrmals geändert.\n" +
            "Das kann beabsichtigt sein oder beim Hochladen einer Bearbeitungsserie mit JOSM passieren.\n" +
            "Der Endzustand wird angezeigt. Klicken, um den vollständigen Wegverlauf zu öffnen",
        sendBugReport: "⚠️ Fehlerbericht senden",
        debugAlert: "⚠ Browser-Konsole lesen (F12).\nDiese Meldung sollte nur der Skriptentwickler sehen",
    },
    editMenuLinks: {
        moreLinks: "mehr Links / Liste bearbeiten",
        editLinksList: "Linkliste bearbeiten",
        editLink: "Link bearbeiten",
        pinThisLink: "diesen Link anheften",
        moveUpLink: "Link nach oben",
        moveDownLink: "Link nach unten",
        removeLink: "Link entfernen",
        saveLink: "Link speichern",
        openPlaceExternalWebsite: "Ort auf externer Website öffnen",
        linkNamePlaceholder: "Linkname",
        urlExamplePrefix: "URL-Beispiel: ",
    },
    routers: {
        routingDataTimeFor: "Routingdatenstand für {name}: {time}",
        openDebugMap: "Debug Map öffnen",
    },
    satellite: {
        setupCustomStyleJson: "Benutzerdefinierte style.json für MapLibre.js einrichten",
        setupCustomMapLayers: "Benutzerdefinierte Kartenebenen einrichten",
        duplicateStyleAndEdit: "Stil duplizieren und bearbeiten",
        openMapStyleHomePage: "Startseite des Kartenstils öffnen",
        openMapLayerHomePage: "Startseite der Kartenebene öffnen",
        switchMapAndSatellite: "Zwischen Karte und Satellitenbildern wechseln",
        setCustomLayer: "Benutzerdefinierte Ebene setzen (Shift + S)\nbetter-osm-org-Funktion",
        setCustomVectorStyle: "Benutzerdefinierten Vektorstil setzen (Shift + V)",
        stravaLoginRequiredAlert: "Für den Zugriff auf die Heatmap ist eine Anmeldung bei Strava erforderlich.\nLade danach den Tab neu.",
        vectorStyleUrlPlaceholder: "Beispiel: https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "style.json einfügen",
        tileUrlPlaceholder: "Beispiel: https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Versuche, die Seite ohne Cache mit Strg + F5 neu zu laden.\n" +
            "Oder:\n" +
            "1. Aktiviere in den TamperMonkey-Einstellungen den Advanced Config Mode\n" +
            '2. Stelle in den TamperMonkey-Einstellungen "Content Script API" auf "UserScript API Dynamic"\n' +
            "Mehr Infos: https://c.osm.org/t/121670/208\n\n" +
            "Oder schließe diesen Tab und öffne einen neuen\n" +
            "Oder schalte das Skript aus und wieder ein\n" +
            "Oder versuche Firefox mit ViolentMonkey zu verwenden",
    },
    geojson: {
        metainfo: "Metainformationen",
        needUpdateBetterOsmOrg: "better-osm-org muss aktualisiert werden",
        switchTableAndRawEditor: "Zwischen Tabelle und Roh-Editor wechseln",
        conflict: "Konflikt",
    },
    overpassSearch: {
        httpError: "Fehler. HTTP-Code: {status}",
        httpErrorWithQuery: "Fehler. HTTP-Code: {status}\nGesendete Anfrage:\n\n{query}",
        invalidQuery: "ungültige Anfrage",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Verfügbare Befehle",
        recent: "Zuletzt",
        noHotkeysMatchSearch: "Keine Tastenkürzel passen zu dieser Suche.",
        noCatalogedCommandsMatchPage: "Keine katalogisierten Befehle passen zu dieser Seite.",
        searchByNamePlaceholder: "Nach Hotkey-Namen suchen",
    },
    objectVersionPage: {
        length: "Länge: {value}",
        area: "Fläche: {value}",
        pinRestrictionSign:
            "restriction-Schild auf der Karte anheften.\nDu kannst alle von better-osm-org hinzugefügten Objekte mit ` oder ~ ausblenden",
        hideRestrictionSign: "restriction-Schild ausblenden",
        selectCoordinatesFormat: "Koordinatenformat zum Kopieren auswählen.\nZum Kopieren einfach auf die Koordinaten klicken",
        setDefaultCopyFormat: "Als Standard fürs Kopieren setzen, wenn du auf Koordinaten klickst",
        defaultCopyFormat: "Dies ist das Standardformat, wenn du auf Koordinaten klickst",
        openExternalRelationViewer: "Klicken, um einen externen Relations-Viewer zu öffnen.\nOder Taste O benutzen",
        clickToCopyTopLeft: "Klicken, um TopLeft zu kopieren: {value}",
        clickToCopyCenter: "Klicken, um den Mittelpunkt zu kopieren: {value}",
        clickToCopyRightBottom: "Klicken, um RightBottom zu kopieren: {value}",
        clickToCopyBbox: "Klicken, um bbox zu kopieren",
        clickToCopyIdOrUrl: `Klicken, um die ID zu kopieren\n{modifier} + Klick, um die URL zu kopieren`,
    },
    userProfile: {
        allEditors: "Alle Editoren",
        editorContributions: ({ editor, count }) => `${editor} (${count} Bearbeitung${count === 1 ? "" : "en"})`,
        osmcha: " [OSMCha] ",
        usernames: "Benutzernamen: ",
        findingBlocks: " Sperren werden gesucht... ",
        copyIds: "IDs kopieren",
        copyIdsCount: "{count} IDs kopieren",
        noComment: "Kein Kommentar",
        pastUsernames: "Frühere Benutzernamen: ",
        userIdLabel: "ID: ",
        disableTrackingProtection: "Bitte deaktiviere den Tracking-Schutz, damit die HDYC-Anmeldung funktioniert",
        goToHdyc: "Zu https://www.hdyc.neis-one.org/ gehen",
        pleaseWaitUserChangesetsLoading: "Bitte warten, während die Changesets des Benutzers geladen werden",
        openSelectedChangesetsOnePage: "Alt + O, um ausgewählte Changesets auf einer Seite zu öffnen",
        regexSearchNotCaseSensitive: "Regex-Suche ohne Groß-/Kleinschreibung",
        viaWhosthat: "via whosthat.osmz.ru",
        viaOverpassApi: "via Overpass API",
        regexSearchByCommentsPlaceholder: "Regex-Suche in Kommentaren",
        filteredChangesets: "{count} Changesets gefiltert",
        singleUserIdLabel: "Benutzer-ID: ",
        foundUserIds: "⚠️ {count} Benutzer-IDs gefunden",
        userIdBadge: "🆔: ",
        scannedBlocksAfter: "Alle Sperren nach #{id} durchsucht",
        lastChangesets: "Letzte {count} Changesets:",
    },
    changesetsHistory: {
        copyIds: "IDs kopieren",
        copyIdsCount: "{count} IDs kopieren",
        displayedCount: " Angezeigt {displayed}/{total}",
        hideBigChangesets: "Große Changesets ausblenden",
        hideChangesetsFrom: "🔄Changesets ausblenden von ",
        showChangesetsFrom: "🔄Changesets anzeigen von ",
        hideChangesetsWith: "🔄Changesets ausblenden mit ",
        showChangesetsWith: "🔄Changesets anzeigen mit ",
        loadMore: "{count} laden",
        displayOnOneMapAll:
            "Auf einer Karte anzeigen\nwenn nichts ausgewählt ist, werden alle geladenen, nicht ausgeblendeten Changesets geöffnet",
        displayOnOneMap: "Auf einer Karte anzeigen",
        addCheckboxesMassActions: "Checkboxen für Massenaktionen mit Changesets hinzufügen",
        clickHideUserChangesets: "Klicken, um die Changesets dieses Benutzers auszublenden",
        filterViaBetterOsmOrg: "Changeset-Filter via better-osm-org",
        filterBySubstringInComments: "Nach Teilzeichenfolge in Changeset-Kommentaren filtern",
        clickCopyChangesetId: "Klicken, um die Changeset-ID zu kopieren",
        usersFilterPlaceholder: "user1,user2,... und Enter drücken",
        commentsFilterPlaceholder: "words1,words2,... und Enter drücken",
        revertViaOsmRevert: "über {name} zurücksetzen",
    },
    measurer: {
        measureFromHere: "Von hier messen",
        endMeasure: "Messung beenden",
        cleanMeasurements: "Messungen löschen",
        clickToSwitchUnits: "Klicken, um die Maßeinheiten zu wechseln",
        orPressEscapeTwice: "Oder zweimal Escape drücken",
        hotkeysTitle: "Alt + Klick: neue Linie beginnen\nEsc: Messen beenden\n{modifier} + Z: letzten Punkt entfernen",
    },
    panoramax: {
        blurFaces: "Gesichter verwischen",
        loginRequiredAlert: "Bitte melde dich bei Panoramax an",
        uploadPhoto: "Foto zu Panoramax hochladen",
        selectFileAlert: "Datei auswählen",
        uploadError: "Fehler: {message}",
        exifInfo: "Info aus EXIF:\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Offsets finden",
        focusIframeAlert: "Bitte setze den Fokus auf das iD-Iframe.\nKlicke einfach irgendwo im Editor.",
    },
    changesetPage: {
        selectObjects: "Objekte auswählen",
        revertViaOsmRevert: "Über osm-revert zurücksetzen",
        openInEditor: "In {editor} öffnen",
        openInLevel0: "In Level0 öffnen",
        openInLevel0WithWaysGeometry: "In Level0 mit Wegegeometrie öffnen",
        openChangesetInOsmcha: "Changeset in OSMCha öffnen (oder O drücken)\n(Shift + O, um Achavi zu öffnen)",
        partialRevertOrEdit: "für teilweisen Revert oder Bearbeitung in JOSM/Level0",
        hotkeyR: "Tastenkürzel: R",
        hotkeyJ: "Tastenkürzel: J",
        hotkeyAltJ: "Tastenkürzel: Alt + J",
        hotkeyShiftAltJ: "Tastenkürzel: Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Knoten hierhin verschieben",
    },
    scriptMenu: {
        settings: "Einstellungen",
        checkScriptUpdates: "Nach Skript-Updates suchen",
        checkDevScriptUpdates: "Nach Updates der Dev-Version suchen",
        listOfHotkeys: "Liste der Tastenkürzel",
    },
    actions: {
        remove: "entfernen",
        level0WorksOnlyOnOsmOrg: "level0 funktioniert nur mit osm.org",
    },
    copying: {
        clickForCopy: "Klicken zum Kopieren",
        failedToCopy: "Kopieren fehlgeschlagen:\n{text}",
        changeCoordinatesFormat: "Koordinatenformat ändern\nAktuell: {format}",
        clickToCopyValue: "Klicken, um {value} zu kopieren",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "hinzugefügt von better-osm-org",
        experimentalFeature: "experimentelle Funktion von better-osm-org",
    },
    osmcha: {
        openProfileInOsmcha: "Profil in OSMCha öffnen",
    },
    selection: {
        shiftClickSelectRange: "Shift + Klick, um einen Bereich leerer Checkboxen auszuwählen",
        clickForInvert: "Klicken zum Umkehren",
    },
    deletedUsers: {
        tryFindDeletedUser: "Versuchen, gelöschten Benutzer zu finden",
    },
    findUserInDiff: {
        clickForCopyUserId: "Klicken, um die Benutzer-ID zu kopieren",
        notFoundAlert: "Benutzer nicht gefunden",
        userIdLabel: "ID: ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "OSMCha-Prüf-Tag. Rechtsklick zum Ändern\n",
        totalUserChangesets: "wie viele Changesets der Benutzer insgesamt hat",
        showHiddenTags: "Versteckte Tags anzeigen",
        clickForViewMore: "Klicken, um mehr anzuzeigen",
        mapperRequestedReview: "Mapper hat eine Changeset-Prüfung angefordert\n\nKlicken, um Changesets mit review_requested=yes zu filtern",
        josmNotRunningAlert: "JOSM läuft nicht",
        customOsmApiInJosmAlert:
            "Du verwendest in JOSM einen benutzerdefinierten OSM-API-Server.\n\nÄndere die JOSM-Einstellungen oder öffne eine andere Website",
        otherOsmInstanceInJosmAlert:
            "Du verwendest eine andere OSM-Instanz, aber JOSM nutzt den Standardsserver.\n\nÄndere die JOSM-Einstellungen oder öffne eine andere Website",
        osmchaReviewLike: "OSMCha positive Bewertung\n\nRechtsklick, um Prüfungstags hinzuzufügen",
        osmchaReviewDislike: "OSMCha negative Bewertung\n\nRechtsklick, um Prüfungstags hinzuzufügen",
        osmchaLoginAlert: "Bitte melde dich bei OSMCha an",
        osmchaChangesetNotFoundTitle:
            "Changeset in der OSMCha-Datenbank nicht gefunden.\nEntweder hatte OSMCha noch keine Zeit, dieses Changeset zu verarbeiten, oder es ist zu alt.",
        osmchaChangesetNotFoundAlert: "Changeset in der OSMCha-Datenbank nicht gefunden",
        selectRangeHotkeys:
            "Mit Shift klicken, um einen Bereich auszuwählen\nDrücke R für Revert über {name}\nDrücke J, um Objekte in JOSM zu öffnen\nDrücke Alt + J, um Objekte in Level0 zu öffnen",
        reverterTitle: "Öffne {name}\nShift + Klick für Revert über JOSM\nDrücke R für teilweisen Revert",
        josmDoesNotSupportPartialRevert: "JOSM unterstützt keinen teilweisen Revert",
        osmRevertOnlyMainServers: "{name} funktioniert nur für www.OpenStreetMap.org und www.OpenHistoricalMap.org",
        osmRevertOnlyMainServersWithJosm:
            "{name} funktioniert nur für www.OpenStreetMap.org und www.OpenHistoricalMap.org\n\n" +
            "Du kannst aber das reverter-Plugin in JOSM installieren und für andere OSM-Server Shift+Klick verwenden.\n\n" +
            "⚠️ Ändere den OSM-Server in den JOSM-Einstellungen!",
        commentTemplateTitle: '"{text}" wird dem Kommentar hinzugefügt.\nDu kannst diesen Text in den Skript-Einstellungen ändern',
    },
    hashtags: {
        searchInOsmcha: "Diesen Hashtag in OSMCha suchen",
        filterNotesByHashtag:
            "Klicken, um Hinweise nach diesem Hashtag zu filtern.\nMit Ctrl oder Shift klicken, um diesen Hashtag in osm-note-viewer zu suchen",
        clickToCopyName: "Klicken, um den Namen zu kopieren",
        clickToCopyType: "Klicken, um den Typ zu kopieren",
    },
    userBadges: {
        userModerator: "Dieser Benutzer ist Moderator",
        userImporter: "Dieser Benutzer ist ein Importeur",
        userBanned: "Der Benutzer wurde gesperrt",
        userNewbie:
            "Zum Zeitpunkt der Erstellung des Changesets oder Hinweises bearbeitete der Benutzer OpenStreetMap seit weniger als einem Monat",
        followingUser: "Du folgst diesem Benutzer",
        corporateMapper: "{names} Firmen-Mapping\n\nKlicken, um die Wiki-Seite zu öffnen\nMit Alt klicken, um die Datenquelle zu öffnen",
    },
    notes: {
        openMapStateSnapshot: "Kartenstand zum Zeitpunkt des Snapshots öffnen",
        openMapStateNoteCreation: "Kartenstand zum Zeitpunkt der Hinweis-Erstellung öffnen",
        commaSeparatedSubstrings: "Durch Kommas getrennte Teilzeichenfolgen\nFilter funktioniert auch über Kommentare",
        commaSeparatedUsernames: 'Durch Kommas getrennte Benutzernamen.\n\nVerwende "anon" für anonyme Hinweise',
        wordInNotesPlaceholder: "Wort in Hinweisen",
        usernamePlaceholder: "Benutzername",
        wordInCommentsPlaceholder: "Wort in Kommentaren",
        downloadVisibleNotesAsKmlTitle: "Sichtbare Hinweise als .kml-Datei herunterladen",
        addNewObjectOnMap: "Neues Objekt auf der Karte hinzufügen\nTags in die Textarea einfügen\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "Die Textarea enthält keine Tags",
        resolveButtonTitle:
            'Zu dem Kommentar "{text}" hinzufügen und den Hinweis schließen.\nDu kannst das Emoji in den Userscript-Einstellungen ändern',
    },
    spyGlass: {
        activateSpyGlass: "SpyGlass-Imitationsmodus aktivieren (better-osm-org-Experiment)",
    },
    gpxFilter: {
        clickToZoomTrack: "klicken zum Zoomen\nTipp: Drücke 8-9, um zwischen vorheriger/nächster Kartenposition zu wechseln",
        showGpxTracks: "GPX-Tracks in der aktuellen Kartenansicht anzeigen",
        downloadFailed: "Download fehlgeschlagen: {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "Zu viele Zeilen in der CSV-Datei. Es werden nur Objekte im aktuellen bbox angezeigt",
    },
    objectPage: {
        gpsTrackerPosition: "Blau — Position vom GPS-Tracker\nOrange — geschätzte tatsächliche Position",
        roofOrientationAcrossAlong: 'roof:orientation muss entweder "across" oder "along" sein',
        conditionalMustContainAt: "Der Wert des Tags :conditional muss @ enthalten",
        emptyPartAfterAt: "leerer Teil nach @",
        emptyPartBeforeAt: "leerer Teil vor @",
        emptyPartBetweenAt: "leerer Teil zwischen @",
        observationNotFound: "Beobachtung in der iNaturalist-API nicht gefunden",
        noErrorsOpeningHours: "opening_hours.js hat keine Fehler gefunden 👍",
        phoneMustStartPlus: "Telefonnummer muss mit + beginnen",
        trackingParamInUrl: 'Tracking-Parameter "{param}=" in der URL',
        urlShouldStartWithHttp: "URL muss mit https:// oder http:// beginnen",
        easterEgg: "better-osm-org-Easter-Egg",
        needRoofOrientation: "es sieht so aus, als müsste das zu roof:orientation geändert werden",
        clickShowEmbedded3d:
            "Klicken, um den eingebetteten 3D-Viewer anzuzeigen.\nIn den Skript-Einstellungen kannst du festlegen, dass er standardmäßig auf der OSM-Seite geöffnet wird",
        setAsDefaultForClick: "Als Standard für Klick setzen",
        defaultViewer: "Dies ist der Standard-Viewer",
        keyTooShort: "Der Schlüssel ist zu kurz",
        suspiciousCapitalKey: "Verdächtiger Schlüssel, der mit einem Großbuchstaben beginnt",
        openObjectHistory: "Klicken, um die Objektverlaufsseite zu öffnen\nOder Taste H drücken",
        photoBy: "Foto von {author}",
        observedBy: "Beobachtet von {user}",
        invalidPhone: "{value} ist laut libphonenumber.js keine gültige Telefonnummer",
        invalidEmail: "{value} ist keine gültige E-Mail-Adresse",
        typeOnlyForRelations: "type=* nur für Relationen",
        suspiciousRelationType: "type={value} wird seltener als 50 Mal verwendet. Das ist wahrscheinlich ein Fehler.",
        openWebsite: "{host} öffnen",
        editWithWebsite: "Mit {host} bearbeiten",
    },
    taginfo: {
        searchWithOverpass: "mit Overpass suchen",
    },
    wiki: {
        needMoreVotes: "noch {count} Stimmen bis 75 % nötig",
        interimResults: "Von better-osm-org berechnete Zwischenergebnisse:",
    },
    relationVersionPage: {
        loadViaOverpass: "Relationsversion über die Overpass API laden",
    },
}

_translations["fr"] = {
    init: {
        disableBetterOsmOrg: "Désactiver better-osm-org",
        scriptVersion: "Version du script : ",
        reportingFeaturesWarning: " avant de signaler des bugs ou de poser des questions sur les fonctionnalités ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org ne fonctionnera pas dans GreasyMonkey :(\n\n" +
            "Il ne prend pas en charge des API importantes sans lesquelles la plupart des fonctions du script ne fonctionneront pas.\n\n" +
            "Utilisez ViolentMonkey ou TamperMonkey\n\n" +
            "Discussion : https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "la version a été créée",
        noteWasCreated: "la note a été créée",
        changesetWasClosed: "le changeset a été fermé",
        clickToChangeTimeFormat: "Cliquer pour changer le format de l'heure",
        clickWithCtrlOpenMapState: "Cliquer avec Ctrl pour ouvrir l'état de la carte au moment où {time}",
        clickWithAltViewAdiff: "Cliquer avec Alt pour voir l'adiff",
        openMapStateAtTime: "Ouvrir l'état de la carte au moment où {time}",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Consultation des modifications",
        sectionWorkingWithNotes: "Gestion des notes",
        sectionNewActions: "Nouvelles actions",
        sectionOther: "Autre",
        darkModeForMap: "Inverser les couleurs de la carte en mode sombre",
        colorblindFriendlyPalette: "Palette adaptée au daltonisme β",
        betterTagsPaste: "Ajouter = lors du collage de tags séparés par des espaces ou des tabulations",
        darkModeForID:
            'Mode sombre pour iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Merci AlexPS</a>)',
        compactChangesetsHistory: "Historique compact des changesets",
        versionsDiff: "Ajouter le diff des tags dans l'historique",
        showPreviousTagValue: "Afficher la valeur précédente du tag",
        fullVersionsDiff: "Ajouter le diff avec les versions intermédiaires dans l'historique d'une voie",
        changesetQuickLook: "Ajouter QuickLook pour les changesets",
        showChangesetGeometry: "Afficher la géométrie des objets du changeset",
        massChangesetsActions: "Ajouter des actions pour la liste des changesets (annulation en masse, filtrage, ...)",
        imagesAndLinksInTags: "Rendre certains tags cliquables, plus courts et afficher les photos",
        hideNoteHighlight: "Masquer la mise en évidence de la note",
        resolveNotesButton: "Boutons supplémentaires de résolution :",
        revertButton: "Bouton de changeset Revert&Osmcha",
        deletor: "Bouton de suppression de nœud",
        oneClickDeletor: "Supprimer le nœud sans confirmation",
        changesetsTemplates:
            'Modèles de commentaires de changeset <a id="last-comments-link" target="_blank">(vos derniers commentaires)</a>',
        hdycInProfile: "Ajouter HDYC au profil utilisateur",
        betterProfileStat: "Ajouter des filtres aux statistiques du profil",
        navigationViaHotkeys:
            'Ajouter des raccourcis clavier <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(Liste)</a>',
        newEditorsLinks: "Ajouter de nouveaux éditeurs au menu Edit",
        resetSearchFormFocus: "Réinitialiser le focus du formulaire de recherche",
        satelliteLayers: "Ajouter des commutateurs de couches satellite",
        swipes: "Ajouter des balayages entre les changesets utilisateur",
        resizableSidebar: "Curseur pour la largeur de la barre latérale",
        clickableAvatar: "Cliquer sur l'avatar pour ouvrir les changesets",
        overzoomForDataLayer: "Autoriser le surzoom quand la couche données/satellite est activée β",
        dragAndDropViewers: "Glisser-déposer pour .geojson, .jpg, .gpx, .osm",
        viewer3DInNewTab: "Toujours ouvrir les visualiseurs 3D de bâtiments dans un nouvel onglet",
        betterTaginfo: "Ajouter de nouveaux boutons dans Taginfo",
        defaultZoomKeysBehaviour: "Ne pas doubler le pas de zoom des boutons +/-",
        addLocationFromNominatim: "Ajouter l'emplacement depuis Nominatim pour les changesets et les notes",
        overpassInstance: '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Serveur Overpass API</a>',
        panoramaxUploader: "Ajouter un formulaire d'envoi de photos vers Panoramax",
        routersTimestamps: "Ajouter la date des données de routage",
        clickableMap: "Rendre la carte cliquable β",
        debugMode: "Activer le débogage et les fonctionnalités expérimentales",
    },
    objectEditor: {
        delete: "Supprimer",
        restore: "Restaurer",
    },
    historyDiff: {
        intermediateWayVersion: "Version intermédiaire",
        intermediateWayVersionTitle:
            "Des modifications des tags ou des coordonnées de nœuds de la voie n'ont pas augmenté la version de la voie",
        intermediateRelationVersion: "Version intermédiaire",
        intermediateRelationVersionTitle:
            "Des modifications des tags ou des coordonnées de nœuds de la relation n'ont pas augmenté la version de la relation",
        allVersions: "Toutes les versions",
        withGeometryChanges: "Avec modifications de géométrie",
        withoutIntermediate: "Sans intermédiaires",
        viewUnredactedHistory: "Voir l'historique non expurgé β",
        errorPleaseReport: "Erreur :( Merci de signaler cette page dans le dépôt GitHub de better-osm-org",
        someNodesHidden: "Certains nœuds ont été masqués par les modérateurs",
        reloadAndReport: "Veuillez recharger la page.\nSi l'erreur persiste, signalez-la dans le dépôt better-osm-org",
        someNodesHiddenSad: "Certains nœuds ont été masqués par les modérateurs :\\",
        filterIntermediateChanges: "Filtrer les modifications intermédiaires",
        downloadAllVersionsWithIntermediate: "Télécharger toutes les versions (avec les versions intermédiaires)",
        someMembersHidden: "Certains membres ont été masqués par les modérateurs",
        downloadAllVersions: "Télécharger toutes les versions",
        toggleCompactTagsDiff: "Basculer entre le diff complet et compact des tags.\nVous pouvez aussi utiliser la touche T.",
        hidePreviousValue: "Cliquer pour masquer la valeur précédente",
        tagsCount: ({ count }) => `${count} balise${count === 1 ? "" : "s"}`,
    },
    changesetQuicklook: {
        lineWasReversed: "ⓘ La ligne a été inversée",
        objectDeletedByAuthor: " ⓘ L'objet est maintenant supprimé par l'auteur",
        objectDeleted: " ⓘ L'objet est maintenant supprimé",
        objectDeletedTitle: "{user} a supprimé cet objet",
        objectRestored: " ⓘ L'objet est maintenant restauré",
        objectRestoredByAuthor: " ⓘ L'objet est maintenant restauré par l'auteur",
        objectRestoredTitle: "{user} a restauré cet objet",
        listWayNodesChanged: "La liste des nœuds de la voie a été modifiée",
        nodesReversed: "Les nœuds de la voie ont été inversés",
        relationMembersChanged: "La liste des membres de la relation a été modifiée.\nCliquez pour voir plus de détails",
        showRelationMembers: "Afficher la liste des membres de la relation",
        relationMembersReversed: "Les membres de la relation ont été inversés",
        pinRelationOnMap: "Épingler la relation sur la carte",
        unpinRelationFromMap: "Désépingler la relation de la carte",
        nodeCoordinatesChanged: "Les coordonnées du nœud ont été modifiées",
        downloadThisRelation: "Télécharger cette relation",
        shiftClickZoomVia: 'Maj + clic pour zoomer sur les membres "via"',
        unableDisplaySomeData: "better-osm-org n'a pas pu afficher certaines données",
        editsWarTitle: "Guerre d'édition. {details}\nCliquer pour les détails",
        tagRestored: "Le tag est maintenant restauré",
        nowIsTag: "Maintenant : {key}={value}",
        tagDeleted: "Le tag est maintenant supprimé",
        wasValue: "était : {value}",
        tagReverted: "Le tag a maintenant été annulé",
        nodesCountChanged: "Nombre de nœuds : {prevCount} → {nextCount}",
        nodesCount: "Nombre de nœuds : {count}",
        intermediateNodeChangesTitle:
            "Les tags et les coordonnées des nœuds de la voie ont été modifiés plusieurs fois pendant le changeset.\n" +
            "Cela peut être intentionnel ou se produire lors d'un téléversement en lot avec JOSM.\n" +
            "L'état final est affiché. Cliquez pour ouvrir l'historique complet de la voie",
        sendBugReport: "⚠️ Envoyer un rapport de bug",
        debugAlert: "⚠ lisez les logs dans la console du navigateur (F12).\nSeul le développeur du script devrait voir ce message",
    },
    editMenuLinks: {
        moreLinks: "plus de liens / modifier la liste",
        editLinksList: "liste des liens d'édition",
        editLink: "modifier le lien",
        pinThisLink: "épingler ce lien",
        moveUpLink: "monter le lien",
        moveDownLink: "descendre le lien",
        removeLink: "supprimer le lien",
        saveLink: "enregistrer le lien",
        openPlaceExternalWebsite: "Ouvrir le lieu sur un site externe",
        linkNamePlaceholder: "Nom du lien",
        urlExamplePrefix: "Exemple d’URL : ",
    },
    routers: {
        routingDataTimeFor: "Date des données de routage pour {name} : {time}",
        openDebugMap: "Ouvrir Debug Map",
    },
    satellite: {
        setupCustomStyleJson: "Configurer un style.json personnalisé pour MapLibre.js",
        setupCustomMapLayers: "Configurer des couches de carte personnalisées",
        duplicateStyleAndEdit: "dupliquer le style et le modifier",
        openMapStyleHomePage: "Ouvrir la page d'accueil du style de carte",
        openMapLayerHomePage: "Ouvrir la page d'accueil de la couche de carte",
        switchMapAndSatellite: "Basculer entre la carte et les images satellite",
        setCustomLayer: "Définir une couche personnalisée (Shift + S)\nfonction de better-osm-org",
        setCustomVectorStyle: "Définir un style vectoriel personnalisé (Shift + V)",
        stravaLoginRequiredAlert: "Une connexion à Strava est nécessaire pour accéder à la heatmap.\nRechargez ensuite l'onglet.",
        vectorStyleUrlPlaceholder: "exemple : https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "Collez style.json",
        tileUrlPlaceholder: "exemple : https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Essayez de recharger la page sans le cache avec Ctrl + F5.\n" +
            "Ou :\n" +
            "1. Dans les paramètres de TamperMonkey, activez Advanced Config Mode\n" +
            '2. Dans les paramètres de TamperMonkey, remplacez "Content Script API" par "UserScript API Dynamic"\n' +
            "Plus d'infos : https://c.osm.org/t/121670/208\n\n" +
            "Ou fermez cet onglet et ouvrez-en un nouveau\n" +
            "Ou désactivez/réactivez le script\n" +
            "Ou essayez Firefox avec ViolentMonkey",
    },
    geojson: {
        metainfo: "métadonnées",
        needUpdateBetterOsmOrg: "better-osm-org doit être mis à jour",
        switchTableAndRawEditor: "Basculer entre le tableau et l'éditeur brut",
        conflict: "Conflit",
    },
    overpassSearch: {
        httpError: "Erreur. Code HTTP : {status}",
        httpErrorWithQuery: "Erreur. Code HTTP : {status}\nRequête envoyée :\n\n{query}",
        invalidQuery: "requête invalide",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Commandes disponibles",
        recent: "Récents",
        noHotkeysMatchSearch: "Aucun raccourci ne correspond à cette recherche.",
        noCatalogedCommandsMatchPage: "Aucune commande cataloguée ne correspond à cette page.",
        searchByNamePlaceholder: "Rechercher par nom de raccourci",
    },
    objectVersionPage: {
        length: "Longueur : {value}",
        area: "Surface : {value}",
        pinRestrictionSign:
            "Épingler le panneau restriction sur la carte.\nVous pouvez masquer tous les objets ajoutés par better-osm-org en appuyant sur ` ou ~",
        hideRestrictionSign: "Masquer le panneau restriction",
        selectCoordinatesFormat: "Sélectionnez le format des coordonnées à copier.\nPour copier, cliquez simplement sur les coordonnées",
        setDefaultCopyFormat: "Définir comme format de copie par défaut lorsque vous cliquez sur les coordonnées",
        defaultCopyFormat: "C'est le format par défaut lorsque vous cliquez sur les coordonnées",
        openExternalRelationViewer: "Cliquer pour ouvrir un visualiseur externe de relations.\nOu utiliser la touche O",
        clickToCopyTopLeft: "Cliquer pour copier TopLeft : {value}",
        clickToCopyCenter: "Cliquer pour copier le centre : {value}",
        clickToCopyRightBottom: "Cliquer pour copier RightBottom : {value}",
        clickToCopyBbox: "Cliquer pour copier la bbox",
        clickToCopyIdOrUrl: `Cliquer pour copier l'ID\n{modifier} + clic pour copier l'URL`,
    },
    userProfile: {
        allEditors: "Tous les éditeurs",
        editorContributions: ({ editor, count }) => `${editor} (${count} modification${count === 1 ? "" : "s"})`,
        osmcha: " [OSMCha] ",
        usernames: "Noms d'utilisateur : ",
        findingBlocks: " Recherche des blocages... ",
        copyIds: "Copier les ID",
        copyIdsCount: "Copier {count} ID",
        noComment: "Sans commentaire",
        pastUsernames: "Anciens noms d'utilisateur : ",
        userIdLabel: "ID : ",
        disableTrackingProtection: "Veuillez désactiver la protection contre le pistage pour que la connexion au compte HDYC fonctionne",
        goToHdyc: "Aller à https://www.hdyc.neis-one.org/",
        pleaseWaitUserChangesetsLoading: "Veuillez patienter pendant le chargement des changesets de l'utilisateur",
        openSelectedChangesetsOnePage: "Alt + O pour ouvrir les changesets sélectionnés sur une seule page",
        regexSearchNotCaseSensitive: "Recherche regex sans distinction de casse",
        viaWhosthat: "via whosthat.osmz.ru",
        viaOverpassApi: "via Overpass API",
        regexSearchByCommentsPlaceholder: "Recherche regex dans les commentaires",
        filteredChangesets: "{count} changesets filtrés",
        singleUserIdLabel: "ID utilisateur : ",
        foundUserIds: "⚠️ {count} ID utilisateur trouvés",
        userIdBadge: "🆔 : ",
        scannedBlocksAfter: "Tous les blocages après #{id} ont été analysés",
        lastChangesets: "Les {count} derniers changesets :",
    },
    changesetsHistory: {
        copyIds: "Copier les ID",
        copyIdsCount: "Copier {count} ID",
        displayedCount: " Affichés {displayed}/{total}",
        hideBigChangesets: "Masquer les gros changesets",
        hideChangesetsFrom: "🔄Masquer les changesets de ",
        showChangesetsFrom: "🔄Afficher les changesets de ",
        hideChangesetsWith: "🔄Masquer les changesets contenant ",
        showChangesetsWith: "🔄Afficher les changesets contenant ",
        loadMore: "Charger {count}",
        displayOnOneMapAll: "Afficher sur une seule carte\nsi rien n'est coché, tous les changesets chargés et non masqués s'ouvriront",
        displayOnOneMap: "Afficher sur une seule carte",
        addCheckboxesMassActions: "Ajouter des cases à cocher pour les actions de masse sur les changesets",
        clickHideUserChangesets: "Cliquer pour masquer les changesets de cet utilisateur",
        filterViaBetterOsmOrg: "Filtre de changesets via better-osm-org",
        filterBySubstringInComments: "Filtrer par sous-chaîne dans les commentaires des changesets",
        clickCopyChangesetId: "Cliquer pour copier l'ID du changeset",
        usersFilterPlaceholder: "user1,user2,... puis appuyez sur Entrée",
        commentsFilterPlaceholder: "words1,words2,... puis appuyez sur Entrée",
        revertViaOsmRevert: "annuler via {name}",
    },
    measurer: {
        measureFromHere: "Mesurer depuis ici",
        endMeasure: "Terminer la mesure",
        cleanMeasurements: "Effacer les mesures",
        clickToSwitchUnits: "Cliquer pour changer d'unité de mesure",
        orPressEscapeTwice: "Ou appuyer deux fois sur Escape",
        hotkeysTitle: "Alt + clic : commencer une nouvelle ligne\nÉchap : arrêter la mesure\n{modifier} + Z : supprimer le dernier point",
    },
    panoramax: {
        blurFaces: "Flouter les visages",
        loginRequiredAlert: "Veuillez vous connecter à Panoramax",
        uploadPhoto: "Téléverser une photo vers Panoramax",
        selectFileAlert: "Sélectionnez un fichier",
        uploadError: "Erreur : {message}",
        exifInfo: "Infos EXIF :\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Trouver les décalages",
        focusIframeAlert: "Veuillez donner le focus à l'iframe iD.\nCliquez simplement n'importe où dans l'éditeur.",
    },
    changesetPage: {
        selectObjects: "Sélectionner les objets",
        revertViaOsmRevert: "Annuler via osm-revert",
        openInEditor: "Ouvrir dans {editor}",
        openInLevel0: "Ouvrir dans Level0",
        openInLevel0WithWaysGeometry: "Ouvrir dans Level0 avec la géométrie des voies",
        openChangesetInOsmcha: "Ouvrir le changeset dans OSMCha (ou appuyez sur O)\n(Maj + O pour ouvrir Achavi)",
        partialRevertOrEdit: "pour une annulation partielle ou une édition dans JOSM/Level0",
        hotkeyR: "Raccourci : R",
        hotkeyJ: "Raccourci : J",
        hotkeyAltJ: "Raccourci : Alt + J",
        hotkeyShiftAltJ: "Raccourci : Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Déplacer le nœud ici",
    },
    scriptMenu: {
        settings: "Paramètres",
        checkScriptUpdates: "Vérifier les mises à jour du script",
        checkDevScriptUpdates: "Vérifier les mises à jour de la version dev du script",
        listOfHotkeys: "Liste des raccourcis clavier",
    },
    actions: {
        remove: "supprimer",
        level0WorksOnlyOnOsmOrg: "level0 fonctionne uniquement avec osm.org",
    },
    copying: {
        clickForCopy: "Cliquer pour copier",
        failedToCopy: "échec de la copie :\n{text}",
        changeCoordinatesFormat: "Changer le format des coordonnées\nActuel : {format}",
        clickToCopyValue: "Cliquer pour copier {value}",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "ajouté par better-osm-org",
        experimentalFeature: "fonction expérimentale de better-osm-org",
    },
    osmcha: {
        openProfileInOsmcha: "Ouvrir le profil dans OSMCha",
    },
    selection: {
        shiftClickSelectRange: "Maj + clic pour sélectionner une plage de cases vides",
        clickForInvert: "Cliquer pour inverser",
    },
    deletedUsers: {
        tryFindDeletedUser: "Essayer de trouver l'utilisateur supprimé",
    },
    findUserInDiff: {
        clickForCopyUserId: "Cliquer pour copier l'ID utilisateur",
        notFoundAlert: "Utilisateur introuvable",
        userIdLabel: "ID : ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "Tag de revue OSMCha. Clic droit pour modifier\n",
        totalUserChangesets: "nombre total de changesets de l'utilisateur",
        showHiddenTags: "Afficher les tags masqués",
        clickForViewMore: "Cliquer pour voir plus",
        mapperRequestedReview:
            "Le contributeur a demandé une revue du changeset\n\nCliquez pour filtrer les changesets avec review_requested=yes",
        josmNotRunningAlert: "JOSM n'est pas lancé",
        customOsmApiInJosmAlert:
            "Vous utilisez un serveur OSM API personnalisé dans JOSM.\n\nModifiez les paramètres de JOSM ou ouvrez un autre site",
        otherOsmInstanceInJosmAlert:
            "Vous utilisez une autre instance OSM, mais JOSM utilise le serveur par défaut.\n\nModifiez les paramètres de JOSM ou ouvrez un autre site",
        osmchaReviewLike: "Évaluation positive OSMCha\n\nClic droit pour ajouter des tags de revue",
        osmchaReviewDislike: "Évaluation négative OSMCha\n\nClic droit pour ajouter des tags de revue",
        osmchaLoginAlert: "Veuillez vous connecter à OSMCha",
        osmchaChangesetNotFoundTitle:
            "Changeset introuvable dans la base de données OSMCha.\nSoit OSMCha n'a pas encore eu le temps de traiter ce changeset, soit il est trop ancien.",
        osmchaChangesetNotFoundAlert: "Changeset introuvable dans la base de données OSMCha",
        selectRangeHotkeys:
            "Cliquer avec Shift pour sélectionner une plage\nAppuyez sur R pour annuler via {name}\nAppuyez sur J pour ouvrir les objets dans JOSM\nAppuyez sur Alt + J pour ouvrir les objets dans Level0",
        reverterTitle: "Ouvrir {name}\nShift + clic pour annuler via JOSM\nAppuyez sur R pour une annulation partielle",
        josmDoesNotSupportPartialRevert: "JOSM ne prend pas en charge l'annulation partielle",
        osmRevertOnlyMainServers: "{name} fonctionne uniquement pour www.OpenStreetMap.org et www.OpenHistoricalMap.org",
        osmRevertOnlyMainServersWithJosm:
            "{name} fonctionne uniquement pour www.OpenStreetMap.org et www.OpenHistoricalMap.org\n\n" +
            "Mais vous pouvez installer le plugin reverter dans JOSM et utiliser Shift+clic pour les autres serveurs OSM.\n\n" +
            "⚠️ Modifiez le serveur OSM dans les paramètres de JOSM !",
        commentTemplateTitle: 'Le texte "{text}" sera ajouté au commentaire.\nVous pouvez modifier ce texte dans les paramètres du script',
    },
    hashtags: {
        searchInOsmcha: "Rechercher ce hashtag dans OSMCha",
        filterNotesByHashtag:
            "Cliquer pour filtrer les notes par ce hashtag.\nCtrl + clic ou Maj + clic pour rechercher ce hashtag dans osm-note-viewer",
        clickToCopyName: "Cliquer pour copier le nom",
        clickToCopyType: "Cliquer pour copier le type",
    },
    userBadges: {
        userModerator: "Cet utilisateur est modérateur",
        userImporter: "Cet utilisateur est un importateur",
        userBanned: "L'utilisateur a été banni",
        userNewbie: "Au moment de créer le changeset ou la note, l'utilisateur modifiait OpenStreetMap depuis moins d'un mois",
        followingUser: "Vous suivez cet utilisateur",
        corporateMapper:
            "{names} contributeur d'entreprise\n\nCliquer pour ouvrir la page wiki\nCliquer avec Alt pour ouvrir la source des données",
    },
    notes: {
        openMapStateSnapshot: "Ouvrir l'état de la carte au moment de la capture",
        openMapStateNoteCreation: "Ouvrir l'état de la carte au moment de la création de la note",
        commaSeparatedSubstrings: "Sous-chaînes séparées par des virgules\nLe filtre fonctionne aussi sur les commentaires",
        commaSeparatedUsernames: 'Noms d’utilisateur séparés par des virgules.\n\nUtilisez "anon" pour les notes anonymes',
        wordInNotesPlaceholder: "mot dans les notes",
        usernamePlaceholder: "nom d'utilisateur",
        wordInCommentsPlaceholder: "mot dans les commentaires",
        downloadVisibleNotesAsKmlTitle: "Télécharger les notes visibles comme fichier .kml",
        addNewObjectOnMap: "Ajouter un nouvel objet sur la carte\nCollez les tags dans la zone de texte\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "La zone de texte ne contient aucun tag",
        resolveButtonTitle:
            'Ajouter "{text}" au commentaire et fermer la note.\nVous pouvez changer l’emoji dans les paramètres du userscript',
    },
    spyGlass: {
        activateSpyGlass: "Activer le mode d'imitation SpyGlass (expérience better-osm-org)",
    },
    gpxFilter: {
        clickToZoomTrack: "cliquer pour zoomer\nAstuce : appuyez sur 8-9 pour naviguer entre la position précédente/suivante de la carte",
        showGpxTracks: "Afficher les traces GPX dans la vue actuelle de la carte",
        downloadFailed: "échec du téléchargement : {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "Trop de lignes dans le fichier CSV. Seuls les objets dans la bbox actuelle seront affichés",
    },
    objectPage: {
        gpsTrackerPosition: "Bleu — position du traceur GPS\nOrange — position réelle estimée",
        roofOrientationAcrossAlong: 'roof:orientation doit être "across" ou "along"',
        conditionalMustContainAt: "la valeur du tag :conditional doit contenir @",
        emptyPartAfterAt: "partie vide après @",
        emptyPartBeforeAt: "partie vide avant @",
        emptyPartBetweenAt: "partie vide entre @",
        observationNotFound: "Observation introuvable dans l'API iNaturalist",
        noErrorsOpeningHours: "aucune erreur trouvée par opening_hours.js 👍",
        phoneMustStartPlus: "le numéro de téléphone doit commencer par +",
        trackingParamInUrl: 'Paramètre de suivi "{param}=" dans l\'URL',
        urlShouldStartWithHttp: "L'URL doit commencer par https:// ou http://",
        easterEgg: "easter egg better-osm-org",
        needRoofOrientation: "il semble qu'il faut le remplacer par roof:orientation",
        clickShowEmbedded3d:
            "Cliquer pour afficher le visualiseur 3D intégré.\nDans les paramètres du script, vous pouvez définir l'ouverture par défaut sur la page OSM",
        setAsDefaultForClick: "Définir par défaut au clic",
        defaultViewer: "C'est le visualiseur par défaut",
        keyTooShort: "La clé est trop courte",
        suspiciousCapitalKey: "Clé suspecte commençant par une majuscule",
        openObjectHistory: "Cliquer pour ouvrir la page d'historique de l'objet\nOu appuyer sur la touche H",
        photoBy: "Photo par {author}",
        observedBy: "Observé par {user}",
        invalidPhone: "{value} numéro de téléphone invalide selon libphonenumber.js",
        invalidEmail: "{value} e-mail invalide",
        typeOnlyForRelations: "type=* uniquement pour les relations",
        suspiciousRelationType: "type={value} est utilisé moins de 50 fois. C'est probablement une erreur.",
        openWebsite: "Ouvrir {host}",
        editWithWebsite: "Modifier avec {host}",
    },
    taginfo: {
        searchWithOverpass: "rechercher avec Overpass",
    },
    wiki: {
        needMoreVotes: "encore {count} votes nécessaires pour atteindre 75 %",
        interimResults: "Résultats intermédiaires calculés par better-osm-org :",
    },
    relationVersionPage: {
        loadViaOverpass: "Charger la version de la relation via l'API Overpass",
    },
}

_translations["hr"] = {
    init: {
        disableBetterOsmOrg: "Onemogući better-osm-org",
        scriptVersion: "Verzija skripte: ",
        reportingFeaturesWarning: " prije prijave grešaka ili pitanja o funkcijama ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org neće raditi u GreasyMonkeyju :(\n\n" +
            "Ne podržava važne API-je bez kojih većina funkcija skripte neće raditi.\n\n" +
            "Koristi ViolentMonkey ili TamperMonkey\n\n" +
            "Rasprava: https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "verzija je stvorena",
        noteWasCreated: "bilješka je stvorena",
        changesetWasClosed: "changeset je zatvoren",
        clickToChangeTimeFormat: "Kliknite za promjenu formata vremena",
        clickWithCtrlOpenMapState: "Kliknite s Ctrl za otvaranje stanja karte u trenutku kada je {time}",
        clickWithAltViewAdiff: "Kliknite s Alt za prikaz adiffa",
        openMapStateAtTime: "Otvori stanje karte u trenutku kada je {time}",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Pregled uređivanja",
        sectionWorkingWithNotes: "Rad s bilješkama",
        sectionNewActions: "Nove radnje",
        sectionOther: "Ostalo",
        darkModeForMap: "Invertiraj boje karte u tamnom načinu rada",
        colorblindFriendlyPalette: "Paleta prilagođena daltonistima β",
        betterTagsPaste: "Dodaj = pri lijepljenju oznaka odvojenih razmacima ili tabulatorima",
        darkModeForID:
            'Tamni način za iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Hvala AlexPS</a>)',
        compactChangesetsHistory: "Kompaktna povijest changeseta",
        versionsDiff: "Dodaj diff oznaka u povijesti",
        showPreviousTagValue: "Prikaži prethodnu vrijednost oznake",
        fullVersionsDiff: "Dodaj diff s međuverzijama u povijesti puta",
        changesetQuickLook: "Dodaj QuickLook za changesete",
        showChangesetGeometry: "Prikaži geometriju objekata u changesetu",
        massChangesetsActions: "Dodaj radnje za popis changeseta (masovni revert, filtriranje, ...)",
        imagesAndLinksInTags: "Učini neke oznake klikabilnima, kraćima i prikaži fotografije",
        hideNoteHighlight: "Sakrij isticanje bilješke",
        resolveNotesButton: "Dodatni gumbi za rješavanje:",
        revertButton: "Gumb changeseta Revert&Osmcha",
        deletor: "Gumb za brisanje čvora",
        oneClickDeletor: "Izbriši čvor bez potvrde",
        changesetsTemplates: 'Predlošci komentara changeseta <a id="last-comments-link" target="_blank">(vaši zadnji komentari)</a>',
        hdycInProfile: "Dodaj HDYC u korisnički profil",
        betterProfileStat: "Dodaj filtre u statistiku profila",
        navigationViaHotkeys: 'Dodaj prečace <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(Popis)</a>',
        newEditorsLinks: "Dodaj nove editore u izbornik Edit",
        resetSearchFormFocus: "Resetiraj fokus obrasca pretraživanja",
        satelliteLayers: "Dodaj prekidače satelitskih slojeva",
        swipes: "Dodaj prijelaze između korisničkih changeseta",
        resizableSidebar: "Klizač za širinu bočne trake",
        clickableAvatar: "Klik na avatar za otvaranje changeseta",
        overzoomForDataLayer: "Dopusti overzoom kada je uključen sloj podataka/satelita β",
        dragAndDropViewers: "Povuci i ispusti za .geojson, .jpg, .gpx, .osm",
        viewer3DInNewTab: "Uvijek otvori 3D preglednike zgrada u novoj kartici",
        betterTaginfo: "Dodaj nove gumbe u Taginfo",
        defaultZoomKeysBehaviour: "Nemoj udvostručiti korak zumiranja gumba +/-",
        addLocationFromNominatim: "Dodaj lokaciju iz Nominatima za changesete i bilješke",
        overpassInstance:
            '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Overpass API poslužitelj</a>',
        panoramaxUploader: "Dodaj obrazac za prijenos fotografija u Panoramax",
        routersTimestamps: "Dodaj datum routing podataka",
        clickableMap: "Učini kartu klikabilnom β",
        debugMode: "Omogući debug i eksperimentalne značajke",
    },
    objectEditor: {
        delete: "Izbriši",
        restore: "Vrati",
    },
    historyDiff: {
        intermediateWayVersion: "Međuverzija",
        intermediateWayVersionTitle: "Došlo je do promjena oznaka ili koordinata čvorova na putu koje nisu povećale verziju puta",
        intermediateRelationVersion: "Međuverzija",
        intermediateRelationVersionTitle: "Došlo je do promjena oznaka ili koordinata čvorova u relaciji koje nisu povećale verziju relacije",
        allVersions: "Sve verzije",
        withGeometryChanges: "S promjenama geometrije",
        withoutIntermediate: "Bez međuverzija",
        viewUnredactedHistory: "Prikaži necenzuriranu povijest β",
        errorPleaseReport: "Greška :( Prijavite ovu stranicu u better-osm-org GitHub repozitoriju",
        someNodesHidden: "Neke čvorove sakrili su moderatori",
        reloadAndReport: "Pokušajte ponovno učitati stranicu.\nAko greška ostane, prijavite je u better-osm-org repozitoriju",
        someNodesHiddenSad: "Neke čvorove sakrili su moderatori :\\",
        filterIntermediateChanges: "Filtriraj međuizmjene",
        downloadAllVersionsWithIntermediate: "Preuzmi sve verzije (s međuverzijama)",
        someMembersHidden: "Neke članove sakrili su moderatori",
        downloadAllVersions: "Preuzmi sve verzije",
        toggleCompactTagsDiff: "Prebaci između punog i sažetog diffa oznaka.\nMožete koristiti i tipku T.",
        hidePreviousValue: "Kliknite za skrivanje prethodne vrijednosti",
        tagsCount: ({ count }) =>
            `${count} oznak${count % 10 === 1 && count % 100 !== 11 ? "a" : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) ? "e" : "a"}`,
    },
    changesetQuicklook: {
        lineWasReversed: "ⓘ Linija je obrnuta",
        objectDeletedByAuthor: " ⓘ Objekt je sada izbrisao autor",
        objectDeleted: " ⓘ Objekt je sada izbrisan",
        objectDeletedTitle: "{user} je izbrisao ovaj objekt",
        objectRestored: " ⓘ Objekt je sada obnovljen",
        objectRestoredByAuthor: " ⓘ Objekt je sada obnovio autor",
        objectRestoredTitle: "{user} je obnovio ovaj objekt",
        listWayNodesChanged: "Popis čvorova puta je promijenjen",
        nodesReversed: "Čvorovi puta su obrnuti",
        relationMembersChanged: "Popis članova relacije je promijenjen.\nKliknite za više detalja",
        showRelationMembers: "Prikaži popis članova relacije",
        relationMembersReversed: "Članovi relacije su obrnuti",
        pinRelationOnMap: "Prikvači relaciju na kartu",
        unpinRelationFromMap: "Otkvači relaciju s karte",
        nodeCoordinatesChanged: "Koordinate čvora su promijenjene",
        downloadThisRelation: "Preuzmi ovu relaciju",
        shiftClickZoomVia: 'Kliknite sa Shift za zumiranje na "via" članove',
        unableDisplaySomeData: "better-osm-org nije mogao prikazati neke podatke",
        editsWarTitle: "Rat izmjena. {details}\nKliknite za detalje",
        tagRestored: "Oznaka je sada vraćena",
        nowIsTag: "Sada je {key}={value}",
        tagDeleted: "Oznaka je sada obrisana",
        wasValue: "bilo je: {value}",
        tagReverted: "Oznaka je sada vraćena na staro",
        nodesCountChanged: "Broj točaka: {prevCount} → {nextCount}",
        nodesCount: "Broj točaka: {count}",
        intermediateNodeChangesTitle:
            "Oznake i koordinate točaka puta promijenjene su više puta tijekom changeseta.\n" +
            "To može biti namjerno ili se može dogoditi pri skupnom učitavanju izmjena preko JOSM-a.\n" +
            "Prikazano je završno stanje. Kliknite za otvaranje pune povijesti puta",
        sendBugReport: "⚠️ Pošalji prijavu greške",
        debugAlert: "⚠ pročitajte logove u konzoli preglednika (F12).\nOvu poruku treba vidjeti samo razvojni programer skripte",
    },
    editMenuLinks: {
        moreLinks: "više poveznica / uredi popis",
        editLinksList: "popis poveznica za uređivanje",
        editLink: "uredi poveznicu",
        pinThisLink: "prikvači ovu poveznicu",
        moveUpLink: "pomakni poveznicu gore",
        moveDownLink: "pomakni poveznicu dolje",
        removeLink: "ukloni poveznicu",
        saveLink: "spremi poveznicu",
        openPlaceExternalWebsite: "Otvori mjesto na vanjskoj stranici",
        linkNamePlaceholder: "Naziv poveznice",
        urlExamplePrefix: "Primjer URL-a: ",
    },
    routers: {
        routingDataTimeFor: "Vrijeme routing podataka za {name}: {time}",
        openDebugMap: "Otvori Debug Map",
    },
    satellite: {
        setupCustomStyleJson: "Postavi prilagođeni style.json za MapLibre.js",
        setupCustomMapLayers: "Postavi prilagođene slojeve karte",
        duplicateStyleAndEdit: "dupliciraj stil i uredi ga",
        openMapStyleHomePage: "Otvori početnu stranicu stila karte",
        openMapLayerHomePage: "Otvori početnu stranicu sloja karte",
        switchMapAndSatellite: "Prebacuj između karte i satelitskih snimaka",
        setCustomLayer: "Postavi prilagođeni sloj (Shift + S)\nznačajka better-osm-org",
        setCustomVectorStyle: "Postavi prilagođeni vektorski stil (Shift + V)",
        stravaLoginRequiredAlert: "Potrebna je prijava na Stravu za pristup heatmapi.\nNakon toga ponovno učitajte karticu.",
        vectorStyleUrlPlaceholder: "primjer: https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "Zalijepite style.json",
        tileUrlPlaceholder: "primjer: https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Pokušajte ponovno učitati stranicu bez predmemorije pomoću Ctrl + F5.\n" +
            "Ili:\n" +
            "1. U postavkama TamperMonkeyja uključite Advanced Config Mode\n" +
            '2. U postavkama TamperMonkeyja promijenite "Content Script API" u "UserScript API Dynamic"\n' +
            "Više informacija: https://c.osm.org/t/121670/208\n\n" +
            "Ili zatvorite ovu karticu i otvorite novu\n" +
            "Ili isključite pa uključite skriptu\n" +
            "Ili pokušajte koristiti Firefox s ViolentMonkeyjem",
    },
    geojson: {
        metainfo: "metapodaci",
        needUpdateBetterOsmOrg: "Potrebno je ažurirati better-osm-org",
        switchTableAndRawEditor: "Prebaci između tablice i sirovog uređivača",
        conflict: "Sukob",
    },
    overpassSearch: {
        httpError: "Greška. HTTP kod: {status}",
        httpErrorWithQuery: "Greška. HTTP kod: {status}\nPoslani upit:\n\n{query}",
        invalidQuery: "neispravan upit",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Dostupne naredbe",
        recent: "Nedavno",
        noHotkeysMatchSearch: "Nijedan prečac ne odgovara ovom pretraživanju.",
        noCatalogedCommandsMatchPage: "Nijedna katalogizirana naredba ne odgovara ovoj stranici.",
        searchByNamePlaceholder: "Pretraži po nazivu prečaca",
    },
    objectVersionPage: {
        length: "Duljina: {value}",
        area: "Površina: {value}",
        pinRestrictionSign: "Prikvači restriction znak na kartu.\nMožete sakriti sve objekte koje better-osm-org dodaje pritiskom na ` ili ~",
        hideRestrictionSign: "Sakrij restriction znak",
        selectCoordinatesFormat: "Odaberite format koordinata za kopiranje.\nZa kopiranje samo kliknite na koordinate",
        setDefaultCopyFormat: "Postavi kao zadani format kopiranja pri kliku na koordinate",
        defaultCopyFormat: "Ovo je zadani format pri kliku na koordinate",
        openExternalRelationViewer: "Kliknite za otvaranje vanjskog preglednika relacija.\nIli koristite tipku O",
        clickToCopyTopLeft: "Kliknite za kopiranje TopLeft: {value}",
        clickToCopyCenter: "Kliknite za kopiranje središta: {value}",
        clickToCopyRightBottom: "Kliknite za kopiranje RightBottom: {value}",
        clickToCopyBbox: "Kliknite za kopiranje bboxa",
        clickToCopyIdOrUrl: `Kliknite za kopiranje ID-a\n{modifier} + klik za kopiranje URL-a`,
    },
    userProfile: {
        allEditors: "Svi editori",
        editorContributions: ({ editor, count }) =>
            `${editor} (${count} izmjen${count % 10 === 1 && count % 100 !== 11 ? "a" : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) ? "e" : "a"})`,
        osmcha: " [OSMCha] ",
        usernames: "Korisnička imena: ",
        findingBlocks: " Traženje blokada... ",
        copyIds: "Kopiraj ID-ove",
        copyIdsCount: "Kopiraj {count} ID-ova",
        noComment: "Bez komentara",
        pastUsernames: "Prethodna korisnička imena: ",
        userIdLabel: "ID: ",
        disableTrackingProtection: "Onemogućite zaštitu od praćenja kako bi prijava na HDYC radila",
        goToHdyc: "Idi na https://www.hdyc.neis-one.org/",
        pleaseWaitUserChangesetsLoading: "Pričekajte dok se korisnički changeseti učitavaju",
        openSelectedChangesetsOnePage: "Alt + O za otvaranje odabranih changeseta na jednoj stranici",
        regexSearchNotCaseSensitive: "Pretraga regularnim izrazom bez razlikovanja velikih i malih slova",
        viaWhosthat: "putem whosthat.osmz.ru",
        viaOverpassApi: "putem Overpass API-ja",
        regexSearchByCommentsPlaceholder: "Regex pretraga po komentarima",
        filteredChangesets: "Filtrirano changeseta: {count}",
        singleUserIdLabel: "ID korisnika: ",
        foundUserIds: "⚠️ Pronađeno ID-ova korisnika: {count}",
        userIdBadge: "🆔: ",
        scannedBlocksAfter: "Sva blokiranja nakon #{id} su pregledana",
        lastChangesets: "Zadnjih {count} changeseta:",
    },
    changesetsHistory: {
        copyIds: "Kopiraj ID-ove",
        copyIdsCount: "Kopiraj {count} ID-ova",
        displayedCount: " Prikazano {displayed}/{total}",
        hideBigChangesets: "Sakrij velike changesete",
        hideChangesetsFrom: "🔄Sakrij changesete od ",
        showChangesetsFrom: "🔄Prikaži changesete od ",
        hideChangesetsWith: "🔄Sakrij changesete s ",
        showChangesetsWith: "🔄Prikaži changesete s ",
        loadMore: "Učitaj {count}",
        displayOnOneMapAll: "Prikaži na jednoj karti\nako ništa nije označeno, otvorit će se svi učitani i neskriveni changeseti",
        displayOnOneMap: "Prikaži na jednoj karti",
        addCheckboxesMassActions: "Dodaj potvrdne okvire za masovne radnje s changesetima",
        clickHideUserChangesets: "Kliknite za skrivanje changeseta ovog korisnika",
        filterViaBetterOsmOrg: "Filtar changeseta putem better-osm-org",
        filterBySubstringInComments: "Filtriraj po podnizu u komentarima changeseta",
        clickCopyChangesetId: "Kliknite za kopiranje ID-a changeseta",
        usersFilterPlaceholder: "user1,user2,... i pritisnite Enter",
        commentsFilterPlaceholder: "words1,words2,... i pritisnite Enter",
        revertViaOsmRevert: "vrati putem {name}",
    },
    measurer: {
        measureFromHere: "Mjeri odavde",
        endMeasure: "Završi mjerenje",
        cleanMeasurements: "Očisti mjerenja",
        clickToSwitchUnits: "Kliknite za promjenu mjernih jedinica",
        orPressEscapeTwice: "Ili dvaput pritisnite Escape",
        hotkeysTitle: "Alt + klik: započni novu liniju\nEsc: zaustavi mjerenje\n{modifier} + Z: ukloni zadnju točku",
    },
    panoramax: {
        blurFaces: "Zamagli lica",
        loginRequiredAlert: "Prijavite se u Panoramax",
        uploadPhoto: "Prenesi fotografiju u Panoramax",
        selectFileAlert: "Odaberite datoteku",
        uploadError: "Greška: {message}",
        exifInfo: "Podaci iz EXIF-a:\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Pronađi pomake",
        focusIframeAlert: "Prebacite fokus na iD iframe.\nSamo kliknite bilo gdje u uređivaču.",
    },
    changesetPage: {
        selectObjects: "Odaberi objekte",
        revertViaOsmRevert: "Vrati putem osm-revert",
        openInEditor: "Otvori u {editor}",
        openInLevel0: "Otvori u Level0",
        openInLevel0WithWaysGeometry: "Otvori u Level0 s geometrijom puteva",
        openChangesetInOsmcha: "Otvori changeset u OSMCha (ili pritisnite O)\n(Shift + O za otvaranje Achavi)",
        partialRevertOrEdit: "za djelomični revert ili uređivanje u JOSM/Level0",
        hotkeyR: "Prečac: R",
        hotkeyJ: "Prečac: J",
        hotkeyAltJ: "Prečac: Alt + J",
        hotkeyShiftAltJ: "Prečac: Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Premjesti čvor ovdje",
    },
    scriptMenu: {
        settings: "Postavke",
        checkScriptUpdates: "Provjeri ažuriranja skripte",
        checkDevScriptUpdates: "Provjeri ažuriranja dev verzije skripte",
        listOfHotkeys: "Popis prečaca",
    },
    actions: {
        remove: "ukloni",
        level0WorksOnlyOnOsmOrg: "level0 radi samo s osm.org",
    },
    copying: {
        clickForCopy: "Kliknite za kopiranje",
        failedToCopy: "kopiranje nije uspjelo:\n{text}",
        changeCoordinatesFormat: "Promijeni format koordinata\nTrenutni: {format}",
        clickToCopyValue: "Kliknite za kopiranje {value}",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "dodao better-osm-org",
        experimentalFeature: "eksperimentalna značajka better-osm-org",
    },
    osmcha: {
        openProfileInOsmcha: "Otvori profil u OSMCha",
    },
    selection: {
        shiftClickSelectRange: "Shift + klik za odabir raspona praznih kućica",
        clickForInvert: "Kliknite za invertiranje",
    },
    deletedUsers: {
        tryFindDeletedUser: "Pokušaj pronaći obrisanog korisnika",
    },
    findUserInDiff: {
        clickForCopyUserId: "Kliknite za kopiranje korisničkog ID-a",
        notFoundAlert: "Korisnik nije pronađen",
        userIdLabel: "ID: ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "OSMCha oznaka pregleda. Desni klik za promjenu\n",
        totalUserChangesets: "ukupan broj changeseta korisnika",
        showHiddenTags: "Prikaži skrivene oznake",
        clickForViewMore: "Kliknite za više",
        mapperRequestedReview: "Maper je zatražio pregled changeseta\n\nKliknite za filtriranje changeseta s review_requested=yes",
        josmNotRunningAlert: "JOSM nije pokrenut",
        customOsmApiInJosmAlert:
            "U JOSM-u koristite prilagođeni OSM API server.\n\nPromijenite postavke JOSM-a ili otvorite drugu web-stranicu",
        otherOsmInstanceInJosmAlert:
            "Koristite drugu OSM instancu, ali JOSM koristi zadani server.\n\nPromijenite postavke JOSM-a ili otvorite drugu web-stranicu",
        osmchaReviewLike: "Pozitivna OSMCha ocjena\n\nDesni klik za dodavanje oznaka pregleda",
        osmchaReviewDislike: "Negativna OSMCha ocjena\n\nDesni klik za dodavanje oznaka pregleda",
        osmchaLoginAlert: "Prijavite se u OSMCha",
        osmchaChangesetNotFoundTitle:
            "Changeset nije pronađen u OSMCha bazi podataka.\nIli OSMCha još nije stigla obraditi taj changeset, ili je previše star.",
        osmchaChangesetNotFoundAlert: "Changeset nije pronađen u OSMCha bazi podataka",
        selectRangeHotkeys:
            "Kliknite sa Shift za odabir raspona\nPritisnite R za vraćanje putem {name}\nPritisnite J za otvaranje objekata u JOSM-u\nPritisnite Alt + J za otvaranje objekata u Level0",
        reverterTitle: "Otvori {name}\nShift + klik za vraćanje putem JOSM-a\nPritisnite R za djelomično vraćanje",
        josmDoesNotSupportPartialRevert: "JOSM ne podržava djelomično vraćanje",
        osmRevertOnlyMainServers: "{name} radi samo za www.OpenStreetMap.org i www.OpenHistoricalMap.org",
        osmRevertOnlyMainServersWithJosm:
            "{name} radi samo za www.OpenStreetMap.org i www.OpenHistoricalMap.org\n\n" +
            "Ali možete instalirati reverter plugin u JOSM i koristiti shift+klik za druge OSM servere.\n\n" +
            "⚠️ Promijenite OSM server u postavkama JOSM-a!",
        commentTemplateTitle: 'Tekst "{text}" bit će dodan u komentar.\nOvaj tekst možete promijeniti u postavkama skripte',
    },
    hashtags: {
        searchInOsmcha: "Pretraži ovaj hashtag u OSMCha",
        filterNotesByHashtag:
            "Kliknite za filtriranje bilješki po ovom hashtagu.\nKliknite s Ctrl ili Shift za pretragu ovog hashtaga u osm-note-viewer",
        clickToCopyName: "Kliknite za kopiranje naziva",
        clickToCopyType: "Kliknite za kopiranje tipa",
    },
    userBadges: {
        userModerator: "Ovaj korisnik je moderator",
        userImporter: "Ovaj korisnik je importer",
        userBanned: "Korisnik je blokiran",
        userNewbie: "U trenutku stvaranja changeseta ili bilješke korisnik je uređivao OpenStreetMap manje od mjesec dana",
        followingUser: "Pratite ovog korisnika",
        corporateMapper: "{names} korporativni mapper\n\nKliknite za otvaranje wiki stranice\nKliknite s Alt za otvaranje izvora podataka",
    },
    notes: {
        openMapStateSnapshot: "Otvori stanje karte u trenutku snimke",
        openMapStateNoteCreation: "Otvori stanje karte u trenutku stvaranja bilješke",
        commaSeparatedSubstrings: "Podnizovi odvojeni zarezima\nFiltar radi i po komentarima",
        commaSeparatedUsernames: 'Korisnička imena odvojena zarezima.\n\nKoristite "anon" za anonimne bilješke',
        wordInNotesPlaceholder: "riječ u bilješkama",
        usernamePlaceholder: "korisničko ime",
        wordInCommentsPlaceholder: "riječ u komentarima",
        downloadVisibleNotesAsKmlTitle: "Preuzmi vidljive bilješke kao .kml datoteku",
        addNewObjectOnMap: "Dodaj novi objekt na kartu\nZalijepite oznake u textarea\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "Textarea ne sadrži nijednu oznaku",
        resolveButtonTitle: 'Dodaj "{text}" u komentar i zatvori bilješku.\nEmoji možete promijeniti u postavkama userscripta',
    },
    spyGlass: {
        activateSpyGlass: "Aktiviraj način imitacije SpyGlass (better-osm-org eksperiment)",
    },
    gpxFilter: {
        clickToZoomTrack: "kliknite za zumiranje\nSavjet: pritisnite 8-9 za prijelaz na prethodnu/sljedeću poziciju karte",
        showGpxTracks: "Prikaži GPX tragove u trenutnom prikazu karte",
        downloadFailed: "preuzimanje nije uspjelo: {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "Previše redaka u CSV datoteci. Prikazat će se samo objekti unutar trenutnog bboxa",
    },
    objectPage: {
        gpsTrackerPosition: "Plava — položaj s GPS trackera\nNarančasta — procijenjeni stvarni položaj",
        roofOrientationAcrossAlong: 'roof:orientation mora biti "across" ili "along"',
        conditionalMustContainAt: "vrijednost oznake :conditional mora sadržavati @",
        emptyPartAfterAt: "prazan dio nakon @",
        emptyPartBeforeAt: "prazan dio prije @",
        emptyPartBetweenAt: "prazan dio između @",
        observationNotFound: "Zapažanje nije pronađeno u iNaturalist API-ju",
        noErrorsOpeningHours: "opening_hours.js nije pronašao greške 👍",
        phoneMustStartPlus: "broj telefona mora početi s +",
        trackingParamInUrl: 'Parametar za praćenje "{param}=" u URL-u',
        urlShouldStartWithHttp: "URL mora početi s https:// ili http://",
        easterEgg: "better-osm-org uskrsno jaje",
        needRoofOrientation: "izgleda da ovo treba promijeniti u roof:orientation",
        clickShowEmbedded3d:
            "Kliknite za prikaz ugrađenog 3D preglednika.\nU postavkama skripte možete postaviti otvaranje na OSM stranici kao zadano",
        setAsDefaultForClick: "Postavi kao zadano za klik",
        defaultViewer: "Ovo je zadani preglednik",
        keyTooShort: "Ključ je prekratak",
        suspiciousCapitalKey: "Sumnjiv ključ koji počinje velikim slovom",
        openObjectHistory: "Kliknite za otvaranje stranice povijesti objekta\nIli pritisnite tipku H",
        photoBy: "Fotografija: {author}",
        observedBy: "Promatrao: {user}",
        invalidPhone: "{value} nije valjan broj telefona prema libphonenumber.js",
        invalidEmail: "{value} nije valjana e-mail adresa",
        typeOnlyForRelations: "type=* samo za relacije",
        suspiciousRelationType: "type={value} koristi se manje od 50 puta. To je vjerojatno greška.",
        openWebsite: "Otvori {host}",
        editWithWebsite: "Uredi pomoću {host}",
    },
    taginfo: {
        searchWithOverpass: "pretraži pomoću Overpassa",
    },
    wiki: {
        needMoreVotes: "potrebno je još {count} glasova do 75 %",
        interimResults: "Privremeni rezultati izračunati pomoću better-osm-org:",
    },
    relationVersionPage: {
        loadViaOverpass: "Učitaj verziju relacije putem Overpass API-ja",
    },
}

_translations["uk"] = {
    init: {
        disableBetterOsmOrg: "Вимкнути better-osm-org",
        scriptVersion: "Версія скрипту: ",
        reportingFeaturesWarning: " перед повідомленням про помилки або питаннями про функції ⚠️⚠️⚠️",
        greasemonkeyUnsupportedAlert:
            "better-osm-org не працюватиме в GreasyMonkey :(\n\n" +
            "Він не підтримує важливі API, без яких більшість функцій скрипта не працюватиме.\n\n" +
            "Використовуйте ViolentMonkey або TamperMonkey\n\n" +
            "Обговорення: https://github.com/deevroman/better-osm-org/issues/217",
    },
    datetimeFormatSwitch: {
        versionWasCreated: "версію було створено",
        noteWasCreated: "нотатку було створено",
        changesetWasClosed: "набір змін було закрито",
        clickToChangeTimeFormat: "Натисніть, щоб змінити формат часу",
        clickWithCtrlOpenMapState: "Натисніть із Ctrl, щоб відкрити стан мапи на момент, коли {time}",
        clickWithAltViewAdiff: "Натисніть із Alt, щоб переглянути adiff",
        openMapStateAtTime: "Відкрити стан мапи на момент, коли {time}",
    },
    config: {
        sectionID: "iD",
        sectionViewingEdits: "Перегляд редагувань",
        sectionWorkingWithNotes: "Робота з нотатками",
        sectionNewActions: "Нові дії",
        sectionOther: "Інше",
        darkModeForMap: "Інвертувати кольори мапи в темній темі",
        colorblindFriendlyPalette: "Палітра для дальтоніків β",
        betterTagsPaste: "Додавати = під час вставлення тегів, розділених пробілами або табуляцією",
        darkModeForID:
            'Темна тема для iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Дякуємо AlexPS</a>)',
        compactChangesetsHistory: "Компактна історія наборів змін",
        versionsDiff: "Додати різницю тегів в історії",
        showPreviousTagValue: "Показувати попереднє значення тега",
        fullVersionsDiff: "Додати diff із проміжними версіями в історії лінії",
        changesetQuickLook: "Додати QuickLook для наборів змін",
        showChangesetGeometry: "Показувати геометрію об'єктів у наборі змін",
        massChangesetsActions: "Додати дії для списку наборів змін (масове скасування, фільтрація, ...)",
        imagesAndLinksInTags: "Зробити деякі теги клікабельними, коротшими та показувати фото",
        hideNoteHighlight: "Приховати підсвічування нотатки",
        resolveNotesButton: "Додаткові кнопки вирішення:",
        revertButton: "Кнопка набору змін Revert&Osmcha",
        deletor: "Кнопка видалення точки",
        oneClickDeletor: "Видаляти точку без підтвердження",
        changesetsTemplates: 'Шаблони коментарів до наборів змін <a id="last-comments-link" target="_blank">(ваші останні коментарі)</a>',
        hdycInProfile: "Додати HDYC до профілю користувача",
        betterProfileStat: "Додати фільтри до статистики профілю",
        navigationViaHotkeys:
            'Додати гарячі клавіші <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(Список)</a>',
        newEditorsLinks: "Додати нові редактори до меню Edit",
        resetSearchFormFocus: "Скидати фокус у формі пошуку",
        satelliteLayers: "Додати перемикачі супутникових шарів",
        swipes: "Додати свайпи між наборами змін користувача",
        resizableSidebar: "Повзунок для ширини бічної панелі",
        clickableAvatar: "Клік по аватару для відкриття наборів змін",
        overzoomForDataLayer: "Дозволити overzoom, коли увімкнено шар даних/супутника β",
        dragAndDropViewers: "Drag&Drop для .geojson, .jpg, .gpx, .osm",
        viewer3DInNewTab: "Завжди відкривати 3D-переглядачі будівель у новій вкладці",
        betterTaginfo: "Додати нові кнопки в Taginfo",
        defaultZoomKeysBehaviour: "Не подвоювати крок масштабування для кнопок +/-",
        addLocationFromNominatim: "Додати локацію з Nominatim для наборів змін і нотаток",
        overpassInstance: '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Сервер Overpass API</a>',
        panoramaxUploader: "Додати форму для завантаження фото в Panoramax",
        routersTimestamps: "Додати дату маршрутизаційних даних",
        clickableMap: "Зробити мапу клікабельною β",
        debugMode: "Увімкнути режим налагодження та експериментальні функції",
    },
    objectEditor: {
        delete: "Видалити",
        restore: "Відновити",
    },
    historyDiff: {
        intermediateWayVersion: "Проміжна версія",
        intermediateWayVersionTitle: "Були зміни тегів або координат точок у лінії, які не збільшили версію лінії",
        intermediateRelationVersion: "Проміжна версія",
        intermediateRelationVersionTitle: "Були зміни тегів або координат точок у відношенні, які не збільшили версію відношення",
        allVersions: "Усі версії",
        withGeometryChanges: "Із геометричними змінами",
        withoutIntermediate: "Без проміжних",
        viewUnredactedHistory: "Переглянути нецензуровану історію β",
        errorPleaseReport: "Помилка :( Будь ласка, повідомте про цю сторінку в репозиторії better-osm-org на GitHub",
        someNodesHidden: "Деякі точки були приховані модераторами",
        reloadAndReport: "Спробуйте перезавантажити сторінку.\nЯкщо помилка не зникає, напишіть про це в репозиторій better-osm-org",
        someNodesHiddenSad: "Деякі точки були приховані модераторами :\\",
        filterIntermediateChanges: "Фільтр проміжних змін",
        downloadAllVersionsWithIntermediate: "Завантажити всі версії (з проміжними версіями)",
        someMembersHidden: "Деякі елементи були приховані модераторами",
        downloadAllVersions: "Завантажити всі версії",
        toggleCompactTagsDiff: "Перемикати між повним і компактним diff тегів.\nТакож можна використати клавішу T.",
        hidePreviousValue: "Натисніть, щоб приховати попереднє значення",
        tagsCount: ({ count }) =>
            `${count} тег${count % 10 === 1 && count % 100 !== 11 ? "" : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) ? "и" : "ів"}`,
    },
    changesetQuicklook: {
        lineWasReversed: "ⓘ Лінію було розвернуто",
        objectDeletedByAuthor: " ⓘ Об'єкт зараз видалений автором",
        objectDeleted: " ⓘ Об'єкт зараз видалений",
        objectDeletedTitle: "{user} видалив цей об'єкт",
        objectRestored: " ⓘ Об'єкт зараз відновлений",
        objectRestoredByAuthor: " ⓘ Об'єкт зараз відновлений автором",
        objectRestoredTitle: "{user} відновив цей об'єкт",
        listWayNodesChanged: "Список точок лінії було змінено",
        nodesReversed: "Точки лінії були розвернуті",
        relationMembersChanged: "Список елементів відношення було змінено.\nНатисніть, щоб побачити більше деталей",
        showRelationMembers: "Показати список елементів відношення",
        relationMembersReversed: "Елементи відношення були розвернуті",
        pinRelationOnMap: "Закріпити відношення на мапі",
        unpinRelationFromMap: "Відкріпити відношення від мапи",
        nodeCoordinatesChanged: "Координати точки було змінено",
        downloadThisRelation: "Завантажити це відношення",
        shiftClickZoomVia: 'Натисніть із Shift, щоб наблизити учасників "via"',
        unableDisplaySomeData: "better-osm-org не зміг показати деякі дані",
        editsWarTitle: "Війна редагувань. {details}\nНатисніть для подробиць",
        tagRestored: "Тег тепер відновлено",
        nowIsTag: "Зараз {key}={value}",
        tagDeleted: "Тег тепер видалено",
        wasValue: "було: {value}",
        tagReverted: "Тег тепер відкочено",
        nodesCountChanged: "Кількість точок: {prevCount} → {nextCount}",
        nodesCount: "Кількість точок: {count}",
        intermediateNodeChangesTitle:
            "Теги й координати точок лінії змінювалися кілька разів протягом набору змін.\n" +
            "Це може бути навмисно або траплятися під час пакетного завантаження редагувань через JOSM.\n" +
            "Показано фінальний стан. Натисніть, щоб відкрити повну історію лінії",
        sendBugReport: "⚠️ Надіслати баг-репорт",
        debugAlert: "⚠ прочитайте логи в консолі браузера (F12).\nЦе повідомлення має бачити лише розробник скрипта",
    },
    editMenuLinks: {
        moreLinks: "більше посилань / змінити список",
        editLinksList: "список посилань для редагування",
        editLink: "редагувати посилання",
        pinThisLink: "закріпити це посилання",
        moveUpLink: "перемістити посилання вгору",
        moveDownLink: "перемістити посилання вниз",
        removeLink: "видалити посилання",
        saveLink: "зберегти посилання",
        openPlaceExternalWebsite: "Відкрити місце на зовнішньому сайті",
        linkNamePlaceholder: "Назва посилання",
        urlExamplePrefix: "Приклад URL: ",
    },
    routers: {
        routingDataTimeFor: "Дата даних маршрутизації для {name}: {time}",
        openDebugMap: "Відкрити Debug Map",
    },
    satellite: {
        setupCustomStyleJson: "Налаштувати власний style.json для MapLibre.js",
        setupCustomMapLayers: "Налаштувати власні шари мапи",
        duplicateStyleAndEdit: "дублювати стиль і відредагувати його",
        openMapStyleHomePage: "Відкрити домашню сторінку стилю мапи",
        openMapLayerHomePage: "Відкрити домашню сторінку шару мапи",
        switchMapAndSatellite: "Перемикатися між мапою та супутниковими знімками",
        setCustomLayer: "Установити власний шар (Shift + S)\nфункція better-osm-org",
        setCustomVectorStyle: "Установити власний векторний стиль (Shift + V)",
        stravaLoginRequiredAlert: "Потрібно увійти в Strava для доступу до heatmap.\nПісля цього перезавантажте вкладку.",
        vectorStyleUrlPlaceholder: "приклад: https://vector.openstreetmap.org/styles/shortbread/neutrino.json",
        pasteStyleJsonPlaceholder: "Вставте style.json",
        tileUrlPlaceholder: "приклад: https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        customStyleSetupHelpAlert:
            "Спробуйте перезавантажити сторінку без кешу за допомогою Ctrl + F5.\n" +
            "Або:\n" +
            "1. У налаштуваннях TamperMonkey увімкніть Advanced Config Mode\n" +
            '2. У налаштуваннях TamperMonkey змініть "Content Script API" на "UserScript API Dynamic"\n' +
            "Докладніше: https://c.osm.org/t/121670/208\n\n" +
            "Або закрийте цю вкладку й відкрийте нову\n" +
            "Або вимкніть і ввімкніть скрипт\n" +
            "Або спробуйте Firefox із ViolentMonkey",
    },
    geojson: {
        metainfo: "метаінформація",
        needUpdateBetterOsmOrg: "Потрібно оновити better-osm-org",
        switchTableAndRawEditor: "Перемкнути між таблицею та сирим редактором",
        conflict: "Конфлікт",
    },
    overpassSearch: {
        httpError: "Помилка. HTTP код: {status}",
        httpErrorWithQuery: "Помилка. HTTP код: {status}\nНадісланий запит:\n\n{query}",
        invalidQuery: "неправильний запит",
    },
    links: {
        webArchive: "WebArchive",
    },
    hotkeys: {
        availableCommands: "Доступні команди",
        recent: "Нещодавні",
        noHotkeysMatchSearch: "Жодні гарячі клавіші не відповідають цьому пошуку.",
        noCatalogedCommandsMatchPage: "Для цієї сторінки немає відповідних команд.",
        searchByNamePlaceholder: "Пошук за назвою хоткея",
    },
    objectVersionPage: {
        length: "Довжина: {value}",
        area: "Площа: {value}",
        pinRestrictionSign:
            "Закріпити знак restriction на мапі.\nВи можете приховати всі об'єкти, які додає better-osm-org, натиснувши ` або ~",
        hideRestrictionSign: "Приховати знак restriction",
        selectCoordinatesFormat: "Виберіть формат координат для копіювання.\nЩоб скопіювати, просто натисніть на координати",
        setDefaultCopyFormat: "Зробити форматом копіювання за замовчуванням під час кліку по координатах",
        defaultCopyFormat: "Це формат за замовчуванням під час кліку по координатах",
        openExternalRelationViewer: "Натисніть, щоб відкрити зовнішній переглядач відношень.\nАбо використайте клавішу O",
        clickToCopyTopLeft: "Натисніть, щоб скопіювати TopLeft: {value}",
        clickToCopyCenter: "Натисніть, щоб скопіювати центр: {value}",
        clickToCopyRightBottom: "Натисніть, щоб скопіювати RightBottom: {value}",
        clickToCopyBbox: "Натисніть, щоб скопіювати bbox",
        clickToCopyIdOrUrl: `Натисніть, щоб скопіювати ID\n{modifier} + click, щоб скопіювати URL`,
    },
    userProfile: {
        allEditors: "Усі редактори",
        editorContributions: ({ editor, count }) =>
            `${editor} (${count} прав${count % 10 === 1 && count % 100 !== 11 ? "ка" : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) ? "ки" : "ок"})`,
        osmcha: " [OSMCha] ",
        usernames: "Імена користувачів: ",
        findingBlocks: " Шукаємо блокування... ",
        copyIds: "Копіювати ID",
        copyIdsCount: "Копіювати {count} ID",
        noComment: "Без коментаря",
        pastUsernames: "Попередні імена: ",
        userIdLabel: "ID: ",
        disableTrackingProtection: "Будь ласка, вимкніть захист від стеження, щоб працював вхід HDYC",
        goToHdyc: "Перейти до https://www.hdyc.neis-one.org/",
        pleaseWaitUserChangesetsLoading: "Зачекайте, поки завантажуються набори змін користувача",
        openSelectedChangesetsOnePage: "Alt + O, щоб відкрити вибрані набори змін на одній сторінці",
        regexSearchNotCaseSensitive: "Пошук regex без урахування регістру",
        viaWhosthat: "через whosthat.osmz.ru",
        viaOverpassApi: "через Overpass API",
        regexSearchByCommentsPlaceholder: "Regex-пошук за коментарями",
        filteredChangesets: "Відфільтровано наборів змін: {count}",
        singleUserIdLabel: "ID користувача: ",
        foundUserIds: "⚠️ Знайдено ID користувачів: {count}",
        userIdBadge: "🆔: ",
        scannedBlocksAfter: "Проскановано всі блокування після #{id}",
        lastChangesets: "Останні {count} наборів змін:",
    },
    changesetsHistory: {
        copyIds: "Копіювати ID",
        copyIdsCount: "Копіювати {count} ID",
        displayedCount: " Показано {displayed}/{total}",
        hideBigChangesets: "Приховувати великі набори змін",
        hideChangesetsFrom: "🔄Приховати зміни від ",
        showChangesetsFrom: "🔄Показати зміни від ",
        hideChangesetsWith: "🔄Приховати зміни із ",
        showChangesetsWith: "🔄Показати зміни із ",
        loadMore: "Завантажити {count}",
        displayOnOneMapAll: "Показати все на одній мапі\nякщо нічого не вибрано, відкриються всі завантажені й не приховані набори змін",
        displayOnOneMap: "Показати на одній мапі",
        addCheckboxesMassActions: "Додати чекбокси для масових дій із наборами змін",
        clickHideUserChangesets: "Натисніть, щоб приховати набори змін цього користувача",
        filterViaBetterOsmOrg: "Фільтр наборів змін через better-osm-org",
        filterBySubstringInComments: "Фільтр за підрядком у коментарях наборів змін",
        clickCopyChangesetId: "Натисніть, щоб скопіювати ID набору змін",
        usersFilterPlaceholder: "user1,user2,... і натисніть Enter",
        commentsFilterPlaceholder: "words1,words2,... і натисніть Enter",
        revertViaOsmRevert: "відкотити через {name}",
    },
    measurer: {
        measureFromHere: "Виміряти звідси",
        endMeasure: "Завершити вимірювання",
        cleanMeasurements: "Очистити вимірювання",
        clickToSwitchUnits: "Натисніть, щоб переключити одиниці вимірювання",
        orPressEscapeTwice: "Або натисніть Escape двічі",
        hotkeysTitle: "Alt + click: почати нову лінію\nEsc: зупинити вимірювання\n{modifier} + Z: видалити останню точку",
    },
    panoramax: {
        blurFaces: "Розмити обличчя",
        loginRequiredAlert: "Будь ласка, увійдіть у Panoramax",
        uploadPhoto: "Завантажити фото в Panoramax",
        selectFileAlert: "Виберіть файл",
        uploadError: "Помилка: {message}",
        exifInfo: "Інформація з EXIF:\nDateTime: {dateTime}\nLat: {lat}\nLon: {lon}",
    },
    idEditor: {
        findOffsets: "Знайти зміщення",
        focusIframeAlert: "Будь ласка, переключіть фокус на iframe iD.\nПросто клікніть будь-де в редакторі.",
    },
    changesetPage: {
        selectObjects: "Вибрати об'єкти",
        revertViaOsmRevert: "Відкотити через osm-revert",
        openInEditor: "Відкрити в {editor}",
        openInLevel0: "Відкрити в Level0",
        openInLevel0WithWaysGeometry: "Відкрити в Level0 з геометрією ліній",
        openChangesetInOsmcha: "Відкрити набір змін в OSMCha (або натисніть O)\n(Shift + O, щоб відкрити Achavi)",
        partialRevertOrEdit: "для часткового відкоту або редагування в JOSM/Level0",
        hotkeyR: "Гаряча клавіша: R",
        hotkeyJ: "Гаряча клавіша: J",
        hotkeyAltJ: "Гаряча клавіша: Alt + J",
        hotkeyShiftAltJ: "Гаряча клавіша: Shift + Alt + J",
    },
    nodeMover: {
        moveNodeToHere: "Перемістити точку сюди",
    },
    scriptMenu: {
        settings: "Налаштування",
        checkScriptUpdates: "Перевірити оновлення скрипту",
        checkDevScriptUpdates: "Перевірити оновлення dev-версії скрипту",
        listOfHotkeys: "Список гарячих клавіш",
    },
    actions: {
        remove: "видалити",
        level0WorksOnlyOnOsmOrg: "level0 працює лише з osm.org",
    },
    copying: {
        clickForCopy: "Натисніть, щоб скопіювати",
        failedToCopy: "не вдалося скопіювати:\n{text}",
        changeCoordinatesFormat: "Змінити формат координат\nПоточний: {format}",
        clickToCopyValue: "Натисніть, щоб скопіювати {value}",
    },
    betterOsmOrg: {
        addedByBetterOsmOrg: "додано better-osm-org",
        experimentalFeature: "експериментальна функція better-osm-org",
    },
    osmcha: {
        openProfileInOsmcha: "Відкрити профіль в OSMCha",
    },
    selection: {
        shiftClickSelectRange: "Shift + click для вибору діапазону порожніх чекбоксів",
        clickForInvert: "Натисніть, щоб інвертувати",
    },
    deletedUsers: {
        tryFindDeletedUser: "Спробувати знайти видаленого користувача",
    },
    findUserInDiff: {
        clickForCopyUserId: "Натисніть, щоб скопіювати ID користувача",
        notFoundAlert: "Користувача не знайдено",
        userIdLabel: "ID: ",
    },
    changesetPageFixes: {
        osmchaReviewTag: "Тег перевірки OSMCha. Клік правою кнопкою для зміни\n",
        totalUserChangesets: "скільки всього наборів змін має користувач",
        showHiddenTags: "Показати приховані теги",
        clickForViewMore: "Натисніть, щоб показати більше",
        mapperRequestedReview: "Мапер запросив перевірку набору змін\n\nНатисніть, щоб відфільтрувати набори змін із review_requested=yes",
        josmNotRunningAlert: "JOSM не запущено",
        customOsmApiInJosmAlert: "У JOSM використовується кастомний OSM API сервер.\n\nЗмініть налаштування JOSM або відкрийте інший сайт",
        otherOsmInstanceInJosmAlert:
            "Ви використовуєте інший екземпляр OSM, але JOSM використовує сервер за замовчуванням.\n\nЗмініть налаштування JOSM або відкрийте інший сайт",
        osmchaReviewLike: "Позитивна оцінка OSMCha\n\nКлік правою кнопкою для додавання тегів перевірки",
        osmchaReviewDislike: "Негативна оцінка OSMCha\n\nКлік правою кнопкою для додавання тегів перевірки",
        osmchaLoginAlert: "Будь ласка, увійдіть в OSMCha",
        osmchaChangesetNotFoundTitle:
            "Набір змін не знайдено в базі OSMCha.\nМожливо, OSMCha ще не встиг обробити цей набір змін або він надто старий.",
        osmchaChangesetNotFoundAlert: "Набір змін не знайдено в базі OSMCha",
        selectRangeHotkeys:
            "Натисніть із Shift для вибору діапазону\nНатисніть R для відкоту через {name}\nНатисніть J для відкриття об'єктів у JOSM\nНатисніть Alt + J для відкриття об'єктів у Level0",
        reverterTitle: "Відкрити {name}\nShift + click для відкоту через JOSM\nНатисніть R для часткового відкоту",
        josmDoesNotSupportPartialRevert: "JOSM не підтримує частковий відкoт",
        osmRevertOnlyMainServers: "{name} працює лише для www.OpenStreetMap.org і www.OpenHistoricalMap.org",
        osmRevertOnlyMainServersWithJosm:
            "{name} працює лише для www.OpenStreetMap.org і www.OpenHistoricalMap.org\n\n" +
            "Але ви можете встановити reverter plugin у JOSM і використовувати shift+click для інших OSM серверів.\n\n" +
            "⚠️ Змініть OSM сервер у налаштуваннях JOSM!",
        commentTemplateTitle: 'Текст "{text}" буде додано в коментар.\nВи можете змінити цей текст у налаштуваннях скрипта',
    },
    hashtags: {
        searchInOsmcha: "Шукати цей хештег в OSMCha",
        filterNotesByHashtag:
            "Натисніть, щоб відфільтрувати нотатки за цим хештегом.\nНатисніть із Ctrl або Shift, щоб шукати цей хештег в osm-note-viewer",
        clickToCopyName: "Натисніть, щоб скопіювати назву",
        clickToCopyType: "Натисніть, щоб скопіювати тип",
    },
    userBadges: {
        userModerator: "Цей користувач є модератором",
        userImporter: "Цей користувач є імпортером",
        userBanned: "Користувача було заблоковано",
        userNewbie: "На момент створення набору змін або нотатки користувач редагував OpenStreetMap менш ніж місяць",
        followingUser: "Ви стежите за цим користувачем",
        corporateMapper: "{names} корпоративний мапер\n\nНатисніть, щоб відкрити сторінку вікі\nНатисніть із Alt, щоб відкрити джерело даних",
    },
    notes: {
        openMapStateSnapshot: "Відкрити стан мапи на момент знімка",
        openMapStateNoteCreation: "Відкрити стан мапи на момент створення нотатки",
        commaSeparatedSubstrings: "Підрядки, розділені комами\nФільтр також працює за коментарями",
        commaSeparatedUsernames: 'Імена користувачів, розділені комами.\n\nВикористовуйте "anon" для анонімних нотаток',
        wordInNotesPlaceholder: "слово в нотатках",
        usernamePlaceholder: "ім'я користувача",
        wordInCommentsPlaceholder: "слово в коментарях",
        downloadVisibleNotesAsKmlTitle: "Завантажити видимі нотатки як .kml файл",
        addNewObjectOnMap: "Додати новий об'єкт на мапу\nВставте теги в textarea\nkey=value\nkey2=value2\n...",
        textareaWithoutTagsAlert: "У textarea немає жодного тега",
        resolveButtonTitle: 'Додати в коментар "{text}" і закрити нотатку.\nВи можете змінити emoji в налаштуваннях userscript',
    },
    spyGlass: {
        activateSpyGlass: "Увімкнути режим імітації SpyGlass (експеримент better-osm-org)",
    },
    gpxFilter: {
        clickToZoomTrack: "натисніть для наближення\nПорада: натискайте 8-9 для переходу між попереднім і наступним положенням мапи",
        showGpxTracks: "Показати GPX-треки в поточному вікні мапи",
        downloadFailed: "помилка завантаження: {error}",
    },
    dragAndDrop: {
        tooManyCsvLines: "Занадто багато рядків у CSV-файлі. Будуть показані лише об'єкти в поточному bbox",
    },
    objectPage: {
        gpsTrackerPosition: "Синій — позиція з GPS-трекера\nПомаранчевий — оцінена реальна позиція",
        roofOrientationAcrossAlong: 'roof:orientation має бути або "across", або "along"',
        conditionalMustContainAt: "значення тега :conditional має містити @",
        emptyPartAfterAt: "порожня частина після @",
        emptyPartBeforeAt: "порожня частина перед @",
        emptyPartBetweenAt: "порожня частина між @",
        observationNotFound: "Спостереження не знайдено в API iNaturalist",
        noErrorsOpeningHours: "opening_hours.js не знайшов помилок 👍",
        phoneMustStartPlus: "номер телефону має починатися з +",
        trackingParamInUrl: 'В URL є трекінговий параметр "{param}="',
        urlShouldStartWithHttp: "URL має починатися з https:// або http://",
        easterEgg: "пасхалка better-osm-org",
        needRoofOrientation: "схоже, це потрібно змінити на roof:orientation",
        clickShowEmbedded3d:
            "Натисніть, щоб показати вбудований 3D-переглядач.\nУ налаштуваннях скрипта можна задати відкриття на сторінці OSM за замовчуванням",
        setAsDefaultForClick: "Установити за замовчуванням для кліку",
        defaultViewer: "Це переглядач за замовчуванням",
        keyTooShort: "Ключ надто короткий",
        suspiciousCapitalKey: "Підозрілий ключ, що починається з великої літери",
        openObjectHistory: "Натисніть, щоб відкрити сторінку історії об'єкта\nАбо натисніть клавішу H",
        photoBy: "Фото: {author}",
        observedBy: "Спостерігав: {user}",
        invalidPhone: "{value} некоректний номер телефону за версією libphonenumber.js",
        invalidEmail: "{value} некоректний email",
        typeOnlyForRelations: "type=* використовується лише для відношень",
        suspiciousRelationType: "type={value} використовується менше 50 разів. Найімовірніше, це помилка.",
        openWebsite: "Відкрити {host}",
        editWithWebsite: "Редагувати через {host}",
    },
    taginfo: {
        searchWithOverpass: "шукати через Overpass",
    },
    wiki: {
        needMoreVotes: "потрібно ще {count} голосів до 75 %",
        interimResults: "Проміжні результати, розраховані better-osm-org:",
    },
    relationVersionPage: {
        loadViaOverpass: "Завантажити версію відношення через Overpass API",
    },
}

/**
 * @param {Translations} translations
 * @return {Object.<string, Translation>}
 */
function flatTranslations(translations) {
    return Object.fromEntries(
        Object.entries(translations).flatMap(([k1, value]) => {
            return Object.entries(value).map(([k2, v]) => [`${k1}.${k2}`, v])
        }),
    )
}

const flattenTranslations = flatTranslations(_translations[currentLocale])
const flattenTranslationsEn = currentLocale === "en" ? flattenTranslations : flatTranslations(_translations["en"])

if (Object.keys(flattenTranslations).length !== Object.keys(flattenTranslationsEn).length) {
    console.error(`⚠️missing translations in ${currentLocale} locale`)
}

/**
 * @param {Translation} translation
 * @param {Object|null} params
 * @return {string}
 */
function formatTranslation(translation, params = null) {
    if (typeof translation === "string") {
        if (params === null) {
            return translation
        }
        return translation.replaceAll(/\{(\w+)}/g, (match, key) => String(params[key] ?? match))
    }
    if (typeof translation === "function") {
        return translation(params)
    }
    console.error(`Invalid translation value: ${translation}`)
    return translation
}

/**
 * @param {string} key
 * @param {Object|null} params
 * @return {string}
 */
function t(key, params = null) {
    return formatTranslation(flattenTranslations[key] ?? flattenTranslationsEn[key] ?? key, params)
}

//</editor-fold>

//<editor-fold desc="init" defaultstate="collapsed">
/*global osmAuth*/
/*global GM*/
/*global GM_info*/
/*global GM_config*/
/*global GM_addElement*/
/*global GM_getResourceURL*/
/*global GM_getResourceText*/
/*global GM_registerMenuCommand*/
/*global unsafeWindow*/
/*global exportFunction*/
/*global cloneInto*/
/*global EXIF*/
/*global osmtogeojson*/
/*global opening_hours*/
/*global libphonenumber*/
/*global runSnowAnimation*/
/*global unzipit*/
/*global bz2*/
function tryAddWarnAboutScriptIntoOsmOrgRepo() {
    if (document.querySelector(".better-osm-org-warn")) {
        return
    }
    function xpath(selector) {
        return document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
    }
    const result = xpath("//h1[normalize-space(text())='Create new issue']") ?? xpath("//h2[normalize-space(text())='Create new issue']")
    if (result) {
        const warn = document.createElement("div")
        warn.textContent = "⚠️⚠️⚠️️ "
        warn.classList.add("better-osm-org-warn")
        const subWarn = document.createElement("span")
        subWarn.textContent = t("init.disableBetterOsmOrg")
        subWarn.style.color = "red"
        warn.appendChild(subWarn)
        warn.appendChild(document.createTextNode(t("init.reportingFeaturesWarning")))
        result.before(warn)
        result.before(document.createElement("br"))
    }
}

if ((location.origin + location.pathname).startsWith("https://github.com/openstreetmap/openstreetmap-website/issues/new")) {
    setInterval(tryAddWarnAboutScriptIntoOsmOrgRepo, 3000)
    setTimeout(tryAddWarnAboutScriptIntoOsmOrgRepo, 100)
    throw "skip better-osm-org run on GitHub"
}

function tryAddScriptInfo() {
    if (document.querySelector(".better-osm-org-rich-textarea")) {
        return
    }
    const textarea = document.querySelector('[class*="CreateIssueForm"] textarea')
    if (!textarea) return
    const cleanedPart = textarea.value
        .replaceAll(/^Script version:.*$/gm, "")
        .replaceAll(/^Script manager:.*$/gm, "")
        .replaceAll(/^User Agent:.*$/gm, "")
        .trim()
    if (cleanedPart !== "") {
        return
    }
    // prettier-ignore
    textarea.value = cleanedPart + `\n\n\nScript version: \`${GM_info.script.version}\`
Script manager: \`${GM_info.scriptHandler} ${GM_info.version}\`
User Agent: \`${navigator.userAgent}\``
    textarea.classList.add("better-osm-org-rich-textarea")
    textarea.dispatchEvent(new Event("input", { bubbles: true }))
}

if ((location.origin + location.pathname).startsWith("https://github.com/deevroman/better-osm-org/issues/new")) {
    const timerId = setInterval(tryAddScriptInfo, 3000)
    setTimeout(() => clearInterval(timerId), 30000)
    setTimeout(tryAddScriptInfo, 1000)
    throw "skip better-osm-org run on GitHub"
}

if (location.search.includes("&kek")) {
    throw "better-osm-org disable via url param"
}

if (["Userscripts", "Greasemonkey", "Firemonkey", "OrangeMonkey"].includes(GM_info.scriptHandler)) {
    console.error("YOU ARE USING AN UNSUPPORTED SCRIPT MANAGER")
}

if (GM_info.scriptHandler === "Greasemonkey") {
    // prettier-ignore
    alert(t("init.greasemonkeyUnsupportedAlert"))
    throw ""
}

const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
const isFirefox = navigator.userAgent.includes("Firefox")
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent)
const isMac = navigator.platform?.toUpperCase()?.indexOf("MAC") >= 0
const CtrlKeyName = isMac ? "Cmd" : "Ctrl"

if (isSafari) {
    console.error("YOU ARE USING AN UNSUPPORTED BROWSER")
}

function initGmApiPolyfills() {
    if (typeof GM_getResourceURL === "undefined") {
        const resources = {}
        setTimeout(async () => {
            const REPO_PREFIX = "https://github.com/deevroman/better-osm-org/raw/master/misc/assets/"
            const resourcesName = {
                OAUTH_HTML: REPO_PREFIX + "finish-oauth.html",
                DARK_THEME_FOR_ID_CSS:
                    "https://gist.githubusercontent.com/deevroman/55f35da68ab1efb57b7ba4636bdf013d/raw/7b94e3b7db91d023f1570ae415acd7ac989fffe0/dark.css",
            }
            for (let resource in resourcesName) {
                GM.xmlHttpRequest({
                    method: "GET",
                    url: resourcesName[resource],
                    responseType: "blob",
                    onload: res => {
                        const a = new FileReader()
                        a.onload = function (e) {
                            resources[resource] = e.target.result
                        }
                        a.readAsDataURL(res.response)
                    },
                })
            }
        })
        window.GM_getResourceURL = name => {
            console.log(resources)
            return resources[name]
        }
    }

    if (typeof GM_addElement === "undefined") {
        window.GM_addElement = function () {
            let parent, type, attrs
            if (arguments.length === 3) {
                ;[parent, type, attrs] = arguments
            } else if (arguments.length === 2) {
                ;[type, attrs] = arguments
            } else {
                return
            }
            const elem = document.createElement(type)
            Object.entries(attrs).forEach(([key, value]) => {
                elem.setAttribute(key, value)
            })
            if (parent) {
                parent.appendChild(elem)
            }
            return elem
        }
    }

    if (typeof exportFunction === "undefined") {
        window.exportFunction = function (fn) {
            return fn
        }
    }

    if (typeof cloneInto === "undefined") {
        window.cloneInto = function (obj) {
            return obj
        }
    }
}

if (
    GM_info.scriptHandler === "Userscripts" ||
    GM_info.scriptHandler === "Greasemonkey" ||
    GM_info.scriptHandler === "OrangeMonkey" ||
    GM_info.scriptHandler === "ScriptCat"
) {
    initGmApiPolyfills()
}

const isRTLLayout = document.querySelector("html").dir === "rtl"
const arrowSymbolForChanges = !isRTLLayout ? " → " : " ← "

const SCRIPT_UPDATE_URL = "https://raw.githubusercontent.com/deevroman/better-osm-org/master/better-osm-org.user.js"
const DEV_SCRIPT_UPDATE_URL = "https://raw.githubusercontent.com/deevroman/better-osm-org/dev/better-osm-org.user.js"

const prod_server = {
    apiBase: "https://www.openstreetmap.org/api/0.6/",
    apiUrl: "https://www.openstreetmap.org/api/0.6",
    url: "https://www.openstreetmap.org",
    origin: "https://www.openstreetmap.org",
}

const ohm_prod_server = {
    apiBase: "https://www.openhistoricalmap.org/api/0.6/",
    apiUrl: "https://www.openhistoricalmap.org/api/0.6",
    url: "https://www.openhistoricalmap.org",
    origin: "https://www.openhistoricalmap.org",
}

const ogf_prod_server = {
    apiBase: "https://opengeofiction.net/api/0.6/",
    apiUrl: "https://opengeofiction.net/api/0.6",
    url: "https://opengeofiction.net",
    origin: "https://opengeofiction.net",
}

const dev_server = {
    apiBase: "https://master.apis.dev.openstreetmap.org/api/0.6/",
    apiUrl: "https://master.apis.dev.openstreetmap.org/api/0.6",
    url: "https://master.apis.dev.openstreetmap.org",
    origin: "https://master.apis.dev.openstreetmap.org",
}

const local_server = {
    apiBase: "http://localhost:3000/api/0.6/",
    apiUrl: "http://localhost:3000/api/0.6",
    url: "http://localhost:3000",
    origin: "http://localhost:3000",
}

const osm_server = (() => {
    if (location.origin === prod_server.origin) return prod_server
    else if (location.origin === dev_server.origin) return dev_server
    else if (location.origin === ohm_prod_server.origin) return ohm_prod_server
    else if (location.origin === ogf_prod_server.origin) return ogf_prod_server
    else if (location.origin === local_server.origin) return local_server
    else return null
})()

function isOHMServer() {
    return location.origin === ohm_prod_server.origin
}

function isOGFServer() {
    return location.origin === ogf_prod_server.origin
}

function isOsmServer() {
    return !!osm_server
}

const storagePrefix = isOHMServer() ? "ohm-" : location.origin === dev_server.origin ? "dev-" : isOGFServer() ? "ogf-" : ""

const accountForceLightTheme = document.querySelector("html")?.getAttribute("data-bs-theme") === "light" || isOGFServer()
const accountForceDarkTheme = document.querySelector("html")?.getAttribute("data-bs-theme") === "dark"
const mediaQueryForWebsiteTheme = `${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""}`

function isDarkMode() {
    return (
        (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches && !accountForceLightTheme) || accountForceDarkTheme
    )
}

const planetOrigin = "https://planet.maps.mail.ru"

const MAIN_OVERPASS_INSTANCE = {
    name: "overpass-api.de",
    apiUrl: "https://overpass-api.de/api",
    url: "https://overpass-turbo.eu/",
    referer: "https://overpass-turbo.eu/",
    origin: "https://overpass-turbo.eu",
}

const MAILRU_OVERPASS_INSTANCE = {
    name: "maps.mail.ru/osm/tools/overpass",
    apiUrl: "https://maps.mail.ru/osm/tools/overpass/api",
    url: "https://maps.mail.ru/osm/tools/overpass/",
}

const PRIVATECOFFEE_OVERPASS_INSTANCE = {
    name: "overpass.private.coffee",
    apiUrl: "https://overpass.private.coffee/api",
    url: "https://turbo.overpass.private.coffee/",
}

const OHM_OVERPASS_INSTANCE = {
    name: "overpass-api.openhistoricalmap.org",
    apiUrl: "https://overpass-api.openhistoricalmap.org/api/",
    url: "https://overpass-turbo.openhistoricalmap.org/",
}

const OGF_OVERPASS_INSTANCE = {
    name: "overpass.opengeofiction.net",
    apiUrl: "https://overpass.opengeofiction.net/api/",
    url: "https://overpass.opengeofiction.net/",
}

let overpass_server = MAIN_OVERPASS_INSTANCE

const MAIN_OSMCHA = "https://osmcha.org"
const OHM_OSMCHA = "https://osmcha.openhistoricalmap.org"

const osmcha_server_origin = isOHMServer() ? OHM_OSMCHA : MAIN_OSMCHA

const MAIN_OSM_REVERT = "https://revert.monicz.dev"
const OHM_OSM_REVERT = "https://ohm-revert.monicz.dev"

const MAIN_OSM_REVERT_NAME = "osm-revert"
const OHM_OSM_REVERT_NAME = "ohm-revert"

const osm_revert_origin = isOHMServer() ? OHM_OSM_REVERT : MAIN_OSM_REVERT
const osm_revert_name = isOHMServer() ? OHM_OSM_REVERT_NAME : MAIN_OSM_REVERT_NAME

const MAIN_LEVEL0_INSTANCE = "https://level0.osmz.ru"
const REBORN_LEVEL0_INSTANCE = "https://deevroman.github.io/level0-reborn"

let level0Instance = REBORN_LEVEL0_INSTANCE

const MAIN_PANORAMAX_DISCOVERY_SERVER = "https://api.panoramax.xyz"
const panoramaxDiscoveryServer = MAIN_PANORAMAX_DISCOVERY_SERVER

/**
 * @typedef {{
 *     [type]: "node"|"way"|"relation",
 *     [id]: number|string,
 *     [x]: string,
 *     [y]: string,
 *     [z]: string,
 *     [waymarkedtrails_type]: "hiking"|"cycling"|"mtb"|"riding"|"skating"|"slopes",
 * }} externalURLParams
 */

/**
 * @typedef {{
 *     name: string,
 *     url: string,
 *     makeURL: (externalURLParams) => string,
 * }} externalURL
 */

/** @type {externalURL[]} */
const instancesOf3DViewers = [
    {
        name: "OSM Building Viewer",
        url: "https://deevroman.github.io/OSMBuilding/",
        makeURL: function ({ type: type, id: id }) {
            if (osm_server === prod_server) {
                return `${this.url}?id=${id}&type=${type}`
            } else {
                return `${this.url}?id=${id}&type=${type}&osmApiUrl=${osm_server.apiUrl}`
            }
        },
    },
    {
        name: "F4Map",
        url: "https://demo.f4map.com/",
        makeURL: function ({ x: x, y: y, z: z }) {
            return `${this.url}#lat=${x}&lon=${y}&zoom=${z}`
        },
    },
    {
        name: "streets.gl",
        url: "https://streets.gl/",
        makeURL: function ({ x: x, y: y }) {
            return `${this.url}#${x},${y},45.00,0.00,2000.00`
        },
    },
    {
        name: "OSM go",
        url: "https://www.osmgo.org/go.html",
        makeURL: function ({ x: x, y: y }) {
            return `${this.url}?lat=${x}&lon=${y}&view=-50&ele=150`
        },
    },
    {
        name: "OSM2World Object Viewer",
        url: "https://osm2world.org/object/",
        makeURL: function ({ type: type, id: id }) {
            return `${this.url}?id=${type[0]}${id}`
        },
    },
    {
        name: "osmbuildings.org",
        url: "https://osmbuildings.org/",
        makeURL: function ({ x: x, y: y, z: z }) {
            return `${this.url}?lat=${x}&lon=${y}&zoom=${z}`
        },
    },
    {
        name: "labs.mapbox.com",
        url: "https://labs.mapbox.com/standard-style?lightPreset=day#",
        makeURL: function ({ x: x, y: y, z: z }) {
            // z-1 looks better
            return `${this.url}${z - 1}/${x}/${y}/0/50`
        },
    },
    {
        name: "OSM Building Inspector",
        url: "https://www.osmgo.org/bevy.html",
        makeURL: function ({ type: type, id: id }) {
            return `${this.url}?${type}=${id}` // todo relation don't work?
        },
    },
    {
        name: "ArcGIS 3D Buildings & Trees",
        url: "https://arcgis.com/home/webscene/viewer.html",
        makeURL: function ({ x: x, y: y }) {
            return `${this.url}?webscene=037cceb0e24440179dbd00846d2a8c4f&viewpoint=cam:${y},${parseFloat(x) - 0.0015},150;0,50` // todo relation don't work?
        },
    },
    // {
    //     name: "OSM Building Viewer (fork)",
    //     url: "https://deevroman.github.io/OSMBuilding/",
    //     makeURL: function ({type: type, id: id}) {
    //         return `${this.url}/?id=${id}&type=${type}`
    //     }
    // }
]

/** @type {externalURL} */
const waymarkedtrailsLink = {
    name: "Waymarked Trails",
    url: "https://{type}.waymarkedtrails.org/",
    makeURL: function ({ x, y, z, id, waymarkedtrails_type }) {
        return `${this.url.replace("{type}", waymarkedtrails_type)}#route?id=${id}&map=${z}/${x}/${y}`
    },
}

/** @type {externalURL} */
const publicTransportNetworkAnalysisLink = {
    name: "Public Transport Network Analysis",
    url: "https://ptna.openstreetmap.de/relation.php",
    makeURL: function ({ id }) {
        return `${this.url}?id=${id}`
    },
}

/** @type {externalURL} */
const mapkiLink = {
    name: "Mapki history viewer",
    url: "https://osm.mapki.com/history",
    makeURL: function ({ type, id }) {
        return `${this.url}/${type}/${id}`
    },
}

/** @type {externalURL} */
const pewuLink = {
    name: "Pewu history viewer",
    url: "https://pewu.github.io/osm-history/#",
    makeURL: function ({ type, id }) {
        return `${this.url}/${type}/${id}`
    },
}

/** @type {externalURL} */
const osmlabDeepHistoryLink = {
    name: "Osmlab deep history",
    url: "https://osmlab.github.io/osm-deep-history/#",
    makeURL: function ({ type, id }) {
        return `${this.url}/${type}/${id}`
    },
}

/** @type {externalURL} */
const relationAnalizerLink = {
    name: "OSM Relation Analyzer",
    url: "https://ra.osmsurround.org/analyzeRelation",
    makeURL: function ({ id }) {
        return `${this.url}?relationId=${id}`
    },
}

/** @type {externalURL[]} */
const instancesOfRelationViewers = [mapkiLink, pewuLink, relationAnalizerLink, osmlabDeepHistoryLink]

/** @type {externalURL} */
const relatifyLink = {
    name: "OSM Relatify",
    url: "https://relatify.monicz.dev/",
    makeURL: function ({ id }) {
        return `${this.url}?load=1&relation=${id}`
    },
}

/** @type {unsafeWindow & windowOSM} **/
const boWindowObject = typeof window.wrappedJSObject !== "undefined" ? window.wrappedJSObject : unsafeWindow
const boGlobalThis = typeof boWindowObject.globalThis !== "undefined" ? boWindowObject.globalThis : boWindowObject

/** @type {null|(function(): null|import('leaflet').Map)}*/
let getMap = null

/**
 * @typedef {{
 *  OSM: {
 *   router: { stateChange: (args: Object.<string, any>) => Object.<string, any> },
 *   i18n: {t: Function},
 *   formatHash: (hash: string) => Object.<string, any>,
 *   parseHash: (args: Object.<string, any>) => string,
 *  },
 *  L: {
 *      polyline: import('leaflet').polyline,
 *      marker: import('leaflet').marker,
 *      circleMarker: import('leaflet').circleMarker,
 *      latLng: import('leaflet').latLng,
 *      latLngBounds: import('leaflet').latLngBounds,
 *      rectangle: import('leaflet').rectangle,
 *      geoJSON: import('leaflet').geoJSON,
 *      tooltip: import('leaflet').tooltip,
 *      point: import('leaflet').point,
 *      DomEvent: import('leaflet').DomEvent,
 *      imageOverlay: import('leaflet').imageOverlay,
 *  },
 *  mapIntercepted: boolean,
 *  scriptInstance: string|undefined,
 *  map: import('leaflet').Map,
 * }} windowOSM
 */

/** @type {null|(function(): null|(boWindowObject & windowOSM)|(window & windowOSM))}*/
const getWindow = isSafari && GM_info.scriptHandler === "Userscripts" ? () => window : () => boWindowObject

/**
 * @template T
 * @param {T} obj
 * @returns {T}
 */
function intoPage(obj) {
    return cloneInto(obj, getWindow())
}

/**
 * @template T
 * @param {T} obj
 * @returns {T}
 */
function intoPageWithFun(obj) {
    return cloneInto(obj, getWindow(), { cloneFunctions: true })
}

const dataUser = document.querySelector("head")?.getAttribute("data-user")
// TrickyFoxy on osm.org, OHM, OGF
let _isDebug =
    dataUser === "11528195" || dataUser === "15560" || dataUser === "26980" || osm_server === local_server || osm_server === dev_server

function isDebug() {
    return _isDebug
}

function debug_alert() {
    if (!isDebug()) return
    alert(JSON.stringify(arguments))
    // eslint-disable-next-line
    debugger
}

function printScriptDebugInfo() {
    console.log(`Script version: ${GM_info.script.version}`)
    console.log(`Script manager: ${GM_info.scriptHandler}`)
    console.debug(
        "Settings:",
        Object.entries(GM_config.fields).map(i => {
            if (typeof i[1].value === "boolean" || typeof i[1].value === "number") {
                return [i[0], i[1].value]
            } else {
                return [i[0], `length: ${i[1].value.length}`]
            }
        }),
    )
}

setTimeout(printScriptDebugInfo, 2000)

function injectMapHooks() {
    console.log("injectMapHooks called")
    function mapHook() {
        console.log("start map intercepting")
        if (boWindowObject.L && boWindowObject.L.Map) {
            boWindowObject.L.Map.addInitHook(
                exportFunction(function () {
                    if (this._container?.id === "map") {
                        boGlobalThis.map = this
                        boGlobalThis.mapIntercepted = true
                        console.log("%cMap intercepted", "background: #000; color: #0f0")
                        if (!boGlobalThis.scriptInstance) {
                            boGlobalThis.scriptInstance = GM_info.scriptHandler
                        } else if (boGlobalThis.scriptInstance !== GM_info.scriptHandler) {
                            const err = `Two copies of the script were running simultaneously via ${boGlobalThis.scriptInstance} and ${GM_info.scriptHandler}. Turn off one of them`
                            console.error(err)
                            alert(err)
                        }
                    }
                }, boWindowObject),
            )
            if (!boWindowObject.L.OSM.MaplibreGL) {
                console.warn("L.OSM.MaplibreGL not found")
            }
            boWindowObject.L.OSM.MaplibreGL?.addInitHook(
                exportFunction(function () {
                    // if (this._container?.id === "map") {
                    if (!boGlobalThis.mapGL) {
                        boGlobalThis.mapGL = intoPage([])
                    }
                    boGlobalThis.mapGL.push(this)
                    boGlobalThis.mapGLIntercepted = true
                    console.log("%cMapGL intercepted", "background: #000; color: #0f0")
                    // }
                }, boWindowObject),
            )
        } else {
            console.error("the script could not access the L.Map object. Some of the functions will not work")
            console.log(GM_info)
            console.log(window?.wrappedJSObject)
            console.log(unsafeWindow)
        }
    }

    if (isSafari && GM_info.scriptHandler === "Userscripts") {
        getMap = () => null
    } else {
        boWindowObject.mapHook = exportFunction(mapHook, boWindowObject)
        boWindowObject.mapHook()
        if (boWindowObject.map instanceof HTMLElement) {
            console.error("Please, reload page, if something doesn't work")
            // todo
            // getMap = () => null
            // } else {
            //     getMap = () => boWindowObject.map
        }

        getMap = () => boWindowObject.map
    }
}

function runOnDOMContentLoaded(callback) {
    if (document.readyState === "loading") {
        console.log("Waiting DOMContentLoaded...")
        document.addEventListener("DOMContentLoaded", callback)
    } else {
        console.log("DOMContentLoaded has already happened. Run immediately")
        callback()
    }
}

function preventWhiteFlashesInIdEditor() {
    if (isDarkMode()) {
        if (location.pathname === "/edit") {
            injectCSSIntoOSMPage(
                `@media ${mediaQueryForWebsiteTheme} {
                #id-embed {
                    background: #212529 !important;
                }
            }`,
            )
        } else {
            injectCSSIntoOSMPage(
                `@media ${mediaQueryForWebsiteTheme} {
                html {
                    background: #212529 !important;
                }

                body {
                    background: #212529 !important;
                }

                #id-embed {
                    background: #212529 !important;
                }

                #id-container {
                    background: #212529 !important;
                }
            }`,
            )
        }
    }
}

if (isOsmServer() && location.pathname !== "/id" && !document.querySelector("#id-embed")) {
    runOnDOMContentLoaded(injectMapHooks)
} else if (isOsmServer() && ["/edit", "/id"].includes(location.pathname)) {
    preventWhiteFlashesInIdEditor()
    GM_registerMenuCommand("JOSM!", function () {
        const iframe = GM_addElement("iframe", {
            src: "https://deevroman.github.io/web-josm",
            width: "100%",
            height: "100%",
            id: "josmembed",
        })
        document.querySelector("#id-embed").replaceWith(iframe)
    })
}

//</editor-fold>

//<editor-fold desc="init-config" defaultstate="collapsed">

function makeRow(label, text, without_delete = false, placeholder = "comment that will be added when clicked") {
    const tr = document.createElement("tr")
    const th = document.createElement("th")
    const td = document.createElement("td")
    const td2 = document.createElement("td")

    th.setAttribute("contenteditable", "true")
    td.setAttribute("contenteditable", "true")

    th.textContent = label
    td.textContent = text
    td.style.paddingLeft = "4px"
    td.style.paddingRight = "4px"
    td.style.wordWrap = "anywhere"
    td.setAttribute("placeholder", placeholder)

    td2.textContent = "×"
    td2.title = t("actions.remove")
    td2.style.width = "21px"
    td2.style.cursor = "pointer"
    td2.style.textAlign = "center"
    td2.onclick = () => {
        if ((label === "" && text === "") || confirm(`Remove "${label}"?`)) {
            tr.remove()
        }
    }

    th.style.width = "30px"
    th.appendChild(document.createElement("br"))

    tr.appendChild(th)
    tr.appendChild(td)
    if (!without_delete) {
        tr.appendChild(td2)
    }
    return tr
}

const copyAnimationStyles = `
    .copied {
      background-color: rgba(9,238,9,0.6);
      transition:all 0.3s;
    }
    .was-copied {
      background-color: initial;
      transition:all 0.3s;
    }
    @media ${mediaQueryForWebsiteTheme} {
        .copied {
          background-color: rgba(0,255,101,0.6);
          transition: all 0.3s;
        }
        .was-copied {
          background-color: initial;
          transition: all 0.3s;
        }
    }
`
const configOptions = {
    id: "Config",
    title: " ",
    fields: {
        DarkModeForMap: {
            label: t("config.darkModeForMap"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        ColorblindFriendlyPalette: {
            label: t("config.colorblindFriendlyPalette"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        BetterTagsPaste: {
            section: [t("config.sectionID")],
            label: t("config.betterTagsPaste"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        DarkModeForID: {
            label: t("config.darkModeForID"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        CompactChangesetsHistory: {
            section: [t("config.sectionViewingEdits")],
            label: t("config.compactChangesetsHistory"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        VersionsDiff: {
            label: t("config.versionsDiff"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ShowPreviousTagValue: {
            label: t("config.showPreviousTagValue"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        FullVersionsDiff: {
            label: t("config.fullVersionsDiff"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ChangesetQuickLook: {
            label: t("config.changesetQuickLook"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ShowChangesetGeometry: {
            label: t("config.showChangesetGeometry"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        MassChangesetsActions: {
            label: t("config.massChangesetsActions"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ImagesAndLinksInTags: {
            label: t("config.imagesAndLinksInTags"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        HideNoteHighlight: {
            section: [t("config.sectionWorkingWithNotes")],
            label: t("config.hideNoteHighlight"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ResolveNotesButton: {
            label: t("config.resolveNotesButton"),
            type: "menu",
            default: '[{"label": "👌", "text": ""}]',
        },
        RevertButton: {
            section: [t("config.sectionNewActions")],
            label: t("config.revertButton"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        Deletor: {
            label: t("config.deletor"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        OneClickDeletor: {
            label: t("config.oneClickDeletor"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        ChangesetsTemplates: {
            label: t("config.changesetsTemplates"),
            type: "menu",
            default: '[{"label": "👋", "text": ""}]',
        },
        HDYCInProfile: {
            section: [t("config.sectionOther")],
            label: t("config.hdycInProfile"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        BetterProfileStat: {
            label: t("config.betterProfileStat"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        NavigationViaHotkeys: {
            label: t("config.navigationViaHotkeys"), // add help button with list
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        NewEditorsLinks: {
            label: t("config.newEditorsLinks"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ResetSearchFormFocus: {
            label: t("config.resetSearchFormFocus"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        SatelliteLayers: {
            label: t("config.satelliteLayers"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        Swipes: {
            label: t("config.swipes"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        ResizableSidebar: {
            label: t("config.resizableSidebar"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        ClickableAvatar: {
            label: t("config.clickableAvatar"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        OverzoomForDataLayer: {
            label: t("config.overzoomForDataLayer"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        DragAndDropViewers: {
            label: t("config.dragAndDropViewers"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        "3DViewerInNewTab": {
            label: t("config.viewer3DInNewTab"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        BetterTaginfo: {
            label: t("config.betterTaginfo"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        DefaultZoomKeysBehaviour: {
            label: t("config.defaultZoomKeysBehaviour"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        AddLocationFromNominatim: {
            label: t("config.addLocationFromNominatim"),
            type: "checkbox",
            default: "checked",
            labelPos: "right",
        },
        OverpassInstance: {
            label: t("config.overpassInstance"),
            labelPos: "left",
            type: "select",
            options: [MAIN_OVERPASS_INSTANCE.name, MAILRU_OVERPASS_INSTANCE.name, PRIVATECOFFEE_OVERPASS_INSTANCE.name],
        },
        // CustomOverpassInstance: {
        //     label: 'Custom Overpass API</a>',
        //     labelPos: "left",
        //     type: "input",
        // },
        PanoramaxUploader: {
            label: t("config.panoramaxUploader"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        RoutersTimestamps: {
            label: t("config.routersTimestamps"),
            type: "checkbox",
            default: true,
            labelPos: "right",
        },
        ClickableMap: {
            label: t("config.clickableMap"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
        DebugMode: {
            label: t("config.debugMode"),
            type: "checkbox",
            default: false,
            labelPos: "right",
        },
    },
    types: {
        menu: {
            default: "",
            toNode: function () {
                const templates = /** @type {string} */ (this.value || this.settings.default)
                const settingNode = this.create("div", {
                    className: "config_var",
                    id: this.configId + "_" + this.id + "_var",
                })

                this.templates = templates

                settingNode.appendChild(
                    this.create("input", {
                        innerHTML: this.settings.label,
                        id: this.configId + "_" + this.id + "_field_filler",
                        className: "filler",
                        type: "checkbox",
                    }),
                )

                const label = this.create("label", {
                    innerHTML: this.settings.label,
                    id: this.configId + "_" + this.id + "_field_label",
                    for: this.configId + "_field_" + this.id,
                    className: "field_label",
                })
                if (label.querySelector("#last-comments-link")) {
                    const userId = document.querySelector("head")?.getAttribute("data-user")
                    if (userId) {
                        label
                            .querySelector("#last-comments-link")
                            .setAttribute("href", `https://resultmaps.neis-one.org/osm-discussion-comments?uid=${userId}&commented`)
                    } else {
                        label.querySelector("#last-comments-link").remove()
                    }
                }
                settingNode.appendChild(label)

                const table = document.createElement("table")
                table.setAttribute("cellspacing", "0")
                table.setAttribute("cellpadding", "0")
                table.style.width = "100%"
                settingNode.appendChild(table)
                const tbody = document.createElement("tbody")
                table.appendChild(tbody)

                const placeholder = this.settings.placeholder ?? "comment that will be added when clicked"
                JSON.parse(templates).forEach(row => {
                    tbody.appendChild(makeRow(row["label"], row["text"], false, placeholder))
                })

                const tr = document.createElement("tr")
                tr.classList.add("add-tag-row")
                tbody.appendChild(tr)
                const th = document.createElement("th")
                th.textContent = "+"
                th.colSpan = 3
                th.style.textAlign = "center"
                th.style.cursor = "pointer"
                tr.appendChild(th)
                th.onclick = () => {
                    tbody.lastElementChild.before(makeRow("", ""))
                }

                return settingNode
            },
            toValue: function () {
                const templates = []
                if (this.wrapper) {
                    for (let row of Array.from(this.wrapper.getElementsByTagName("tr")).slice(0, -1)) {
                        const forPush = {
                            label: row.querySelector("th").textContent,
                            text: row.querySelector("td").textContent,
                        }
                        if (!(forPush.label.trim() === "" && forPush.text.trim() === "")) {
                            templates.push(forPush)
                        }
                    }
                }
                return JSON.stringify(templates)
            },
            reset: function () {
                if (this.wrapper) {
                    for (let row of Array.from(this.wrapper.getElementsByTagName("tr")).slice(0, -1)) {
                        row.remove()
                    }
                    JSON.parse(/** @type {string} */ (this.settings.default)).forEach(i => {
                        this.wrapper
                            .querySelector(`#${this.configId}_${this.id}_var table`)
                            .lastElementChild.before(makeRow(i["label"], i["text"]))
                    })
                }
            },
        },
    },
    frameStyle: `
            border: 1px solid #000;
            height: min(85%, 760px);
            width: max(25%, 380px);
            z-index: 9999;
            opacity: 0;
            position: absolute;
            margin-left: auto;
            margin-right: auto;
        `,
    css: `
            #Config_saveBtn {
                cursor: pointer;
            }
            #Config_closeBtn {
                cursor: pointer;
            }
            #Config_field_ResolveNotesButton {
                width: 100%;
                max-width: 100%;
            }
            #Config table {
                border-collapse: collapse;
            }
            th, td {
                border: 1px solid black;
                min-height: 21px;
            }
            #Config [placeholder]:empty::before {
                content: attr(placeholder);
                color: #555;
            }

            #Config [placeholder]:empty:focus::before {
                content: "";
            }

            #Config .filler {
                visibility: hidden;
            }
            #version {
                position: absolute;
                left: 12;
                font-size: small;
            }
        @media ${mediaQueryForWebsiteTheme} {
            #Config {
                background: #232528;
                color: white;
            }
            #Config a {
                color: darkgray;
            }
            #Config_field_OverpassInstance {
                filter: invert(0.9);
            }
            #Config_saveBtn {
                filter: invert(0.9);
            }
            #Config_closeBtn {
                filter: invert(0.9);
            }
            #Config_resetLink {
                color: gray !important;
            }
            #Config textarea {
                background: #232528;
                color: white;
                background: rgb(31, 32, 35);
                border: 1px solid rgb(60, 63, 68);
                border-radius: 4px;
                font-size: 13px;
                color: rgb(247, 248, 248);
                appearance: none;
            }
            #Config input:focus-visible {
                outline-style: none;
            }
            th, td {
                border-color: white;
            }
            #version {
                color: gray !important;
            }
        }
        ${copyAnimationStyles}
        `,
    events: {
        init: main,
        save: function () {
            GM_config.close()
        },
        open: function (doc) {
            const versionSection = document.createElement("span")
            versionSection.id = "version"
            versionSection.textContent = t("init.scriptVersion")
            const version = document.createElement("span")
            version.textContent = GM_info.script.version
            version.title = t("copying.clickForCopy")
            version.style.cursor = "pointer"
            version.onclick = e => {
                navigator.clipboard.writeText(GM_info.script.version).then(() => copyAnimation(e, GM_info.script.version))
            }
            versionSection.appendChild(version)
            doc.querySelector(".reset_holder").prepend(versionSection)
        },
    },
}

GM_config.init(configOptions)
//</editor-fold>

//<editor-fold desc="svg" defaultstate="collapsed">

const filterIconSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
    'stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-funnel-icon lucide-funnel">' +
    '<path d="M 10 20 a 1 1 0 0 0 0.553 0.895 l 2 1 A 1 1 0 0 0 14 21 v -8 a 2 3 0 0 1 1 -2 L 21.74 4.67 A 1 1 0 0 0 ' +
    '21 3 H 3 a 1 1 0 0 0 -0.742 1.67 l 6.742 6.33 A 2 3 0 0 1 10 13 z"/>' +
    "</svg>"

const tagSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
    'stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tag-icon lucide-tag">' +
    '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 ' +
    '0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/>' +
    '<circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>' +
    "</svg>"

const osmchaLikeSvg =
    "data:image/svg+xml;base64," +
    btoa(
        '<svg width="100" height="99" viewBox="0 0 100 99" xmlns="http://www.w3.org/2000/svg">' +
            '<g fill="#39DBC0" fill-rule="evenodd">' +
            '<path d="M41.817 42H8.65a8 8 0 0 0-7.862 9.483l6.981 37A8 8 0 0 0 15.632 95H92a8 8 0 0 0 ' +
            "8-8V50a8 8 0 0 0-8-8H64.814c.034-.66.012-1.334-.072-2.013L61.086 10.21C60.312 3.906 " +
            '54.574-.576 48.27.198c-6.303.774-10.786 6.511-10.012 12.815z" fill-opacity=".3"/>' +
            '<rect fill-opacity=".9" x="76" y="37" width="24" height="62" rx="8"/></g></svg>',
    )

// todo is equal with rotate and color
const osmchaDislikeSvg =
    "data:image/svg+xml;base64," +
    btoa(
        '<svg width="100" height="99" viewBox="0 0 100 99" xmlns="http://www.w3.org/2000/svg">' +
            '<g transform="rotate(-180 50 49.5)" fill="#CC2C47" fill-rule="evenodd">' +
            '<path d="M41.817 42H8.65a8 8 0 0 0-7.862 9.483l6.981 37A8 8 0 0 0 15.632 95H92a8 8 0 0 0 ' +
            "8-8V50a8 8 0 0 0-8-8H64.814c.034-.66.012-1.334-.072-2.013L61.086 10.21C60.312 3.906 " +
            '54.574-.576 48.27.198c-6.303.774-10.786 6.511-10.012 12.815z" fill-opacity=".3"/>' +
            '<rect fill-opacity=".9" x="76" y="37" width="24" height="62" rx="8"/></g></svg>',
    )

const osmchaSvgLogo =
    '<svg viewBox="2.5 2.5 13 13" xmlns="http://www.w3.org/2000/svg">' +
    '<path d="M2.5 2.5l1 4-1 2 1 3-1 3h1l4-4c-.6-.8-1-1.9-1-3 0-1.9 1.1-3.5 2.6-4.4l-.6-.6-3 1-3-1zM15 ' +
    '11c-.9.9-2.2 1.5-3.5 1.5-1.1 0-2.2-.4-3-1l-4 4h2l2-1 2 1 4-1 1-3-.5-.5z"></path>' +
    '<path d="M15 7.5c0 1.9-1.6 3.5-3.5 3.5S8 9.4 8 7.5 9.6 4 11.5 4 15 5.6 15 7.5"></path></svg>'

const commentSvg =
    '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">' +
    '<path d="M2.678 11.894a1 1 0 0 1 .287.801 11 11 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 ' +
    ".71-.074A8 8 0 0 0 8 14c3.996 0 7-2.807 7-6s-3.004-6-7-6-7 2.808-7 6c0 1.468.617 2.83 1.678 3.894m-.493 " +
    "3.905a22 22 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a10 10 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 " +
    '11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9 9 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105">' +
    "</path>" +
    "</svg>"

const diffSvg =
    '<svg class="lucide lucide-diff better-diff-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" ' +
    'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">' +
    '<path d="M12 3v14"/><path d="M5 10h14"/>' +
    '<path d="M5 21h14"/>' +
    "</svg>"

const fitToObjectSvg =
    '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">\n' +
    '  <path d="M3 7V3H7" stroke-width="2" stroke-linecap="round"/>\n' +
    '  <path d="M17 3H21V7" stroke-width="2" stroke-linecap="round"/>\n' +
    '  <path d="M21 17V21H17" stroke-width="2" stroke-linecap="round"/>\n' +
    '  <path d="M7 21H3V17" stroke-width="2" stroke-linecap="round"/>\n' +
    "</svg>\n"

const externalLinkSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" ' +
    'stroke-linecap="round" stroke-linejoin="round" ' +
    'class="lucide lucide-external-link-icon lucide-external-link" width="16" height="16">' +
    '<path d="M15 3h6v6"></path><path d="M10 14 21 3"></path>' +
    '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>' +
    "</svg>"

const pencilLinkSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
    'stroke-width="1.25" stroke-linecap="round" ' +
    'stroke-linejoin="round" class="lucide lucide-pencil-icon lucide-pencil">' +
    '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 ' +
    '.623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>' +
    '<path d="m15 5 4 4"/>' +
    "</svg>"

const compactModeSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" ' +
    'stroke-linejoin="round" height="17" width="17" viewBox="0 0 17 17">' +
    '<path d="M15 12 l-5-4  5-4"></path><path d="M2 12 l 5-4 -5-4"></path>' +
    "</svg>"

const expandModeSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" ' +
    'stroke-linejoin="round" height="17" width="17" viewBox="0 0 17 17">' +
    '<path d="M7 12 l-5-4  5-4"></path>' +
    '<path d="M10 12 l 5-4 -5-4"></path>' +
    "</svg>"

const copyBtnSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
    'stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" ' +
    'class="lucide lucide-file-icon lucide-file">' +
    '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>' +
    '<path d="M14 2v4a2 2 0 0 0 2 2h4"/>' +
    "</svg>"

// TODO without encode
const nodeIcon =
    "data:image/svg+xml;base64," +
    btoa(
        '<svg xmlns="http://www.w3.org/2000/svg" height="256" width="256">' +
            '<rect width="242" height="242" fill="#fff" ry="32" x="7" y="7"/>' +
            '<circle cx="128" cy="128" r="24" fill="#bee6be" stroke="#000" stroke-width="10"/>' +
            '<rect width="242" height="242" stroke="#000" fill="none" stroke-width="12" ry="32" x="7" y="7"/></svg>',
    )

const wayIcon =
    "data:image/svg+xml;base64," +
    btoa(
        '<svg xmlns="http://www.w3.org/2000/svg" height="256" width="256">' +
            '<rect width="242" height="242" fill="#fff" ry="32" x="7" y="7"/>' +
            '<path stroke="#ccc" fill="none" stroke-width="16" d="M169 58 57 145l138 54"/>' +
            '<circle cx="169" cy="58" r="24"/>' +
            '<circle cx="57" cy="145" r="24"/>' +
            '<circle cx="195" cy="199" r="24"/>' +
            '<rect width="242" height="242" stroke="#000" fill="none" stroke-width="12" ry="32" x="7" y="7"/></svg>',
    )

const relationIcon =
    "data:image/svg+xml;base64," +
    btoa(
        '<svg xmlns="http://www.w3.org/2000/svg" height="256" width="256" viewBox="0 0 256 256">' +
            '<rect width="242" height="242" stroke="#000" fill="#f0f0f0" stroke-width="12" ry="32" x="7" y="7"/>' +
            '<path d="m68 68 128-6M68 68l128 74M68 68l-6 128" stroke-width="16" stroke="#ccc"/>' +
            '<circle cx="196" cy="62" r="24"/><circle cx="196" cy="142" r="24"/>' +
            '<circle cx="62" cy="196" r="24"/>' +
            '<path d="m68 68 74 128" stroke-width="16" stroke="#ccc"/>' +
            '<circle cx="142" cy="196" r="24"/>' +
            '<circle cx="72" cy="72" r="32" fill="#bee6be" stroke="#000" stroke-width="8"/></svg>',
    )

const toolsIconSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-tools" viewBox="0 0 16 16">' +
    '<path d="M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0' +
    " 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414" +
    " 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242L10.5 9.5l-.96-.96 2.68-2.643A3.005 3.005" +
    " 0 0 0 16 3q0-.405-.102-.777l-2.14 2.141L12 4l-.364-1.757L13.777.102a3 3 0 0 0-3.675 3.68L7.462 6.46 4.793 3.793a1" +
    " 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814zm9.646 10.646a.5.5 0 0 1 .708 0l2.914 2.915a.5.5 0 0 " +
    "1-.707.707l-2.915-2.914a.5.5 0 0 1 0-.708M3 11l.471.242.529.026.287.445.445.287.026.529L5 " +
    "13l-.242.471-.026.529-.445.287-.287.445-.529.026L3 15l-.471-.242L2 14.732l-.287-.445L1.268 14l-.026-.529L1 " +
    '13l.242-.471.026-.529.445-.287.287-.445.529-.026z"/>' +
    "</svg>"

const rawEditSvg =
    '<svg class="fa-i-cursor" height="14px" width="14px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512">' +
    '<path fill="currentColor" d="M.1 29.3C-1.4 47 11.7 62.4 29.3 63.9l8 .7C70.5 67.3 96 95 96 128.3L96 224l-32 ' +
    "0c-17.7 0-32 14.3-32 32s14.3 32 32 32l32 0 0 95.7c0 33.3-25.5 61-58.7 63.8l-8 .7C11.7 449.6-1.4 465 .1 " +
    "482.7s16.9 30.7 34.5 29.2l8-.7c34.1-2.8 64.2-18.9 85.4-42.9c21.2 24 51.2 40 85.4 42.9l8 .7c17.6 1.5 33.1-11.6 " +
    "34.5-29.2s-11.6-33.1-29.2-34.5l-8-.7C185.5 444.7 160 417 160 383.7l0-95.7 32 0c17.7 0 32-14.3 " +
    "32-32s-14.3-32-32-32l-32 0 0-95.7c0-33.3 25.5-61 58.7-63.8l8-.7c17.6-1.5 30.7-16.9 29.2-34.5S239-1.4 221.3 " +
    '.1l-8 .7C179.2 3.6 149.2 19.7 128 43.7c-21.2-24-51.2-40-85.4-42.9l-8-.7C17-1.4 1.6 11.7 .1 29.3z"></path></svg>'

const tableEditSvg =
    '<svg class="fa-table-list" height="14px" width="14px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">' +
    '<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 ' +
    "64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64L64 96zm384 0L192 96l0 64 256 0 0-64zM64 224l0 64 64 0 " +
    '0-64-64 0zm384 0l-256 0 0 64 256 0 0-64zM64 352l0 64 64 0 0-64-64 0zm384 0l-256 0 0 64 256 0 0-64z"></path></svg>'

const downloadSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" viewBox="0 0 24 24" fill="none" ' +
    'stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" ' +
    'class="lucide lucide-arrow-down-to-line-icon lucide-arrow-down-to-line">' +
    '<path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>'

//</editor-fold>

//<editor-fold desc="colors" defaultstate="collapsed">

// prettier-ignore
const colorsPalette = {
    "#ff00e3" : "#ff00e3",
    "#0022ff": "#0022ff",

    "rgba(17, 238, 9, 0.6)": "rgba(17, 238, 9, 0.6)",              // .quick-look-new-tag
    "rgba(223, 238, 9, 0.6)": "rgba(223, 238, 9, 0.6)",            // .quick-look-modified-tag
    "rgba(238, 51, 9, 0.6)": "rgba(238, 51, 9, 0.6)",              // .quick-look-deleted-tag
    "#000.quick-look-deleted-tag": "#000",                   // color.quick-look-deleted-tag
    "#fff.quick-look-deleted-tag": "#fff",                   // color.quick-look-deleted-tag
    "rgba(25, 223, 25, 0.6)": "rgba(25, 223, 25, 0.6)",      // .new-letter
    "rgba(255, 144, 144, 0.6)": "rgba(255, 144, 144, 0.6)",  // .deleted-letter

    "rgba(17, 238, 9, 0.3)": "rgba(17, 238, 9, 0.3)",              // dark.quick-look-new-tag
    "rgba(238, 51, 9, 0.4)": "rgba(238, 51, 9, 0.4)",              // dark.quick-look-deleted-tag
    "rgba(25, 223, 25, 0.9)": "rgba(25, 223, 25, 0.9)",      // dark.new-letter
    "rgba(253, 83, 83, 0.8)": "rgba(253, 83, 83, 0.8)",      // dark.deleted-letter

    "rgba(0, 128, 0, 0.6)": "rgba(0, 128, 0, 0.6)",                // displayWay for version 1
    "rgba(120, 238, 9, 0.6)": "rgba(120, 238, 9, 0.6)",            // displayWay for restored way
    "#ff0000.deleted-way-geom": "#ff0000",                   // displayWay for deleted

    "#00a500.first-node-version": "#00a500",                 // showNodeMarker for version 1
    "rgba(89, 170, 9, 0.6).restored-node-version": "#00a500",   // showNodeMarker for version 1
    "#ff0000.deleted-node-geom": "#ff0000",                  // showNodeMarker for deleted

    "rgba(17, 238, 9, 0.3).members-changed-flag": "rgba(17, 238, 9, 0.3)",  // dark
    "rgba(101, 238, 9, 0.6).members-changed-flag": "rgba(101, 238, 9, 0.6)",
    "rgba(238, 51, 9, 0.4).members-changed-flag": "rgba(238, 51, 9, 0.4)",  // dark
    "rgba(238, 9, 9, 0.42).members-changed-flag": "rgba(238, 9, 9, 0.42)",

    "rgba(17, 238, 9, 0.6).members-changed-row": "rgba(17, 238, 9, 0.6)",
    "rgba(238, 51, 9, 0.6).members-changed-row": "rgba(238, 51, 9, 0.6)",

    "rgba(17, 238, 9, 0.3).nodes-changed-flag": "rgba(17, 238, 9, 0.3)",  // dark
    "rgba(101, 238, 9, 0.6).nodes-changed-flag": "rgba(101, 238, 9, 0.6)",
    "rgba(238, 51, 9, 0.4).nodes-changed-flag": "rgba(238, 51, 9, 0.4)",  // dark
    "rgba(238, 9, 9, 0.42).nodes-changed-flag": "rgba(238, 9, 9, 0.42)",

    "rgba(17, 238, 9, 0.6).nodes-changed-row": "rgba(17, 238, 9, 0.6)",
    "rgba(238, 51, 9, 0.6).nodes-changed-row": "rgba(238, 51, 9, 0.6)",

    "rgba(4, 123, 0, 0.6).history-diff-new-tag" : "rgba(4, 123, 0, 0.6)",
    "rgba(238, 51, 9, 0.4).history-diff-deleted-tag": "rgba(238, 51, 9, 0.4)"
}

// prettier-ignore
const colorblindFriendlyPalette = {
    "rgba(17, 238, 9, 0.6)": "#0074FF75",            // .quick-look-new-tag
    "rgba(238, 51, 9, 0.6)": "#F80000",              // .quick-look-deleted-tag
    "#000.quick-look-deleted-tag": "#fff",           // color.quick-look-deleted-tag

    "rgba(25, 223, 25, 0.6)": "#8EA5FFC4",           // .new-letter
    "rgba(255, 144, 144, 0.6)": "#FFB7B7",           // .deleted-letter

    "rgba(25, 223, 25, 0.9)": "#8EA5FFC4",           // dark.new-letter
    "rgba(253, 83, 83, 0.8)": "#FFB7B7",             // dark.deleted-letter

    "rgba(17, 238, 9, 0.3)": "#1A385C",              // dark.quick-look-new-tag
    "rgba(238, 51, 9, 0.4)": "#732a1d",              // dark.quick-look-deleted-tag

    "rgba(0, 128, 0, 0.6)": "#0074FF75",                     // displayWay for version 1
    "rgba(120, 238, 9, 0.6)": "rgba(64,152,255,0.46)",       // displayWay for restored way
    "#ff0000.deleted-way-geom": "#f34c4c",                   // displayWay for deleted

    "#00a500.first-node-version": "#0074FF75",                                // showNodeMarker for version 1
    "rgba(89, 170, 9, 0.6).restored-node-version": "rgba(64,152,255,0.46)",   // showNodeMarker for version 1
    "#ff0000.deleted-node-geom": "#f34c4c",                                   // showNodeMarker for deleted

    "rgba(17, 238, 9, 0.3).members-changed-flag": "#0074FF75",   // dark
    "rgba(101, 238, 9, 0.6).members-changed-flag": "#0074FF75",
    "rgba(238, 51, 9, 0.4).members-changed-flag": "#f34c4c",     // dark
    "rgba(238, 9, 9, 0.42).members-changed-flag": "rgb(242, 0, 0)",

    "rgba(17, 238, 9, 0.6).members-changed-row": "#0074FF75",
    "rgba(238, 51, 9, 0.6).members-changed-row": "rgb(242, 0, 0)",

    "rgba(17, 238, 9, 0.3).nodes-changed-flag": "#0074FF75",       // dark
    "rgba(101, 238, 9, 0.6).nodes-changed-flag": "#0074FF75",
    "rgba(238, 51, 9, 0.4).nodes-changed-flag": "rgb(242, 0, 0)",  // dark
    "rgba(238, 9, 9, 0.42).nodes-changed-flag": "rgb(242, 0, 0)",

    "rgba(17, 238, 9, 0.6).nodes-changed-row": "#0074FF75",
    "rgba(238, 51, 9, 0.6).nodes-changed-row": "rgb(242, 0, 0)",

    "rgba(4, 123, 0, 0.6).history-diff-new-tag" : "#1A385C",
    "rgba(238, 51, 9, 0.4).history-diff-deleted-tag": "#732a1d"
}

function setColorblindFriendlyPalette() {
    Object.entries(colorblindFriendlyPalette).forEach(([k, v]) => {
        colorsPalette[k] = v
    })
}

/**
 * @param color {string}
 * @param context {string}
 * @return {string}
 */
function c(color, context = "") {
    const res = colorsPalette[color + context]
    if (res) {
        return res
    }
    debug_alert(`color ${color + context} not found in palette`)
    return color
}

//</editor-fold>

//<editor-fold desc="utils" defaultstate="collapsed">

// For WebStorm: Settings | Editor | Language Injections
// Places Patterns + jsLiteralExpression(jsArgument(jsReferenceExpression().withQualifiedName("injectJSIntoPage"), 0))

/**
 * @param {string} text
 */
function injectJSIntoPage(text) {
    GM_addElement("script", {
        textContent: text,
    })
}

/**
 * @param {string} text
 */
function injectCSSIntoOSMPage(text) {
    if (
        (GM_info.scriptHandler === "FireMonkey" && parseFloat(GM_info.version) < 3.0) ||
        GM_info.scriptHandler === "Userscripts" ||
        GM_info.scriptHandler === "Greasemonkey" ||
        isSafari
    ) {
        const styleElem = document.querySelector("style")
        if (!styleElem) {
            console.trace("<style> elem not found. Try wait this elem")
            setTimeout(async () => {
                for (let i = 0; i < 20; i++) {
                    const styleElem = document.querySelector("style")
                    if (!styleElem) {
                        await sleep(100)
                        continue
                    }
                    styleElem.innerHTML += text
                }
            }, 100)
            return
        }
        styleElem.innerHTML += text
        if (!isSafari) {
            styleElem.parentElement.appendChild(styleElem)
        }
    } else {
        return GM_addElement(document.head, "style", {
            textContent: text,
        })
    }
}

/**
 * @param {string} text
 */
function injectCSSIntoSimplePage(text) {
    return GM_addElement(document.head, "style", {
        textContent: text,
    })
}

// In Tampermonkey access to Math.* very slow
const min = Math.min
const max = Math.max
const abs = Math.abs
const hypot = Math.hypot
const cos = Math.cos
const sin = Math.sin
const PI = Math.PI

/**
 * @type {Object.<string, AbortController>}
 */
const abortDownloadingControllers = {}

/**
 * @return {AbortController}
 */
function getAbortController() {
    return (abortDownloadingControllers[location.pathname] ??= new AbortController())
}

function abortPrevControllers(reason = null) {
    console.log("abort prev controllers")
    Object.entries(abortDownloadingControllers).forEach(([path, controller]) => {
        if (path !== location.pathname) {
            console.log("abort for", path)
            controller.abort(reason)
            delete abortDownloadingControllers[path]
        }
    })
}

let tilesAbortController = new AbortController()

function abortTilesAbortController(reason) {
    tilesAbortController.abort(reason)
    tilesAbortController = new AbortController()
}

/**
 * @param ms {number}
 * @param signal {AbortSignal}
 * @return {Promise<void>}
 */
async function abortableSleep(ms, { signal }) {
    console.debug(`sleep ${ms}ms`)
    await new Promise((resolve, reject) => {
        signal?.throwIfAborted()

        function done() {
            resolve()
            signal?.removeEventListener("abort", stop)
        }

        function stop() {
            console.debug("sleep aborted")
            reject(this.reason)
            clearTimeout(timer)
        }

        const timer = setTimeout(done, ms)
        signal?.addEventListener("abort", stop)
    })
}

async function sleep(ms) {
    console.debug(`sleep ${ms}ms`)
    await new Promise(r => setTimeout(r, ms))
}

/**
 * @param X {number}
 * @param signal {AbortSignal}
 * @return {Promise<unknown>}
 */
async function abortableSleepUntilNextXthSeconds(X, { signal }) {
    const nowTime = new Date().getTime()
    const nextTime = new Date(Math.ceil(nowTime / 1000 / X) * X * 1000)
    const delay = nextTime - nowTime
    return await abortableSleep(delay, { signal })
}

/**
 * @param {Event} e
 * @param {string} text
 */
function copyAnimation(e, text) {
    console.log(`Copying ${text} to clipboard was successful!`)
    e.target.classList.add("copied")
    setTimeout(() => {
        e.target.classList.remove("copied")
        e.target.classList.add("was-copied")
        setTimeout(() => e.target.classList.remove("was-copied"), 300)
    }, 300)
}

/**
 * @param {function(): boolean|void} fn
 * @param {number} interval
 * @param {number} timeout
 */
function tryApplyModule(fn, interval, timeout) {
    const intervalTimerId = setInterval(async () => {
        if ((await fn()) === true) {
            console.debug("fast stop calling", fn.name)
            clearInterval(intervalTimerId)
            clearTimeout(timeoutTimerId)
        }
    }, interval)
    const timeoutTimerId = setTimeout(() => {
        clearInterval(intervalTimerId)
        console.debug("stop calling", fn.name)
    }, timeout)
    if (fn() === true) {
        clearInterval(intervalTimerId)
        clearTimeout(timeoutTimerId)
    }
}

/**
 * @param {string} unsafe
 * @returns {string}
 */
function escapeHtml(unsafe) {
    // prettier-ignore
    return unsafe
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

/**
 * @template T
 * @param {T[]} arr
 * @param N
 * @return {T[][]}
 */
function arraySplit(arr, N = 2) {
    const chunkSize = Math.max(1, Math.floor(arr.length / N)) // todo это неправильно, но и так сойдёт
    const res = []
    for (let i = 0; i < arr.length; i += chunkSize) {
        res.push(arr.slice(i, i + chunkSize))
    }
    return res
}

//</editor-fold>

//<editor-fold desc="network-utils" defaultstate="collapsed">

const limiterStorageWritesQueue = new Set()

/**
 *
 * @param {string} key
 * @param {number} ms
 * @return {Promise<void>}
 */
async function globalRateLimitByKey(key, ms) {
    // simple rate limiter
    // fixme rate limiting only request start
    while (true) {
        const lastReqTime = await GM.getValue(key)
        if (limiterStorageWritesQueue.has(key)) {
            await abortableSleep(ms, getAbortController())
            continue
        }
        if (!lastReqTime || new Date(lastReqTime).getTime() + ms < Date.now()) {
            limiterStorageWritesQueue.add(key)
            await GM.setValue(key, Date.now())
            limiterStorageWritesQueue.delete(key)
            break
        }
        const delay = 1100
        console.log(`wait ${delay / 1000}s for "${key}" key`)
        await abortableSleep(delay, getAbortController()) // todo extract const
    }
}

/**
 * @param details {Tampermonkey.Request}
 * @return {Promise<Tampermonkey.Response>}
 */
async function externalFetch(details) {
    if (GM_info.scriptHandler === "FireMonkey" && parseFloat(GM_info.version) < 3.0) {
        const res = await GM.fetch(details.url, details)
        if (details["responseType"] === "json") {
            res.response = res.json
        } else {
            res.responseText = res.text
        }
        return res
    } else {
        return await GM.xmlHttpRequest(details)
    }
}

/**
 * @param details {Tampermonkey.Request}
 * @return {Promise<Tampermonkey.Response>}
 */
async function abortableXmlHttpRequest(details) {
    if (details.signal) {
        // + GM API doesn't reject await GM.xmlHttpRequest. Workaround
        return await new Promise((resolve, reject) => {
            details.onabort = () => {
                // https://github.com/violentmonkey/violentmonkey/issues/2474
                console.log("abort in onabort", details.url)
                reject(new DOMException("Aborted", "AbortError"))
            }
            details.onerror = reject
            details.ontimeout = reject
            details.onload = resolve
            const req = GM.xmlHttpRequest(details)
            if (details.signal?.aborted) {
                req.abort()
                console.log("abort", details.url)
                reject(new DOMException("Aborted", "AbortError"))
                return
            }
            details.signal?.addEventListener(
                "abort",
                () => {
                    req.abort()
                    console.log("abort", details.url)
                    reject(new DOMException("Aborted", "AbortError"))
                },
                { once: true },
            )
        })
    } else {
        return await GM.xmlHttpRequest(details)
    }
}

/**
 * @param details {Tampermonkey.Request}
 * @return {Promise<Tampermonkey.Response>}
 */
async function externalFetchRetry(details) {
    if (GM_info.scriptHandler === "FireMonkey" && parseFloat(GM_info.version) < 3.0) {
        const res = await _fetchRetry(GM.fetch, details.url, details)
        if (details["responseType"] === "json") {
            res.response = res.json
        } else {
            res.responseText = res.text
        }
        return res
    } else {
        return await _fetchRetry(abortableXmlHttpRequest, details)
    }
}

/**
 * @param input {string | Request}
 * @param [init] {RequestInit}
 * @return {Promise<Response>}
 */
async function fetchRetry(input, init) {
    return await _fetchRetry(fetch, input, init)
}

async function _fetchRetry(fetchImpl, ...args) {
    const RETRY_COUNT = args?.[1]?.retryCount ?? 10 // FIXME навести порядок в способе задания аргументов
    let count = RETRY_COUNT
    while (count > 0) {
        try {
            if (args[0].rateLimiter) {
                await globalRateLimitByKey(args[0].rateLimiter.key, args[0].rateLimiter.minDelay)
            }
            const res = await fetchImpl(...args)
            if (res.status === 509 || res.status === 429 || res.status === 504) {
                console.warn(`HTTP ${res.status}. Waiting before retry`)
                let sleepTime
                if (res.headers?.get("retry-after")) {
                    sleepTime = (parseInt(res.headers.get("retry-after")) + 1) * 1000
                } else {
                    if (res.status === 504) {
                        sleepTime = (10 + Math.random() * 10) * 1000
                    } else {
                        sleepTime = (30 + Math.random() * 10) * 1000
                    }
                }
                await abortableSleep(sleepTime, getAbortController())
                count -= 1
                if (count === 0) {
                    console.error("oops, DOS block")
                    setTimeout(async () => {
                        setAttributionPrefix(escapeHtml(await res.text()))
                    })
                    throw "rate limit ban is too long"
                }
                continue
            }
            return res
        } catch (error) {
            if (!error?.message || !error.message.startsWith("NetworkError")) {
                throw error
            }
            console.error(error, "fetch. Remain retries:", count)
            await abortableSleepUntilNextXthSeconds(10, getAbortController())
        }
        count -= 1
        if (!navigator.onLine) {
            console.log("wait online")
            await new Promise(resolve => {
                window.addEventListener("online", () => {
                    console.log("online")
                    resolve()
                })
            })
            console.log("online")
            continue
        }
        if (count === 0) {
            console.error("Big wait before new retries")
            await abortableSleep(60 * 1000, getAbortController())
            count = RETRY_COUNT
        }
    }
}

const fetchBlobWithCache = (() => {
    const cache = new Map()

    return async (url, options = {}) => {
        if (cache.has(url)) {
            return cache.get(url)
        }

        const promise = externalFetchRetry({
            url: url,
            responseType: "blob",
            headers: options.headers ?? {},
            signal: options.signal,
        })
        cache.set(url, promise)

        try {
            const result = await promise
            cache.set(url, result)
            if (options?.timeout) {
                setTimeout(() => {
                    cache.delete(url)
                    console.debug("Purged cache for", url)
                }, options.timeout)
            }
            return result
        } catch (error) {
            cache.delete(url)
            throw error
        }
    }
})()

/**
 * @param imgSrc {string}
 * @return {Promise<string>}
 */
async function fetchImageWithCache(imgSrc) {
    const res = await fetchBlobWithCache(imgSrc)
    const blob = res.response

    return await new Promise(resolve => {
        const reader = new FileReader()
        reader.onload = () => resolve(reader.result)
        reader.readAsDataURL(blob)
    })
}

const fetchJSONWithCache = (() => {
    /**@type {Map<string, Object | Promise<Object>>}*/
    const cache = new Map()

    return async (url, init = {}, responseChecker = _ => true) => {
        if (cache.has(url)) {
            return cache.get(url)
        }

        const promise = fetchRetry(url, init).then(res => {
            if (!res) console.trace()
            if (responseChecker(res)) {
                return res.json()
            }
        })
        cache.set(url, promise)

        try {
            const result = await promise
            cache.set(url, result)
            return result
        } catch (error) {
            cache.delete(url)
            throw error
        }
    }
})()

const fetchJSONorResWithCache = (() => {
    /**@type {Map<string, Object | Promise<Object|Response>>}*/
    const cache = new Map()

    return async (url, init = {}) => {
        if (cache.has(url)) {
            return cache.get(url)
        }

        const promise = fetchRetry(url, init).then(res => {
            if (!res.ok) return res
            return res.json()
        })
        cache.set(url, promise)

        try {
            const result = await promise
            cache.set(url, result)
            return result
        } catch (error) {
            cache.delete(url)
            throw error
        }
    }
})()

const fetchTextWithCache = (() => {
    /**@type {Map<string, string | undefined | Promise<string | undefined> >}*/
    const cache = new Map()
    /**
     * @param {string} url
     * @return {Promise<string>}
     */
    return async url => {
        if (cache.has(url)) {
            return cache.get(url)
        }

        const promise = externalFetchRetry({ url: url }).then(r => r.responseText)
        cache.set(url, promise)

        try {
            const result = await promise
            cache.set(url, result)
            return result
        } catch (error) {
            cache.delete(url)
            throw error
        }
    }
})()

const originalFetchTextWithCache = (() => {
    /**@type {Map<string, string | undefined | Promise<string | undefined> >}*/
    const cache = new Map()

    /**
     * @param {string} url
     * @param {RequestInit} init
     */
    return async (url, init) => {
        if (cache.has(url)) {
            return cache.get(url)
        }

        const promise = fetchRetry(url, init).then(res => {
            if (!res) console.trace()
            return res.text()
        })
        cache.set(url, promise)

        try {
            const result = await promise
            cache.set(url, result)
            return result
        } catch (error) {
            cache.delete(url)
            throw error
        }
    }
})()

function resourceCacher(url, storageKey, name, dateDelta, type) {
    let database
    async function load() {
        const raw_data = (
            await externalFetchRetry({
                url: url,
                responseType: type,
            })
        ).response
        if (!raw_data) {
            throw `${name} not downloaded`
        }
        database = raw_data
        console.log(`${name} database updated`)
        await GM.setValue(
            storageKey,
            JSON.stringify({
                raw_data: raw_data,
                cacheTime: new Date(),
            }),
        )
    }
    return {
        get: () => database,
        init: async function () {
            if (database) return
            const cache = await GM.getValue(storageKey, "")
            if (database) return
            if (cache) {
                console.log(`${name} cached`)
                const json = JSON.parse(cache)
                const cacheTime = new Date(json["cacheTime"])
                const timeLater = new Date(cacheTime.getTime() + dateDelta)
                if (timeLater < new Date()) {
                    console.log("but cache outdated")
                    setTimeout(load, 0)
                }
                database = JSON.parse(cache)["raw_data"]
                return
            }
            console.log(`loading ${name}`)
            try {
                await load()
            } catch (e) {
                console.log(`loading ${name} failed`, e)
            }
        },
    }
}

/**
 * @param {string} query
 * @param {"json"|"xml"} responseType
 * @return {Promise<Tampermonkey.Response<unknown>>}
 */
async function overpassRequest(query, responseType = "json") {
    console.log("overpass request", query)
    console.count("kek")
    const rateLimiter = {
        key: "overpass",
        minDelay: 1000,
    }
    if (overpass_server.referer) {
        return await externalFetchRetry({
            method: "POST",
            url: overpass_server.apiUrl + "/interpreter",
            data: query,
            headers: {
                Referer: overpass_server.referer,
                Origin: overpass_server.origin,
            },
            responseType: responseType,
            rateLimiter: rateLimiter,
        })
    }
    return await externalFetchRetry({
        method: "POST",
        url: overpass_server.apiUrl + "/interpreter",
        data: query,
        responseType: responseType,
        rateLimiter: rateLimiter,
    })
}

//</editor-fold>

//<editor-fold desc="geom utils" defaultstate="collapsed">

/**
 * @param {number} lat1
 * @param {number} lon1
 * @param {number} lat2
 * @param {number} lon2
 */
function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) {
    function deg2rad(deg) {
        return deg * (Math.PI / 180)
    }

    const R = 6371
    const dLat = deg2rad(lat2 - lat1)
    const dLon = deg2rad(lon2 - lon1)
    const a =
        Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
    return R * c
}

/**
 * @param {number} lat
 * @param {number} lng
 * @returns {{x: number, y: number}}
 */
function toMercator(lat, lng) {
    const x = (earthRadius * lng * Math.PI) / 180
    const y = earthRadius * Math.log(Math.tan((lat * Math.PI) / 180 / 2 + Math.PI / 4))
    return { x, y }
}

/**
 * @param {number} x
 * @param {number} y
 * @returns {{lat: number, lng: number}}
 */
function fromMercator(x, y) {
    const lat = ((2 * Math.atan(Math.exp(y / earthRadius)) - Math.PI / 2) * 180) / Math.PI
    const lng = ((x / earthRadius) * 180) / Math.PI
    return { lat, lng }
}

/**
 * @param {number} lat1
 * @param {number} lon1
 * @param {number} lat2
 * @param {number} lon2
 * @param {number} angleDeg
 * @param {number} lengthMeters
 * @returns {{lat: number, lon: number}}
 */
function rotateSegment(lat1, lon1, lat2, lon2, angleDeg, lengthMeters) {
    const { x: x1, y: y1 } = toMercator(lat1, lon1)
    const { x: x2, y: y2 } = toMercator(lat2, lon2)

    const angleRad = (angleDeg * Math.PI) / 180

    const dx = y2 - y1
    const dy = x2 - x1

    const segmentLength = Math.sqrt(dx * dx + dy * dy)

    const ndx = dx / segmentLength
    const ndy = dy / segmentLength

    const scaledDx = ndx * lengthMeters
    const scaledDy = ndy * lengthMeters

    const rotatedDx = scaledDx * Math.cos(angleRad) - scaledDy * Math.sin(angleRad)
    const rotatedDy = scaledDx * Math.sin(angleRad) + scaledDy * Math.cos(angleRad)

    const { lat: lat, lng: lon } = fromMercator(x1 + rotatedDy, y1 + rotatedDx)
    return { lat: lat, lon: lon }
}

// based on https://github.com/eatgrass/geo-area
function ringArea(points) {
    const RADIUS = 6378137
    const rad = coord => (coord * Math.PI) / 180

    let p1
    let p2
    let p3
    let lowerIndex
    let middleIndex
    let upperIndex
    let area = 0

    if (points.length > 2) {
        for (let i = 0; i < points.length; i++) {
            if (i === points.length - 2) {
                lowerIndex = points.length - 2
                middleIndex = points.length - 1
                upperIndex = 0
            } else if (i === points.length - 1) {
                lowerIndex = points.length - 1
                middleIndex = 0
                upperIndex = 1
            } else {
                lowerIndex = i
                middleIndex = i + 1
                upperIndex = i + 2
            }
            p1 = points[lowerIndex]
            p2 = points[middleIndex]
            p3 = points[upperIndex]
            area += (rad(p3.lon) - rad(p1.lon)) * Math.sin(rad(p2.lat))
        }
        area = (area * RADIUS * RADIUS) / 2
    }

    return area
}

/**
 * @param {{x: number, y: number}} p
 * @param {{x: number, y: number}} a
 * @param {{x: number, y: number}} b
 * @return {number}
 */
function distanceToSegment(p, a, b) {
    const dx = b.x - a.x
    const dy = b.y - a.y

    if (dx === 0 && dy === 0) {
        const dpx = p.x - a.x
        const dpy = p.y - a.y
        return Math.hypot(dpx, dpy)
    }

    const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy)))
    const proj = { x: a.x + t * dx, y: a.y + t * dy }

    return Math.hypot(p.x - proj.x, p.y - proj.y)
}

function distanceToSegmentInMeters(p, a, b, lat) {
    return distanceToSegment(p, a, b) * cos((lat * PI) / 180)
}

/*
function psevdoskal(x1, y1, x2, y2) {
    return x1 * y2 - x2 * y1
}

function skal(x1, y1, x2, y2) {
    return x1 * x2 + y1 * y2
}

function _check(a, b, c) {
    if (a.x === b.x) {
        return min(a.y, b.y) <= c.y && c.y <= max(a.y, b.y)
    } else {
        return min(a.x, b.x) <= c.x && c.x <= max(a.x, b.x)
    }
}

/!**
 * @param {{x: number, y: number}} c
 * @param {{x: number, y: number}} a
 * @param {{x: number, y: number}} b
 * @return {number}
 *!/
function distanceToSegment2(c, a, b) {
    if (psevdoskal(b.x - a.x, b.y - a.y, c.x - a.x, c.y - a.y) === 0) {
        if (_check(a, b, c)) {
            return 0.0
        } else {
            return min(hypot(b.x - c.x, b.y - c.y), hypot(a.x - c.x, a.y - c.y))
        }
    }
    if (skal(c.x - b.x, c.y - b.y, a.x - b.x, a.y - b.y) < 0) {
        return hypot(b.x - c.x, b.y - c.y)
    }
    if (skal(c.x - a.x, c.y - a.y, b.x - a.x, b.y - a.y) < 0) {
        return hypot(a.x - c.x, a.y - c.y)
    }
    return abs(psevdoskal(c.x - a.x, c.y - a.y, b.x - a.x, b.y - a.y) / hypot(b.x - a.x, b.y - a.y))
}*/

//</editor-fold>

//<editor-fold desc="map-utils" defaultstate="collapsed">

function getMapCenter() {
    try {
        return getMap().getCenter()
    } catch {
        console.warn("Couldn't get the map center through the map object. Trying a workaround")

        const curURL = document.querySelector("#edit_tab ul")?.querySelector("li a")?.href
        const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
        console.log("Parsed coordinates link:", match)
        return { lng: match[3], lat: match[2] }
    }
}

async function getMapBounds() {
    try {
        return getMap().getBounds()
    } catch {
        console.warn("Couldn't get the map bound through the map object. Trying a workaround")

        document.querySelector("#sidebar").style.display = "none"
        document.querySelector("#sidebar").style.width = "0px"
        try {
            document.querySelector('nav a[href="/export"]').click()
            for (let i = 0; i < 10; i++) {
                await sleep(500)
                if (document.querySelector("#minlat")) {
                    break
                }
            }
            const res = {
                getNorthWest: () => ({
                    lat: document.querySelector("#maxlat").value,
                    lng: document.querySelector("#minlon").value,
                }),
                getSouthEast: () => ({
                    lat: document.querySelector("#minlat").value,
                    lng: document.querySelector("#maxlon").value,
                }),
            }
            console.log(res.getNorthWest())
            console.log(res.getSouthEast())
            // console.log(getMap().getBounds().getNorthWest())
            document.querySelectorAll("#sidebar .sidebar-close-controls .btn-close").forEach(i => i?.click())

            return res
        } finally {
            document.querySelector("#sidebar").style.display = ""
            document.querySelector("#sidebar").style.width = ""
        }
    }
}

/**
 * @return {[string, string, string]}
 */
function getCurrentXYZ() {
    try {
        const [, z, x, y] = new URL(document.querySelector("#editanchor").href).hash.match(/map=([0-9]+)\/([0-9.-]+)\/([0-9.-]+)/)
        return [x, y, z]
    } catch (e) {
        // for iD
        const [, z, x, y] = location.hash.match(/map=([0-9.]+)\/([0-9.-]+)\/([0-9.-]+)/)
        return [x, y, z]
    }
}

/**
 * @name getZoom
 * @return {number}
 */
function getZoom() {
    try {
        return getMap().getZoom()
    } catch {
        console.warn("Couldn't get the map zoom through the map object. Trying a workaround")

        const curURL = document.querySelector("#edit_tab ul")?.querySelector("li a")?.href
        const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
        console.log("Parsed coordinates link:", match)
        return parseFloat(match[1])
    }
}

/**
 * @name setZoom
 * @param {number} zoomLevel
 */
function setZoom(zoomLevel) {
    try {
        getMap().setZoom(zoomLevel)
    } catch {
        console.warn("Couldn't set the map zoom through the map object. Trying a workaround")

        const curURL = document.querySelector("#edit_tab ul")?.querySelector("li a")?.href
        const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
        if (match) {
            location.hash = location.hash.replace(/map=(\d+)\//, `map=${zoomLevel}/`)
        } else {
            const [x, y] = getCurrentXYZ()
            location.hash = `map=${zoomLevel}/${x}/${y}`
        }
    }
}

function setAttributionPrefix(prefix) {
    console.trace("New prefix:", prefix) // todo -> .log
    getMap?.()?.attributionControl?.setPrefix(prefix)
}

function resetMapHover() {
    document.querySelectorAll(".map-hover").forEach(el => {
        el.classList.remove("map-hover")
    })
}

function resetSelectedChangesets() {
    document.querySelectorAll(".selected").forEach(el => {
        el.classList.remove("selected")
    })
}

/**
 * @type {{
 *  customObjects: (import('leaflet').Path)[],
 *  activeObjects: (import('leaflet').Path)[],
 *  changesetBounds: (import('leaflet').Path)[]
 * }}
 */
const layers = {
    customObjects: [],
    activeObjects: [],
    changesetBounds: [],
}

function cleanAllObjects() {
    for (let member in layers) {
        layers[member].forEach(i => {
            i.remove()
        })
        layers[member] = []
    }
}

async function cleanAllPrevAfter(cb, ...args) {
    const toRemove = []
    for (let member in layers) {
        layers[member].forEach(i => {
            toRemove.push(i)
        })
        layers[member] = []
    }
    await cb(...args)
    toRemove.forEach(i => i.remove())
}

/**
 * @name cleanObjectsByKey
 * @param {string} key
 */
function cleanObjectsByKey(key) {
    if (layers[key]) {
        layers[key]?.forEach(i => i.remove())
        layers[key] = []
    }
}

/**
 * @name cleanCustomObjects
 */
function cleanCustomObjects() {
    layers["customObjects"].forEach(i => i.remove())
    layers["customObjects"] = []
}

/**
 * @name showWay
 * @param {[]} nodesList
 * @param {string=} color
 * @param {boolean} needFly
 * @param {boolean} addStroke
 */
function showWay(nodesList, color = "#000000", needFly = false, addStroke = false) {
    cleanCustomObjects()
    const line = getWindow()
        .L.polyline(
            intoPage(nodesList.map(elem => getWindow().L.latLng(intoPage(elem)))),
            intoPage({
                color: color,
                weight: 4,
                clickable: false,
                opacity: 1,
                fillOpacity: 1,
            }),
        )
        .addTo(getMap())
    if (addStroke) {
        line._path.classList.add("stroke-polyline")
    }
    layers["customObjects"].push(line)
    if (needFly) {
        if (nodesList.length) {
            fitBounds(get4Bounds(line))
        }
    }
}

/**
 * @name showWays
 * @param {[][]} ListOfNodesList
 * @param {string=} layerName
 * @param {string=} color
 */
function showWays(ListOfNodesList, layerName = "customObjects", color = "#000000") {
    cleanObjectsByKey(layerName)
    ListOfNodesList.forEach(nodesList => {
        const line = getWindow()
            .L.polyline(
                intoPage(nodesList.map(elem => getWindow().L.latLng(intoPage(elem)))),
                intoPage({
                    color: color,
                    weight: 4,
                    clickable: false,
                    opacity: 1,
                    fillOpacity: 1,
                }),
            )
            .addTo(getMap())
        layers[layerName].push(line)
    })
}

/**
 * @name displayWay
 * @param {any[]} nodesList
 * @param {boolean=} needFly
 * @param {string=} color
 * @param {number=} width
 * @param {string|null=} infoElemID
 * @param {string=} layerName
 * @param {string|null=} dashArray
 * @param {string|null=} popupContent
 * @param {boolean|null=} addStroke
 * @param {boolean=} geometryCached
 */
function displayWay(
    nodesList,
    needFly = false,
    color = "#000000",
    width = 4,
    infoElemID = null,
    layerName = "customObjects",
    dashArray = null,
    popupContent = null,
    addStroke = null,
    geometryCached = false,
) {
    if (!layers[layerName]) {
        layers[layerName] = []
    }

    function bindPopup(line, popup) {
        if (popup) return line.bindPopup(popup)
        return line
    }

    const line = bindPopup(
        getWindow().L.polyline(
            geometryCached ? nodesList : intoPage(nodesList),
            intoPage({
                color: color,
                weight: width,
                clickable: false,
                opacity: 1,
                fillOpacity: 1,
                dashArray: dashArray,
            }),
        ),
        popupContent,
    ).addTo(getMap())
    layers[layerName].push(line)
    if (needFly) {
        getMap().flyTo(
            intoPage(line.getBounds().getCenter()),
            18,
            intoPage({
                animate: false,
                duration: 0.5,
            }),
        )
    }
    if (infoElemID) {
        layers[layerName][layers[layerName].length - 1].on(
            "click",
            cloneInto(
                function () {
                    const elementById = document.getElementById(infoElemID)
                    elementById?.scrollIntoView()
                    resetMapHover()
                    elementById?.parentElement?.parentElement?.classList.add("map-hover")
                    cleanObjectsByKey("activeObjects")
                },
                getWindow(),
                { cloneFunctions: true },
            ),
        )
    }
    if (addStroke) {
        line._path.classList.add("stroke-polyline")
    }
    return line
}

/**
 * @name showNodeMarker
 * @param {string|number} a
 * @param {string|number} b
 * @param {string=} color
 * @param {string|null=} infoElemID
 * @param {string=} layerName
 * @param {number=} radius
 */
function showNodeMarker(a, b, color = "#00a500", infoElemID = null, layerName = "customObjects", radius = 5) {
    const haloStyle = {
        weight: 2.5,
        radius: radius,
        fillOpacity: 0,
        color: color,
    }
    const marker = getWindow().L.circleMarker(getWindow().L.latLng(a, b), intoPage(haloStyle)).addTo(getMap())
    layers[layerName].push(marker)
    if (infoElemID) {
        marker.on(
            "click",
            cloneInto(
                function () {
                    const elementById = document.getElementById(infoElemID)
                    elementById?.scrollIntoView()
                    resetMapHover()
                    elementById?.parentElement?.parentElement.classList?.add("map-hover")
                },
                getWindow(),
                { cloneFunctions: true },
            ),
        )
    }
    return marker
}

/**
 * @name showActiveNodeMarker
 * @param {string} lat
 * @param {string} lon
 * @param {string} color
 * @param {boolean=} removeActiveObjects
 * @param {number} radius
 * @param {number=} weight
 */
function showActiveNodeMarker(lat, lon, color, removeActiveObjects = true, radius = 5, weight = 2.5) {
    const haloStyle = {
        weight: weight,
        radius: radius,
        fillOpacity: 0,
        color: color,
    }
    if (removeActiveObjects) {
        cleanObjectsByKey("activeObjects")
    }
    layers["activeObjects"].push(getWindow().L.circleMarker(getWindow().L.latLng(lat, lon), intoPage(haloStyle)).addTo(getMap()))
}

/**
 * @name showActiveNodeIconMarker
 * @param {string} lat
 * @param {string} lon
 * @param {string|null=} color
 * @param {boolean=} removeActiveObjects
 */
function showActiveNodeIconMarker(lat, lon, color = null, removeActiveObjects = true) {
    if (removeActiveObjects) {
        cleanObjectsByKey("activeObjects")
    }
    const marker = getWindow().L.marker(
        intoPage([lat, lon]),
        intoPage({ draggable: false, icon: getWindow().OSM.getMarker({ color: color ?? "var(--marker-blue)" }) }),
    )
    layers["activeObjects"].push(marker.addTo(getMap()))
}

/**
 * @name showActiveWay
 * @param {any[]} nodesList
 * @param {string=} color
 * @param {boolean=} needFly
 * @param {string|null=} infoElemID
 * @param {boolean=} removeActiveObjects
 * @param {number=} weight
 * @param {string=} dashArray
 */
function showActiveWay(
    nodesList,
    color = c("#ff00e3"),
    needFly = false,
    infoElemID = null,
    removeActiveObjects = true,
    weight = 4,
    dashArray = null,
) {
    const line = getWindow()
        .L.polyline(
            intoPage(nodesList.map(elem => intoPage(getWindow().L.latLng(intoPage(elem))))),
            intoPage({
                color: color,
                weight: weight,
                clickable: false,
                opacity: 1,
                fillOpacity: 1,
                dashArray: dashArray,
            }),
        )
        .addTo(getMap())
    if (removeActiveObjects) {
        cleanObjectsByKey("activeObjects")
    }
    layers["activeObjects"].push(line)
    if (needFly) {
        fitBounds(get4Bounds(line))
    }
    if (infoElemID) {
        layers["activeObjects"][layers["activeObjects"].length - 1].on(
            "click",
            cloneInto(
                function () {
                    const elementById = document.getElementById(infoElemID)
                    elementById?.scrollIntoView()
                    resetMapHover()
                    elementById.classList.add("map-hover")
                },
                getWindow(),
                { cloneFunctions: true },
            ),
        )
    }
}

/**
 * @name panTo
 * @param {string} lat
 * @param {string} lon
 * @param {number=} zoom
 * @param {boolean=} animate
 */
function panTo(lat, lon, zoom = 18, animate = false) {
    if (!getMap()?.flyTo) {
        // todo initial implementation
        const maxZoom = 19
        console.log("try workaround. TODO")
        const curURL = document.querySelector("#edit_tab ul")?.querySelector("li a")?.href
        const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
        if (match) {
            const zoomLevel = parseInt(match[1])
            let newHash = location.hash.replace(/map=(\d+)\//, `map=${min(zoomLevel + 1, maxZoom)}/`)
            newHash = newHash.replace(/map=(\d+)\/(\d+)\/(\d+)/, `map=$1/${lat}/${lon}`)
            location.hash = newHash
        } else {
            const [x, y, z] = getCurrentXYZ()
            location.hash = `map=${min(parseInt(z) + 1, maxZoom)}/${x}/${y}`
        }
    } else {
        getMap().flyTo(intoPage([lat, lon]), zoom, intoPage({ animate: animate }))
    }
}

/**
 * @name panInside
 * @param {string} lat
 * @param {string} lon
 * @param {boolean=} animate
 * @param {[number, number]=} padding
 */
function panInside(lat, lon, animate = false, padding = [0, 0]) {
    getMap().panInside(intoPage([lat, lon]), intoPage({ animate: animate, padding: padding }))
}

function get4Bounds(b) {
    try {
        return [
            [b.getBounds().getSouth(), b.getBounds().getWest()],
            [b.getBounds().getNorth(), b.getBounds().getEast()],
        ]
    } catch {
        console.error("Please, reload page")
    }
}

/**
 * @name fitBounds
 * @param {import('leaflet').LatLngBoundsExpression} bound
 * @param {number} maxZoom
 */
function fitBounds(bound, maxZoom = 19) {
    fitBoundsWithPadding(bound, 0, maxZoom)
}

/**
 * @name fitBoundsWithPadding
 * @param {import('leaflet').LatLngBoundsExpression} bound
 * @param {number} padding
 * @param {number} maxZoom
 */
function fitBoundsWithPadding(bound, padding, maxZoom = 19) {
    if (!getMap()?.fitBounds) {
        // todo initial implementation
        console.log("try workaround. TODO")
        const curURL = document.querySelector("#edit_tab ul")?.querySelector("li a")?.href
        const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
        if (match) {
            const [lat, lon] = bound[0]
            const zoomLevel = parseInt(match[1])
            let newHash = location.hash.replace(/map=(\d+)\//, `map=${min(zoomLevel + 1, maxZoom)}/`)
            newHash = newHash.replace(/map=(\d+)\/(\d+)\/(\d+)/, `map=$1/${lat}/${lon}`)
            location.hash = newHash
        } else {
            const [x, y, z] = getCurrentXYZ()
            location.hash = `map=${min(parseInt(z) + 1, maxZoom)}/${x}/${y}`
        }
    } else {
        getMap().fitBounds(intoPageWithFun(bound), intoPage({ padding: [padding, padding], maxZoom: maxZoom }))
    }
}

const earthRadius = 6378137

function drawRay(lat, lon, angle, color) {
    const rad = (angle * Math.PI) / 180
    const length = 7
    const latOffset = (length * Math.cos(rad)) / earthRadius
    const lonOffset = (length * Math.sin(rad)) / (earthRadius * Math.cos((lat * Math.PI) / 180))
    showActiveWay(
        [
            [lat, lon],
            [lat + (latOffset * 180) / Math.PI, lon + (lonOffset * 180) / Math.PI],
        ],
        color,
        false,
        null,
        false,
    )
}

//</editor-fold>

//<editor-fold desc="osm-utils" defaultstate="collapsed">

/** @type {import("osm-auth").OSMAuth|null}*/
let osmEditAuth = null

function initOsmAuth() {
    if (osmEditAuth === null) {
        osmEditAuth = osmAuth.osmAuth({
            apiUrl: osm_server.apiUrl,
            url: osm_server.url,
            client_id: "FwA",
            client_secret: "ZUq",
            redirect_uri: GM_getResourceURL("OAUTH_HTML"),
            scope: "write_api",
            auto: true,
        })
    }
}

/**
 * @param {string} comment
 * @returns {Promise<string>} changesetId
 */
async function openOsmChangeset(comment) {
    const changesetTags = {
        created_by: `better osm.org v${GM_info.script.version}`,
        comment: comment,
    }

    const changesetPayload = document.implementation.createDocument(null, "osm")
    const cs = changesetPayload.createElement("changeset")
    changesetPayload.documentElement.appendChild(cs)
    tagsToXml(changesetPayload, cs, changesetTags)
    const chPayloadStr = new XMLSerializer().serializeToString(changesetPayload)

    const changesetId = await osmEditAuth
        .fetch(osm_server.apiBase + "changeset/create", {
            method: "PUT",
            prefix: false,
            body: chPayloadStr,
        })
        .then(res => {
            if (res.ok) return res.text()
            throw new Error(res)
        })
    console.log("Open changeset", changesetId)
    return changesetId
}

async function closeOsmChangeset(changesetId) {
    const res = await osmEditAuth.fetch(osm_server.apiBase + "changeset/" + changesetId + "/close", {
        method: "PUT",
        prefix: false,
    })
    if (!res.ok) {
        console.warn(await res.text())
    }
}

async function getOsmObjectInfo(object_type, object_id) {
    // todo drop osmEditAuth
    const rawObjectInfo = await (
        await osmEditAuth.fetch(osm_server.apiBase + object_type + "/" + object_id, {
            method: "GET",
            prefix: false,
        })
    ).text()
    const res = new DOMParser().parseFromString(rawObjectInfo, "text/xml")
    const error = res.querySelector("parsererror")
    if (error) {
        throw new Error("getOsmObjectInfo: Parsing failed: " + error.textContent)
    }
    return res
}

async function getOsmObjectHistory(object_type, object_id) {
    // todo drop osmEditAuth
    const rawObjectInfo = await (
        await osmEditAuth.fetch(osm_server.apiBase + object_type + "/" + object_id + "/history", {
            method: "GET",
            prefix: false,
        })
    ).text()
    const res = new DOMParser().parseFromString(rawObjectInfo, "text/xml")
    const error = res.querySelector("parsererror")
    if (error) {
        throw new Error("getOsmObjectInfo: Parsing failed: " + error.textContent)
    }
    return res
}

/**
 * @param body {string}
 * @return {Promise<void>}
 */
async function createOsmNodes(body) {
    const res = await osmEditAuth.fetch(osm_server.apiBase + "nodes", {
        method: "POST",
        prefix: false,
        body: body,
        headers: { "Content-Type": "application/xml; charset=utf-8" },
    })
    if (!res.ok) {
        throw new Error(await res.text())
    }
}

async function deleteOsmObjectByInfo(object_type, object_id, objectInfo) {
    const res = await osmEditAuth.fetch(osm_server.apiBase + object_type + "/" + object_id, {
        method: "DELETE",
        prefix: false,
        body: new XMLSerializer().serializeToString(objectInfo),
        headers: { "Content-Type": "application/xml; charset=utf-8" },
    })
    if (!res.ok) {
        throw new Error(await res.text())
    }
}

async function closeNote(note_id, text) {
    const path = `${osm_server.apiBase}notes/${note_id}/close.json?${new URLSearchParams({
        text: text,
    }).toString()}`
    const res = await osmEditAuth.fetch(path, {
        method: "POST",
        prefix: false,
    })
    if (!res.ok) {
        throw new Error(await res.text())
    }
}

//</editor-fold>

//<editor-fold desc="way-geometry-validator" defaultstate="collapsed">

/**
 * @typedef {{
 *     index: number,
 *     id: number
 *     lat: number,
 *     lon: number,
 * }} WayGeometryPoint
 */

/**
 * @typedef {Object} WayGeometryValidationIssue
 * @property {"too_few_points"|"duplicate_points"|"self_intersection"|"point_on_segment"} code
 * @property {string} description
 * @property {WayGeometryPoint[]=} points
 * @property {WayGeometryPoint[][]=} pointGroups
 */

/**
 * @param {WayGeometryPoint[]} points
 * @return {WayGeometryValidationIssue[]}
 */
function findDuplicatesPoints(points) {
    const issues = []

    const isPolygon = isClosedWay(points)
    if (!isPolygon) {
        for (let i = 2; i < points.length; i++) {
            const point = points[i]
            if (points[i - 2].id === point.id || areSamePointCoordinates(points[i - 2], point)) {
                const prevDup = points[i - 2]
                issues.push({
                    code: "duplicate_points",
                    description: `Duplicate point in geometry at positions ${prevDup.index + 1} and ${point.index + 1}`,
                    points: [prevDup, point],
                })
            }
            if (points[i - 1].id === point.id || areSamePointCoordinates(points[i - 1], point)) {
                const prevDup = points[i - 1]
                issues.push({
                    code: "duplicate_points",
                    description: `Duplicate point in geometry at positions ${prevDup.index + 1} and ${point.index + 1}`,
                    points: [prevDup, point],
                })
            }
        }
        return issues
    }

    /** @type {Map<string, WayGeometryPoint[]>} */
    const duplicateGroupsMap = new Map()
    points.forEach(point => {
        const key = makePointKey(point)
        if (!duplicateGroupsMap.has(key)) {
            duplicateGroupsMap.set(key, [])
        }
        duplicateGroupsMap.get(key).push(point)
    })

    duplicateGroupsMap.forEach(group => {
        if (group.length < 2) {
            return
        }
        issues.push({
            code: "duplicate_points",
            description: `Duplicate point in geometry at positions ${group.map(i => i.index + 1).join(", ")}`,
            points: group,
        })
    })
    return issues
}

/**
 * @param {WayGeometryPoint[]} points
 * @param {boolean} isPolygon
 * @return {WayGeometryValidationIssue[]}
 */
function findSelfIntersections(points, isPolygon) {
    const issues = []
    const segmentsCount = points.length - 1
    for (let i = 0; i < segmentsCount; i++) {
        const a = points[i]
        const b = points[i + 1]
        if (areSamePointCoordinates(a, b)) {
            continue
        }
        for (let j = i + 2; j < segmentsCount; j++) {
            if (isPolygon && i === 0 && j === segmentsCount - 1) {
                continue
            }
            const c = points[j]
            const d = points[j + 1]
            if (a.id === c.id || b.id === c.id || a.id === d.id || b.id === d.id) {
                continue
            }
            if (areSamePointCoordinates(c, d)) {
                continue
            }
            if (!segmentsCrossOrOverlap(a, b, c, d)) {
                continue
            }
            issues.push({
                code: "self_intersection",
                description: `Self-intersection between segments ${i + 1}-${i + 2} and ${j + 1}-${j + 2}`,
                pointGroups: [
                    [a, b],
                    [c, d],
                ],
            })
        }
    }
    return issues
}

/**
 * @param {WayGeometryPoint[]} points
 * @return {WayGeometryValidationIssue[]}
 */
function findPointsOnSegments(points) {
    const issues = []
    const segmentsCount = points.length - 1

    for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
        const point = points[pointIndex]
        for (let segmentIndex = 0; segmentIndex < segmentsCount; segmentIndex++) {
            if (pointIndex === segmentIndex || pointIndex === segmentIndex + 1) {
                continue
            }
            const segmentStart = points[segmentIndex]
            const segmentEnd = points[segmentIndex + 1]
            if (areSamePointCoordinates(segmentStart, segmentEnd)) {
                continue
            }
            if (areSamePointCoordinates(point, segmentStart) || areSamePointCoordinates(point, segmentEnd)) {
                continue
            }
            if (abs(area(segmentStart, segmentEnd, point)) >= EPS) {
                continue
            }
            if (!isPointInBbox(point, segmentStart, segmentEnd)) {
                continue
            }
            issues.push({
                code: "point_on_segment",
                description: `Point ${pointIndex + 1} lies on segment ${segmentIndex + 1}-${segmentIndex + 2}`,
                pointGroups: [[point], [segmentStart, segmentEnd]],
            })
        }
    }
    return issues
}

/**
 * @param {WayVersion} wayInfo
 * @param {Map<string, NodeVersion>} nodesMap
 * @return {WayGeometryValidationIssue[]}
 */
function validateWayGeometry(wayInfo, nodesMap) {
    if (!isDebug()) {
        return []
    }
    /** @type {number[]} */
    const nodeIDs = wayInfo?.nodes ?? []
    /** @type {WayGeometryPoint[]} */
    const points = nodeIDs.map((nodeID, index) => {
        const node = nodesMap.get(nodeID.toString()) ?? nodesMap.get(nodeID)
        return {
            index,
            id: nodeID,
            lat: node?.lat,
            lon: node?.lon,
        }
    })

    if (points.length === 0) {
        return [
            {
                code: "too_few_points",
                description: "Geometry has no points",
            },
        ]
    }

    const isPolygon = isClosedWay(points)
    const pointsForValidation = isPolygon ? points.slice(0, -1) : points
    const uniquePointKeys = new Set(pointsForValidation.map(makePointKey))
    const minPointsRequired = isPolygon ? 3 : 2
    /** @type {WayGeometryValidationIssue[]} */
    const issues = []

    if (uniquePointKeys.size < minPointsRequired) {
        issues.push({
            code: "too_few_points",
            description: isPolygon
                ? `Polygon should contain at least ${minPointsRequired} unique points`
                : `Line should contain at least ${minPointsRequired} unique points`,
            points: pointsForValidation,
        })
        return issues
    }

    issues.push(...findDuplicatesPoints(pointsForValidation))
    issues.push(...findSelfIntersections(points, isPolygon))
    issues.push(...findPointsOnSegments(points))

    return issues
}

/**
 * @param {WayGeometryValidationIssue[]} issues
 * @return {Set<number>}
 */
function collectWayGeometryAffectedNodeIds(issues) {
    const ids = new Set()
    issues.forEach(issue => {
        issue.points?.forEach(point => ids.add(point.id))
        issue.pointGroups?.forEach(group => group.forEach(point => ids.add(point.id)))
    })
    return ids
}

/**
 * @param {{id: number}[]} points
 * @return {boolean}
 */
function isClosedWay(points) {
    if (points.length < 2) {
        return false
    }
    return points[0].id === points[points.length - 1].id
}

/**
 * @param {LatLon} point
 * @return {string}
 */
function makePointKey(point) {
    return `${point.lat}|${point.lon}`
}

/**
 * @param {LatLon} p1
 * @param {LatLon} p2
 * @return {boolean}
 */
function areSamePointCoordinates(p1, p2) {
    return p1.lat === p2.lat && p1.lon === p2.lon
}

/**
 * @param {LatLon} a
 * @param {LatLon} b
 * @param {LatLon} c
 * @return {number}
 */
function area(a, b, c) {
    return (b.lon - a.lon) * (c.lat - a.lat) - (b.lat - a.lat) * (c.lon - a.lon)
}

/**
 * @param {LatLon} a
 * @param {LatLon} b
 * @param {LatLon} c
 * @return {-1|0|1}
 */
function orientation(a, b, c) {
    const ar = area(a, b, c)
    return ar > 0 ? 1 : ar < 0 ? -1 : 0
}

/**
 * @param {LatLon} a
 * @param {LatLon} b
 * @param {LatLon} c
 * @param {LatLon} d
 * @return {boolean}
 */
function segmentsCrossOrOverlap(a, b, c, d) {
    function intersect(a, b, c, d) {
        if (a > b) {
            ;[a, b] = [b, a]
        }
        if (c > d) {
            ;[c, d] = [d, c]
        }
        return max(a, c) <= min(b, d)
    }

    // prettier-ignore
    return intersect(a.lat, b.lat, c.lat, d.lat)
        && intersect(a.lon, b.lon, c.lon, d.lon)
        && area(a, b, c) * area(a, b, d) <= 0
        && area(c, d, a) * area(c, d, b) <= 0
}

const EPS = 1e-10

/**
 * @param {LatLon} p
 * @param {LatLon} a
 * @param {LatLon} b
 * @return {boolean}
 */
function isPointInBbox(p, a, b) {
    // prettier-ignore
    return p.lon <= max(a.lon, b.lon) + EPS
         && p.lon + EPS >= min(a.lon, b.lon)
         && p.lat <= max(a.lat, b.lat) + EPS
         && p.lat + EPS >= min(a.lat, b.lat)
}

//</editor-fold>

//<editor-fold desc="datetime-format-switch" defaultstate="collapsed">

let timestampMode = "natural_text"

function makeTimesSwitchable() {
    document.querySelectorAll("time:not([natural_text])").forEach(j => {
        j.setAttribute("natural_text", j.textContent)
        if (timestampMode !== "natural_text") {
            j.textContent = j.getAttribute("datetime")
        }
    })

    function switchTimestamp() {
        if (window.getSelection().type === "Range") {
            return
        }
        document.querySelectorAll("time:not([natural_text])").forEach(j => {
            j.setAttribute("natural_text", j.textContent)
        })

        function switchElement(j) {
            if (j.childNodes[0].textContent === j.getAttribute("natural_text")) {
                j.childNodes[0].textContent = j.getAttribute("datetime")
                timestampMode = "datetime"
            } else {
                j.childNodes[0].textContent = j.getAttribute("natural_text")
                timestampMode = "natural_text"
            }
            if (j.querySelector(".timeback-btn") && j.nextElementSibling?.id !== "timeback-btn") {
                j.querySelector(".timeback-btn").style.display = ""
            }
        }

        document.querySelectorAll("time").forEach(switchElement)
    }

    const isObjectPage = location.pathname.includes("node") || location.pathname.includes("way") || location.pathname.includes("relation")
    const isNotePage = location.pathname.includes("note")
    const mapStateTimeLabel = isObjectPage
        ? t("datetimeFormatSwitch.versionWasCreated")
        : isNotePage
          ? t("datetimeFormatSwitch.noteWasCreated")
          : t("datetimeFormatSwitch.changesetWasClosed")

    async function openMapStateInOverpass(elem, adiff = false) {
        const { lng: lon, lat: lat } = getMapCenter()
        const zoom = getZoom()
        let beforeChangesetPrefix = ""
        let changesetId = elem.parentElement.querySelector(".changeset_id")?.href?.match(/\/changeset\/([0-9]+)/)?.[1]
        if (!changesetId) {
            changesetId = location.pathname.match(/\/changeset\/([0-9]+)/)?.[1]
        }
        if (changesetId) {
            const metadata = await loadChangesetMetadata(parseInt(changesetId))
            const createdAtDate = new Date(metadata.created_at)
            createdAtDate.setTime(createdAtDate.getTime() - 1000)
            beforeChangesetPrefix = `[${adiff ? "adiff" : "date"}:"${createdAtDate.toISOString().slice(0, -5) + "Z"}"]; // time before opening changeset\n`
        }
        const closingTimePrefix = `[${adiff ? "adiff" : "date"}:"${elem.getAttribute("datetime")}"]; // changeset closing time\n`
        const queryBody = `(
  node({{bbox}});
  way({{bbox}});
  //relation({{bbox}});
);
(._;>;);
out meta;
`
        const query =
            beforeChangesetPrefix && !adiff ? "//" + beforeChangesetPrefix + closingTimePrefix + queryBody : closingTimePrefix + queryBody
        window.open(`${overpass_server.url}?Q=${encodeURI(query)}&C=${lat};${lon};${zoom}${zoom > 15 ? "&R" : ""}`, "_blank")
    }

    document.querySelectorAll("time:not([switchable])").forEach(i => {
        if (i.title !== "") {
            i.title += `\n\n`
        }
        i.title += t("datetimeFormatSwitch.clickToChangeTimeFormat")
        i.title +=
            "\n" +
            t("datetimeFormatSwitch.clickWithCtrlOpenMapState", { time: mapStateTimeLabel }) +
            "\n" +
            t("datetimeFormatSwitch.clickWithAltViewAdiff")

        async function clickEvent(e) {
            if (e.metaKey || e.ctrlKey || e.altKey) {
                if (window.getSelection().type === "Range") {
                    return
                }
                await openMapStateInOverpass(i, e.altKey)
            } else {
                switchTimestamp()
            }
        }

        i.addEventListener("click", clickEvent)
    })
    document.querySelectorAll("time:not([switchable])").forEach(i => {
        i.setAttribute("switchable", "true")
        const btn = document.createElement("a")
        btn.classList.add("timeback-btn")
        btn.title = t("datetimeFormatSwitch.openMapStateAtTime", { time: mapStateTimeLabel })
        btn.textContent = " 🕰"
        btn.style.cursor = "pointer"
        btn.style.display = "none"
        btn.style.userSelect = "none"
        btn.onclick = async e => {
            e.preventDefault()
            e.stopPropagation()
            await openMapStateInOverpass(i, e.altKey)
        }
        i.appendChild(btn)
    })
}

//</editor-fold>

//<editor-fold desc="short-links" defaultstate="collapsed">

/**
 * @param {string} text
 * @returns {string}
 */
function shortOsmOrgLinksInText(text) {
    return text
        .replaceAll("https://www.openstreetmap.org", "osm.org")
        .replaceAll("https://wiki.openstreetmap.org/wiki/", "osm.wiki/")
        .replaceAll("https://wiki.openstreetmap.org", "osm.wiki")
        .replaceAll("https://community.openstreetmap.org", "c.osm.org")
        .replaceAll("https://openstreetmap.org", "osm.org")
}

function shortOsmOrgLinks(elem) {
    if (!GM_config.get("ImagesAndLinksInTags")) return
    elem?.querySelectorAll(
        'a[href^="https://www.openstreetmap.org"], a[href^="https://wiki.openstreetmap.org"], a[href^="https://community.openstreetmap.org"], a[href^="https://openstreetmap.org"]',
    )?.forEach(i => {
        i.textContent = shortOsmOrgLinksInText(i.textContent)
    })
}
//</editor-fold>

//<editor-fold desc="find-user-in-dif" defaultstate="collapsed">

async function decompressBlob(blob) {
    const ds = new DecompressionStream("gzip")
    const decompressedStream = blob.stream().pipeThrough(ds)
    return await new Response(decompressedStream).blob()
}

async function tryFindChangesetInDiffGZ(gzURL, changesetId) {
    const diffGZ = await externalFetchRetry({
        method: "GET",
        url: gzURL,
        responseType: "blob",
    })
    const blob = await decompressBlob(diffGZ.response)
    const diffXML = await blob.text()

    const diffParser = new DOMParser()
    const doc = diffParser.parseFromString(diffXML, "application/xml")
    return doc.querySelector(`osm changeset[id='${changesetId}']`)
}

async function parseBBB(target, url) {
    const response = await externalFetchRetry({
        method: "GET",
        url: planetOrigin + "/replication/changesets/" + url,
    })
    const BBBHTML = new DOMParser().parseFromString(response.responseText, "text/html")

    const a = Array.from(BBBHTML.querySelector("pre").childNodes).slice(2)
    let x = 0
    let found = false
    for (x; x < a.length; x += 2) {
        const num = parseInt(a[x].textContent)
        let d = new Date(a[x + 1].textContent.trim().slice(0, -1).trim())
        if (url === "003/") {
            if (num === 115) {
                d = new Date("2018-10-1918T01:21:00Z")
            } else if (num === 116) {
                d = new Date("2018-10-1918T13:25:00Z")
            }
        }
        if (target < d) {
            found = true
            break
        }
    }
    if (x === 0) {
        return found ? [a[x].getAttribute("href"), a[x].getAttribute("href")] : false
    } else {
        return found ? [a[x].getAttribute("href"), a[x - 2].getAttribute("href")] : false
    }
}

async function parseCCC(target, url) {
    const response = await externalFetchRetry({
        method: "GET",
        url: planetOrigin + "/replication/changesets/" + url,
    })
    const CCCHTML = new DOMParser().parseFromString(response.responseText, "text/html")

    const a = Array.from(CCCHTML.querySelector("pre").childNodes).slice(2)
    let x = 0
    let found = false
    /**
     * HTML format:
     *              xxx.ext         datetime
     *              xxx.state.txt   datetime <for new changesets>
     *              file.tmp        datetime <sometimes>
     *              yyy.ext         ....
     */
    for (x; x < a.length; x += 2) {
        if (!a[x].textContent.match(/^\d+\.osm\.gz$/)) {
            continue
        }
        const d = new Date(a[x + 1].textContent.trim().slice(0, -1).trim().split(" ").slice(0, -1).join(" ").trim() + " UTC")
        if (target <= d) {
            found = true
            break
        }
    }
    if (!found) {
        return false
    }
    if (x + 2 >= a.length) {
        return [a[x].getAttribute("href"), a[x].getAttribute("href")]
    }
    try {
        // state files are missing in old diffs folders
        if (a[x + 2].getAttribute("href")?.match(/^\d+\.osm\.gz$/)) {
            return [a[x].getAttribute("href"), a[x + 2].getAttribute("href")]
        }
    } catch {
        /* empty */
    }
    if (x + 4 >= a.length) {
        return [a[x].getAttribute("href"), a[x].getAttribute("href")]
    }
    return [a[x].getAttribute("href"), a[x + 4].getAttribute("href")]
}

async function checkBBB(AAA, BBB, targetTime, targetChangesetID) {
    const CCC = await parseCCC(targetTime, AAA + BBB)
    if (!CCC) {
        return
    }
    const gzURL = planetOrigin + "/replication/changesets/" + AAA + BBB

    let foundedChangeset = await tryFindChangesetInDiffGZ(gzURL + CCC[0], targetChangesetID)
    if (!foundedChangeset) {
        foundedChangeset = await tryFindChangesetInDiffGZ(gzURL + CCC[1], targetChangesetID)
    }
    return foundedChangeset
}

async function checkAAA(AAA, targetTime, targetChangesetID) {
    const BBBs = await parseBBB(targetTime, AAA)
    if (!BBBs) {
        return
    }

    let foundedChangeset = await checkBBB(AAA, BBBs[0], targetTime, targetChangesetID)
    if (!foundedChangeset) {
        foundedChangeset = await checkBBB(AAA, BBBs[1], targetTime, targetChangesetID)
    }
    return foundedChangeset
}

/**
 * @param {string} datetime
 * @return {Promise<{user: string|null, uid: string|null}>}
 */
async function tryFindDeletedChangesetAuthorViaOverpass(datetime) {
    let foundedChangesetXml
    const match = location.pathname.match(/\/(node|way|relation|changeset)\/(\d+)/)
    let [, type, objID] = match
    if (type === "changeset") {
        const ch = (await getChangeset(objID)).data
        type = ch.querySelector(`[changeset="${objID}"]`).nodeName
        objID = ch.querySelector(`[changeset="${objID}"]`).getAttribute("id")
    }
    if (new Date(datetime) > OVERPASS_NEW_ERA_DATE) {
        if (type === "node") {
            foundedChangesetXml = await getNodeViaOverpassXML(objID, datetime)
        } else if (type === "way") {
            foundedChangesetXml = await getWayViaOverpassXML(objID, datetime)
        } else if (type === "relation") {
            foundedChangesetXml = await getRelationViaOverpassXML(objID, datetime)
        }
    }
    if (!foundedChangesetXml?.getAttribute("user")) {
        foundedChangesetXml = null
        console.log("Loading via overpass failed. Try via diffs")
    }
    return {
        user: foundedChangesetXml?.getAttribute("user"),
        uid: foundedChangesetXml?.getAttribute("uid"),
    }
}
/**
 * @param {string} datetime
 * @param {number} targetChangesetID
 * @return {Promise<{user: string|null, uid: string|null}>}
 */
async function tryFindDeletedChangesetAuthorViaDiffs(datetime, targetChangesetID) {
    if (new Date(datetime) < OVERPASS_NEW_ERA_DATE) {
        return { user: null, uid: null }
    }
    const response = await externalFetchRetry({
        method: "GET",
        url: planetOrigin + "/replication/changesets/",
    })
    const AAAHTML = new DOMParser().parseFromString(response.responseText, "text/html")
    const targetTime = new Date(datetime)
    targetTime.setSeconds(0)

    const a = Array.from(AAAHTML.querySelector("pre").childNodes).slice(2).slice(0, -4)
    a.push(...a.slice(-2))
    let x = 0
    for (x; x < a.length - 2; x += 2) {
        const d = new Date(a[x + 1].textContent.trim().slice(0, -1).trim())
        if (targetTime < d) break
    }
    let AAAs
    if (x === 0) {
        AAAs = [a[x].getAttribute("href"), a[x].getAttribute("href")]
    } else {
        AAAs = [a[x - 2].getAttribute("href"), a[x].getAttribute("href")]
    }

    let foundedChangeset = await checkAAA(AAAs[0], targetTime, targetChangesetID)
    if (!foundedChangeset) {
        foundedChangeset = await checkAAA(AAAs[1], targetTime, targetChangesetID)
    }
    return {
        user: foundedChangeset?.getAttribute("user"),
        uid: foundedChangeset?.getAttribute("uid"),
    }
}

/**
 * @param {string} datetime
 * @param {number} targetChangesetID
 * @return {Promise<{user: string|null, uid: string|null}>}
 */
async function tryFindDeletedChangesetAuthorViaWhosthat(datetime, targetChangesetID) {
    const userID = (await loadChangesetMetadata(targetChangesetID)).uid

    const res = await externalFetchRetry({
        url: "https://whosthat.osmz.ru/whosthat.php?action=names&id=" + userID,
        responseType: "json",
    })
    // FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
    // but here need resolve problem with return promise
    const userInfo = structuredClone(res.response)
    if (userInfo?.[0]?.["names"]?.length > 1) {
        // todo what if multiple names?
        return { user: userInfo[0]["names"][0], uid: userID }
    }
    return { user: null, uid: null }
}

// tests
// https://osm.org/way/488322838/history
// https://osm.org/way/74034517/history
// https://osm.org/relation/17425783/history
// https://osm.org/way/554280669/history
// https://osm.org/node/4122049406 (/replication/changesets/005/638/ contains .tmp files)
// https://osm.org/node/2/history (very hard)
// https://osm.org/changeset/5427478
async function findChangesetInDiff(e) {
    e.preventDefault()
    e.stopPropagation()
    e.target.style.cursor = "progress"

    const changesetID = parseInt(e.target.value)
    let foundedInfo = await tryFindDeletedChangesetAuthorViaOverpass(e.target.datetime)
    if (!foundedInfo.user) {
        foundedInfo = await tryFindDeletedChangesetAuthorViaDiffs(e.target.datetime, changesetID)
        if (!foundedInfo.user) {
            foundedInfo = await tryFindDeletedChangesetAuthorViaWhosthat(e.target.datetime, changesetID)
            if (!foundedInfo.user) {
                alert(t("findUserInDiff.notFoundAlert"))
                e.target.style.cursor = "pointer"
                return
            }
        }
    }
    let { user: foundedUser, uid: foundedUserUid } = foundedInfo
    const userInfo = document.createElement("a")
    userInfo.setAttribute("href", "/user/" + foundedUser)
    userInfo.style.cursor = "pointer"
    userInfo.textContent = foundedUser

    e.target.before(document.createTextNode("\xA0"))
    e.target.before(userInfo)
    e.target.before(document.createTextNode("\xA0"))

    const uid = document.createElement("span")
    uid.style.cursor = "pointer"
    uid.title = t("findUserInDiff.clickForCopyUserId")
    uid.onclick = e => {
        const text = foundedUserUid
        navigator.clipboard.writeText(text).then(() => copyAnimation(e, text))
    }
    uid.textContent = foundedUserUid

    e.target.before(document.createTextNode(t("findUserInDiff.userIdLabel")))
    e.target.before(uid)
    e.target.before(document.createTextNode("\xA0"))

    const webArchiveLink = document.createElement("a")
    webArchiveLink.textContent = t("links.webArchive")
    webArchiveLink.target = "_blank"
    webArchiveLink.href = "https://web.archive.org/web/*/https://www.openstreetmap.org/user/" + foundedUser
    e.target.before(webArchiveLink)
    e.target.before(document.createTextNode("\xA0"))

    e.target.remove()
}

//</editor-fold>

//<editor-fold desc="restriction-render" defaultstate="collapsed">

/**
 * @param {{lat: number, lon: number}} latLon1
 * @param {{lat: number, lon: number}} latLon2
 * @return {boolean}
 */
function isEqualCoordinates(latLon1, latLon2) {
    return latLon1.lat === latLon2.lat && latLon1.lon === latLon2.lon
}

/**
 * @param {ExtendedRelationVersion} rel
 * @return {string[]}
 */
function validateRestriction(rel) {
    /** @type {ExtendedRelationWayMember[]} */
    const from = []
    /** @type {ExtendedRelationNodeMember[]} */
    const viaNodes = []
    /** @type {ExtendedRelationWayMember[]} */
    const viaWays = []
    /** @type {ExtendedRelationWayMember[]} */
    const to = []
    const errors = []
    const { value: restrictionValue } = getRestrictionKeyValue(rel.tags)
    rel.members?.forEach(i => {
        if (i.type === "way" && i.role === "from") {
            from.push(i)
        } else if (i.type === "way" && i.role === "to") {
            to.push(i)
        } else if (i.type === "node" && i.role === "via") {
            viaNodes.push(i)
        } else if (i.type === "way" && i.role === "via") {
            viaWays.push(i)
        } else {
            errors.push(`Incorrect member: ${i.type}/${i.ref} with "${i.role}" role`)
        }
    })
    if (viaNodes.length + viaWays.length === 0) {
        errors.push('Missing member with "via" role')
    }
    if (from.length === 0) {
        errors.push('Missing member with "from" role')
    }
    if (to.length === 0) {
        errors.push('Missing member with "to" role')
    }

    ;[...from, ...to].forEach(i => {
        if (i.geometry?.length < 2) {
            errors.push(`${i.type}/${i.ref} (${i.role}) way contains < 2 nodes`)
        }
    })
    if (restrictionValue !== "no_exit" && restrictionValue !== "no_entry") {
        if (from.length > 1) {
            errors.push('Multiple "from" role')
        }
        if (to.length > 1) {
            errors.push('Multiple "to" role')
        }
    }
    if (errors.length) return errors

    if (viaNodes.length && viaWays.length) {
        errors.push(`Mixed "node" and "way" types for via role`)
    } else if (viaNodes.length > 1) {
        errors.push(`multiple "via" nodes`)
    } else if (viaWays.length > 0) {
        let lastNode = from[0].geometry[0]
        if (isEqualCoordinates(from[0].geometry[0], lastNode)) {
            lastNode = viaWays[0].geometry[viaWays[0].geometry.length - 1]
        } else if (isEqualCoordinates(from[0].geometry[from[0].geometry.length - 1], lastNode)) {
            lastNode = viaWays[0].geometry[0]
        } else {
            errors.push(`"from" -> "via" are arranged in the wrong order or torn`)
        }
        viaWays.slice(1).forEach(w => {
            if (isEqualCoordinates(w.geometry[0], lastNode)) {
                lastNode = w.geometry[w.geometry.length - 1]
            } else if (isEqualCoordinates(w.geometry[w.geometry.length - 1], lastNode)) {
                lastNode = w.geometry[0]
            } else {
                errors.push(`"via" -> "via" are arranged in the wrong order or torn`)
            }
        })
        if (!isEqualCoordinates(to[0].geometry[0], lastNode) && isEqualCoordinates(to[0].geometry[to[0].geometry.length - 1], lastNode)) {
            errors.push(`"via" -> "to" are arranged in the wrong order or torn`)
        }
    }
    if (errors.length) return errors

    return errors
}

// prettier-ignore
const restrictionColors = {
    no_left_turn:       "#ff0000",
    no_right_turn:      "#ff0000",
    no_straight_on:     "#ff0000",
    no_u_turn:          "#ff0000",
    only_right_turn:    "#0000ff",
    only_left_turn:     "#0000ff",
    only_straight_on:   "#0000ff",
    no_entry:           "#ff0000",
    no_exit:            "#ff0000",
}

const restrictionsImagesPrefix =
    "https://raw.githubusercontent.com/deevroman/better-osm-org/ad552f9f3a3d7a11a1448686fc0ccc164905bf62/misc/assets/icons/restrictions/"
// prettier-ignore
const restrictionsSignImages = {
    no_left_turn:       restrictionsImagesPrefix + "France_road_sign_B2a.svg",
    no_right_turn:      restrictionsImagesPrefix + "France_road_sign_B2b.svg",
    no_straight_on:     restrictionsImagesPrefix + "MUTCD_R3-27.svg",
    no_u_turn:          restrictionsImagesPrefix + "France_road_sign_B2c.svg",
    only_right_turn:    restrictionsImagesPrefix + "France_road_sign_B21c1.svg",
    only_left_turn:     restrictionsImagesPrefix + "France_road_sign_B21c2.svg",
    only_straight_on:   restrictionsImagesPrefix + "France_road_sign_B21b.svg",
    no_entry:           restrictionsImagesPrefix + "RU_road_sign_3.1.svg",
    no_exit:            restrictionsImagesPrefix + "RU_road_sign_3.1.svg",
}

function azimuth(lat1, lon1, lat2, lon2) {
    const p1 = toMercator(lat1, lon1)
    const p2 = toMercator(lat2, lon2)

    const dx = p2.x - p1.x
    const dy = p2.y - p1.y

    const angleRad = Math.atan2(dx, dy)
    const angleDeg = (angleRad * 180) / Math.PI

    return (angleDeg + 360) % 360
}

function getRestrictionKeyValue(tags) {
    const key = Object.keys(tags).find(k => k === "restriction" || k === "was:restriction" || k.startsWith("restriction:"))
    return {
        key: key,
        value: tags[key],
    }
}

function renderArrowHead(startPoint, endPoint, angle, len, arrows, layer, color) {
    const { lat: p1_lat, lon: p1_lon } = startPoint
    const { lat: p2_lat, lon: p2_lon } = endPoint
    const rotated1 = rotateSegment(p1_lat, p1_lon, p2_lat, p2_lon, -angle, len)
    const rotated2 = rotateSegment(p1_lat, p1_lon, p2_lat, p2_lon, angle, len)
    arrows.push(displayWay([startPoint, rotated1], false, "white", 7, null, layer))
    arrows.push(displayWay([startPoint, rotated2], false, "white", 7, null, layer))
    arrows.push(displayWay([startPoint, rotated1], false, color, 4, null, layer))
    arrows.push(displayWay([startPoint, rotated2], false, color, 4, null, layer))
}

/**
 * @param {ExtendedRelationVersion} rel
 * @param {string} color
 * @param {string} layer
 * @return {[]}
 */
function renderRestriction(rel, color, layer) {
    /** @type {ExtendedRelationWayMember[]} */
    const from = []
    /** @type {ExtendedRelationNodeMember[]} */
    const viaNodes = []
    /** @type {ExtendedRelationWayMember[]} */
    const viaWays = []
    /** @type {ExtendedRelationWayMember[]} */
    const to = []
    rel.members?.forEach(i => {
        if (i.type === "way" && i.role === "from") {
            from.push(i)
        } else if (i.type === "way" && i.role === "to") {
            to.push(i)
        } else if (i.type === "node" && i.role === "via") {
            viaNodes.push(i)
        } else if (i.type === "way" && i.role === "via") {
            viaWays.push(i)
        }
    })
    let via = viaNodes?.[0]
    if (!via) {
        if (
            isEqualCoordinates(from[0].geometry[0], viaWays[0].geometry[0]) ||
            isEqualCoordinates(from[0].geometry[from[0].geometry.length - 1], viaWays[0].geometry[0])
        ) {
            via = viaWays[0].geometry[0]
        } else if (
            isEqualCoordinates(from[0].geometry[0], viaWays[0].geometry[viaWays[0].geometry.length - 1]) ||
            isEqualCoordinates(from[0].geometry[from[0].geometry.length - 1], viaWays[0].geometry[viaWays[0].geometry.length - 1])
        ) {
            via = viaWays[0].geometry[viaWays[0].geometry.length - 1]
        } else {
            console.error("Restriction validation error")
            debug_alert("Restriction validation error")
        }
    }
    const { value: restrictionValue } = getRestrictionKeyValue(rel.tags)
    const angle = 25
    const len = 7
    const arrows = []
    let signAngle = 0.0
    from.forEach(f => {
        let startPoint = f.geometry[0]
        let endPoint = f.geometry[1]
        // оверпас не даёт айдишники точек геометрии
        if (isEqualCoordinates(f.geometry[f.geometry.length - 1], via)) {
            startPoint = f.geometry[f.geometry.length - 1]
            endPoint = f.geometry[f.geometry.length - 2]
        }
        signAngle = 360 - azimuth(endPoint.lat, endPoint.lon, startPoint.lat, startPoint.lon)
        console.debug(signAngle)
        // renderArrowHead(startPoint, endPoint, angle, len, arrows, layer, color)
    })
    to.forEach(t => {
        let startPoint = t.geometry[0]
        let endPoint = t.geometry[1]
        if (t.geometry[0].lat === via.lat && t.geometry[0].lon === via.lon) {
            if (from.length > 1) {
                signAngle = 360 - azimuth(endPoint.lat, endPoint.lon, startPoint.lat, startPoint.lon)
            }
            startPoint = t.geometry[t.geometry.length - 1]
            endPoint = t.geometry[t.geometry.length - 2]
        } else {
            if (from.length > 1) {
                signAngle =
                    360 -
                    azimuth(
                        t.geometry[t.geometry.length - 2].lat,
                        t.geometry[t.geometry.length - 2].lon,
                        t.geometry[t.geometry.length - 1].lat,
                        t.geometry[t.geometry.length - 1].lon,
                    )
            }
        }
        if (restrictionValue === "no_exit" || restrictionValue === "no_entry") {
            renderArrowHead(startPoint, endPoint, angle, len, arrows, layer, color)
        }
    })
    ;[100, 250, 500, 1000].forEach(t => {
        setTimeout(() => {
            arrows.forEach(i => i.bringToFront())
        }, t)
    })

    queueMicrotask(async () => {
        const imageUrl = restrictionsSignImages[restrictionValue]
        if (!imageUrl) {
            return
        }
        let img = await fetchTextWithCache(imageUrl)
        img = img.replace('version="1.1"', `style="rotate: -${Math.round(signAngle)}deg" version="1.1"`)

        function getSquareBounds(center) {
            const { x, y } = toMercator(center.lat, center.lon)
            // prettier-ignore
            return [
                [...Object.values(fromMercator(x - 10, y - 10))],
                [...Object.values(fromMercator(x + 10, y + 10))]
            ];
        }

        const imgLayer = getWindow()
            .L.imageOverlay(
                "data:image/svg+xml;base64," + btoa(img),
                intoPage(getSquareBounds(via, 0.0002)),
                intoPage({
                    interactive: true,
                    zIndex: 99999,
                    className: "restriction-img",
                }),
            )
            .addTo(getMap())
        arrows.push(imgLayer)
        arrows.forEach(l => layers[layer].push(l))
        imgLayer.bringToFront()
    })
    return arrows
}

function isRestrictionObj(tags) {
    return Object.entries(tags).some(([k]) => k === "restriction" || k === "was:restriction" || k.startsWith("restriction:"))
}

//</editor-fold>

//<editor-fold desc="direction-render" defaultstate="collapsed">

/**
 *
 * @param {float} lat
 * @param {float} lon
 * @param {string} values
 * @param {string} color
 */
function renderDirectionTag(lat, lon, values, color = c("#ff00e3")) {
    const cardinalToAngle = {
        N: 0.0,
        north: 0.0,
        NNE: 22.0,
        NE: 45.0,
        ENE: 67.0,
        E: 90.0,
        east: 90.0,
        ESE: 112.0,
        SE: 135.0,
        SSE: 157.0,
        S: 180.0,
        south: 180.0,
        SSW: 202.0,
        SW: 225.0,
        WSW: 247.0,
        W: 270.0,
        west: 270.0,
        WNW: 292.0,
        NW: 315.0,
        NNW: 337.0,
    }
    values.split(";").forEach(angleStr => {
        if (angleStr.slice(1).includes("-")) {
            if (angleStr === "0-360") {
                return
            }
            const [firstAngleStr, remAngleStr] = angleStr.split("-")
            const firstAngle = cardinalToAngle[firstAngleStr] !== undefined ? cardinalToAngle[firstAngleStr] : parseFloat(firstAngleStr)
            const remAngle = cardinalToAngle[remAngleStr] !== undefined ? cardinalToAngle[remAngleStr] : parseFloat(remAngleStr)
            if (!isNaN(firstAngle) && !isNaN(remAngle)) {
                drawRay(lat, lon, firstAngle, color)
                drawRay(lat, lon, remAngle, color)
            }
        } else {
            const angle = cardinalToAngle[angleStr] !== undefined ? cardinalToAngle[angleStr] : parseFloat(angleStr)
            if (!isNaN(angle)) {
                drawRay(lat, lon, angle - 30, color)
                drawRay(lat, lon, angle + 30, color)
            }
        }
    })
}

//</editor-fold>

//<editor-fold desc="editor-utils" defaultstate="collapsed">

/**
 * @param {XMLDocument} doc
 * @param {HTMLElement} node
 * @param {Object.<string, string>} tags
 */
function tagsToXml(doc, node, tags) {
    for (const [k, v] of Object.entries(tags)) {
        const tag = doc.createElement("tag")
        tag.setAttribute("k", k)
        tag.setAttribute("v", v)
        node.appendChild(tag)
    }
}

/**
 *
 * @param {string} text
 * @param {boolean} strict
 * @return {Map<string, string>}
 */
function buildTags(text, strict = false) {
    const lines = text.split("\n")
    const tags = new Map()
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i]
        let eqPos = line.indexOf("=")
        if (eqPos <= 0 || eqPos === line.length - 1) {
            eqPos = line.indexOf("\t")
            if (eqPos <= 0 || eqPos === line.length - 1) {
                if (strict && line.trim() !== "") {
                    throw `Empty key or value in line №${i}: ${line}`
                }
                continue
            }
        }
        const k = line.substring(0, eqPos).trim()
        const v = line.substring(eqPos + 1).trim()
        if (v === "" || k === "") {
            if (strict && line.trim() !== "") {
                throw `Empty key or value in line №${i + 1}: ${line}`
            }
            continue
        }
        tags.set(k, v.replaceAll("\\\\", "\n"))
    }
    return tags
}

/**
 * @param {Map<string, string>} a
 * @param {Map<string, string>} b
 * @return {boolean}
 */
function isDifferentTagsMap(a, b) {
    return JSON.stringify(Object.fromEntries(a)) !== JSON.stringify(Object.fromEntries(b))
}

//</editor-fold>

//<editor-fold desc="search-form-fixes" defaultstate="collapsed">

function showSearchForm() {
    document.querySelector("#sidebar .search_forms")?.removeAttribute("hidden")
    document.querySelector("#sidebar .search_forms")?.style?.removeProperty("display") // quick fix
}

function hideSearchForm() {
    if (location.pathname.startsWith("/search") || location.pathname.startsWith("/directions")) return
    if (!document.querySelector("#sidebar .search_forms")?.hasAttribute("hidden")) {
        document.querySelector("#sidebar .search_forms")?.setAttribute("hidden", "true")
        document.querySelector("#sidebar .search_forms")?.style?.setProperty("display", "none", "important") // quick fix
    }

    document.querySelector(".sidebar-close-controls .btn-close:not(.hotkeyed)")?.addEventListener("click", () => {
        showSearchForm()
        cleanAllObjects()
    })
    document.querySelector(".sidebar-close-controls .btn-close:not(.hotkeyed)")?.classList?.add("hotkeyed")
    document.querySelector("h1 .icon-link:not(.hotkeyed)")?.addEventListener("click", () => {
        showSearchForm()
        cleanAllObjects()
    })
    document.querySelector("h1 .icon-link:not(.hotkeyed)")?.classList?.add("hotkeyed")
}

function blurSearchField() {
    if (document.querySelector("#sidebar #query") && !document.querySelector("#sidebar #query").getAttribute("blured")) {
        document.querySelector("#sidebar #query").setAttribute("blured", "true")
        document.querySelector("#sidebar #query").removeAttribute("autofocus")
        if (document.activeElement?.nodeName === "INPUT") {
            document.activeElement?.blur()
        }
        // dirty hack. If your one multiple tabs focus would reseted only on active tab
        // In the case of Safari, this is generally a necessity.
        // Sometimes it still doesn't help
        ;[50, 100, 250, 500].forEach(ms => {
            setTimeout(() => {
                if (document.activeElement?.nodeName === "INPUT" && document.activeElement.getAttribute("type") !== "radio") {
                    document.activeElement?.blur()
                }
            }, ms)
        })
    }
}

function resetSearchFormFocus() {
    if (!GM_config.get("ResetSearchFormFocus")) {
        return
    }
    blurSearchField()
}

//</editor-fold>

//<editor-fold desc="changeset-page-fixes" defaultstate="collapsed">

function makeContextMenuElem(e) {
    const x = e.pageX ? e.pageX : e.target.getBoundingClientRect().right
    const y = e.pageY ? e.pageY : e.target.getBoundingClientRect().bottom
    const menu = document.createElement("div")
    menu.classList.add("betterOsmContextMenu")
    menu.style.left = `${max(5, x - 30)}px`
    menu.style.top = `${y}px`
    return menu
}

function makeUsernameInNotesFilterable() {
    let usernameLink = document.querySelector("#sidebar_content .details p")?.querySelector('a[href^="/user/"]')
    if (!usernameLink) {
        usernameLink = document.createElement("a")
        usernameLink.setAttribute("href", "/user/anon")
        usernameLink.style.display = "none"
        document.querySelector(".details time").before(usernameLink)
        if (usernameLink.previousSibling?.nodeType === Node.TEXT_NODE) {
            usernameLink.previousSibling.textContent = usernameLink.previousSibling.textContent.trim()
        }
    }
    if (usernameLink.classList.contains("filterable")) {
        return
    }
    usernameLink.classList.add("filterable")
    const username = decodeURI(usernameLink.getAttribute("href").match(/\/user\/(.*)$/)[1])
    const filterIcon = document.createElement("span")
    filterIcon.innerHTML = filterIconSvg
    filterIcon.style.cursor = "pointer"
    filterIcon.style.position = "relative"
    filterIcon.style.top = "-2px"
    filterIcon.style.marginLeft = "3px"
    filterIcon.style.marginRight = "3px"
    filterIcon.onclick = () => {
        if (document.querySelector(".layers-ui").style.display === "none") {
            document.querySelector(".control-layers a").click()
        }
        Array.from(document.querySelectorAll(".overlay-layers label"))[0].scrollIntoView({ block: "center" })
        if (document.querySelector("#filter-notes-by-username").value === "") {
            document.querySelector("#filter-notes-by-username").value = username
        } else {
            document.querySelector("#filter-notes-by-username").value += "," + username
        }
        document.querySelector("#filter-notes-by-username")?.classList?.add("wait-fetch")
        updateNotesLayer()
    }
    usernameLink.after(filterIcon)
    if (filterIcon.nextSibling?.nodeType === Node.TEXT_NODE) {
        filterIcon.nextSibling.remove()
    }
}

// todo remove this
// prettier-ignore
const mainTags = ["shop", "building", "amenity", "man_made", "highway", "natural", "aeroway", "historic", "railway", "tourism", "landuse", "leisure"]

async function getPrevNextChangesetsIDs(changeset_id) {
    const changesetMetadata = await loadChangesetMetadata(changeset_id)
    if (!changesetMetadata || !changesetMetadata.uid) return

    const prevChangesetsPromise = fetchJSONWithCache(
        osm_server.apiBase +
            "changesets.json?" +
            new URLSearchParams({
                user: changesetMetadata.uid.toString(),
                order: "newest",
                from: "2005-01-01T00:00:00Z",
                to: new Date(new Date(changesetMetadata.created_at).getTime() + 1000).toISOString(), // на случай если в одну секунду создано несколько пакетов правок
            }).toString(),
    )

    /*** @type {{changesets: ChangesetMetadata[]}}*/
    const nextChangesets = await fetchJSONWithCache(
        osm_server.apiBase +
            "changesets.json?" +
            new URLSearchParams({
                user: changesetMetadata.uid.toString(),
                order: "oldest",
                from: changesetMetadata.created_at,
                to: new Date().toISOString(),
            }).toString(),
    )

    /*** @type {{changesets: ChangesetMetadata[]}}*/
    const prevChangesets = await prevChangesetsPromise

    return [prevChangesets.changesets.find(i => i.id < changeset_id)?.id, nextChangesets.changesets.find(i => i.id > changeset_id)?.id]
}

async function restorePrevNextChangesetButtons(changeset_id) {
    if (document.querySelector(".restored-secondary-actions")) return
    console.log("try restore prev/next deleted user's changesets")
    const [prevID, nextID] = await getPrevNextChangesetsIDs(changeset_id)
    if (!prevID && !nextID) return
    const secondaryActions = document.querySelector("#sidebar_content .secondary-actions")
    const secondarySecondaryActions = document.createElement("div")
    secondarySecondaryActions.classList.add("secondary-actions", "restored-secondary-actions")
    if (prevID) {
        const prevLink = document.createElement("a")
        prevLink.classList.add("icon-link")
        prevLink.href = "/changeset/" + prevID
        prevLink.innerHTML =
            '<svg width="8" height="11" viewBox="-8 0 8 11"><path d="M-2,2 l-3.5,3.5 l3.5,3.5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>'
        prevLink.appendChild(document.createTextNode(prevID.toString()))
        secondarySecondaryActions.appendChild(prevLink)
    }
    secondarySecondaryActions.appendChild(document.createTextNode(` · ${(await loadChangesetMetadata(changeset_id)).user} · `))
    if (nextID) {
        const nextLink = document.createElement("a")
        nextLink.classList.add("icon-link")
        nextLink.href = "/changeset/" + nextID
        nextLink.innerHTML =
            '<svg width="8" height="11"><path d="M2,2 l3.5,3.5 l-3.5,3.5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>'
        nextLink.prepend(document.createTextNode(nextID.toString()))
        secondarySecondaryActions.appendChild(nextLink)
    }
    secondaryActions.after(secondarySecondaryActions)
}

let changesetObjectsSelectionModeEnabled = false

async function validateOsmServerInJOSM() {
    /**
     * @type {{
     *     application: string
     *     version: number
     *     osm_server: "custom"|"default"|undefined
     *     protocolversion: {
     *         major: number,
     *         minor: number
     *     }
     * }}
     */
    const res = await (
        await fetch("http://127.0.0.1:8111/version").catch(() => {
            alert(t("changesetPageFixes.josmNotRunningAlert"))
            throw "JOSM is not running"
        })
    ).json()
    if (res["osm_server"] === "custom" && osm_server === prod_server) {
        alert(t("changesetPageFixes.customOsmApiInJosmAlert"))
        return false
    }
    if (res["osm_server"] === "default" && osm_server !== prod_server) {
        alert(t("changesetPageFixes.otherOsmInstanceInJosmAlert"))
        return false
    }
    return true
}

function makeUsernameTitle(userInfo) {
    let title = `changesets_count: ${userInfo["changesets"]["count"]}\naccount_created: ${userInfo["account_created"]}`
    if (userInfo["blocks"]["received"]["count"]) {
        title += `\nreceived_blocks: ${userInfo["blocks"]["received"]["count"]}`
    }
    return title
}

async function getOsmchaToken() {
    const tokens = await GM.getValue(isOHMServer() ? "OHM_OSMCHA_TOKENS" : "OSMCHA_TOKENS")
    const fallback = await GM.getValue(isOHMServer() ? "OHM_OSMCHA_TOKEN" : "OSMCHA_TOKEN")
    if (!tokens) {
        return fallback
    }
    if (tokens[dataUser]) {
        return tokens[dataUser]
    }
    return fallback
}

function addOsmchaButtons(changeset_id, reactionsContainer) {
    // https://osmcha.org/api/v1/tags/
    const osmchaTags = [
        {
            id: 1,
            name: "Intentional",
            description: "to capture the intent of the user. This is contextual information subjective to the edits and users.",
        },
        {
            id: 2,
            name: "Unintentional",
            description: "to capture the intent of the user. This is contextual information subjective to the edits and users.",
        },
        {
            id: 6,
            name: "Severity: Low",
            description: "to estimate how bad do these edits on a changeset affect OpenStreetMap data",
        },
        {
            id: 7,
            name: "Severity: High",
            description: "to estimate how bad do these edits on a changeset affect OpenStreetMap data",
        },
        {
            id: 8,
            name: "Severity: Critical",
            description: "to estimate how bad do these edits on a changeset affect OpenStreetMap data",
        },
        {
            id: 9,
            name: "Resolved",
            description: "Resolved",
        },
        {
            id: 10,
            name: "Unresolved",
            description:
                "Unresolved: To input action taken by the you (reviewer) on a changeset. " +
                "It is unresolved when the you (reviewer) have commented on the changeset to inform the mapper for corrections or no action has been taken by the you (reviewer) to correct the map data.",
        },
        {
            id: 11,
            name: "DWG",
            description: "When a changeset needs to be reported to the Data Working Group", // changed
        },
    ]

    const likeTitle = t("changesetPageFixes.osmchaReviewLike")
    const dislikeTitle = t("changesetPageFixes.osmchaReviewDislike")

    async function osmchaRequest(url, method) {
        console.debug(`osmchaRequest(${url}, ${method})`)
        return await externalFetchRetry({
            url: url,
            headers: {
                Authorization: "Token " + (await getOsmchaToken()),
            },
            method: method,
            responseType: "json",
        })
    }

    async function uncheck(changeset_id) {
        return await osmchaRequest(`${osmcha_server_origin}/api/v1/changesets/${changeset_id}/uncheck/`, "PUT")
    }

    const likeBtn = document.createElement("span")
    likeBtn.title = likeTitle
    const likeImg = document.createElement("img")
    likeImg.title = likeTitle
    likeImg.src = osmchaLikeSvg
    likeImg.style.height = "1.1em"
    likeImg.style.cursor = "pointer"
    likeImg.style.filter = "grayscale(1)"
    likeImg.style.marginTop = "-8px"
    likeBtn.onclick = async e => {
        const osmchaToken = await getOsmchaToken()
        if (!osmchaToken) {
            alert(t("changesetPageFixes.osmchaLoginAlert"))
            window.open(osmcha_server_origin)
            return
        }
        if (e.target.hasAttribute("active")) {
            await uncheck(changeset_id)
            await updateReactions()
            return
        }
        if (document.querySelector(".check_user")) {
            await uncheck(changeset_id)
            await updateReactions()
        }
        await osmchaRequest(`${osmcha_server_origin}/api/v1/changesets/${changeset_id}/set-good/`, "PUT")
        await updateReactions()
    }
    likeBtn.appendChild(likeImg)

    const dislikeBtn = document.createElement("span")
    dislikeBtn.title = dislikeTitle
    const dislikeImg = document.createElement("img")
    dislikeImg.title = dislikeTitle
    dislikeImg.src = osmchaLikeSvg // dirty hack for different gray style colors
    dislikeImg.style.height = "1.1em"
    dislikeImg.style.cursor = "pointer"
    dislikeImg.style.filter = "grayscale(1)"
    dislikeImg.style.transform = "rotate(180deg)"
    dislikeImg.style.marginTop = "3px"
    dislikeBtn.appendChild(dislikeImg)
    dislikeBtn.onclick = async e => {
        const osmchaToken = await getOsmchaToken()
        if (!osmchaToken) {
            alert(t("changesetPageFixes.osmchaLoginAlert"))
            window.open(osmcha_server_origin)
            return
        }
        if (e.target.hasAttribute("active")) {
            await uncheck(changeset_id)
            await updateReactions()
            return
        }
        if (document.querySelector(".check_user")) {
            await uncheck(changeset_id)
            await updateReactions()
        }
        await osmchaRequest(`${osmcha_server_origin}/api/v1/changesets/${changeset_id}/set-harmful/`, "PUT")
        await updateReactions()
        if (isDebug()) {
            e.stopPropagation()
            e.stopImmediatePropagation()
            contextMenuHandler(e)
        }
    }

    let changesetProps = null

    async function updateReactions() {
        const osmchaToken = await getOsmchaToken()
        if (!osmchaToken) {
            // todo
            throw "Open Osmcha for get access to reactions"
        }
        const res = await osmchaRequest(`${osmcha_server_origin}/api/v1/changesets/${changeset_id}/`, "GET")
        if (res.status === 404) {
            likeImg.title = t("changesetPageFixes.osmchaChangesetNotFoundTitle")
            dislikeImg.title = t("changesetPageFixes.osmchaChangesetNotFoundTitle")
            console.warn("Changeset not found in OSMCha database") // todo show alert/title
            return
        }
        if (res.status !== 200) {
            console.trace(res)
        }
        const json = res.response
        changesetProps = json["properties"]
        if (json["properties"]["check_user"]) {
            document.querySelector(".check_user")?.remove()
            likeImg.style.filter = "grayscale(1)"
            dislikeImg.style.filter = "grayscale(1)"

            const username = document.createElement("span")
            username.classList.add("check_user")
            username.textContent = json["properties"]["check_user"]
            if (json["properties"]["harmful"] === true) {
                dislikeImg.style.filter = ""
                dislikeImg.style.transform = ""
                dislikeImg.src = osmchaDislikeSvg
                dislikeImg.setAttribute("active", "true")
                dislikeImg.title = dislikeTitle
                username.style.color = "red"
                dislikeBtn.after(username)
            } else {
                likeImg.style.filter = ""
                likeImg.setAttribute("active", "true")
                likeImg.title = likeTitle
                username.style.color = "green"
                likeBtn.after(username)
            }
        } else {
            likeImg.style.filter = "grayscale(1)"
            dislikeImg.style.filter = "grayscale(1)"
            dislikeImg.style.transform = "rotate(180deg)"
            dislikeImg.src = osmchaLikeSvg
            dislikeImg.title = dislikeTitle
            likeImg.title = likeTitle
            likeImg.removeAttribute("active")
            dislikeImg.removeAttribute("active")
            document.querySelector(".check_user")?.remove()
        }
        document.querySelector(".review-tags")?.remove()
        if (changesetProps["tags"].length) {
            const spanWrapper = document.createElement("span")
            spanWrapper.classList.add("review-tags")
            spanWrapper.title = t("changesetPageFixes.osmchaReviewTag")
            spanWrapper.style.marginBottom = "3px"
            spanWrapper.style.position = "relative"
            spanWrapper.innerHTML = tagSvg
            const svg = spanWrapper.querySelector("svg")
            svg.style.position = "absolute"
            svg.style.top = json["properties"]["check_user"] ? "24px" : "24px"
            svg.style.left = "-10px"
            svg.style.color = "gray"

            const span = document.createElement("span")
            span.style.top = json["properties"]["check_user"] ? "24px" : "24px"
            span.style.left = "6px"
            span.style.position = "absolute"
            span.style.fontSize = "smaller"
            span.style.color = "gray"
            span.textContent = " "
            changesetProps["tags"].forEach(({ id, name }) => {
                span.textContent += name.replace(" ", " ") + " "
                const desc = osmchaTags.find(i => i.id === id)?.description
                if (desc) {
                    spanWrapper.title += `\n${name}: ${desc}`
                }
            })
            const firstComment = document.querySelector("#sidebar_content article")
            if (changesetProps["tags"].length > 1) {
                if (firstComment) {
                    // https://osm.org/changeset/172368459
                    // https://osm.org/changeset/171888749
                    firstComment.style.marginTop = 3 + 17 * (changesetProps["tags"].length - 1) + "px"
                }
            }
            if (changesetProps["tags"].length > 0 && !firstComment) {
                const textarea = document.querySelector("#sidebar_content form")
                // not good because layout jumps :(
                textarea.style.marginTop = 3 + 17 * changesetProps["tags"].length + "px"
            }
            spanWrapper.appendChild(span)
            dislikeBtn.after(spanWrapper)
        }
    }

    setTimeout(updateReactions, 0)

    reactionsContainer.appendChild(likeBtn)
    reactionsContainer.appendChild(document.createTextNode("\xA0"))
    reactionsContainer.appendChild(dislikeBtn)
    reactionsContainer.appendChild(document.createTextNode("\xA0"))

    function contextMenuHandler(e) {
        e.preventDefault()
        const currentUser = decodeURI(
            document
                .querySelector('.user-menu [href^="/user/"]')
                .getAttribute("href")
                .match(/\/user\/(.*)$/)[1],
        )
        if (!changesetProps) {
            alert(t("changesetPageFixes.osmchaChangesetNotFoundAlert"))
            return
        }
        if (changesetProps["check_user"] && changesetProps["check_user"] !== currentUser) {
            return
        }
        injectContextMenuCSS()

        const menu = makeContextMenuElem(e)
        osmchaTags.forEach(i => {
            const listItem = document.createElement("div")

            const ch = document.createElement("input")
            ch.id = i.name
            ch.type = "checkbox"
            ch.style.margin = "4px"
            ch.classList.add("review-checkbox")
            ch.setAttribute("name", "review-tag")
            const label = document.createElement("label")
            label.setAttribute("for", i.name)
            label.textContent = i.name
            label.classList.add("review-label")
            label.style.padding = "4px"
            label.style.cursor = "pointer"
            if (changesetProps["tags"].some(t => t.name === i.name)) {
                ch.checked = true
            }

            ch.onchange = async () => {
                if (ch.checked) {
                    const res = await osmchaRequest(`${osmcha_server_origin}/api/v1/changesets/${changeset_id}/tags/${i.id}/`, "POST")
                    if (res.status !== 200) {
                        console.trace(res)
                        ch.checked = "false"
                    } else {
                        console.log(`${i.name} applied`)
                    }
                } else {
                    const res = await osmchaRequest(`${osmcha_server_origin}/api/v1/changesets/${changeset_id}/tags/${i.id}/`, "DELETE")
                    if (res.status !== 200) {
                        console.trace(res)
                        ch.checked = "true"
                    } else {
                        console.log(`${i.name} removed`)
                    }
                }
                await updateReactions()
            }
            listItem.appendChild(ch)
            listItem.appendChild(label)
            document.addEventListener(
                "click",
                function fn(e) {
                    if (!e.isTrusted) {
                        document.addEventListener("click", fn, { once: true })
                        return
                    }
                    if (
                        e.target.classList.contains("review-label") ||
                        e.target.classList.contains("review-checkbox") ||
                        e.target.classList.contains("betterOsmContextMenu")
                    ) {
                        document.addEventListener("click", fn, { once: true })
                        return
                    }
                    menu.remove()
                },
                { once: true },
            )
            menu.appendChild(listItem)
        })
        document.body.appendChild(menu)
    }

    reactionsContainer.addEventListener("contextmenu", contextMenuHandler)
}

function addCheckboxesForChangesetObjects() {
    if (changesetObjectsSelectionModeEnabled) return
    changesetObjectsSelectionModeEnabled = true
    document.querySelectorAll("#changeset_nodes, #changeset_ways, #changeset_relations").forEach(i => {
        i.querySelectorAll("button, input, a, textarea, select, [tabindex]").forEach(el => {
            el.setAttribute("tabindex", "-1")
        })
    })
    ;["#changeset_nodes", "#changeset_ways", "#changeset_relations"].forEach(selector => {
        document.querySelectorAll(`${selector} .browse-element-list li`).forEach(obj => {
            const checkbox = document.createElement("input")
            checkbox.type = "checkbox"
            checkbox.title = t("changesetPageFixes.selectRangeHotkeys", { name: osm_revert_name })
            checkbox.tabIndex = 0
            checkbox.style.width = "18px"
            checkbox.style.height = "18px"
            checkbox.style.margin = "1px"
            checkbox.classList.add("align-bottom", "object-fit-none", "browse-icon")

            function selectRange() {
                let currentCheckboxFound = false
                const checkboxes = Array.from(
                    document.querySelectorAll(
                        `#changeset_nodes li input[type=checkbox], #changeset_ways li input[type=checkbox], #changeset_relations li input[type=checkbox]`,
                    ),
                )
                for (const cBox of checkboxes.toReversed()) {
                    if (!currentCheckboxFound) {
                        if (cBox === checkbox) {
                            currentCheckboxFound = true
                        }
                    } else {
                        if (cBox.checked) {
                            break
                        }
                        cBox.checked = true
                    }
                }
            }

            checkbox.onclick = e => {
                e.stopPropagation()
                e.stopImmediatePropagation()
                if (e.shiftKey) {
                    selectRange()
                }
            }
            checkbox.onkeydown = e => {
                if (e.shiftKey && e.code === "Space") {
                    selectRange()
                } else if (e.code === "Enter") {
                    e.target.click()
                    if (e.shiftKey) {
                        selectRange()
                    }
                }
            }
            const icon = obj.querySelector("img")
            icon.style.display = "none"
            const label = document.createElement("label")
            label.appendChild(checkbox)
            label.onclick = e => {
                e.stopPropagation()
                e.stopImmediatePropagation()
            }
            icon.after(label)
        })
    })
    document
        .querySelector(
            "#changeset_nodes input[type=checkbox], #changeset_ways input[type=checkbox], #changeset_relations input[type=checkbox]",
        )
        .focus()
}

function addUserChangesetRssLink(username) {
    //<link rel="alternate" type="application/atom+xml" title="ATOM" href="https://www.openstreetmap.org/user/Elizen/history/feed">
    const rssfeed = document.createElement("link")
    rssfeed.id = "fixed-rss-feed"
    rssfeed.type = "application/atom+xml"
    rssfeed.rel = "alternate"
    rssfeed.href = `https://www.openstreetmap.org/user/${encodeURI(username)}/history/feed`
    document.head.appendChild(rssfeed)
}

/**
 * @param changeset_id {string}
 */
function addUsernameBadgesOrRestoreAction(changeset_id) {
    const metainfoHTML = document.querySelector("#sidebar_content .details")
    const time = metainfoHTML.querySelector("time") ?? (isOGFServer() && metainfoHTML.querySelector("abbr"))
    if (metainfoHTML.querySelector('a[href*="/user/"]:not([rel])')) {
        const usernameA = metainfoHTML.querySelector('a[href*="/user/"]:not([rel])')
        metainfoHTML.innerHTML = ""
        metainfoHTML.appendChild(time)
        metainfoHTML.appendChild(document.createTextNode(" "))
        metainfoHTML.appendChild(usernameA)
        metainfoHTML.appendChild(document.createTextNode(" "))
        getCachedUserInfo(usernameA.textContent).then(res => {
            usernameA.before(makeBadge(res, new Date(time.getAttribute("datetime"))))
            usernameA.before(document.createTextNode(" "))
            usernameA.title = makeUsernameTitle(res)

            document.querySelectorAll(".browse-tag-list tr").forEach(i => {
                const key = i.querySelector("th")
                if (!key) return
                if (key.textContent === "changesets_count") {
                    function insertAllChangesets(info) {
                        const allChangesets = document.createElement("span")
                        allChangesets.textContent = `/${info["changesets"]["count"]}`
                        allChangesets.style.color = "gray"
                        allChangesets.title = t("changesetPageFixes.totalUserChangesets")
                        i.querySelector("td").appendChild(allChangesets)
                    }

                    if (parseInt(i.querySelector("td").textContent) >= res["changesets"]["count"]) {
                        updateUserInfo(usernameA.textContent).then(res => {
                            insertAllChangesets(res)
                        })
                    } else {
                        insertAllChangesets(res)
                    }
                }
            })
        })
        addUserChangesetRssLink(usernameA.textContent)
    } else {
        metainfoHTML.innerHTML = ""
        metainfoHTML.appendChild(time)
        const findBtn = document.createElement("span")
        findBtn.title = t("deletedUsers.tryFindDeletedUser")
        findBtn.textContent = " 🔍 "
        findBtn.value = changeset_id
        findBtn.datetime = time.dateTime
        findBtn.style.cursor = "pointer"
        findBtn.onclick = findChangesetInDiff
        metainfoHTML.appendChild(findBtn)

        void restorePrevNextChangesetButtons(parseInt(changeset_id))
    }
}

function addRevertButton() {
    if (!location.pathname.startsWith("/changeset")) return
    if (document.querySelector("#revert_button_class")) return true
    const sidebar = document.querySelector("#sidebar_content h2")
    if (sidebar) {
        hideSearchForm()
        // sidebar.classList.add("changeset-header")
        const changeset_id = sidebar.innerHTML.match(/([0-9]+)/)[0]
        const reverterTitle = t("changesetPageFixes.reverterTitle", { name: osm_revert_name })
        // prettier-ignore
        sidebar.innerHTML +=
            ` <a href="${osm_revert_origin}/?changesets=${changeset_id}" target=_blank rel="noreferrer" id=revert_button_class title="${reverterTitle}">↩️</a>
              <a href="${osmcha_server_origin}/changesets/${changeset_id}" id="osmcha_link" target="_blank" rel="noreferrer">${osmchaSvgLogo}</a>`
        changesetObjectsSelectionModeEnabled = false
        document.querySelector("#revert_button_class").onclick = async e => {
            if (changesetObjectsSelectionModeEnabled) {
                if (e.shiftKey) {
                    alert(t("changesetPageFixes.josmDoesNotSupportPartialRevert"))
                    return
                }
                e.preventDefault()
                if (osm_server !== prod_server && osm_server !== ohm_prod_server) {
                    e.preventDefault()
                    alert(t("changesetPageFixes.osmRevertOnlyMainServers", { name: osm_revert_name }))
                    return
                }

                let selector = ""

                const nodes = Array.from(document.querySelectorAll("#changeset_nodes input[type=checkbox]:checked"))
                if (nodes.length) {
                    selector +=
                        "node(id:" +
                        nodes
                            .map(n => {
                                return n.parentElement.nextElementSibling.id.match(/[0-9]+n([0-9]+)/)[1]
                            })
                            .join(",") +
                        ");\n"
                }
                const ways = Array.from(document.querySelectorAll("#changeset_ways input[type=checkbox]:checked"))
                if (ways.length) {
                    selector +=
                        "way(id:" +
                        ways
                            .map(w => {
                                return w.parentElement.nextElementSibling.id.match(/[0-9]+w([0-9]+)/)[1]
                            })
                            .join(",") +
                        ");\n"
                }
                const relations = Array.from(document.querySelectorAll("#changeset_relations input[type=checkbox]:checked"))
                if (relations.length) {
                    selector +=
                        "rel(id:" +
                        relations
                            .map(r => {
                                return r.parentElement.nextElementSibling.id.match(/[0-9]+r([0-9]+)/)[1]
                            })
                            .join(",") +
                        ");"
                }

                window.open(
                    `${osm_revert_origin}/?` +
                        new URLSearchParams({
                            changesets: changeset_id,
                            "query-filter": selector,
                        }).toString(),
                    "_blank",
                )
                return
            }
            if (!e.shiftKey) {
                if (osm_server !== prod_server && osm_server !== ohm_prod_server) {
                    e.preventDefault()
                    alert(t("changesetPageFixes.osmRevertOnlyMainServersWithJosm", { name: osm_revert_name }))
                }
                return
            }
            e.preventDefault()
            if (!isOHMServer()) {
                if (!(await validateOsmServerInJOSM())) {
                    return
                }

                if (osm_server !== prod_server) {
                    if (!confirm("⚠️This is not the main OSM server!\n\n⚠️To change the OSM server in the JOSM settings!")) {
                        return
                    }
                }
            } else {
                if (await validateOsmServerInJOSM()) {
                    return
                }

                if (osm_server !== ohm_prod_server) {
                    if (!confirm("⚠️This is not the OHM server!\n\n⚠️To change the OSM server in the JOSM settings!")) {
                        return
                    }
                }
            }
            window.location = "http://127.0.0.1:8111/revert_changeset?id=" + changeset_id // todo open in new tab. It's broken in Firefox and open new window
        }
        document.querySelector("#revert_button_class").style.textDecoration = "none"
        const osmcha_link = document.querySelector("#osmcha_link svg")
        osmcha_link.style.height = "1em"
        osmcha_link.style.cursor = "pointer"
        osmcha_link.style.marginTop = "-3px"
        osmcha_link.title = t("changesetPage.openChangesetInOsmcha")
        if (isDarkMode()) {
            osmcha_link.style.filter = "invert(0.7)"
        }

        const secondaryActionsWithXml = document.querySelector('.secondary-actions:has(a[href$="/download"])')
        if (secondaryActionsWithXml && !document.querySelector(".objects-selector")) {
            const selectObjects = document.createElement("a")
            selectObjects.classList.add("select-objects-btn")
            selectObjects.textContent = t("changesetPage.selectObjects").replace(" ", "\xA0")
            selectObjects.href = ""
            selectObjects.title = t("changesetPage.partialRevertOrEdit")
            selectObjects.onclick = e => {
                e.preventDefault()
                e.stopPropagation()
                e.stopImmediatePropagation()
                addCheckboxesForChangesetObjects()
                selectObjects.style.display = "none"
                separator.remove()

                const wrapper = document.createElement("div")

                const revertBtn = document.createElement("button")
                revertBtn.textContent = t("changesetPage.revertViaOsmRevert")
                revertBtn.title = t("changesetPage.hotkeyR")
                revertBtn.style.marginBottom = "4px"
                revertBtn.onclick = () => document.querySelector("#revert_button_class").click()
                wrapper.appendChild(revertBtn)
                wrapper.appendChild(document.createTextNode("\xA0"))

                const josmBtn = document.createElement("button")
                josmBtn.textContent = t("changesetPage.openInEditor", { editor: isMobile ? "Vespucci" : "JOSM" })
                josmBtn.title = t("changesetPage.hotkeyJ")
                josmBtn.style.marginBottom = "4px"
                josmBtn.onclick = openSelectedObjectsOnChangesetPage
                wrapper.appendChild(josmBtn)
                wrapper.appendChild(document.createElement("br"))

                const level0Btn = document.createElement("button")
                level0Btn.textContent = t("changesetPage.openInLevel0")
                level0Btn.title = t("changesetPage.hotkeyAltJ")
                level0Btn.style.marginBottom = "4px"
                level0Btn.onclick = () => openSelectedObjectsOnChangesetPage({ altKey: true })
                wrapper.appendChild(level0Btn)
                wrapper.appendChild(document.createTextNode("\xA0"))

                const level0BtnWithGeometry = document.createElement("button")
                level0BtnWithGeometry.textContent = t("changesetPage.openInLevel0WithWaysGeometry")
                level0BtnWithGeometry.title = t("changesetPage.hotkeyShiftAltJ")
                level0BtnWithGeometry.style.marginBottom = "4px"
                level0BtnWithGeometry.onclick = () => openSelectedObjectsOnChangesetPage({ altKey: true, shiftKey: true })
                wrapper.appendChild(level0BtnWithGeometry)

                secondaryActionsWithXml.before(wrapper)
            }
            const separator = document.createTextNode("·\xA0")
            secondaryActionsWithXml.appendChild(separator)
            secondaryActionsWithXml.appendChild(selectObjects)
        } else {
            console.trace()
        }

        addUsernameBadgesOrRestoreAction(changeset_id)
        // compact changeset tags
        if (!document.querySelector(".browse-tag-list[compacted]")) {
            makeHashtagsClickable()
            shortOsmOrgLinks(document.querySelector("#sidebar_content h2 ~ div p"))
            let needUnhide = false

            function isSimpleNode(elem) {
                return elem.childNodes.length === 1 && elem.childNodes[0].nodeType === Node.TEXT_NODE
            }

            document.querySelectorAll(".browse-tag-list tr").forEach(i => {
                const key = i.querySelector("th")
                if (!key) return
                i.querySelectorAll("a").forEach(i => (i.tabIndex = -1))
                if (key.textContent === "host") {
                    if (i.querySelector("td").textContent === "https://www.openstreetmap.org/edit") {
                        i.style.display = "none"
                        i.classList.add("hidden-tag")
                    }
                } else if (key.textContent.startsWith("ideditor:")) {
                    key.title = key.textContent
                    key.textContent = key.textContent.replace("ideditor:", "iD:")
                } else if (key.textContent === "revert:id") {
                    const cell = i.querySelector("td")
                    if (isSimpleNode(cell) && cell.textContent.match(/^((\d+(;|…?$))+$)/)) {
                        cell.innerHTML = cell.innerHTML.replaceAll(
                            /(\d+)(;|$)/g,
                            `<a href="/changeset/$1" class="changeset_link_in_changeset_tags">$1</a>$2`,
                        )
                    } else if (cell.textContent.match(/https:\/\/(www\.)?openstreetmap.org\/changeset\//g)) {
                        cell.innerHTML = cell.innerHTML.replaceAll(/>https:\/\/(www\.)?openstreetmap.org\/changeset\//g, ">")
                    }
                } else if (key.textContent === "revert:dmp:relation:id" || key.textContent === "revert:dmp:fail:relation:id") {
                    const cell = i.querySelector("td")
                    if (isSimpleNode(cell) && cell.textContent.match(/^((\d+(;|…?$))+$)/)) {
                        cell.innerHTML = cell.innerHTML.replaceAll(
                            /(\d+)(;|$)/g,
                            `<a href="/relation/$1" class="relation_link_in_changeset_tags">$1</a>$2`,
                        )
                    }
                } else if (key.textContent === "revert:dmp:way:id" || key.textContent === "revert:dmp:fail:way:id") {
                    const cell = i.querySelector("td")
                    if (isSimpleNode(cell) && cell.textContent.match(/^((\d+(;|…?$))+$)/)) {
                        cell.innerHTML = cell.innerHTML.replaceAll(
                            /(\d+)(;|$)/g,
                            `<a href="/way/$1" class="way_link_in_changeset_tags">$1</a>$2`,
                        )
                    }
                } else if (key.textContent === "redacted_changesets") {
                    const cell = i.querySelector("td")
                    if (isSimpleNode(cell) && cell.textContent.match(/^((\d+(,|$))+$)/)) {
                        cell.innerHTML = cell.innerHTML.replaceAll(
                            /(\d+)/g,
                            `<a href="/changeset/$1" class="changeset_link_in_changeset_tags">$1</a>`,
                        )
                    } else if (cell.textContent.match(/https:\/\/(www\.)?openstreetmap.org\/changeset\//g)) {
                        cell.innerHTML = cell.innerHTML.replaceAll(/>https:\/\/(www\.)?openstreetmap.org\/changeset\//g, ">")
                    }
                } else if (key.textContent.startsWith("v:") && GM_config.get("ChangesetQuickLook")) {
                    i.style.display = "none"
                    i.classList.add("hidden-tag")
                    needUnhide = true
                } else if (
                    key.textContent === "hashtags" &&
                    i.querySelector("td").textContent.includes("#") &&
                    document.querySelector("#sidebar_content h2 ~ div p")?.textContent?.includes(i.querySelector("td").textContent)
                ) {
                    i.style.display = "none"
                    i.classList.add("hidden-tag")
                }
            })
            if (needUnhide) {
                const expander = document.createElement("td")
                expander.onclick = e => {
                    document.querySelectorAll(".hidden-tag").forEach(i => {
                        i.style.display = ""
                    })
                    e.target.remove()
                }
                expander.colSpan = 2
                expander.textContent = "∇"
                expander.style.textAlign = "center"
                expander.style.cursor = "pointer"
                expander.title = t("changesetPageFixes.showHiddenTags")
                document.querySelector(".browse-tag-list").appendChild(expander)
            }
            document.querySelector(".browse-tag-list")?.setAttribute("compacted", "true")
        }
    }
    const textarea = document.querySelector("#sidebar_content textarea")
    if (textarea) {
        textarea.rows = 1
        const comment = document.querySelector("#sidebar_content button[name=comment]")
        if (comment) {
            comment.parentElement.hidden = true
            textarea.addEventListener("input", () => {
                comment.hidden = false
            })
            textarea.addEventListener(
                "click",
                () => {
                    textarea.rows = textarea.rows + 5
                    comment.parentElement.hidden = false
                },
                { once: true },
            )
            comment.onclick = () => {
                ;[500, 1000, 2000, 4000, 6000].map(i => setTimeout(setupRevertButton, i))
            }
            const templates = /** @type {string} */ (GM_config.get("ChangesetsTemplates"))
            if (templates) {
                const buttonsWrapper = document.querySelectorAll("form.mb-3 [name=comment]")[0].parentElement
                buttonsWrapper.style.display = "flex"
                buttonsWrapper.style.flexWrap = "wrap"
                buttonsWrapper.style.gap = "4px"
                buttonsWrapper.style.rowGap = "4px"
                JSON.parse(templates).forEach(row => {
                    const label = row["label"]
                    let text = label
                    if (row["text"] !== "") {
                        text = row["text"]
                    }
                    const b = document.createElement("span")
                    b.classList.add("comment-template", "btn", "btn-primary")
                    b.textContent = label
                    b.title = t("changesetPageFixes.commentTemplateTitle", { text })
                    buttonsWrapper.appendChild(b)
                    b.onmousedown = e => {
                        e.preventDefault()
                    }
                    b.onclick = e => {
                        e.preventDefault()
                        e.stopImmediatePropagation()
                        const textarea = document.querySelector("form.mb-3 textarea")
                        const prev = textarea.value
                        const cursor = textarea.selectionEnd
                        textarea.value = prev.substring(0, cursor) + text + prev.substring(cursor)

                        const ev = new InputEvent("input", {
                            bubbles: true,
                            cancelable: false,
                            data: textarea.value,
                            inputType: "insertFromPaste",
                        })
                        textarea.dispatchEvent(ev)
                    }
                })
            }
        }
    }
    const tagsHeader = document.querySelector("#sidebar_content h4")
    if (tagsHeader) {
        tagsHeader.remove()
    }
    const primaryButtons = document.querySelector("[name=subscribe], [name=unsubscribe]")
    if (primaryButtons?.getAttribute("name") === "subscribe") {
        primaryButtons.tabIndex = -1
    }
    if (primaryButtons && (osm_server.url === prod_server.url || osm_server.url === ohm_prod_server.url)) {
        const changeset_id = sidebar.innerHTML.match(/(\d+)/)[0]
        const reactionsContainer = document.createElement("span")
        primaryButtons.before(reactionsContainer)
        addOsmchaButtons(changeset_id, reactionsContainer)
    }
    document.querySelectorAll('#sidebar_content article[id^=c] small > a[href^="/user/"]').forEach(elem => {
        getCachedUserInfo(elem.textContent).then(info => {
            elem.before(makeBadge(info, new Date(elem.nextElementSibling.getAttribute("datetime"))))
            elem.title = makeUsernameTitle(info)
        })
    })
    document.querySelectorAll("#sidebar_content h2 ~ div article p").forEach(c => {
        c.childNodes?.forEach(node => {
            if (node.nodeType !== Node.TEXT_NODE) return
            const span = document.createElement("span")
            span.textContent = node.textContent
            span.innerHTML = span.innerHTML.replaceAll(/((changesets )((\d+)([,. ])(\s|$|<\/))+|changeset \d+)/gm, match => {
                return match.replaceAll(/(\d+)/g, `<a href="/changeset/$1" class="changeset_link_in_comment">$1</a>`)
            })
            node.replaceWith(span)
        })
    })
    makeHeaderPartsClickable()
}

function setupRevertButton() {
    if (!location.pathname.startsWith("/changeset")) return
    tryApplyModule(addRevertButton, 100, 3000)
}

// noinspection CssUnusedSymbol,CssUnresolvedCustomProperty
const compactSidebarStyleText = `
    .changesets p {
      margin-bottom: 0px !important;
      font-weight: 788;
      font-style: italic;
      font-size: 14px !important;
      font-synthesis: none;
    }
    #sidebar_content > div:not(.changesets) .changeset_num_comments {
        display: none !important;
    }
    @media ${mediaQueryForWebsiteTheme} {
        .changesets time {
            color: darkgray;
        }

        .changesets p {
            font-weight: 400;
        }

        span:has(.changeset_id.custom-changeset-id-click) {
            color: #767676 !important;
        }
    }

    .changeset_id.custom-changeset-id-click {
        color: #767676 !important;
    }
    
    #sidebar_content h2 + div:not(:has(.turn-on-satellite)) > div:has(p) {
        overflow-x: visible !important;
    }
    
    #sidebar_content h2 + div:not(:has(.turn-on-satellite)) > div > p:nth-of-type(1) {
        font-size: 14px !important;
        font-style: italic;
        font-synthesis: none;
    }

    #element_versions_list div > div > p:nth-of-type(1) {
        font-size: 14px !important;
        font-style: italic;
        font-synthesis: none;
    }
    
    #sidebar_content h2 + div > h4 + div {
        margin-bottom: 0px !important;
        overflow-x: visible !important;
    }

    #element_versions_list div > h4 + div {
        margin-bottom: 0px !important;
    }

    .hidden-comments-badge:not(.increased-specificity-for-fucked-safari) {
        display: none !important;
    }

    .better-diff-icon {
        position: relative;
        top: 2px;
    }
    
    @media (min-width: 768px) {
        .map-layout #sidebar${isSafari ? ":not(.increased-specificity-for-fucked-safari)" : ""} {
          width: 450px;
        }
    }
    turbo-frame {
        word-wrap: anywhere;
    }
    .numbered_pagination {
        word-wrap: initial;
    }

    turbo-frame th {
        min-width: max-content;
        word-wrap: break-word;
    }

${copyAnimationStyles}

    #sidebar_content h2:not(.changeset-header) {
        font-size: 1rem;
    }
    #sidebar {
      border-top: solid;
      border-top-width: 1px;
      border-top-color: rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important;
    }

    .fixme-tag {
      color: red !important;
      font-weight: bold;
    }
    
    .warn-tag {
      color: #ff5400 !important;
      font-weight: bold;
    }

    .note-tag {
      font-weight: bold;
    }

    @media ${mediaQueryForWebsiteTheme} {
      .fixme-tag {
        color: #ff5454 !important;
        font-weight: unset;
      }
      
      .warn-tag {
        color: #ff5400 !important;
        font-weight: unset;
      }

      .note-tag:not(.current-value-span):not(.history-diff-new-tag):not(.history-diff-deleted-tag) {
        background: black !important;
        font-weight: unset;
      }
    }

    @media ${mediaQueryForWebsiteTheme} {
        table.browse-tag-list tr td[colspan="2"]{
          background: var(--bs-body-bg) !important;
        }
    }

    .sidebar-close-controls.position-relative .position-absolute {
        padding: 1.5rem !important;
        padding-bottom: 1.3rem !important;
        padding-top: 1.4rem !important;
        padding-left: 1.3rem !important;
    }

    .way-last-version-node:hover, .relation-last-version-member:hover, .node-last-version-parent:hover {
        background-color: rgba(244, 244, 244);
    }

    @media ${mediaQueryForWebsiteTheme} {
        .way-last-version-node:hover, .relation-last-version-member:hover, .node-last-version-parent:hover {
            background-color: rgb(14, 17, 19);
        }
    }

    .copy-changesets-ids-btn {
        padding-left: 5px;
        padding-right: 5px;
    }

    #mouse-trap {
        position: fixed;
        top: 0;
        left: 0;
        width: 100vw;
        height: 100vh;
        pointer-events: auto;
        cursor: none;
        z-index: 999999;
        /* background: rgba(255, 0, 0, 0.3); */
        background: transparent;
    }
    
    .is-loading {
      position: relative;
      pointer-events: none;
    }
    
    .is-loading::after {
      content: "";
      position: absolute;
      inset: 0;
      background: linear-gradient(
        90deg,
        transparent,
        light-dark(rgba(153,212,255,0.4), rgba(255,255,255,.4)),
        transparent
      );
      animation: sweep 1s infinite;
    }
    
    @keyframes sweep {
      from { transform: translateX(-100%); }
      to   { transform: translateX(100%); }
    }

    `

let styleForSidebarApplied = false

function addCompactSidebarStyle() {
    if (styleForSidebarApplied) return
    styleForSidebarApplied = true
    injectCSSIntoOSMPage(compactSidebarStyleText)
}

async function getChangesetComments(changeset_id) {
    const res = await fetchJSONWithCache(osm_server.apiBase + "changeset" + "/" + changeset_id + ".json?include_discussion=true")
    return res["changeset"]["comments"]
}

function externalizeLinks(links) {
    links?.forEach(i => i.setAttribute("target", "_blank"))
}

let sidebarObserver = null

function setupCompactChangesetsHistory() {
    if (!location.pathname.includes("/history") && !location.pathname.startsWith("/changeset")) {
        // prettier-ignore
        if (!styleForSidebarApplied && (location.pathname.startsWith("/node")
            || location.pathname.startsWith("/way")
            || location.pathname.startsWith("/relation"))) {
            addCompactSidebarStyle()
        }
        return
    }

    if (location.pathname.startsWith("/changeset")) {
        externalizeLinks(document.querySelectorAll("#sidebar_content p:first-of-type a"))
        externalizeLinks(document.querySelector("#sidebar_content ul")?.querySelectorAll("a:not(.page-link)"))
    }

    addCompactSidebarStyle()

    // увы, инвалидация в этом месте ломает зум при загрузке объекте самим сайтом
    // try {
    // getMap()?.invalidateSize()
    // } catch (e) {
    // }

    function handleNewChangesets() {
        if (!location.pathname.includes("/history")) {
            return
        }
        // remove useless
        document.querySelectorAll("#sidebar ol > li > .overflow-hidden:not(.better)").forEach(e => {
            e.classList.add("better")
            e.childNodes[0].textContent = ""
            if (e.querySelector("time")?.nextSibling?.nodeType === Node.TEXT_NODE) {
                if (e.querySelector("time").nextSibling.textContent.length < 5) {
                    // hack for deleted users
                    e.querySelector("time").nextSibling.textContent = "\xA0"
                }
            }
            e.classList.remove("pt-3")
            const badgesDiv = e.nextElementSibling
            if (badgesDiv) {
                badgesDiv.classList.remove("flex-column")
                badgesDiv.classList.add("flex-row")
                badgesDiv.style.gap = "5px"
                if (badgesDiv.querySelector(".changeset_line")) {
                    badgesDiv.querySelector(".changeset_line").style.flexDirection = "row-reverse"
                }
            }
            const changesBadge = badgesDiv.querySelector("span:not(.changeset_num_comments) svg")
            if (changesBadge && !changesBadge.classList.contains("better-diff-icon")) {
                changesBadge.outerHTML = diffSvg
                changesBadge.style.position = "relative"
                changesBadge.style.top = "3px"
            }
            const changesetIDspan = badgesDiv?.querySelector('a[href^="/changeset/"]:not([rel])')?.parentElement
            if (changesetIDspan) {
                e.appendChild(changesetIDspan)
            }
            const wrapper = document.createElement("div")
            wrapper.classList.add("better-wrapper", "d-flex", "flex-nowrap", "justify-content-between", "align-items-end")
            e.parentElement.appendChild(wrapper)
            wrapper.appendChild(e)
            wrapper.appendChild(badgesDiv)
        })
        makeTimesSwitchable()
        hideSearchForm()

        document.querySelectorAll("ol li a.changeset_id bdi:not(.compacted)").forEach(description => {
            description.classList.add("compacted")
            description.textContent = shortOsmOrgLinksInText(description.textContent)
        })

        for (const elem of document.querySelectorAll("ol li:not(:has(.comment)):not(.comments-loaded)")) {
            elem.classList.add("comments-loaded")
            const commentsBadge = elem.querySelector(".changeset_num_comments")
            commentsBadge.querySelector("i").outerHTML = commentSvg
            const commentsCount = parseInt(commentsBadge.firstChild.textContent.trim())
            if (commentsCount) {
                if (commentsCount > 3) {
                    commentsBadge.firstElementChild.style.setProperty("color", "red", "important")
                } else if (commentsCount > 1) {
                    commentsBadge.firstElementChild.style.setProperty("color", "#ff7800", "important")
                } else if (commentsCount > 0) {
                    commentsBadge.firstElementChild.style.setProperty("color", "#ffae00", "important")
                }

                const changeset_id = elem.querySelector(".changeset_id").href.match(/\/(\d+)/)[1]
                getChangesetComments(changeset_id).then(res => {
                    res.forEach((comment, idx) => {
                        const commentElem = document.createElement("div")
                        commentElem.classList.add("comment")
                        commentElem.style.fontSize = "0.7rem"
                        commentElem.style.borderTopColor = "#0000"
                        commentElem.style.borderTopStyle = "solid"
                        commentElem.style.borderTopWidth = "1px"
                        if (idx !== 0) {
                            commentElem.style.display = "none"
                        }
                        elem.appendChild(commentElem)

                        const userLink = document.createElement("a")
                        userLink.href = osm_server.url + "/user/" + encodeURI(comment["user"])
                        userLink.textContent = comment["user"]
                        commentElem.appendChild(userLink)
                        getCachedUserInfo(comment["user"]).then(res => {
                            const badge = makeBadge(res /* fixme */)
                            const svg = badge.querySelector("svg")
                            if (svg) {
                                badge.style.marginLeft = "-4px"
                                badge.style.height = "1rem"
                                badge.style.float = "left"
                                svg.style.transform = "scale(0.7)"
                            }
                            userLink.before(badge)
                        })
                        const shortText = shortOsmOrgLinksInText(comment["text"])
                        if (shortText.length > 500) {
                            const text = document.createElement("span")
                            text.textContent = " " + shortText.slice(0, 500)
                            commentElem.appendChild(text)
                            const more = document.createElement("span")
                            more.textContent = "..."
                            more.title = t("changesetPageFixes.clickForViewMore")
                            more.style.cursor = "pointer"
                            more.style.color = "rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1))"
                            more.onclick = () => {
                                more.remove()
                                text.textContent = " " + shortText
                            }
                            commentElem.appendChild(more)
                        } else {
                            commentElem.appendChild(document.createTextNode(" " + shortText))
                        }
                    })

                    commentsBadge.firstElementChild.style.cursor = "pointer"
                    commentsBadge.firstElementChild.style.overflow = "visible"

                    let state = commentsCount === 1 ? "" : "none"
                    commentsBadge.firstElementChild.onclick = () => {
                        elem.querySelectorAll(".comment").forEach(comment => {
                            if (state === "none") {
                                comment.style.display = ""
                            } else {
                                comment.style.display = "none"
                            }
                        })
                        state = state === "none" ? "" : "none"
                    }
                    commentsBadge.firstElementChild.title = ""
                    res.forEach(comment => {
                        const shortText = shortOsmOrgLinksInText(comment["text"])
                        commentsBadge.firstElementChild.title += `${comment["user"]}:\n${shortText}\n\n`
                    })
                    commentsBadge.firstElementChild.title = commentsBadge.firstElementChild.title.trimEnd()
                })
            } else {
                commentsBadge.classList.add("hidden-comments-badge")
                elem.classList.add("has-hidden-comments-badge")
            }
        }

        queueMicrotask(async () => {
            const changesetIdElems = document.querySelectorAll("#sidebar ol li div .changeset_id:not(.metadata-loading)")
            const changesetsIDs = Array.from(changesetIdElems)
                .map(elem => {
                    elem.classList.add("metadata-loading")
                    return parseInt(elem.href.match(/\/(\d+)/)[1])
                })
                .filter(ch => !changesetMetadatas[ch])
            await loadChangesetMetadatas(changesetsIDs)
            changesetIdElems.forEach(elem => {
                const changesetId = parseInt(elem.href.match(/\/(\d+)/)[1])
                const li = elem.parentElement.parentElement.parentElement.parentElement
                if (changesetMetadatas[changesetId]?.tags?.["review_requested"] === "yes") {
                    li.classList.add("review-requested-changeset")
                    const reviewRequestedBadge = document.createElement("span")
                    reviewRequestedBadge.textContent = " " + REVIEW_REQUESTED_EMOJI
                    reviewRequestedBadge.title = t("changesetPageFixes.mapperRequestedReview")
                    reviewRequestedBadge.style.cursor = "pointer"
                    if (!li.classList.contains("has-hidden-comments-badge")) {
                        if (isDarkMode()) {
                            reviewRequestedBadge.style.filter = "grayscale(1) brightness(0.5)"
                        } else {
                            reviewRequestedBadge.style.filter = "grayscale(1)"
                        }
                    }
                    elem.after(reviewRequestedBadge)
                    reviewRequestedBadge.onclick = () => {
                        if (!massModeActive) {
                            document.querySelector("#changesets-filter-btn").click()
                        }
                        // dirty hack
                        setTimeout(() => {
                            if (document.querySelector("#invert-user-filter-checkbox").getAttribute("checked") === "false") {
                                if (["", REVIEW_REQUESTED_EMOJI].includes(document.querySelector("#filter-by-user-input").value)) {
                                    document.querySelector("#invert-user-filter-checkbox").click()
                                }
                            }
                            addUsernameIntoChangesetsFilter(REVIEW_REQUESTED_EMOJI)
                        })
                    }
                }
                if (li.title !== "") {
                    li.title += "\n"
                }
                li.title += Object.entries(changesetMetadatas[changesetId]?.tags)
                    .filter(
                        ([k, v]) => k !== "comment" && !(k === "host" && v === "https://www.openstreetmap.org/edit") && !k.startsWith("v:"),
                    )
                    .map(([k, v]) => `${k}=${v}`)
                    .join("\n")
            })
        })
    }

    if (!location.pathname.startsWith("/node") && !location.pathname.startsWith("/way") && !location.pathname.startsWith("/relation")) {
        sidebarObserver?.disconnect()
        handleNewChangesets()
        sidebarObserver = new MutationObserver(handleNewChangesets)
        if (document.querySelector("#sidebar_content") && !location.pathname.startsWith("/changeset")) {
            sidebarObserver.observe(document.querySelector("#sidebar_content"), { childList: true, subtree: true })
        }
    }

    void initCorporateMappersList()
}

//</editor-fold>

//<editor-fold desc="hashtags" defaultstate="collapsed">

function makeHashtagsClickable() {
    if (!GM_config.get("ImagesAndLinksInTags")) return

    const comment =
        document.querySelector("#element_versions_list > div p") ??
        document.querySelector(".browse_section > div p") ??
        document.querySelector("#sidebar_content h2 + div > div > p")
    comment?.childNodes?.forEach(node => {
        if (node.nodeType !== Node.TEXT_NODE) return
        const span = document.createElement("span")
        span.textContent = node.textContent
        span.innerHTML = span.innerHTML.replaceAll(/\B(#[\p{L}_-][\p{L}\d_-]*)\b/gu, function (match) {
            const osmchaFilter = { comment: [{ label: match, value: match }] }
            const osmchaLink = `${osmcha_server_origin}?` + new URLSearchParams({ filters: JSON.stringify(osmchaFilter) }).toString()
            const a = document.createElement("a")
            a.href = osmchaLink
            a.target = "_blank"
            a.title = t("hashtags.searchInOsmcha")
            a.textContent = match
            return a.outerHTML
        })
        node.replaceWith(span)
    })
}

function makeHashtagsInNotesClickable() {
    if (!GM_config.get("ImagesAndLinksInTags")) return

    const notesParagraphs = Array.from(document.querySelectorAll("#sidebar_content h4 ~ div:first-of-type > p"))
    const commentsParagraphs = Array.from(document.querySelectorAll("#sidebar_content article p"))
    ;[...commentsParagraphs, ...notesParagraphs].forEach(p => {
        p?.childNodes?.forEach(node => {
            if (node.nodeType !== Node.TEXT_NODE) return
            const span = document.createElement("span")
            span.textContent = node.textContent
            span.innerHTML = span.innerHTML
                .replaceAll(/(^|\B)(#[\p{L}\d_-]+)(\b|$)/gu, function (match) {
                    if (match.match(/^#[0-9]+$/)) {
                        return match
                    }
                    // const notesReviewLink = "https://ent8r.github.io/NotesReview/?" + new URLSearchParams({
                    //     view: "map",
                    //     status: "open",
                    //     area: "view",
                    //     limit: 30,
                    //     query: match
                    // }).toString()

                    const a = document.createElement("a")
                    a.id = "note-link-" + Math.random()
                    a.href = ""
                    a.target = "_blank"
                    a.title = t("hashtags.filterNotesByHashtag")
                    a.textContent = match

                    function fixLink() {
                        const center = getMapCenter()
                        const zoom = getZoom()
                        const notesReviewLink =
                            "https://antonkhorev.github.io/osm-note-viewer/#" +
                            new URLSearchParams({
                                mode: "search",
                                q: match,
                                bbox: [
                                    Math.round(getMap().getBounds().getWest() * 10000) / 10000,
                                    Math.round(getMap().getBounds().getSouth() * 10000) / 10000,
                                    Math.round(getMap().getBounds().getEast() * 10000) / 10000,
                                    Math.round(getMap().getBounds().getNorth() * 10000) / 10000,
                                ].join(","),
                                sort: "created_at",
                                order: "newest",
                                closed: "0",
                                map: `${zoom}/${center.lat}/${center.lng}`,
                            }).toString()
                        const link = document.getElementById(a.id)
                        link.href = notesReviewLink
                        link.onclick = e => {
                            if (e.shiftKey || e.ctrlKey || e.metaKey) {
                                return
                            }
                            e.preventDefault()
                            if (document.querySelector(".layers-ui").style.display === "none") {
                                document.querySelector(".control-layers a").click()
                            }
                            Array.from(document.querySelectorAll(".overlay-layers label"))[0].scrollIntoView({ block: "center" })
                            document.querySelector("#filter-notes-by-string").value = match
                            e.target?.classList?.add("wait-fetch")
                            updateNotesLayer()
                        }
                        console.log("search link in note was fixed")
                    }

                    setTimeout(() => {
                        interceptMapManually().then(fixLink)
                    })
                    setTimeout(fixLink, 1000)
                    return a.outerHTML
                })
                .replaceAll(/(?<=(POI name: ))(.+)/gu, function (match) {
                    const span = document.createElement("span")
                    span.classList.add("poi-name-in-note")
                    span.title = t("hashtags.clickToCopyName")
                    span.setAttribute("data-name", match)
                    span.textContent = match
                    return span.outerHTML
                })
                .replaceAll(/(?<=(POI types: ))(.+)/gu, function (match) {
                    injectCSSIntoOSMPage(copyAnimationStyles)
                    const span = document.createElement("span")
                    span.classList.add("poi-name-in-note")
                    span.title = t("hashtags.clickToCopyType")
                    span.setAttribute("data-name", match)
                    span.textContent = match
                    return span.outerHTML
                })
            document.querySelectorAll(".poi-name-in-note").forEach(elem => {
                elem.style.cursor = "pointer"
                elem.addEventListener("click", e => {
                    const name = e.target.getAttribute("data-name")
                    navigator.clipboard.writeText(name).then(() => copyAnimation(e, name))
                })
            })
            node.replaceWith(span)
        })
    })
}

//</editor-fold>

//<editor-fold desc="user-badges" defaultstate="collapsed">

const CORPORATE_EMOJI = "🏢"
const BAN_EMOJI = "⛔️"
const REVIEW_REQUESTED_EMOJI = "🙏"
const NEWBIE_EMOJI = "🍼"

const moderatorBadgeSvg =
    '<svg width="20" height="20">' +
    '<path d="M 10,2 8.125,8 2,8 6.96875,11.71875 5,18 10,14 15,18 13.03125,11.71875 18,8 11.875,8 10,2 z" fill="#447eff"' +
    ' stroke="#447eff" stroke-width="2" stroke-linejoin="round"></path>' +
    "</svg>"
const importerBadgeSvg =
    '<svg width="20" height="20">' +
    '<path d="M 10,2 8.125,8 2,8 6.96875,11.71875 5,18 10,14 15,18 13.03125,11.71875 18,8 11.875,8 10,2 z" fill="#38e13a"' +
    ' stroke="#38e13a" stroke-width="2" stroke-linejoin="round"></path>' +
    "</svg>"

async function loadFriends() {
    console.debug("Loading friends list")
    const res = await (await fetch(osm_server.url + "/dashboard")).text()
    const parser = new DOMParser()
    const doc = parser.parseFromString(res, "text/html")
    const friends = []
    doc.querySelectorAll('a[data-method="delete"][href*="/follow"]').forEach(a => {
        const username = a.getAttribute("href").match(/\/user\/(.+)\/follow/)[1]
        friends.push(decodeURI(username))
    })
    await GM.setValue("friends", JSON.stringify(friends))
    console.debug("Friends list updated")
    return friends
}

let friendsLoadingLock = false

async function getFriends() {
    const friendsStr = await GM.getValue("friends")
    if (friendsStr) {
        return JSON.parse(friendsStr)
    } else {
        while (friendsLoadingLock) {
            await sleep(500)
        }
        friendsLoadingLock = true
        const res = await loadFriends()
        friendsLoadingLock = false
        return res
    }
}

function makeBadge(userInfo, changesetDate = new Date()) {
    // todo make changesetDate required
    const userBadge = document.createElement("span")
    userBadge.classList.add("user-badge")

    function makeModeratorBadge() {
        userBadge.style.position = "relative"
        userBadge.style.bottom = "2px"
        userBadge.title = t("userBadges.userModerator")
        userBadge.innerHTML = moderatorBadgeSvg
        userBadge.querySelector("svg").style.transform = "scale(0.9)"
    }

    function makeImporterBadge() {
        userBadge.style.position = "relative"
        userBadge.style.bottom = "2px"
        userBadge.title = t("userBadges.userImporter")
        userBadge.innerHTML = importerBadgeSvg
        userBadge.querySelector("svg").style.transform = "scale(0.9)"
    }

    function makeBannedUserBadge() {
        userBadge.title = t("userBadges.userBanned")
        userBadge.textContent = BAN_EMOJI + " "
        userBadge.classList.add("banned-badge")
        userBadge.style.cursor = "pointer"
        userBadge.onclick = e => {
            if (massModeActive && !e.ctrlKey && !e.metaKey) {
                addUsernameIntoChangesetsFilter(BAN_EMOJI)
            } else {
                window.open("/user/" + userInfo["display_name"] + "/blocks", "_blank")
            }
        }
        setTimeout(async () => {
            const xml = (
                await externalFetchRetry({
                    url: "/user/" + userInfo["display_name"] + "/blocks",
                })
            ).response
            const lastBlockLinks = new DOMParser()
                .parseFromString(xml, "text/html")
                .querySelector('a[href^="/user_blocks/"]')
                .getAttribute("href")
            const blockID = lastBlockLinks.match(/\/user_blocks\/([0-9]+)/)[1]
            const blockInfo = (
                await externalFetchRetry({
                    url: "/api/0.6/user_blocks/" + blockID + ".json",
                    responseType: "json",
                    headers: { "turbo-frame": "pagination" },
                })
            ).response
            userBadge.title += `\n\n${blockInfo["user_block"]["created_at"]}\n${blockInfo["user_block"]["creator"]["user"]}: ${blockInfo["user_block"]["reason"]}`
        })
    }

    function makeNewbieBadge() {
        userBadge.classList.add("newbie-badge")
        userBadge.title = t("userBadges.userNewbie")
        userBadge.textContent = NEWBIE_EMOJI + " "
        userBadge.style.cursor = "pointer"
        userBadge.onclick = e => {
            if (location.pathname !== "/history") {
                return
            }
            e.preventDefault()
            if (!massModeActive) {
                document.querySelector("#changesets-filter-btn").click()
            }
            // dirty hack
            setTimeout(async () => {
                if (document.querySelector("#invert-user-filter-checkbox").getAttribute("checked") === "false") {
                    if (["", NEWBIE_EMOJI].includes(document.querySelector("#filter-by-user-input").value)) {
                        document.querySelector("#invert-user-filter-checkbox").click()
                    }
                }
                await addUsernameIntoChangesetsFilter(NEWBIE_EMOJI)
            })
        }
    }

    function makeCorporateBadge() {
        const info = corporateMappers.get(userInfo["display_name"])
        userBadge.title = t("userBadges.corporateMapper", { names: info.join(", ") })
        userBadge.textContent = CORPORATE_EMOJI + " "
        userBadge.classList.add("corporate-badge")
        userBadge.style.cursor = "pointer"
        userBadge.onclick = async e => {
            e.preventDefault()
            if (e.altKey) {
                window.open(corporationContributorsSource, "_blank")
            } else {
                if (massModeActive && !e.ctrlKey && !e.metaKey) {
                    await addUsernameIntoChangesetsFilter(CORPORATE_EMOJI)
                } else {
                    info.forEach(k => {
                        window.open(corporatesLinks.get(k), "_blank")
                    })
                }
            }
        }
    }

    function makeFriendBadge() {
        getFriends().then(res => {
            if (res.includes(userInfo["display_name"])) {
                // todo warn if username startsWith 🫂 or use svg
                userBadge.title = t("userBadges.followingUser")
                userBadge.textContent = "🫂 "
            }
        })
    }

    if (userInfo["roles"].some(i => i === "moderator")) {
        makeModeratorBadge()
    } else if (userInfo["roles"].some(i => i === "importer")) {
        makeImporterBadge()
    } else if (userInfo["blocks"]["received"]["active"]) {
        makeBannedUserBadge()
    } else if (
        new Date(userInfo["firstChangesetCreationTime"] ?? userInfo["account_created"]).setUTCDate(
            new Date(userInfo["firstChangesetCreationTime"] ?? userInfo["account_created"]).getUTCDate() + 30,
        ) > changesetDate
    ) {
        makeNewbieBadge()
    } else if (!corporateMappers || corporateMappers?.has(userInfo["display_name"])) {
        if (!corporateMappers) {
            initCorporateMappersList().then(() => {
                if (corporateMappers?.has(userInfo["display_name"])) {
                    makeCorporateBadge()
                } else {
                    makeFriendBadge()
                }
            })
        } else {
            makeCorporateBadge()
        }
    } else {
        makeFriendBadge()
    }
    return userBadge
}

//</editor-fold>

//<editor-fold desc="user-utils" defaultstate="collapsed">
function osmXmlToJson(xmlString) {
    const doc = new DOMParser().parseFromString(xmlString, "application/xml")

    if (doc.querySelector("parsererror")) {
        throw new Error("Invalid XML")
    }

    const osm = doc.querySelector("osm")
    if (!osm) {
        throw new Error("No <osm> root element found")
    }

    const toValue = v => {
        if (v === "true") return true
        if (v === "false") return false
        if (/^-?\d+$/.test(v)) return Number(v) // int
        if (/^-?\d*\.\d+$/.test(v)) return Number(v) // float
        return v // string
    }

    const result = {}

    for (const attr of osm.attributes) {
        result[attr.name] = attr.value
    }

    result.changesets = Array.from(osm.querySelectorAll("changeset")).map(cs => {
        const obj = {}

        for (const attr of cs.attributes) {
            obj[attr.name] = toValue(attr.value)
        }

        const tags = {}
        for (const tag of cs.querySelectorAll("tag")) {
            const k = tag.getAttribute("k")
            const v = tag.getAttribute("v")
            if (k != null && v != null) tags[k] = v
        }
        if (Object.keys(tags).length) obj.tags = tags

        return obj
    })

    return result
}

/**
 * @param username {string}
 * @return {Promise<Object|*>}
 */
async function getFirstUserChangeset(username) {
    /*if (isOGFServer()) {
        return await fetchTextWithCache(
            osm_server.apiBase +
                "changesets?" +
                new URLSearchParams({
                    display_name: username,
                    limit: 1,
                    order: "oldest", // это не работает
                }).toString(),
        ).then(osmXmlToJson)
    }*/
    return await fetchJSONWithCache(
        osm_server.apiBase +
            "changesets.json?" +
            new URLSearchParams({
                display_name: username,
                limit: 1,
                order: "oldest",
            }).toString(),
    )
}

async function updateUserInfo(username) {
    void initCorporateMappersList()
    const res = await getFirstUserChangeset(username)
    let uid
    let firstObjectCreationTime
    let firstChangesetID
    if (res["changesets"].length === 0) {
        const res = await fetchJSONWithCache(
            osm_server.apiBase +
                "notes/search.json?" +
                new URLSearchParams({
                    display_name: username,
                    limit: 1,
                    closed: -1,
                    order: "oldest",
                }).toString(),
        )
        uid = res["features"][0]["properties"]["comments"].find(i => i["user"] === username)["uid"]
        firstObjectCreationTime = res["features"][0]["properties"]["comments"].find(i => i["user"] === username)["date"]
    } else {
        uid = res["changesets"][0]["uid"]
        firstObjectCreationTime = res["changesets"][0]["created_at"]
        firstChangesetID = res["changesets"][0]["id"]
    }

    // FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
    const userInfo = structuredClone((await fetchJSONWithCache(osm_server.apiBase + "user/" + uid + ".json")).user)
    userInfo["description"] = ""
    userInfo["cacheTime"] = new Date().toISOString()
    if (firstObjectCreationTime) {
        userInfo["firstChangesetCreationTime"] = new Date(firstObjectCreationTime).toISOString()
    }
    if (firstChangesetID) {
        userInfo["firstChangesetID"] = firstChangesetID
    }
    await GM.setValue(storagePrefix + "userinfo-" + username, JSON.stringify(userInfo))
    return userInfo
}

/**
 * @param {string} username
 * @return {Promise<*>}
 */
async function getCachedUserInfo(username) {
    if (!username) {
        console.trace("invalid username")
        return
    }
    const localUserInfo = await GM.getValue(storagePrefix + "userinfo-" + username, "")
    if (localUserInfo) {
        const json = JSON.parse(localUserInfo)
        const cacheTime = new Date(json["cacheTime"])
        const threeDaysLater = new Date(cacheTime.getTime() + 24 * 60 * 60 * 1000)
        if (threeDaysLater < new Date()) {
            setTimeout(updateUserInfo, 0, username)
        }
        return json
    }
    return await updateUserInfo(username)
}

//</editor-fold>

//<editor-fold desc="dark-map" defaultstate="collapsed">

let darkModeForMap = false
let darkMapStyleElement = null

function injectDarkMapStyle() {
    darkMapStyleElement = injectCSSIntoOSMPage(`
    @media (prefers-color-scheme: dark) {
        .leaflet-tile-container, .mapkey-table-entry td:first-child > * {
            filter: none !important;
        }

        .leaflet-tile-container * {
            filter: none !important;
        }

        .leaflet-tile-container .leaflet-tile:not(.no-invert), .mapkey-table-entry td:first-child > * {
            filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%) !important;
        }
    }
    `)
}

function setupDarkModeForMap() {
    if (!GM_config.get("DarkModeForMap") || darkModeForMap) {
        return
    }
    injectDarkMapStyle()
    darkModeForMap = true
}
//</editor-fold>

//<editor-fold desc="swipes" defaultstate="collapsed">

let startTouch = null
let touchMove = null
let touchEnd = null

function addSwipes() {
    if (!GM_config.get("Swipes")) {
        return
    }
    let startX = 0
    let startY = 0
    let direction = null
    const sidebar = document.querySelector("#sidebar_content")
    sidebar.style.transform = "translateX(var(--touch-diff, 0px))"

    if (!location.pathname.startsWith("/changeset/")) {
        sidebar.removeEventListener("touchstart", startTouch)
        sidebar.removeEventListener("touchmove", touchMove)
        sidebar.removeEventListener("touchend", touchEnd)
        startTouch = null
        touchMove = null
        touchEnd = null
    } else {
        if (startTouch !== null) return
        startTouch = e => {
            startX = e.touches[0].clientX
            startY = e.touches[0].clientY
        }

        touchMove = e => {
            const diffY = e.changedTouches[0].clientY - startY
            const diffX = e.changedTouches[0].clientX - startX
            if (direction == null) {
                if (diffY >= 10 || diffY <= -10) {
                    direction = "v"
                } else if (diffX >= 10 || diffX <= -10) {
                    direction = "h"
                    startX = e.touches[0].clientX
                }
            } else if (direction === "h") {
                e.preventDefault()
                sidebar.style.setProperty("--touch-diff", `${diffX}px`)
            }
        }

        touchEnd = e => {
            const diffX = startX - e.changedTouches[0].clientX

            sidebar.style.removeProperty("--touch-diff")
            if (direction === "h") {
                if (diffX > sidebar.offsetWidth / 3) {
                    const navigationLinks = document.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
                    if (navigationLinks && Array.from(navigationLinks).at(-1).href.includes("/changeset/")) {
                        getAbortController().abort(ABORT_ERROR_PREV)
                        Array.from(navigationLinks).at(-1).click()
                    }
                } else if (diffX < -sidebar.offsetWidth / 3) {
                    const navigationLinks = document.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
                    if (navigationLinks && navigationLinks[0].href.includes("/changeset/")) {
                        getAbortController().abort(ABORT_ERROR_NEXT)
                        navigationLinks[0].click()
                    }
                }
            }
            direction = null
        }

        sidebar.addEventListener("touchstart", startTouch)
        sidebar.addEventListener("touchmove", touchMove)
        sidebar.addEventListener("touchend", touchEnd)
    }
}

//</editor-fold>

//<editor-fold desc="click" defaultstate="collapsed">

function getWaynodesCentroid(wayNodes) {
    // simple impl
    let latSum = 0.0
    let lonSum = 0.0
    wayNodes.forEach(i => {
        latSum += i.lat
        lonSum += i.lon
    })
    return {
        lat: latSum / wayNodes.length,
        lon: lonSum / wayNodes.length,
    }
}

function getBooxAroundPoint(lat, lng, halfSizeMeters) {
    const dLat = halfSizeMeters / 111320
    const dLng = halfSizeMeters / (111320 * Math.cos((lat * Math.PI) / 180))
    return [lng - dLng, lat - dLat, lng + dLng, lat + dLat].join(",")
}

async function getElementsAroundNode(lat, lng) {
    for (const radius of [15, 70, 300, 750, 2000, 5000]) {
        console.time(`/map call radius=${radius}`)
        const response = await fetch(`${osm_server.apiBase}map.json?bbox=${getBooxAroundPoint(lat, lng, radius)}`)
        console.timeEnd(`/map call radius=${radius}`)
        if (!response.ok) {
            throw new Error(`OSM API error: ${response.status}`)
        }
        const data = await response.json()
        if (data.elements.length === 0) {
            continue
        }
        return data.elements
    }
    throw "empty region"
}

async function mapClickHandler(e) {
    if (skipClick) {
        return
    }
    const z = getZoom()
    if (z < 13) {
        return
    }
    if (location.hash.includes("D") || getCurrentLayers().includes("D")) {
        return
    }
    if (jsonLayer) {
        return
    }
    if (stopClick) {
        return
    }
    if (document.querySelector(".control-query.active")) {
        return
    }
    if (document.getElementById("Config")) {
        return
    }
    if (location.pathname === "/export" || location.pathname === "/note/new") {
        return
    }
    if (e.originalEvent.explicitOriginalTarget.id !== "map") {
        if (e.originalEvent.explicitOriginalTarget.nodeName !== "path") {
            return
        }
    }
    const { lat: lat, lng: lng } = e.latlng

    /** @type {(NodeVersion|WayVersion|RelationVersion)[]} */
    const elements = await getElementsAroundNode(lat, lng)
    if (stopClick) {
        return
    }

    /** @type {Map<number, NodeVersion>} */
    const nodes = new Map()
    /** @type {Map<number, WayVersion>} */
    const downloadedWays = new Map()
    elements.forEach(i => {
        if (i.type === "node") {
            nodes.set(i.id, i)
        } else if (i.type === "way") {
            downloadedWays.set(i.id, i)
        }
    })

    let bestObj = elements.find(i => i.type === "node")
    let bestDist = bestObj ? getDistanceFromLatLonInKm(lat, lng, bestObj.lat, bestObj.lon) * 1000 : 1e9
    for (const i of elements) {
        if (!i.tags) {
            continue
        }
        if (i.type === "node") {
            const dist = getDistanceFromLatLonInKm(lat, lng, i.lat, i.lon) * 1000
            if (dist < bestDist) {
                bestDist = dist
                bestObj = i
            }
        } else if (i.type === "way") {
            const wayNodes = i.nodes.map(id => nodes.get(id))
            if (wayNodes[0].id === wayNodes.at(-1).id) {
                // polygon
                const centroid = getWaynodesCentroid(wayNodes)
                const dist = getDistanceFromLatLonInKm(lat, lng, centroid.lat, centroid.lon) * 1000
                if (dist < bestDist) {
                    bestDist = dist
                    bestObj = i
                }
            }
            for (let j = 0; j < wayNodes.length - 1; j++) {
                const a = toMercator(wayNodes[j].lat, wayNodes[j].lon)
                const b = toMercator(wayNodes[j + 1].lat, wayNodes[j + 1].lon)

                const dist = distanceToSegmentInMeters(toMercator(lat, lng), a, b, lat)
                if (dist < bestDist) {
                    bestDist = dist
                    bestObj = i
                }
            }
        } else if (i.type === "relation") {
            const relationChildNodes = []
            for (let member of i.members) {
                if (member.type !== "way") {
                    continue
                }
                if (downloadedWays.has(member.ref)) {
                    const way = downloadedWays.get(member.ref)
                    way.nodes.forEach(id => {
                        if (nodes.has(id)) {
                            relationChildNodes.push(nodes.get(id))
                        }
                    })
                    for (let j = 0; j < way.nodes.length - 1; j++) {
                        const _a = nodes.get(way.nodes[j])
                        const _b = nodes.get(way.nodes[j + 1])
                        if (!_a || !_b) {
                            continue
                        }
                        const a = toMercator(_a.lat, _a.lon)
                        const b = toMercator(_b.lat, _b.lon)

                        const dist = distanceToSegmentInMeters(toMercator(lat, lng), a, b, lat)
                        if (dist < bestDist) {
                            bestDist = dist
                            bestObj = i
                        }
                    }
                }
            }
            const centroid = getWaynodesCentroid(relationChildNodes)
            const dist = getDistanceFromLatLonInKm(lat, lng, centroid.lat, centroid.lon) * 1000
            if (dist < bestDist) {
                bestDist = dist
                bestObj = i
            }
        }
    }
    const targetURL = `/${bestObj.type}/${bestObj.id}`
    if (location.pathname.startsWith("/changeset") /* && !confirm(`Open ${targetURL}`)*/) {
        return
    }
    getWindow().OSM.router.route(targetURL)
    console.log(elements)
}

let clickableMapSetuped = false
let stopClick = false
let skipClick = false

async function setupClickableMap() {
    if (!isDebug() && !GM_config.get("ClickableMap")) {
        return
    }
    await interceptMapManually()
    if (clickableMapSetuped) {
        return
    }
    clickableMapSetuped = true
    getMap().on("click", intoPageWithFun(mapClickHandler))
    getMap().on(
        "dblclick",
        intoPageWithFun(() => {
            stopClick = true
            setTimeout(() => {
                stopClick = false
            }, 1200)
        }),
    )
    getMap().on(
        "mousedown",
        intoPageWithFun(() => {
            skipClick =
                document.querySelector("#map-context-menu").checkVisibility() ||
                document.querySelector(".dropdown-menu.show") ||
                window.getSelection().type === "Range"
        }),
    )

    injectCSSIntoOSMPage(`
    #map.leaflet-grab:not(.leaflet-drag-target) {
        cursor: revert;
    }
    `)
}

//</editor-fold>

//<editor-fold desc="notes" defaultstate="collapsed">

function makeTextareaFromTagsTable(table) {
    const textarea = document.createElement("textarea")
    table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
        if (i.querySelector("th").textContent.trim() === "" || i.querySelector("td").textContent.trim() === "") return
        textarea.value += `${i.querySelector("th").textContent}=${i.querySelector("td").textContent.replaceAll("\\\\", "\n")}\n`
    })
    textarea.value = textarea.value.trim()
    textarea.rows = 5
    return textarea
}

function addCreateNewPOIButton() {
    if (!document.querySelector("#sidebar_content form")) {
        return
    }
    if (newNotePlaceholder && document.querySelector(".note form textarea")) {
        document.querySelector(".note form textarea").textContent = newNotePlaceholder
        document.querySelector(".note form textarea").selectionEnd = 0
        newNotePlaceholder = null
    }
    if (document.querySelector(".add-new-object-btn")) return
    const b = document.createElement("span")
    b.classList.add("add-new-object-btn", "btn", "btn-primary")
    b.textContent = "➕"
    b.style.marginLeft = "auto"
    if (!getMap()?.getZoom) {
        b.style.display = "none"
        interceptMapManually().then(() => {
            b.style.display = ""
        })
    }
    b.title = t("notes.addNewObjectOnMap")
    document.querySelector("#sidebar_content form div:has(input)").appendChild(b)
    b.parentElement.style.display = "flex"
    b.before(document.createTextNode("\xA0"))
    b.onclick = async e => {
        e.stopImmediatePropagation()
        initOsmAuth()
        console.log("Begin creating node")

        let tagsHint = ""
        /** @type {Map<string, string>} */
        let tags
        try {
            tags = buildTags(document.querySelector("#sidebar_content form textarea").value, true)
        } catch (e) {
            alert(e)
            return
        }
        if (tags.size === 0) {
            alert(t("notes.textareaWithoutTagsAlert"))
            return
        }

        for (const i of tags.entries()) {
            if (mainTags.includes(i[0])) {
                tagsHint = tagsHint + ` ${i[0]}=${i[1]}`
                break
            }
        }
        for (const i of tags.entries()) {
            if (i[0] === "name") {
                tagsHint = tagsHint + ` ${i[0]}=${i[1]}`
                break
            }
        }
        const comment = tagsHint !== "" ? `Create${tagsHint}` : `Create node`
        const changesetId = await openOsmChangeset(comment)

        const nodePayload = document.createElement("osm")
        const node = document.createElement("node")
        nodePayload.appendChild(node)
        node.setAttribute("changeset", changesetId)

        const l = []
        getMap().eachLayer(intoPageWithFun(i => l.push(i)))
        const { lat: lat, lng: lng } = l.find(i => !!i._icon && i._icon.classList.contains("leaflet-marker-draggable"))._latlng
        node.setAttribute("lat", lat)
        node.setAttribute("lon", lng)

        for (const tag of tags.entries()) {
            const tagElem = document.createElement("tag")
            tagElem.setAttribute("k", tag[0])
            tagElem.setAttribute("v", tag[1])
            node.appendChild(tagElem)
        }
        const nodeStr = new XMLSerializer().serializeToString(nodePayload).replace(/xmlns="[^"]+"/, "")
        await createOsmNodes(nodeStr)
        await closeOsmChangeset(changesetId)
        window.location = osm_server.url + "/changeset/" + changesetId
    }
}

const kmlXmlNamespace = "http://www.opengis.net/kml/2.2"
const streetCompletePhotoUrlRegex = /^(https:\/\/streetcomplete\.app\/|https:\/\/westnordost\.de\/).+\.jpg$/

function makeCommentKmlHtml(text) {
    return text
        .split("\n")
        .map(line => {
            const html = escapeHtml(line)
            const url = line.trim()
            if (!streetCompletePhotoUrlRegex.test(url)) {
                return html
            }
            return `${html}<br><img width="100%" src="${escapeHtml(url)}">`
        })
        .join("<br>")
}

function makeNoteKmlDescription(note) {
    const { comments, date_created, id, status } = note.properties ?? {}
    const url = `${osm_server.url}/note/${id}`
    const noteText = note.properties?.comments?.[0]?.text ?? ""
    const author = comments?.[0]?.user
    const commentsHtml = (comments ?? [])
        .slice(1)
        .map(comment => {
            const user = comment.user ? ` by ${comment.user}` : ""
            return [
                "<p>",
                `<b>${escapeHtml(comment.action ?? "comment")}${escapeHtml(user)}</b>`,
                comment.date ? ` <small>${escapeHtml(comment.date)}</small>` : "",
                "<br>",
                makeCommentKmlHtml(comment.text),
            ].join("")
        })
        .join("")

    return [
        noteText ? `<p>${makeCommentKmlHtml(noteText)}</p>` : "",
        url ? `<b>OSM note: </b><a href="${escapeHtml(url)}">${escapeHtml(String(id))}</a><br>` : "",
        author ? `<b>Author:</b> ${escapeHtml(author)}<br>` : "",
        status === "closed" ? `<b>Status:</b> ${escapeHtml(status)}<br>` : "",
        date_created ? `<b>Created:</b> ${escapeHtml(date_created)}` : "",
        "</p>",
        commentsHtml,
    ].join("")
}

function makeNotesKml(notes) {
    const ns = kmlXmlNamespace
    const doc = document.implementation.createDocument(ns, "kml")

    function appendKmlTextElement(parent, name, value) {
        const element = doc.createElementNS(kmlXmlNamespace, name)
        element.textContent = value
        parent.appendChild(element)
    }

    const documentElement = doc.createElementNS(ns, "Document")
    doc.documentElement.appendChild(documentElement)
    appendKmlTextElement(documentElement, "name", "OpenStreetMap notes")

    const style = doc.createElementNS(ns, "Style")
    style.setAttribute("id", "placemark-blue")
    const iconStyle = doc.createElementNS(ns, "IconStyle")
    const icon = doc.createElementNS(ns, "Icon")
    appendKmlTextElement(icon, "href", "https://omaps.app/placemarks/placemark-blue.png")
    iconStyle.appendChild(icon)
    style.appendChild(iconStyle)
    documentElement.appendChild(style)

    notes.forEach(note => {
        if (note.geometry?.type !== "Point") {
            return
        }

        const props = note.properties ?? {}
        const placemark = doc.createElementNS(ns, "Placemark")
        placemark.setAttribute("id", `note-${props.id}`)
        documentElement.appendChild(placemark)

        const shortText = (note.properties?.comments?.[0]?.text ?? "")
            .replace(/\s+/g, " ")
            .trim()
            .replace(/A user of .*$/, "")
            .replace(/A CoMaps user .*$/, "")
            .replace(/OSM snapshot date.*$/, "")
            .replace(/via StreetComplete \d+\.\d+/im, "#StreetComplete")
            .replace(/via StreetComplete_ee \d+\.\d+/im, "#StreetComplete_ee")
            .replace(/Attached photo.*$/im, "+ photo")
            .replace(/https?:\/\/(www.)?.*$/i, "")
        const title = shortText.length > 70 ? shortText.slice(0, 67).trim() + "..." : shortText
        appendKmlTextElement(placemark, "name", title)

        appendKmlTextElement(placemark, "styleUrl", "#placemark-blue")
        appendKmlTextElement(placemark, "description", makeNoteKmlDescription(note))

        const extendedData = doc.createElementNS(ns, "ExtendedData")
        ;["id", "url", "date_created", "status"].forEach(key => {
            if (props[key] == null) {
                return
            }
            const data = doc.createElementNS(ns, "Data")
            data.setAttribute("name", key)
            appendKmlTextElement(data, "value", props[key])
            extendedData.appendChild(data)
        })
        if (props.comments) {
            const data = doc.createElementNS(ns, "Data")
            data.setAttribute("name", "comments_count")
            appendKmlTextElement(data, "value", props.comments.length)
            extendedData.appendChild(data)
        }
        placemark.appendChild(extendedData)

        const point = doc.createElementNS(ns, "Point")
        const coordinates = note.geometry?.coordinates
        appendKmlTextElement(point, "coordinates", `${coordinates[0]},${coordinates[1]},0`)
        placemark.appendChild(point)
    })

    return `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(doc)}\n`
}

function downloadTextFile(filename, text, mimeType) {
    const link = document.createElement("a")
    link.href = URL.createObjectURL(new Blob([text], { type: mimeType }))
    link.download = filename
    link.style.display = "none"
    document.body.appendChild(link)
    link.click()
    link.remove()
    setTimeout(() => URL.revokeObjectURL(link.href))
}

function downloadVisibleNotesAsKml() {
    const notes = structuredClone(getWindow().visibleNotes ?? [])
    if (notes.length === 0) {
        alert("No visible notes to download")
        return
    }

    const filenameDate = new Date()
        .toISOString()
        .replaceAll(":", "-")
        .replace(/\.\d{3}Z$/, "Z")
    downloadTextFile(`osm-notes-${filenameDate}.kml`, makeNotesKml(notes), "application/vnd.google-earth.kml+xml;charset=utf-8")
}

const noteHashtags = [
    "#added",
    "#fixed",
    "#irrelevant",
    "#alreadyfixed",
    "#needmoreinfo",
    "#surveyme",
    "#tooold",
    "#notonortophoto",
    "#notenoughinfo",
    "#inacurratelocation",
    "#needconfirmation",
]

function addAutoComplete() {
    const container = document.querySelector("#sidebar")
    const ta = document.querySelector("form.mb-3 .form-control")
    let anchorPos = null // { left, top }
    let anchorStart = null

    const box = document.createElement("div")
    box.style.position = "absolute"
    box.style.border = "1px solid var(--bs-border-color)"
    box.style.background = "var(--bs-body-bg)"
    box.style.fontSize = "1rem"
    box.style.display = "none"
    box.style.zIndex = 1000
    container.appendChild(box)

    let items = []
    let index = 0
    let match = null

    function findMatch(value, pos) {
        const left = value.slice(0, pos)
        const m = left.match(/(^|\s)(#\w*)$/)
        if (!m) return null
        return { start: pos - m[2].length, text: m[2] }
    }

    function caretCoords(ta, pos, container) {
        const taRect = ta.getBoundingClientRect()
        const cRect = container.getBoundingClientRect()

        const div = document.createElement("div")
        div.style.padding = getComputedStyle(ta).padding

        div.style.position = "absolute"
        div.style.visibility = "hidden"
        div.style.top = taRect.top - cRect.top + container.scrollTop + "px"
        div.style.left = taRect.left - cRect.left + container.scrollLeft + "px"

        div.textContent = ta.value.slice(0, pos)

        const span = document.createElement("span")
        span.textContent = "\u200b"
        div.appendChild(span)

        container.appendChild(div)
        const r = span.getBoundingClientRect()
        container.removeChild(div)

        return {
            left: r.left - cRect.left + container.scrollLeft - ta.scrollLeft,
            top: r.top - cRect.top + container.scrollTop - ta.scrollTop,
            bottom: r.bottom - cRect.top + container.scrollTop - ta.scrollTop,
        }
    }

    function render() {
        box.innerHTML = ""
        items.forEach((t, i) => {
            const d = document.createElement("div")
            d.textContent = t
            d.style.padding = "0px 4px"
            d.style.cursor = "pointer"
            if (i === index) {
                d.style.background = "var(--bs-border-color-translucent)"
            }
            d.onmousedown = e => {
                e.preventDefault()
                index = i
                apply()
            }
            box.appendChild(d)
        })
        box.style.display = items.length ? "block" : "none"
    }

    function apply() {
        if (!items.length || !match) {
            return
        }
        ta.value = ta.value.slice(0, match.start) + items[index] + " " + ta.value.slice(match.start + match.text.length)
        box.style.display = "none"
    }

    ta.addEventListener("input", () => {
        const m = findMatch(ta.value, ta.selectionStart)
        if (!m) {
            box.style.display = "none"
            anchorPos = null
            anchorStart = null
            return
        }

        if (anchorStart !== m.start) {
            anchorStart = m.start
            const r = caretCoords(ta, m.start, container)
            anchorPos = { left: r.left - 4, top: r.bottom + 4 }
        }

        box.style.left = anchorPos.left + "px"
        box.style.top = anchorPos.top + "px"

        match = m
        items = noteHashtags.filter(t => t.startsWith(match.text))
        index = 0

        if (!items.length) {
            box.style.display = "none"
            return
        }

        box.style.left = anchorPos.left + "px"
        box.style.top = anchorPos.top + "px"
        render()
    })

    ta.addEventListener("keydown", e => {
        if (box.style.display === "none") {
            return
        }
        if (e.key === "Tab" || e.key === "Enter") {
            e.preventDefault()
            apply()
        } else if (e.key === "ArrowDown") {
            e.preventDefault()
            index = (index + 1) % items.length
            render()
        } else if (e.key === "ArrowUp") {
            e.preventDefault()
            index = (index - 1 + items.length) % items.length
            render()
        } else if (e.key === "Escape") {
            box.style.display = "none"
        }
    })
}

function tryReloadSidebar() {
    try {
        getWindow().OSM.router.route(location.pathname)
    } catch (e) {
        console.error(e)
        window.location.reload()
    }
}

function addTimebackButton(timestamp) {
    try {
        let timeSource = "note creation date"
        const noteText = document.querySelector(".overflow-hidden")?.textContent
        const mapsmeDate = noteText?.match(/OSM data version: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/)
        if (mapsmeDate) {
            timestamp = mapsmeDate[1]
            timeSource = "MAPS.ME snapshot date"
        }
        const organicmapsDate = noteText?.match(/OSM snapshot date: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/)
        if (organicmapsDate) {
            timestamp = organicmapsDate[1]
            const appName = noteText.includes("CoMaps") ? "CoMaps" : "OrganicMaps"
            timeSource = appName + " snapshot date"
        }
        const lat = document.querySelector("#sidebar_content .latitude").textContent.replace(",", ".")
        const lon = document.querySelector("#sidebar_content .longitude").textContent.replace(",", ".")
        const zoom = 18
        const tooltipText = noteText
            .trim()
            .slice(0, 50)
            .replaceAll('"', "")
            .replaceAll("\n", " ")
            .replace(/The place has gone.*$/, "")
            .trim()
        const styleSuffix = `

make way ::geom = lstr(pt(${lat}, ${lon})), ::id=0;
out center;

{{style:
node[@id=0] {
  symbol-size: 2;
  fill-opacity: 1;
  color: red;
  fill-color: red;
  text: eval("'${tooltipText}'");
}
}}
`
        const query = `// via ${timeSource}
[date:"${timestamp}"][maxsize:32Mi];
(
  node({{bbox}});
  way({{bbox}});
  //relation({{bbox}});
);
(._;>;);
out meta;
${styleSuffix}`
        const btn = document.createElement("a")
        btn.id = "timeback-btn"
        if (organicmapsDate || mapsmeDate) {
            btn.title = t("notes.openMapStateSnapshot")
        } else {
            btn.title = t("notes.openMapStateNoteCreation")
        }
        btn.textContent = " 🕰"
        btn.style.cursor = "pointer"
        document.querySelector("#sidebar_content time").after(btn)
        btn.onclick = e => {
            e.preventDefault()
            const params = new URLSearchParams({
                Q: query,
                C: `${lat};${lon};${zoom}`,
            })
            window.open(`${overpass_server.url}?${params}&R`)
        }
    } catch (e) {
        console.error("setup timeback button fail", e)
    }
}

function processAttachedGpsTracks() {
    document.querySelectorAll("#sidebar_content div:has(h4) a:not(.gpx-displayed)").forEach(i => {
        i.classList.add("gpx-displayed")
        const m = i.href.match(new RegExp(`${osm_server.url}/user/.+/traces/(\\d+)`))
        if (m) {
            externalFetchRetry({
                url: `${osm_server.url}/traces/${m[1]}/data`,
            }).then(res => displayGPXTrack(res.response))
        }
    })
}

function addStreetCompletePhotos(isClosedNote) {
    document.querySelectorAll("#sidebar_content div:has(h4) a").forEach(i => {
        if (i.href?.match(streetCompletePhotoUrlRegex)) {
            const imgSrc = i.href
            if (isSafari) {
                fetchImageWithCache(imgSrc).then(async imgData => {
                    const img = document.createElement("img")
                    img.src = imgData
                    if (!isClosedNote) {
                        img.style.width = "100%"
                    }
                    img.onerror = () => {
                        img.style.display = "none"
                    }
                    img.onload = () => {
                        img.style.width = "100%"
                    }
                    i.after(img)
                })
            } else {
                const img = GM_addElement("img", {
                    src: imgSrc,
                    // crossorigin: "anonymous"
                })
                if (!isClosedNote) {
                    img.style.width = "100%"
                }
                img.onerror = () => {
                    img.style.display = "none"
                }
                img.onload = () => {
                    img.style.width = "100%"
                }
                i.after(img)
            }
            document.querySelector("#sidebar").style.resize = "horizontal"
            document.querySelector("#sidebar").style.width = "450px"
            // hideSearchForm()
        }
    })
}

function addRelatedChangesetsSearch() {
    if (true || !isDebug()) {
        return
    }
    const lastCommentTime = Array.from(document.querySelectorAll("#sidebar_content article time")).at(-1)
    const findChangesetsBtn = document.createElement("span")
    findChangesetsBtn.textContent = " 🔍"
    findChangesetsBtn.style.cursor = "pointer"
    findChangesetsBtn.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        findChangesetsBtn.style.cursor = "progress"
        const closeTime = new Date(lastCommentTime.getAttribute("datetime"))
        const prevDay = new Date(closeTime.getTime() - 24 * 60 * 60 * 1000)
        const nextDay = new Date(closeTime.getTime() + 24 * 60 * 60 * 1000)
        const username = lastCommentTime.previousElementSibling.getAttribute("href").match(/user\/(.+)$/)[1]
        const changesetsPrev = await loadChangesetsBetween(username, prevDay, closeTime)
        const changesetsNext = await loadChangesetsBetween(username, closeTime, nextDay)

        const menu = makeContextMenuElem(e)
        const ul = document.createElement("ul")
        const li = document.createElement("li")
        li.textContent = "test"
        ul.appendChild(li)
        menu.appendChild(ul)
        document.body.appendChild(menu)
        console.log(changesetsPrev)
        console.log(changesetsNext)

        findChangesetsBtn.style.cursor = "pointer"
    }
    lastCommentTime.after(findChangesetsBtn)
}

function insertNoteResolveButtons() {
    /** @type {string} */
    const resolveButtonsText = GM_config.get("ResolveNotesButton")
    if (!resolveButtonsText) {
        return
    }
    initOsmAuth()
    const note_id = location.pathname.match(/note\/(\d+)/)[1]
    JSON.parse(resolveButtonsText).forEach(row => {
        const label = row["label"]
        let text = label
        if (row["text"] !== "") {
            text = row["text"]
        }
        const b = document.createElement("button")
        b.classList.add("resolve-note-done", "btn", "btn-primary")
        b.textContent = label
        b.title = t("notes.resolveButtonTitle", { text })
        document.querySelectorAll("form.mb-3")[0].before(b)
        b.after(document.createTextNode("\xA0"))
        b.onclick = async () => {
            try {
                await closeNote(note_id, text)
            } catch (err) {
                alert(err)
            } finally {
                tryReloadSidebar()
                getMap().fire("moveend")
            }
        }
    })
    document.querySelectorAll("form.mb-3")[0].before(document.createElement("p"))
    document.querySelector("form.mb-3 .form-control").rows = 3
}

function addResolveNotesButton() {
    if (!location.pathname.includes("/note")) return
    if (location.pathname.includes("/note/new")) {
        addCreateNewPOIButton()
        return
    }
    if (document.querySelector(".resolve-note-done")) return true
    if (document.querySelector("#timeback-btn")) return true
    resetSearchFormFocus()
    void geocodeCurrentView()

    document.querySelectorAll('#sidebar_content a[href^="/user/"]').forEach(elem => {
        getCachedUserInfo(elem.textContent).then(info => {
            elem.before(makeBadge(info, new Date(elem.parentElement.querySelector("time")?.getAttribute("datetime") ?? new Date())))
            elem.title = makeUsernameTitle(info)
        })
    })
    externalizeLinks(document.querySelectorAll(".overflow-hidden a"))

    makeTimesSwitchable()

    const timestamp = document.querySelector("#sidebar_content time")?.dateTime
    if (!timestamp) {
        return
    }
    addTimebackButton(timestamp)

    try {
        makeHashtagsInNotesClickable()
        makeUsernameInNotesFilterable()
    } catch {
        console.error("fail when setuping filterable links in note")
    }

    processAttachedGpsTracks()
    try {
        addAltClickHandlerForNotes()
    } catch (err) {
        console.error(err, "fail add alt clicks handlers for note layer")
    }

    const isClosedNote = !document.querySelector("#sidebar_content textarea.form-control")

    addStreetCompletePhotos(isClosedNote)

    if (isClosedNote) {
        addRelatedChangesetsSearch()
        return
    }
    insertNoteResolveButtons()
    addAutoComplete()
}

function setupResolveNotesButton() {
    if (!location.pathname.startsWith("/note")) return
    tryApplyModule(addResolveNotesButton, 100, 3000)
}

let updateNotesLayer = null

function addAltClickHandlerForNotes() {
    getMap()?.noteLayer?.on(
        "click",
        intoPageWithFun(e => {
            if (!e.originalEvent.altKey) {
                return
            }
            console.log("removing current note", e.layer.id)
            getWindow().notesIDsFilter.add(e.layer.id)
            try {
                e.propagatedFrom.getElement().style.display = "none"
            } catch (err) {
                console.error(err, "when e.propagatedFrom.getElement()")
            }
            getMap().fire("moveend")
            const match = location.pathname.match(/note\/(\d+)/)
            if (match && parseInt(match[1]) === e.layer.id) {
                console.log("removing also current note layer")
                getMap()._objectLayer.remove()
            }
        }),
    )
    getMap()?._objectLayer?.on(
        "click",
        intoPageWithFun(e => {
            if (!e.originalEvent.altKey) {
                return
            }
            console.log("removing current note", getMap()._object.id)
            e.originalEvent.stopPropagation()
            e.originalEvent.stopImmediatePropagation()
            getWindow().notesIDsFilter.add(getMap()._object.id)
            try {
                getMap()._objectLayer.remove()
                getMap().noteLayer?.eachLayer(l => {
                    if (l.id === getMap()._object.id) {
                        l.remove()
                    }
                })
            } catch (err) {
                console.error(err, "when click for _objectLayer ")
            }
            getMap().fire("moveend")
        }),
    )
}

function addNotesFiltersButtons() {
    if (document.getElementById("notes-filter")) {
        return
    }
    const noteLabel = Array.from(document.querySelectorAll(".overlay-layers label"))[0]
    if (!noteLabel) {
        return
    }
    if (!getWindow().fetchIntercepterScriptInited) {
        return
    }
    injectCSSIntoOSMPage(`
        .wait-fetch {
            cursor: progress !important;
        }

        label:has(:not(:checked)) + #download-notes {
          display: none;
        }
    `)
    const checkbox = noteLabel.querySelector("input")
    const filters = document.createElement("div")

    function updateNotesFilters() {
        if (checkbox.checked) {
            filters.style.display = ""
            getWindow().notesDisplayName = filterByUsername.value
            getWindow().invertDisplayName = inverterForFilterByUsername.checked
            getWindow().notesQFilter = filterByString.value
            getWindow().invertQ = inverterForFilterByString.checked
            getWindow().notesClosedFilter = filterByClosed.value
            getWindow().notesCommentsFilter = filterByComment.value
        } else {
            filters.style.display = "none"
            getWindow().notesDisplayName = ""
            getWindow().invertDisplayName = ""
            getWindow().notesQFilter = ""
            getWindow().invertQ = ""
            getWindow().notesClosedFilter = ""
            getWindow().notesCommentsFilter = ""
        }
        if (filterByComment.value === "") {
            filterByComment.style.color = "gray"
        } else {
            filterByComment.style.removeProperty("color")
        }
    }

    updateNotesLayer = function () {
        updateNotesFilters()
        getMap().fire("moveend")
    }

    checkbox.onchange = updateNotesFilters
    filters.id = "notes-filter"
    filters.style.display = "none"
    filters.style.paddingTop = "4px"

    const filterByString = document.createElement("input")
    const inverterForFilterByString = document.createElement("span")

    function makeFilterByStringWrapper() {
        filterByString.type = "input"
        filterByString.id = "filter-notes-by-string"
        filterByString.style.width = "100%"
        filterByString.placeholder = t("notes.wordInNotesPlaceholder")
        filterByString.title = t("notes.commaSeparatedSubstrings")
        filterByString.addEventListener("keypress", function (event) {
            if (event.key === "Enter") {
                filterByString.classList?.add("wait-fetch")
                updateNotesLayer()
            }
        })

        inverterForFilterByString.textContent = "🚫"
        const hideWithWordTitle = "Click to hide notes with this word"
        const onlyWithWordTitle = "Click to show notes only with this word"
        inverterForFilterByString.title = hideWithWordTitle
        inverterForFilterByString.style.filter = "grayscale(1)"
        inverterForFilterByString.style.position = "absolute"
        inverterForFilterByString.style.cursor = "pointer"
        inverterForFilterByString.style.marginLeft = "-1.5em"
        inverterForFilterByString.onclick = () => {
            inverterForFilterByString.checked = !inverterForFilterByString.checked
            if (inverterForFilterByString.checked) {
                inverterForFilterByString.style.filter = ""
                inverterForFilterByString.title = onlyWithWordTitle
            } else {
                inverterForFilterByString.style.filter = "grayscale(1)"
                inverterForFilterByString.title = hideWithWordTitle
            }
            inverterForFilterByString.classList?.add("wait-fetch")
            updateNotesLayer()
        }

        const resetFilter = document.createElement("button")
        resetFilter.style.all = "unset"
        resetFilter.textContent = "✖"
        resetFilter.style.position = "absolute"
        resetFilter.style.right = "20px"
        resetFilter.style.cursor = "pointer"
        resetFilter.tabIndex = -1
        resetFilter.onclick = () => {
            filterByString.value = ""
            updateNotesLayer()
        }

        const datalistInstances = document.createElement("datalist")
        datalistInstances.id = "words-in-notes-filter"
        ;[
            // prettier-ignore
            "StreetComplete",
            "never exist",
            "photo",
            "#EveryDoor",
        ].forEach(i => {
            const opt = document.createElement("option")
            opt.value = i
            datalistInstances.appendChild(opt)
        })
        filterByString.setAttribute("list", "words-in-notes-filter")

        const wrapper = document.createElement("div")
        wrapper.style.display = "flex"
        wrapper.style.alignItems = "center"
        wrapper.style.marginBottom = "2px"
        wrapper.appendChild(inverterForFilterByString)
        wrapper.appendChild(filterByString)
        wrapper.appendChild(resetFilter)
        // wrapper.appendChild(datalistInstances) // TODO

        return wrapper
    }

    const filterByUsername = document.createElement("input")
    const inverterForFilterByUsername = document.createElement("span")

    function makeFilterByUsernameWrapper() {
        filterByUsername.type = "input"
        filterByUsername.placeholder = t("notes.usernamePlaceholder")
        filterByUsername.title = t("notes.commaSeparatedUsernames")
        filterByUsername.id = "filter-notes-by-username"
        filterByUsername.style.width = "100%"
        filterByUsername.addEventListener("keypress", function (event) {
            if (event.key === "Enter") {
                filterByUsername.classList?.add("wait-fetch")
                updateNotesLayer()
            }
        })

        inverterForFilterByUsername.textContent = "🚫"
        const hideFromUserTitle = "Click to hide notes from this user"
        const onlyFromUserTitle = "Click to show notes only from this user"
        inverterForFilterByUsername.title = hideFromUserTitle
        inverterForFilterByUsername.style.filter = "grayscale(1)"
        inverterForFilterByUsername.style.position = "absolute"
        inverterForFilterByUsername.style.cursor = "pointer"
        inverterForFilterByUsername.style.marginLeft = "-1.5em"
        inverterForFilterByUsername.checked = false
        inverterForFilterByUsername.onclick = () => {
            inverterForFilterByUsername.checked = !inverterForFilterByUsername.checked
            if (inverterForFilterByUsername.checked) {
                inverterForFilterByUsername.style.filter = ""
                inverterForFilterByUsername.title = onlyFromUserTitle
            } else {
                inverterForFilterByUsername.style.filter = "grayscale(1)"
                inverterForFilterByUsername.title = hideFromUserTitle
            }
            inverterForFilterByUsername?.classList?.add("wait-fetch")
            updateNotesLayer()
        }

        const resetFilter = document.createElement("button")
        resetFilter.textContent = "✖"
        resetFilter.style.all = "unset"
        resetFilter.style.position = "absolute"
        resetFilter.style.right = "20px"
        resetFilter.style.cursor = "pointer"
        resetFilter.tabIndex = -1
        resetFilter.onclick = () => {
            filterByUsername.value = ""
            updateNotesLayer()
        }

        const wrapper = document.createElement("div")
        wrapper.style.display = "flex"
        wrapper.style.alignItems = "center"
        wrapper.style.marginBottom = "2px"
        wrapper.appendChild(inverterForFilterByUsername)
        wrapper.appendChild(filterByUsername)
        wrapper.appendChild(resetFilter)

        return wrapper
    }

    const filterByClosed = document.createElement("select")

    function makeFilterByClosedWrapper() {
        filterByClosed.id = "filter-notes-by-comments"
        filterByClosed.style.width = "100%"
        filterByClosed.addEventListener("input", function () {
            filterByClosed.classList?.add("wait-fetch")
            updateNotesLayer()
        })
        ;[
            ["❌ + ✅ at 1 days ago", 1],
            ["❌ + ✅ at 7 days ago", 7],
            ["❌ + ✅ at 30 days ago", 30],
            ["❌ + ✅ at 365 days ago", 365],
            ["created 7 days ago", -7],
            ["created 30 days ago", -30],
            ["created > 3 years ago", -365 * 3],
            ["only opened", 0],
            ["all notes", -1],
        ].forEach(([title, value]) => {
            const option = document.createElement("option")
            option.textContent = title
            option.value = value
            if (value === 7) {
                option.selected = "selected"
            }
            filterByClosed.appendChild(option)
        })

        const wrapper = document.createElement("div")
        wrapper.style.marginBottom = "2px"
        wrapper.appendChild(filterByClosed)

        return wrapper
    }

    const filterByComment = document.createElement("select")

    function makeFilterByCommentsWrapper() {
        filterByComment.id = "filter-notes-by-closed"
        filterByComment.placeholder = t("notes.wordInCommentsPlaceholder")
        filterByComment.style.width = "100%"
        filterByComment.style.color = "gray"
        filterByComment.setAttribute("list", "comments-filters")
        filterByComment.addEventListener("click", function (e) {
            if (e.isTrusted) {
                filterByComment.click()
            }
        })
        filterByComment.addEventListener("input", function () {
            filterByComment.classList?.add("wait-fetch")
            updateNotesLayer()
        })
        ;[
            ["comments filters", ""],
            ["only with comments", "only with comments"],
            ["only with my comments", "only with my comments"],
            ["without comments", "without comments"],
            ["without my comments", "without my comments"],
            ["commented by other users", "commented by other users"],
        ].forEach(([title, value]) => {
            const option = document.createElement("option")
            option.textContent = title
            option.value = value
            filterByComment.appendChild(option)
        })

        const wrapperForFilterByClosed = document.createElement("div")
        wrapperForFilterByClosed.appendChild(filterByComment)

        return wrapperForFilterByClosed
    }

    filters.appendChild(makeFilterByStringWrapper())
    filters.appendChild(makeFilterByUsernameWrapper())
    filters.appendChild(makeFilterByClosedWrapper())
    filters.appendChild(makeFilterByCommentsWrapper())

    noteLabel.after(filters)
    updateNotesFilters()
    addAltClickHandlerForNotes()
    document.querySelector(".overlay-layers p").style.display = "none"
    document.querySelector(".layers-ui :is(h2,h4)").style.fontSize = "20px"

    const downloadNotes = document.createElement("span")
    downloadNotes.id = "download-notes"
    downloadNotes.title = t("notes.downloadVisibleNotesAsKmlTitle")
    downloadNotes.innerHTML = downloadSvg
    downloadNotes.querySelector("svg").style.marginTop = "-3px"
    downloadNotes.style.opacity = "0.5"
    downloadNotes.style.cursor = "pointer"
    downloadNotes.style.paddingLeft = "5px"
    noteLabel.after(downloadNotes)
    downloadNotes.onclick = downloadVisibleNotesAsKml
}

function setupNotesFiltersButtons() {
    if (document.getElementById("map")) {
        tryApplyModule(addNotesFiltersButtons, 200, 5000)
    }
}

let mapDataSwitcherUnderSupervision = false

function hideNoteHighlight() {
    const g = document.querySelector("#map g")
    if (!g || g.childElementCount === 0) return
    const mapDataCheckbox = document.querySelector(".layers-ui #label-layers-data input")
    if (mapDataCheckbox && !mapDataCheckbox.checked) {
        if (mapDataSwitcherUnderSupervision) return
        mapDataSwitcherUnderSupervision = true
        mapDataCheckbox.addEventListener(
            "click",
            () => {
                mapDataSwitcherUnderSupervision = false
                hideNoteHighlight()
            },
            { once: true },
        )
        return
    }
    console.log("hide halo around note")
    if (
        g.childNodes[g.childElementCount - 1].getAttribute("stroke") === "#FF6200" &&
        g.childNodes[g.childElementCount - 1].getAttribute("d").includes("a20,20 0 1,0 -40,0 ")
    ) {
        g.childNodes[g.childElementCount - 1].remove()
        document.querySelector("div.leaflet-marker-icon:last-child").style.filter = "contrast(120%)"
    }
    // getMap().dataLayer.on('click', intoPageWithFun((e) => {
    //     if (!e.originalEvent.altKey) {
    //         return
    //     }
    //     debugger
    //     getWindow().mapDataIDsFilter.add(e.layer.id);
    //     e.propagatedFrom.getElement().style.display = "none";
    //     getMap().fire("moveend");
    // }))
}

function setupHideNoteHighlight() {
    if (!location.pathname.startsWith("/note/")) return
    tryApplyModule(hideNoteHighlight, 1000, 5000)
}

//</editor-fold>

//<editor-fold desc="spy-glass" defaultstate="collapsed">

function addSpyGlassButtons() {
    if (getWindow().fetchIntercepterScriptInited !== true) {
        return
    }
    if (document.getElementById("spy-glass")) {
        return
    }
    const mapDataLabel = Array.from(document.querySelectorAll(".overlay-layers label"))[1]
    if (!mapDataLabel) {
        return
    }
    injectCSSIntoOSMPage(`
    path.spy-glass-stroke-polyline {
        filter: drop-shadow(2px 2px 0 yellow) drop-shadow(-2px -2px 0 yellow) drop-shadow(2px -2px 0 yellow) drop-shadow(-2px 2px 0 yellow);
    }
    
    table.spy-glass tr > td:first-of-type {
        width: 200px;
    }
    
    #map.spy-glass {
        cursor: pointer;
    }
    `)
    const spyGlassBtn = document.createElement("span")
    spyGlassBtn.id = "spy-glass"
    spyGlassBtn.style.cursor = "pointer"
    spyGlassBtn.style.marginLeft = "3px"
    spyGlassBtn.classList.add("bi", "bi-eye-slash-fill")
    spyGlassBtn.title = t("spyGlass.activateSpyGlass")

    spyGlassBtn.onclick = async () => {
        getWindow().spyGlassMode = !getWindow().spyGlassMode
        getWindow().photosMode = !getWindow().photosMode
        if (getWindow().spyGlassMode) {
            spyGlassBtn.classList.remove("bi-eye-slash-fill")
            spyGlassBtn.classList.add("bi-eye-fill")
            document.getElementById("map").classList.add("spy-glass")
            if (!location.hash.includes("D")) {
                Array.from(document.querySelectorAll(".overlay-layers label"))[1].click()
            } else {
                const b = getMap().getBounds()
                const c = getMap().getCenter()
                getMap().setView({ lat: c.lat, lng: c.lng + 0.000005 })
            }
        } else {
            spyGlassBtn.classList.remove("bi-eye-fill")
            spyGlassBtn.classList.add("bi-eye-slash-fill")
            document.getElementById("map").classList.remove("spy-glass")
        }
    }
    mapDataLabel.after(spyGlassBtn)
}

function setupSpyGlassButtons() {
    if (document.getElementById("map") && isDebug()) {
        tryApplyModule(addSpyGlassButtons, 500, 5000)
    }
}

//</editor-fold>

//<editor-fold desc="gpx-filter" defaultstate="collapsed">

function addGPXFiltersButtons() {
    if (document.getElementById("gpx-filter")) {
        return
    }
    const gpxLabel = Array.from(document.querySelectorAll(".overlay-layers label"))[2]
    if (!gpxLabel) {
        return
    }
    const filters = document.createElement("span")
    filters.id = "gpx-filter"
    filters.style.cursor = "pointer"

    let bbox

    filters.onclick = async () => {
        const prevTracksList = document.getElementById("tracks-list")

        const bounds = await getMapBounds()
        const lat1 = bounds.getNorthWest().lat
        const lng1 = bounds.getNorthWest().lng
        const lat2 = bounds.getSouthEast().lat
        const lng2 = bounds.getSouthEast().lng

        const tracksList = document.createElement("div")
        tracksList.id = "tracks-list"
        filters.after(tracksList)

        try {
            for (let page = 0; page < 100; page++) {
                console.log("Tracks page #" + page)
                filters.style.cursor = "progress"
                filters.setAttribute("disabled", true)
                const response = await externalFetchRetry({
                    url:
                        osm_server.url +
                        "/api/0.6/trackpoints?" +
                        new URLSearchParams({
                            bbox: [lng1, lat2, lng2, lat1].join(","),
                            page: page,
                        }),
                })
                if (response.status !== 200) {
                    alert(t("gpxFilter.downloadFailed", { error: response.responseText }))
                    throw response.responseText
                }
                if (page === 0) {
                    prevTracksList?.remove()
                }
                try {
                    bbox?.remove()
                    bbox = getWindow()
                        .L.rectangle(
                            intoPage([
                                [lat1, lng1],
                                [lat2, lng2],
                            ]),
                            intoPage({ color: "red", weight: 2, fillOpacity: 0, dashArray: "10, 10" }),
                        )
                        .addTo(getMap())
                } catch (e) {
                    console.error(e)
                }

                const tracks = new DOMParser().parseFromString(response.responseText, "text/html")

                const countAllTrackPoints = tracks.querySelectorAll("trkpt").length
                console.log(`Tracks points: ${countAllTrackPoints}`)

                const goodTracks = tracks.querySelectorAll("trk:has(url)")
                console.log(`Tracks with URL: ${goodTracks.length}`)

                goodTracks.forEach(trk => {
                    const trackInfo = document.createElement("a")
                    const name = trk.querySelector("name").textContent
                    const desc = trk.querySelector("desc").textContent
                    const url = trk.querySelector("url").textContent
                    const [, username, trackID] = url.match(/\/user\/([^/]+)\/traces\/([0-9]+)/)
                    trackInfo.setAttribute("href", url)
                    trackInfo.setAttribute("target", "_blank")
                    trackInfo.style.display = "block"
                    trackInfo.textContent = decodeURI(username) + "#" + trackID
                    trackInfo.title = `${name}\n\n${desc}`
                    trackInfo.onmouseenter = () => {
                        bbox?.remove()
                        bbox = getWindow()
                            .L.rectangle(
                                intoPage([
                                    [lat1, lng1],
                                    [lat2, lng2],
                                ]),
                                intoPage({ color: "red", weight: 2, fillOpacity: 0, dashArray: "10, 10" }),
                            )
                            .addTo(getMap())

                        const trackForDisplay = document.implementation.createDocument(null, "gpx")
                        trackForDisplay.documentElement.appendChild(trk)
                        cleanAllObjects()
                        displayGPXTrack(trackForDisplay, "rgb(255,0,47)")
                    }
                    trackInfo.onclick = e => {
                        if (!e.altKey) {
                            return
                        }
                        fitBounds([
                            [trackMetadata.min_lat, trackMetadata.min_lon],
                            [trackMetadata.max_lat, trackMetadata.max_lon],
                        ])
                    }
                    tracksList.appendChild(trackInfo)

                    const downloadBtn = document.createElement("span")
                    downloadBtn.textContent = "📥"
                    downloadBtn.style.position = "absolute"
                    downloadBtn.style.marginLeft = "-1.5em"
                    downloadBtn.style.filter = "grayscale(1)"
                    downloadBtn.addEventListener(
                        "mouseenter",
                        async () => {
                            downloadBtn.style.cursor = "progress"
                            downloadBtn.style.filter = ""

                            const res = await externalFetchRetry({
                                url: `${osm_server.url}/traces/${trackID}/data`,
                                responseType: "blob",
                            })
                            if (res.status !== 200) {
                                console.error(`Track #${trackID} not download:`, res.status)
                                return
                            }
                            const contentType = res.response.type
                            let xml
                            if (contentType === "application/gpx+xml") {
                                xml = new DOMParser().parseFromString(await res.response.text(), "application/xml")
                            } else if (contentType === "application/gzip") {
                                xml = new DOMParser().parseFromString(await (await decompressBlob(res.response)).text(), "application/xml")
                            } else if (contentType === "application/x-bzip2") {
                                // fuck Tampermonkey, structuredClone for TM
                                xml = new DOMParser().parseFromString(
                                    new TextDecoder().decode(bz2.decompress(structuredClone(await res.response.bytes()))),
                                    "application/xml",
                                )
                            } else {
                                throw `Unknown track #${trackID} format: ` + contentType
                            }
                            cleanAllObjects()
                            displayGPXTrack(xml, "rgb(255,0,47)")

                            function hoverHandler() {
                                bbox?.remove()
                                bbox = getWindow()
                                    .L.rectangle(
                                        intoPage([
                                            [lat1, lng1],
                                            [lat2, lng2],
                                        ]),
                                        intoPage({ color: "red", weight: 2, fillOpacity: 0, dashArray: "10, 10" }),
                                    )
                                    .addTo(getMap())

                                cleanAllObjects()
                                displayGPXTrack(xml, "rgb(255,0,47)")
                            }
                            trackInfo.onmouseenter = hoverHandler
                            downloadBtn.onmouseenter = hoverHandler

                            downloadBtn.textContent = "⧈"
                            downloadBtn.innerHTML = fitToObjectSvg
                            downloadBtn.style.cursor = "pointer"
                            downloadBtn.title = t("gpxFilter.clickToZoomTrack")
                            downloadBtn.onclick = () => {
                                fitBounds([
                                    [trackMetadata.min_lat, trackMetadata.min_lon],
                                    [trackMetadata.max_lat, trackMetadata.max_lon],
                                ])
                            }
                        },
                        { once: true },
                    )
                    trackInfo.before(downloadBtn)
                })

                const hr = document.createElement("hr")
                hr.style.margin = "unset"
                tracksList.appendChild(hr)
                if (countAllTrackPoints === 0) {
                    break
                }
            }
        } finally {
            filters.style.cursor = "pointer"
            filters.removeAttribute("disabled")
        }
        bbox?.remove()
        bbox = getWindow()
            .L.rectangle(
                intoPage([
                    [lat1, lng1],
                    [lat2, lng2],
                ]),
                intoPage({ color: "red", weight: 2, fillOpacity: 0, dashArray: "10, 10" }),
            )
            .addTo(getMap())
    }

    filters.textContent = " 🔍"
    filters.title = t("gpxFilter.showGpxTracks")
    gpxLabel.after(filters)
}

function setupGPXFiltersButtons() {
    if (document.getElementById("map")) {
        tryApplyModule(addGPXFiltersButtons, 100, 3000)
    }
}

//</editor-fold>

//<editor-fold desc="object-editor" defaultstate="collapsed">

async function deleteObject(object_type, object_id) {
    const objectInfo = await getOsmObjectInfo(object_type, object_id)

    let tagsHint = ""
    const tags = Array.from(objectInfo.children[0].children[0]?.children)
    for (const i of tags) {
        if (mainTags.includes(i.getAttribute("k"))) {
            tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`
            break
        }
    }
    for (const i of tags) {
        if (i.getAttribute("k") === "type") {
            tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`
        }
    }
    for (const i of tags) {
        if (i.getAttribute("k") === "restriction") {
            tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`
        }
    }
    for (const i of tags) {
        if (i.getAttribute("k") === "name") {
            tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`
            break
        }
    }
    const comment = tagsHint !== "" ? `Delete${tagsHint}` : `Delete ${object_type} ${object_id}`

    if (object_type === "relation" && !prompt("⚠️ Delete relation?\n\nChangeset comment:", comment)) {
        return
    }

    const changesetId = await openOsmChangeset(comment)
    objectInfo.children[0].children[0].setAttribute("changeset", changesetId)
    try {
        await deleteOsmObjectByInfo(object_type, object_id, objectInfo)
    } finally {
        try {
            await closeOsmChangeset(changesetId)
        } finally {
            if (location.hash.includes("D") || Object.keys(getMap?.()?.dataLayer?._layers ?? {}).length) {
                window.location.reload()
            } else {
                tryReloadSidebar()
            }
        }
    }
}

async function restoreObject(object_type, object_id) {
    const objectHistory = await getOsmObjectHistory(object_type, object_id)
    const prevVersionInfo = Array.from(objectHistory.querySelectorAll(object_type)).at(-2)

    let tagsHint = ""
    const tags = Array.from(prevVersionInfo.querySelectorAll("tag"))
    for (const i of tags) {
        if (mainTags.includes(i.getAttribute("k"))) {
            tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`
            break
        }
    }
    for (const i of tags) {
        if (i.getAttribute("k") === "name") {
            tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`
            break
        }
    }
    const comment = `Restore ${object_type}/${object_id} ${tagsHint}`
    const changesetId = await openOsmChangeset(comment)
    try {
        const nodePayload = document.createElement("osm")
        nodePayload.appendChild(prevVersionInfo)

        prevVersionInfo.setAttribute("changeset", changesetId)
        prevVersionInfo.setAttribute("version", parseInt(prevVersionInfo.getAttribute("version")) + 1)

        const nodeStr = new XMLSerializer().serializeToString(nodePayload).replace(/xmlns="[^"]+"/, "")
        await osmEditAuth.fetch(osm_server.apiBase + object_type + "/" + object_id, {
            method: "PUT",
            prefix: false,
            body: nodeStr,
        })
    } finally {
        try {
            await closeOsmChangeset(changesetId)
        } finally {
            window.location.reload()
        }
    }
}

function addRestoreButton(object_type, object_id) {
    if (!document.querySelector("#sidebar_content nav")) {
        return
    }
    if (!document.querySelector(".secondary-actions")) {
        const secondaryActions = document.createElement("div")
        secondaryActions.classList.add("secondary-actions")
        document.querySelector("#sidebar_content nav").before(secondaryActions)
    }
    const secondaryActions = document.querySelector(".secondary-actions")
    secondaryActions.classList.add("deleted-object")
    secondaryActions.style.fontSize = "0px" // hide Edit tags

    const link = document.createElement("a")
    link.text = t("objectEditor.restore")
    link.href = ""
    link.style.setProperty("font-size", "14px", "important")
    link.classList.add("restore_object_button_class")
    secondaryActions.appendChild(link)

    secondaryActions.previousElementSibling.style.setProperty("margin-bottom", "0px", "important")
    secondaryActions.previousElementSibling.style.setProperty("padding-bottom", "0px", "important")

    link.onclick = e => {
        e.preventDefault()
        return restoreObject(object_type, object_id)
    }
}

function addDeleteButton() {
    if (!location.pathname.startsWith("/node/") && !location.pathname.startsWith("/relation/")) return
    if (location.pathname.includes("/history")) return

    if (document.querySelector(".delete_object_button_class")) return true
    if (document.querySelector(".restore_object_button_class")) return true

    const match = location.pathname.match(/(node|relation)\/(\d+)/)
    if (!match) return
    const object_type = match[1]
    const object_id = match[2]

    initOsmAuth()
    // skip deleted
    if (object_type === "node") {
        if (
            document.querySelectorAll("#sidebar_content > div:first-of-type h4").length < 2 &&
            document.querySelector("#sidebar_content > div .latitude") === null
        ) {
            addRestoreButton(object_type, object_id)
            return
        }
    } else if (object_type === "way") {
    } else if (object_type === "relation") {
        if (document.querySelectorAll("#sidebar_content > div:first-of-type h4").length < 2) {
            return
        }
    }
    // skip having a parent
    if (object_type === "node" && document.querySelectorAll("#sidebar_content > div:first-of-type details").length !== 0) {
        return
    } else if (object_type === "relation") {
        if (document.querySelectorAll("#sidebar_content > div:first-of-type details").length > 1) {
            return
        }
        if (Array.from(document.querySelectorAll(".browse-tag-list th")).some(i => i.textContent === "wikidata")) {
            return
        }
        const dangerousType = Array.from(document.querySelectorAll(".browse-tag-list td"))
            .map(i => i.textContent)
            .find(i => ["route_master", "route", "multipolygon", "public_transport"].includes(i))
        if (dangerousType) {
            if (dangerousType === "multipolygon") {
                if (document.querySelectorAll("#sidebar_content > div:first-of-type details li").length > 1) {
                    return
                }
            } else {
                return
            }
        }
    }

    const link = document.createElement("a")
    link.text = t("objectEditor.delete")
    link.href = ""
    link.classList.add("delete_object_button_class")
    if (!document.querySelector(".secondary-actions")) return
    document.querySelector(".secondary-actions").appendChild(link)
    link.after(document.createTextNode("\xA0"))
    link.before(document.createTextNode("\xA0· "))

    if (!document.querySelector(".secondary-actions .edit_tags_class")) {
        const tagsEditorExtensionWaiter = new MutationObserver(() => {
            if (document.querySelector(".secondary-actions .edit_tags_class")) {
                tagsEditorExtensionWaiter.disconnect()

                const tmp = document.createComment("")
                const node1 = document.querySelector(".delete_object_button_class")
                const node2 = document.querySelector(".edit_tags_class")

                node2.replaceWith(tmp)
                node1.replaceWith(node2)
                tmp.replaceWith(node1)

                console.log("Delete button replaced for Tags editor extension capability")
            }
        })
        tagsEditorExtensionWaiter.observe(document.querySelector(".secondary-actions"), {
            childList: true,
            subtree: true,
        })
        setTimeout(() => tagsEditorExtensionWaiter.disconnect(), 3000)
    }

    async function deleteObjectHandler(e) {
        e.preventDefault()
        link.classList.add("dbclicked")

        console.log("Delete clicked. Getting info")

        await deleteObject(object_type, object_id)
    }
    if (GM_config.get("OneClickDeletor") || object_type === "relation") {
        link.onclick = deleteObjectHandler
    } else {
        link.onclick = e => {
            e.preventDefault()
            setTimeout(() => {
                if (!link.classList.contains("dbclicked")) {
                    link.text = "Double click please"
                }
            }, 200)
        }
        link.ondblclick = deleteObjectHandler
    }
}

function setupDeletor() {
    if (!location.pathname.startsWith("/node/") && /*!location.pathname.startsWith("/way/") &&*/ location.pathname.startsWith("/relation/"))
        return
    tryApplyModule(addDeleteButton, 100, 3000)
}

//</editor-fold>

//<editor-fold desc="copy-coordinates" defaultstate="collapsed">

let copyCoordinatesMenuItem

function removeCopyCoordinatesMenuItem() {
    copyCoordinatesMenuItem?.remove()
    copyCoordinatesMenuItem = null
}

let contextMenuStylesAdded = false

function addContextMenuStyles() {
    if (contextMenuStylesAdded) return
    contextMenuStylesAdded = true
    injectCSSIntoOSMPage(`
        .copy-coordinates-btn:not(:hover) {
            color: gray;
        }
    `)
}

function addCopyCoordinatesMenuItem(prevItem) {
    addContextMenuStyles()
    removeCopyCoordinatesMenuItem()
    if (!measurerAdded) {
        return
    }
    const clickLat = parseFloat(getMap().osm_contextmenu._$element.data("lat"))
    const clickLon = parseFloat(getMap().osm_contextmenu._$element.data("lng"))
    const coordinatesFormatters = makeCoordinatesFormatters(clickLat, clickLon, 6)
    let text = coordinatesFormatters[coordinatesFormat]["getter"]()

    copyCoordinatesMenuItem = document.createElement("li")
    copyCoordinatesMenuItem.classList.add("copy-li")
    copyCoordinatesMenuItem.style.cursor = "pointer"

    const a = document.createElement("a")
    a.classList.add("dropdown-item", "d-flex", "align-items-center", "gap-3")
    const textSpan = document.createElement("span")
    textSpan.textContent = text
    a.appendChild(textSpan)

    const i = document.createElement("i")
    i.classList.add("bi", "bi-copy")
    a.prepend(i)
    copyCoordinatesMenuItem.appendChild(a)
    prevItem.after(copyCoordinatesMenuItem)

    a.onclick = async function moveNode(e) {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        navigator.clipboard.writeText(text).catch(e => alert(t("copying.failedToCopy", { text })))
        getMap().osm_contextmenu.hide()
    }

    const formatSwitch = document.createElement("span")
    formatSwitch.classList.add("bi", "bi-arrow-left-right", "copy-coordinates-btn")
    formatSwitch.style.marginLeft = "auto"
    formatSwitch.title = t("copying.changeCoordinatesFormat", { format: coordinatesFormat })

    formatSwitch.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()

        const formats = [...Object.keys(coordinatesFormatters), ...Object.keys(coordinatesFormatters)]
        const nextFormat = formats.indexOf(coordinatesFormat) + 1
        coordinatesFormat = formats[nextFormat]
        await GM.setValue("CoordinatesFormat", coordinatesFormat)
        text = coordinatesFormatters[coordinatesFormat]
        textSpan.textContent = text
    }
    a.appendChild(formatSwitch)
}

//</editor-fold>

//<editor-fold desc="node-mover" defaultstate="collapsed">

let nodeMoverMenuItem

function removePOIMoverMenu() {
    nodeMoverMenuItem?.remove()
    nodeMoverMenuItem = null
}

function addPOIMoverItem(measuringMenuItem) {
    const object_type = location.pathname.startsWith("/node") ? "node" : ""
    if (object_type !== "node") return
    removePOIMoverMenu()
    nodeMoverMenuItem = document.createElement("li")
    nodeMoverMenuItem.classList.add("mover-li")
    nodeMoverMenuItem.style.cursor = "pointer"
    const a = document.createElement("a")
    a.classList.add("dropdown-item", "d-flex", "align-items-center", "gap-3")
    a.textContent = t("nodeMover.moveNodeToHere")

    const i = document.createElement("i")
    i.classList.add("bi", "bi-arrow-down")
    a.prepend(i)
    nodeMoverMenuItem.appendChild(a)
    measuringMenuItem.after(nodeMoverMenuItem)

    a.onclick = async function moveNode(e) {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        const match = location.pathname.match(/(node)\/(\d+)/)
        if (!match) return
        const object_type = match[1]
        const object_id = match[2]
        initOsmAuth()
        if (!confirm("⚠️ move node?")) {
            return
        }
        const newLat = getMap().osm_contextmenu._$element.data("lat")
        const newLon = getMap().osm_contextmenu._$element.data("lng")
        console.log("Opening changeset")
        const objectInfo = await getOsmObjectInfo(object_type, object_id)
        // prettier-ignore
        const dist = Math.round(getDistanceFromLatLonInKm(
            parseFloat(objectInfo.querySelector("node").getAttribute("lat")),
            parseFloat(objectInfo.querySelector("node").getAttribute("lon")),
            newLat,
            newLon
        ) * 1000) / 1000
        if (dist > 50) {
            if (!confirm(`⚠️ ${dist} kilometers.\n\nARE YOU SURE?`)) {
                getMap().osm_contextmenu.hide()
                return
            }
            getMap().osm_contextmenu.hide()
        }
        objectInfo.querySelector("node").setAttribute("lat", newLat)
        objectInfo.querySelector("node").setAttribute("lon", newLon)

        const changesetId = await openOsmChangeset("Moving node")

        try {
            objectInfo.children[0].children[0].setAttribute("changeset", changesetId)

            const objectInfoStr = new XMLSerializer().serializeToString(objectInfo).replace(/xmlns="[^"]+"/, "")
            console.log(objectInfoStr)
            await osmEditAuth
                .fetch(osm_server.apiBase + object_type + "/" + object_id, {
                    method: "PUT",
                    prefix: false,
                    body: objectInfoStr,
                })
                .then(async res => {
                    const text = await res.text()
                    if (res.ok) return text
                    alert(text)
                    throw new Error(text)
                })
        } finally {
            await closeOsmChangeset(changesetId)
            window.location.reload()
        }
    }
}

//</editor-fold>

//<editor-fold desc="measurer" defaultstate="collapsed">

let measurerAdded = false
let measuring = false

function makeEmptyMeasuring() {
    return {
        way: [],
        nodes: [],
        wayLine: null,
        tempLine: null,
    }
}

let currentMeasuring = makeEmptyMeasuring()
let prevMeasurements = []

let lastLatLng = null
let movingTooltip = null

let measuringUnits = "km"

GM.getValue("measuringUnits").then(res => {
    measuringUnits = res ?? "km"
})

function getMeasureDistance(way, floatingCoord) {
    const [res] = way.reduce(
        ([dist, prev], cur) => {
            if (prev === null) {
                return [0.0, cur]
            }
            // prettier-ignore
            return [dist + getDistanceFromLatLonInKm(
                prev.lat,
                prev.lng,
                cur.lat,
                cur.lng
            ), cur]
        },
        [0.0, null],
    )
    // prettier-ignore
    return (Math.round((res + getDistanceFromLatLonInKm(
        way[way.length - 1].lat,
        way[way.length - 1].lng,
        floatingCoord.lat,
        floatingCoord.lng
    )) * 100000) / 100.0)
}

function formatMeasureDistance(distInMeters) {
    if (measuringUnits === "km") {
        return distInMeters < 2500 ? distInMeters.toString() + "m" : (Math.round(distInMeters) / 1000).toString() + "km"
    } else {
        const distInFeets = distInMeters * 3.28084
        const distInMiles = distInMeters * 0.000621371192
        return distInFeets < 1200 ? Math.round(distInFeets).toString() + "ft" : (Math.round(distInMiles * 100) / 100).toString() + "mi"
    }
}

let measuringMouseDownHandler = null
let measuringMouseUpHandler = null
let measuringMouseMoveHandler = null
let measuringMenuItem = null
let measuringCleanMenuItem = null

function endMeasuring() {
    document.querySelector("#map").style.cursor = "drag"
    const a = measuringMenuItem.querySelector("a")
    a.textContent = t("measurer.measureFromHere")

    const i = document.createElement("i")
    i.classList.add("bi", "bi-rulers")
    a.prepend(i)

    measuring = false

    getMap().off("mousedown", measuringMouseDownHandler)
    getMap().off("mouseup", measuringMouseUpHandler)
    getMap().off("mousemove", measuringMouseMoveHandler)
    movingTooltip?.remove()
    const lastNode = currentMeasuring.nodes[currentMeasuring.nodes.length - 1]
    const distInMeters = getMeasureDistance(currentMeasuring.way, lastNode.getLatLng())
    const text = formatMeasureDistance(distInMeters)
    currentMeasuring.nodes[currentMeasuring.nodes.length - 1]
        .bindTooltip(
            text,
            intoPage({
                content: text,
                sticky: true,
                permanent: true,
                offset: getWindow().L.point(10, 0),
            }),
        )
        .openTooltip()
    currentMeasuring.tempLine?.remove()
    prevMeasurements.push(currentMeasuring)
    currentMeasuring = makeEmptyMeasuring()
}

function addMenuSeparator(menu) {
    let customSeparator = menu.querySelector(".custom-separator")
    if (!customSeparator) {
        customSeparator = document.createElement("li")
        customSeparator.classList.add("custom-separator")
        const separatorHr = document.createElement("hr")
        separatorHr.classList.add("dropdown-divider")
        customSeparator.appendChild(separatorHr)
        menu.querySelector("ul").appendChild(customSeparator)
    }
    return customSeparator
}

function cleanMeasurements(e) {
    e.preventDefault()
    prevMeasurements.push(currentMeasuring)
    prevMeasurements.forEach(m => {
        m.wayLine?.remove()
        m.tempLine?.remove()
        m.nodes.forEach(i => i.remove())
    })
    prevMeasurements = []
    currentMeasuring = makeEmptyMeasuring()
    getMap().osm_contextmenu.hide()
    movingTooltip?.remove()
    measuringCleanMenuItem.remove()
    measuringCleanMenuItem = null
}

function addMeasureMenuItem(customSeparator) {
    measuringMenuItem = document.querySelector(".measurer-li")
    if (measuringMenuItem) {
        return
    }
    measuringMenuItem = document.createElement("li")
    measuringMenuItem.classList.add("measurer-li")
    measuringMenuItem.style.cursor = "pointer"
    const a = document.createElement("a")
    a.classList.add("dropdown-item", "d-flex", "align-items-center", "gap-3")
    a.textContent = measuring ? t("measurer.endMeasure") : t("measurer.measureFromHere")
    a.title = t("measurer.hotkeysTitle", { modifier: CtrlKeyName })

    const unitsSwitch = document.createElement("span")
    unitsSwitch.style.color = "gray"
    unitsSwitch.style.cursor = "pointer"
    unitsSwitch.style.marginLeft = "auto"
    unitsSwitch.textContent = measuringUnits
    unitsSwitch.title = t("measurer.clickToSwitchUnits")
    unitsSwitch.onclick = e => {
        e.preventDefault()
        e.stopPropagation()
        measuringUnits = measuringUnits === "km" ? "mi" : "km"
        GM.setValue("measuringUnits", measuringUnits)
        unitsSwitch.textContent = measuringUnits
    }
    a.appendChild(unitsSwitch)

    measuringCleanMenuItem = null
    if ((measuring && currentMeasuring.nodes.length) || prevMeasurements.length) {
        const hotkeyText = document.createElement("span")
        hotkeyText.style.color = "gray"
        hotkeyText.textContent = "esc"
        a.appendChild(hotkeyText)

        measuringCleanMenuItem = document.createElement("li")
        measuringCleanMenuItem.classList.add("measurer-li-clean")
        measuringCleanMenuItem.style.cursor = "pointer"

        const cleanA = document.createElement("a")
        cleanA.classList.add("dropdown-item", "d-flex", "align-items-center", "gap-3")
        cleanA.textContent = t("measurer.cleanMeasurements")
        cleanA.title = t("measurer.orPressEscapeTwice")

        const cleanI = document.createElement("i")
        cleanI.classList.add("bi", "bi-eraser")
        cleanA.prepend(cleanI)

        measuringCleanMenuItem.appendChild(cleanA)
        cleanA.onclick = cleanMeasurements
    }

    const i = document.createElement("i")
    i.classList.add("bi", "bi-rulers")
    a.prepend(i)

    a.onclick = async function startMeasuring(e) {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        if (measuring) {
            endMeasuring()
            getMap().osm_contextmenu.hide()
            return
        }
        prevMeasurements.push(currentMeasuring)
        currentMeasuring = makeEmptyMeasuring()
        const initLat = getMap().osm_contextmenu._$element.data("lat")
        const initLng = getMap().osm_contextmenu._$element.data("lng")
        currentMeasuring.way.push({ lat: initLat, lng: initLng })
        currentMeasuring.nodes.push(showNodeMarker(initLat, initLng, darkModeForMap ? "#FFFFFF" : "#000000"))
        if (currentMeasuring.way.length > 1) {
            currentMeasuring.wayLine?.remove()
            currentMeasuring.wayLine = displayWay(currentMeasuring.way)
        } else {
            document.querySelector("#map").style.cursor = "pointer"
            measuring = true
            a.textContent = t("measurer.endMeasure")

            getMap().on("mousedown", measuringMouseDownHandler)
            getMap().on("mouseup", measuringMouseUpHandler)
            getMap().on("mousemove", measuringMouseMoveHandler)
        }
        getMap().osm_contextmenu.hide()
    }

    measuringMenuItem.appendChild(a)
    if (measuringCleanMenuItem) {
        customSeparator.after(measuringCleanMenuItem)
    }
    customSeparator.after(measuringMenuItem)
    measurerAdded = true
}

function makeMeasureMouseHandlers() {
    // sometime click don't fire when move 1px
    let lastMouseDownX = 0
    let lastMouseDownY = 0
    measuringMouseDownHandler = intoPageWithFun(e => {
        lastMouseDownX = e.originalEvent.clientX
        lastMouseDownY = e.originalEvent.clientY
    })
    measuringMouseUpHandler = intoPageWithFun(e => {
        if (!measuring) {
            return
        }
        if (e.originalEvent.button === 2) {
            return
        }
        if (e.originalEvent.altKey) {
            prevMeasurements.push(currentMeasuring)
            currentMeasuring = makeEmptyMeasuring()
        }

        const { lat: lat, lng: lng } = e.latlng
        // prettier-ignore
        if (lastMouseDownX - e.originalEvent.clientX > 1 || lastMouseDownY - e.originalEvent.clientY > 1 ||
            lastMouseDownX - e.originalEvent.clientX < -1 || lastMouseDownY - e.originalEvent.clientY < -1) {
            console.log("skipped click");
            console.log(lastMouseDownX - e.originalEvent.clientX);
            console.log(lastMouseDownY - e.originalEvent.clientY);
            return
        }
        currentMeasuring.way.push({ lat: lat, lng: lng })
        currentMeasuring.nodes.push(showNodeMarker(lat, lng, darkModeForMap ? "#FFFFFF" : "#000000"))
        currentMeasuring.wayLine?.remove()

        const distInMeters = getMeasureDistance(currentMeasuring.way, { lat: lat, lng: lng })
        const text = formatMeasureDistance(distInMeters)
        currentMeasuring.wayLine = displayWay(currentMeasuring.way, false, darkModeForMap ? "#FFFFFF" : "#000000", 1)
        movingTooltip?.remove()
        movingTooltip = getWindow()
            .L.tooltip(
                getWindow().L.latLng(intoPage({ lat: lat, lng: lng })),
                intoPage({
                    content: text,
                    sticky: true,
                    offset: getWindow().L.point(10, 0),
                }),
            )
            .addTo(getMap())
        currentMeasuring.wayLine
            .bindTooltip(
                text,
                intoPage({
                    content: text,
                    sticky: true,
                    offset: getWindow().L.point(10, 0),
                }),
            )
            .openTooltip()
    })
    measuringMouseMoveHandler = intoPageWithFun(e => {
        if (!measuring) {
            return
        }

        lastLatLng = e.latlng
        const { lat: lat, lng: lng } = e.latlng

        currentMeasuring.tempLine?.remove()
        if (!currentMeasuring.way.length) return
        const distInMeters = getMeasureDistance(currentMeasuring.way, { lat: lat, lng: lng })
        const text = formatMeasureDistance(distInMeters)
        if (!e.originalEvent.altKey) {
            cleanObjectsByKey("activeObjects")
            currentMeasuring.tempLine = displayWay(
                [
                    currentMeasuring.way[currentMeasuring.way.length - 1],
                    {
                        lat: lat,
                        lng: lng,
                    },
                ],
                false,
                darkModeForMap ? "#FFFFFF" : "#000000",
                1,
            )
            movingTooltip?.remove()
            movingTooltip = getWindow()
                .L.tooltip(
                    getWindow().L.latLng(intoPage({ lat: lat, lng: lng })),
                    intoPage({
                        content: text,
                        sticky: true,
                        offset: getWindow().L.point(10, 0),
                    }),
                )
                .addTo(getMap())
        } else {
            showActiveNodeMarker(e.latlng.lat, e.latlng.lng, darkModeForMap ? "#FFFFFF" : "#000000")
        }
    })
}

//</editor-fold>

//<editor-fold desc="context-menu-items" defaultstate="collapsed">

let contextMenuObserver = null
let coordinatesFormat = "Lat Lon"

async function setupNewContextMenuItems() {
    coordinatesFormat = (await GM.getValue("CoordinatesFormat")) ?? "Lat Lon"
    await interceptMapManually()
    if (!getMap) {
        await sleep(1000)
    }
    if (!getMap) {
        console.error("Ruler can't be configured: map object not available")
        return
    }
    const menu = document.getElementById("map-context-menu")
    if (!menu) {
        console.error("context menu not found on page")
        return
    }
    makeMeasureMouseHandlers()
    contextMenuObserver = new MutationObserver((mutationList, observer) => {
        observer.disconnect()
        const customSeparator = addMenuSeparator(menu)
        addMeasureMenuItem(customSeparator)
        addCopyCoordinatesMenuItem(measuringCleanMenuItem ?? measuringMenuItem)
        addPOIMoverItem(copyCoordinatesMenuItem ?? measuringCleanMenuItem ?? measuringMenuItem)

        contextMenuObserver.observe(menu, { childList: true, subtree: true, attributes: true })
    })
    contextMenuObserver.observe(menu, { childList: true, subtree: true, attributes: true })
}

//</editor-fold>

//<editor-fold desc="satellite switching">
const OSMPrefix = "https://tile.openstreetmap.org/"
const OGFPrefix = "https://tiles05.opengeofiction.net/ogf-carto/"
const BaseLayerPrefix = isOGFServer() ? OGFPrefix : OSMPrefix

const ESRIPrefix = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
let ESRITemplate = ESRIPrefix + "{z}/{y}/{x}"
const ESRIBetaPrefix = "https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
let ESRIBetaTemplate = ESRIBetaPrefix + "{z}/{y}/{x}"

const SAT_MODE = "🛰"
const MAPNIK_MODE = "🗺️"
let currentTilesMode = MAPNIK_MODE
let tilesObserver = undefined

const OSMGPSPrefix = "https://gps.tile.openstreetmap.org/lines/"
const StravaPrefix = "https://content-a.strava.com/identified/globalheat/all/blue/"
let currentOverlayModeIsStrava = false
let overlayTilesObserver = undefined

function invertTilesMode(mode) {
    return mode === "🛰" ? "🗺️" : "🛰"
}

function invertOverlayMode(mode) {
    // fixme
    return (currentOverlayModeIsStrava = !currentOverlayModeIsStrava)
}

function parseOSMTileURL(url) {
    const match = url.match(new RegExp(`${BaseLayerPrefix}(\\d+)\\/(\\d+)\\/(\\d+)\\.png`))
    if (!match) {
        return false
    }
    return {
        x: match[2],
        y: match[3],
        z: match[1],
    }
}

function parseOSMGPSTileURL(url) {
    const match = url.match(new RegExp(`${OSMGPSPrefix}(\\d+)\\/(\\d+)\\/(\\d+)\\.png`))
    if (!match) {
        return false
    }
    return {
        x: match[2],
        y: match[3],
        z: match[1],
    }
}

function parseStravaTileURL(url) {
    const match = url.match(new RegExp(`${StravaPrefix}(\\d+)\\/(\\d+)\\/(\\d+)`))
    if (!match) {
        return false
    }
    return {
        x: match[3],
        y: match[2],
        z: match[1],
    }
}

let needStravaAuth = false

const r2d = 180 / Math.PI

function tile2lon(x, z) {
    return (x / Math.pow(2, z)) * 360 - 180
}

function tile2lat(y, z) {
    const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z)
    return r2d * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
}

function tileToBBOX(tile) {
    const e = tile2lon(tile[0] + 1, tile[2])
    const w = tile2lon(tile[0], tile[2])
    const s = tile2lat(tile[1] + 1, tile[2])
    const n = tile2lat(tile[1], tile[2])
    return [w, s, e, n]
}

function coord4326To3857(lon, lat) {
    const X = 20037508.34
    let long3857 = (lon * X) / 180
    let lat3857 = parseFloat(lat) + 90
    lat3857 = lat3857 * (Math.PI / 360)
    lat3857 = Math.tan(lat3857)
    lat3857 = Math.log(lat3857)
    lat3857 = lat3857 / (Math.PI / 180)
    lat3857 = (lat3857 * X) / 180
    return [long3857, lat3857]
}

function stravaAuthNotify() {
    if (!needStravaAuth) {
        needStravaAuth = true
        alert(t("satellite.stravaLoginRequiredAlert"))
        const [x, y, z] = getCurrentXYZ()
        const hash = `#${z}/${x}/${y}`
        window.open("https://www.strava.com/maps/global-heatmap" + hash, "_blank")
    }
}

async function bypassCSPForImagesSrc(imgElem, url) {
    const opt = {}
    if (url.startsWith("https://tiles.openrailwaymap.org")) {
        opt.headers = { Referer: "https://www.openrailwaymap.org/" }
    }
    if (url.startsWith("https://tile.tracestrack.com")) {
        opt.headers = { Referer: "https://www.openstreetmap.org/" }
    }
    const res = await fetchBlobWithCache(url, opt)
    if (res.status !== 200) {
        console.log("bypassCSPForImagesSrc", url, res.status)
        if (!GM_config.get("OverzoomForDataLayer")) {
            return
        }
        if (res.status === 403 && url.includes("strava")) {
            stravaAuthNotify()
            return
        }
        if (getZoom() >= 18) {
            const zoomStr = url.match(/(tile|org)\/([0-9]+)/)[2]
            if (zoomStr) {
                const tileZoom = parseInt(zoomStr)
                console.log(tileZoom)
                setZoom(Math.min(19, Math.min(getZoom(), tileZoom - 1)))
            }
        }
        tileErrorHandler({ currentTarget: imgElem }, url)
        imgElem.src = "/dev/null"
        return
    }

    const blob = res.response

    const satTile = await new Promise(resolve => {
        const reader = new FileReader()
        reader.onload = () => resolve(reader.result)
        reader.readAsDataURL(blob)
    })
    if (currentTilesMode === SAT_MODE || currentOverlayModeIsStrava) {
        imgElem.src = satTile
        imgElem.setAttribute("custom-tile-url", url)
    }
}

let lastEsriZoom = 0
let lastEsriDate = ""

function updateShotEsriDateNeeded() {
    return lastEsriZoom !== parseInt(getCurrentXYZ()[2]) && customLayerInfo.url === ESRITemplate
}

function addEsriDate() {
    console.debug("Updating imagery date")
    const [x, y, z] = getCurrentXYZ()
    const zoom = min(19, parseInt(z))
    lastEsriZoom = zoom
    const centerPoint = y + "," + x
    externalFetch({
        url: `https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/4/query?returnGeometry=false&geometry='${centerPoint}'&inSR=4326&geometryType=esriGeometryPoint&outFields=*&f=json`,
        responseType: "json",
    }).then(res => {
        if (currentTilesMode !== SAT_MODE || customLayerInfo.url !== ESRITemplate) {
            return
        }
        console.debug(res.response)
        const result = res.response.features.map(f => f.attributes).filter(a => a.MinMapLevel <= zoom && a.MaxMapLevel >= zoom)[0]
        console.debug(result)
        if (result && result.SRC_DATE2) {
            const date = new Date(result.SRC_DATE2)
            const strDate = `${date.getUTCFullYear()}-${(date.getUTCMonth() + 1).toString().padStart(2, "0")}-${date.getUTCDate().toString().padStart(2, "0")}`
            lastEsriDate = strDate
            setAttributionPrefix(strDate + " ESRI")
        } else if (result && !result.SRC_DATE2) {
            lastEsriDate = ""
            setAttributionPrefix("ESRI")
        }
    })
}

function addLayerPrefix() {
    if (customLayerInfo.url === ESRIBetaTemplate) {
        setAttributionPrefix("ESRI beta")
    } else if (customLayerInfo.url === ESRITemplate) {
        setAttributionPrefix("ESRI")
    } else if (customLayerInfo.url) {
        setAttributionPrefix(customLayerInfo.label)
    }
    if (customLayerInfo.url === ESRITemplate) {
        addEsriDate()
    }
}

const needBypassSatellite = !isFirefox || GM_info.scriptHandler === "Violentmonkey"

function tileErrorHandler(e, url = null) {
    if (!GM_config.get("OverzoomForDataLayer")) {
        return
    }
    if (getZoom() >= 18) {
        if (e?.currentTarget?.src?.endsWith("/dev/null") && !url) {
            return
        }
        let tileURL = e?.currentTarget?.src?.match(/(tile|org)\/([0-9]+)/)
        if (!tileURL) {
            tileURL = e.currentTarget.getAttribute("custom-tile-url").match(/(tile|org)\/([0-9]+)/)
            console.log(e.currentTarget.getAttribute("custom-tile-url"))
        }
        const zoomStr = tileURL[2]
        if (zoomStr) {
            const tileZoom = parseInt(zoomStr)
            console.log(tileZoom)
            setZoom(Math.min(19, Math.min(getZoom(), tileZoom - 1)))
        }
    }
}

function makeCustomTileUrl(template, xyz) {
    if (!customLayerInfo.isWms) {
        return template
            .replaceAll("{x}", xyz.x)
            .replaceAll("{y}", xyz.y)
            .replaceAll("{z}", xyz.z)
            .replaceAll("{wikimapia}", ((xyz.x % 4) + (xyz.y % 4) * 4).toString())
    }
    const bbox = tileToBBOX([parseInt(xyz.x), parseInt(xyz.y), parseInt(xyz.z)])
    const a = coord4326To3857(bbox[0], bbox[1])
    const b = coord4326To3857(bbox[2], bbox[3])
    return template.replaceAll("{bbox-epsg-3857}", [...a, ...b].join(","))
}

const retina = window.retina || window.devicePixelRatio > 1

function makeStravaURL(x, y, z) {
    return StravaPrefix + z + "/" + x + "/" + y + (retina ? "@2x" : "") + ".png"
}

function makeOSMURL(x, y, z) {
    return BaseLayerPrefix + z + "/" + x + "/" + y + ".png"
}

function makeBaseLayerURL(x, y, z) {
    return makeOSMURL(x, y, z)
}

function makeOSMGPSURL(x, y, z) {
    return OSMGPSPrefix + z + "/" + x + "/" + y + ".png"
}

/** @typedef {{
 * url : string,
 * url_template: string,
 * label: string,
 * isWms: boolean|undefined,
 * fieldsValues: Object|undefined
 * forceVector: boolean|undefined,
 * }} layerInfo */

/** @type {layerInfo} */
let customLayerInfo = { url: "", url_template: "", forceVector: false, label: "", isWms: false, fieldsValues: {} }

GM.getValue("customLayerUrlInfo").then(res => {
    if (!res) return
    customLayerInfo = JSON.parse(res)
    if (customLayerInfo.url.trim()) {
        applyCustomLayer(customLayerInfo)
    }
})

let lastCustomLayerUrl = null
let customLayerUrlOrigin = null
GM.getValue("lastCustomLayerUrl").then(res => (lastCustomLayerUrl = res))

let lastVectorLayerStyleUrl = null
let customLayerTextArea = null
let vectorLayerStyleUrlOrigin = null
GM.getValue("lastVectorLayerStyleUrl").then(res => (lastVectorLayerStyleUrl = res))
GM.getValue("customLayerTextarea").then(res => (customLayerTextArea = res))

/**
 * @return {import('maplibre-gl').Map}
 */
function findVectorMap() {
    for (const i of getWindow().mapGL) {
        if (i && i.getMaplibreMap?.()) {
            return i.getMaplibreMap()
        }
    }
}

let vectorStyleValidatorTimer = null

async function applyCustomVectorMapStyle(styleInfo, updateUrlInStorage = false) {
    let { url: styleUrl } = styleInfo
    try {
        getWindow().customVectorStyleLayerOrigin = vectorLayerStyleUrlOrigin = new URL(styleUrl).origin
        if (updateUrlInStorage) {
            void GM.setValue("lastVectorLayerStyleUrl", (lastVectorLayerStyleUrl = styleUrl))
        }
    } catch (e) {
        const parsedJson = JSON.parse(styleUrl)
        void GM.setValue("customLayerTextarea", styleUrl)
        styleUrl = parsedJson
        getWindow().customVectorStyleLayerOrigin = vectorLayerStyleUrlOrigin = "https://www.openstreetmap.org"
    }
    initCustomFetch()
    const map = findVectorMap()
    map.on("error", e => {
        if (typeof styleUrl === "object") {
            if (vectorStyleValidatorTimer) clearTimeout(vectorStyleValidatorTimer)
            vectorStyleValidatorTimer = setTimeout(() => {
                const ta = document.querySelector("#custom-layer-textarea")
                if (e.source) {
                    ta.setCustomValidity(JSON.stringify(e.source) + ":" + e.error.toString())
                } else {
                    ta.setCustomValidity(e.error.toString())
                }
                ta.reportValidity()
                ta.style.borderColor = "orange"
            }, 1000)
        }
    })
    map.setStyle(intoPage(styleUrl))
}

/**
 * @param {layerInfo} layerInfo
 */
function applyCustomLayer(layerInfo) {
    layerInfo.isWms = layerInfo.url.includes("{bbox-epsg-3857}")
    customLayerInfo = layerInfo
    void GM.setValue("customLayerUrlInfo", JSON.stringify(layerInfo))
    getWindow().customLayerOrigin = customLayerUrlOrigin = new URL(layerInfo.url).origin
}

/**
 * @return {string}
 */
function getCurrentLayers() {
    return `; ${document.cookie}`.split(`; _osm_location=`).pop().split(";").shift().split("|").at(-1)
}

function vectorLayerEnabled() {
    const layers = getCurrentLayers()
    return layers.includes("S") || layers.includes("V")
}

// const localMapStylesURL = "http://localhost:7777/misc/assets/vector-map-styles.json"
const githubMapStylesURL = `https://raw.githubusercontent.com/deevroman/better-osm-org/refs/heads/dev/misc/assets/vector-map-styles-v2.json?bypasscache=${Math.random()}`

const mapStylesDatabase = resourceCacher(githubMapStylesURL, "custom-vector-map-styles", "vector map styles list", 6 * 60 * 60 * 1000, "json")

async function askCustomStyleUrl() {
    if (!initCustomFetch) {
        alert(t("satellite.customStyleSetupHelpAlert"))
        return
    }
    if (document.querySelector(".vector-tiles-selector-popup")) {
        document.querySelector(".vector-tiles-selector-popup").remove()
        return
    }
    await mapStylesDatabase.init()
    const options = mapStylesDatabase.get()?.styles ?? [
        {
            label: "SomeoneElse's vector map style",
            value: "https://map.atownsend.org.uk/vector/style_svwd03.json",
            about: "https://github.com/SomeoneElseOSM/SomeoneElse-vector-web-display",
        },
        {
            label: "StreetComplete map style",
            value: "https://raw.githubusercontent.com/streetcomplete/maplibre-streetcomplete-style/refs/heads/master/demo/streetcomplete.json",
            about: "https://github.com/streetcomplete/maplibre-streetcomplete-style",
        },
        { label: "OpenFreeMap Positron", value: "https://tiles.openfreemap.org/styles/positron" },
        {
            label: "VersaTiles Colorful (Shortbread)",
            value: "https://vector.openstreetmap.org/styles/shortbread/colorful.json",
            about: "https://github.com/versatiles-org/versatiles-style",
        },
        {
            label: "VersaTiles Shadow",
            value: "https://vector.openstreetmap.org/styles/shortbread/shadow.json",
            about: "https://github.com/versatiles-org/versatiles-style",
        },
        {
            label: "VersaTiles Graybeard",
            value: "https://vector.openstreetmap.org/styles/shortbread/graybeard.json",
            about: "https://github.com/versatiles-org/versatiles-style",
        },
    ]
    const popup = document.createElement("div")
    popup.classList.add("vector-tiles-selector-popup")
    const radioContainer = document.createElement("div")

    injectCSSIntoOSMPage(`
    .vector-tiles-selector-popup {
        position: relative;
        width: fit-content;
        max-height: calc(100vh - 55px);
        overflow-y: auto;
        background: var(--bs-body-bg);
        border: 1px solid rgba(204,204,204,0.5);
        padding: 12px;
        border-radius: 6px;
        box-shadow: 0 4px 12px rgba(0,0,0,.2);
        font-family: sans-serif;
        z-index: 9999;
    }
    .vector-tiles-selector-popup label:hover {
        background: light-dark("gray", "gray");
    }
`)

    const h = document.createElement("h3")
    h.style.display = "flex"
    h.style.gap = "25px"
    h.style.marginLeft = "auto"
    h.textContent = t("satellite.setupCustomStyleJson")
    popup.appendChild(h)

    const closeBtn = document.createElement("button")
    closeBtn.classList.add("better-btn-close")
    closeBtn.style.all = "unset"
    closeBtn.style.cursor = "pointer"
    closeBtn.style.marginLeft = "auto"
    closeBtn.innerHTML =
        '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">' +
        '  <path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>' +
        "</svg>"
    closeBtn.querySelector("svg").style.height = "1.5rem"
    closeBtn.onclick = () => popup.remove()
    h.append(closeBtn)

    popup.appendChild(radioContainer)

    const radiosName = `vector-styles-url`

    function makeWrapper() {
        const wrapper = document.createElement("label")
        wrapper.style.display = "flex"
        wrapper.style.paddingTop = "2px"
        wrapper.style.paddingBottom = "2px"
        wrapper.style.margin = "2px"
        wrapper.style.gap = "4px"
        return wrapper
    }

    function addRadio({ label, value, about, fields }) {
        const wrapper = makeWrapper()

        const input = document.createElement("input")
        input.type = "radio"
        input.name = radiosName
        wrapper.title = input.value = value

        async function onChange() {
            if (input.checked) {
                const { url, fieldsValues } = applyFields(input.value, fields, wrapper)
                await applyCustomVectorMapStyle({ url: url, url_template: input.value, label: label, fieldsValues: fieldsValues })
                setAttributionPrefix(label)
            }
        }
        input.addEventListener("change", onChange)

        const editBtn = document.createElement("button")
        editBtn.classList.add("edit-vector-style-btn", "bi", "bi-download")
        editBtn.style.all = "unset"
        editBtn.style.cursor = "pointer"
        editBtn.style.marginLeft = "auto"
        editBtn.style.marginRight = "10px"
        editBtn.style.color = "gray"
        editBtn.title = t("satellite.duplicateStyleAndEdit")
        editBtn.onclick = async e => {
            if (!e.isTrusted) return
            editBtn.style.cursor = "progress"
            const r = await externalFetch({
                url: value,
                responseType: "json",
            })
            editBtn.style.cursor = "pointer"
            const ta = document.querySelector("textarea#custom-layer-textarea")
            ta.click()
            ta.select()
            if (isFirefox) {
                document.execCommand("insertText", false, ta.value)
                ta.select()
                document.execCommand("insertText", false, JSON.stringify(r.response, null, 2))
            } else {
                // fucking Chrome. Or Ctrl + Z, or fast insert
                ta.setRangeText(JSON.stringify(r.response, null, 2), 0, ta.value.length, "start")
            }
            ta.setSelectionRange(0, 0)
            if (ta.parentElement.querySelector("input")?.checked) {
                ta.style.removeProperty("border-color")
                if (ta.value.trim() !== "") {
                    await applyCustomVectorMapStyle({ url: ta.value }, true)
                } else {
                    void GM.setValue("customLayerTextarea", "")
                }
            } else {
                ta.parentElement.click()
            }
        }

        const externalLink = document.createElement("a")
        externalLink.title = t("satellite.openMapStyleHomePage")
        externalLink.setAttribute("href", about)
        externalLink.setAttribute("target", "_blank")
        externalLink.innerHTML = '<i class="bi bi-box-arrow-up-right"></i>'
        externalLink.style.marginRight = "2px"
        externalLink.style.color = "gray"
        externalLink.tabIndex = -1
        externalLink.target = "_blank"

        const labelSpan = document.createElement("span")
        labelSpan.textContent = label
        wrapper.append(input, labelSpan, editBtn, externalLink)

        if (fields) {
            Object.entries(fields).forEach(([field_name, values]) => {
                const select = document.createElement("select")
                select.name = field_name
                values.forEach(o => {
                    const opt = document.createElement("option")
                    opt.textContent = o.name
                    opt.value = o.value
                    select.appendChild(opt)
                })
                select.onchange = async () => {
                    if (!input.checked) {
                        input.click()
                    } else {
                        await onChange()
                    }
                }
                // if (customLayerInfo.url_template === value && customLayerInfo.fieldsValues?.[field_name]) {
                //     select.value = customLayerInfo.fieldsValues[field_name]
                // }
                labelSpan.after(select)
            })
        }

        radioContainer.appendChild(wrapper)
    }

    options.forEach(addRadio)
    {
        const wrapper = makeWrapper()

        const input = document.createElement("input")
        input.type = "radio"
        input.name = radiosName
        input.value = ""

        wrapper.append(input)
        const urlInput = document.createElement("input")
        urlInput.type = "text"
        urlInput.placeholder = t("satellite.vectorStyleUrlPlaceholder")
        urlInput.style.width = "100%"
        if (lastVectorLayerStyleUrl) {
            urlInput.value = lastVectorLayerStyleUrl
        }
        wrapper.append(urlInput)

        input.onchange = async e => {
            if (!e.isTrusted) return
            if (input.checked && urlInput.value.trim() !== "") {
                await applyCustomVectorMapStyle({ url: urlInput.value }, true)
            }
        }

        urlInput.onkeydown = async e => {
            if (!e.isTrusted) return
            if (e.key === "Enter" && urlInput.value.trim() !== "") {
                input.click()
                await applyCustomVectorMapStyle({ url: urlInput.value }, true)
                setAttributionPrefix("Custom map style from " + escapeHtml(new URL(urlInput.value).host))
            }
        }
        radioContainer.appendChild(wrapper)
    }
    {
        const wrapper = makeWrapper()

        const input = document.createElement("input")
        input.type = "radio"
        input.name = radiosName
        input.value = ""

        wrapper.append(input)
        const jsonArea = document.createElement("textarea")
        jsonArea.id = "custom-layer-textarea"
        jsonArea.type = "text"
        if (customLayerTextArea) {
            jsonArea.value = customLayerTextArea
            jsonArea.rows = 3
        } else {
            jsonArea.rows = 1
        }
        jsonArea.placeholder = t("satellite.pasteStyleJsonPlaceholder")
        jsonArea.style.width = "100%"
        jsonArea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
        jsonArea.addEventListener(
            "click",
            () => {
                popup.style.minWidth = "50vw"
                popup.style.resize = "horizontal"
                jsonArea.style.height = "80vh"
                jsonArea.scrollIntoView({ block: "nearest", inline: "nearest" })
                setTimeout(() => jsonArea.focus(), 0)
            },
            { once: true },
        )
        wrapper.append(jsonArea)

        input.onchange = async e => {
            if (!e.isTrusted) return
            if (input.checked) {
                jsonArea.style.removeProperty("border-color")
                if (jsonArea.value.trim() !== "") {
                    await applyCustomVectorMapStyle({ url: jsonArea.value }, true)
                } else {
                    void GM.setValue("customLayerTextarea", "")
                }
            }
        }

        jsonArea.onkeyup = async e => {
            if (!e.isTrusted) return
            if (!jsonArea.value.trim()) {
                void GM.setValue("customLayerTextarea", "")
                return
            }
            try {
                JSON.parse(jsonArea.value)
            } catch {
                if (vectorStyleValidatorTimer) clearTimeout(vectorStyleValidatorTimer)
                vectorStyleValidatorTimer = setTimeout(() => (jsonArea.style.borderColor = "red"), 1000)
                return
            }
            if (vectorStyleValidatorTimer) clearTimeout(vectorStyleValidatorTimer)
            jsonArea.style.removeProperty("border-color")
            jsonArea.setCustomValidity("")
            input.click()
            await applyCustomVectorMapStyle({ url: jsonArea.value }, true)
            setAttributionPrefix("Custom map style")
        }

        radioContainer.appendChild(wrapper)
    }

    const note = document.createElement("span")
    note.style.color = "gray"
    note.innerHTML =
        "You can <a target='_blank' href='https://github.com/deevroman/better-osm-org/issues/new'>suggest</a> other styles. One of styles <a target='_blank' href='https://github.com/pnorman/maplibre-styles'>collection</a>"
    popup.appendChild(note)
    document.body.appendChild(popup)
    popup.querySelector('label:has([href^="https://github.com/versatiles-org/versatiles-style"])')?.querySelector("input")?.focus()
}

function applyFields(url, fields, wrapper) {
    const fieldsValues = {}
    if (!fields) {
        return { url, fieldsValues }
    }
    Object.entries(fields).forEach(([field_name, values]) => {
        const select = wrapper.querySelector(`select[name="${field_name}"]`)
        url = url.replaceAll(`{${field_name}}`, (fieldsValues[field_name] = select.value))
    })
    return { url, fieldsValues }
}

async function askCustomTileUrl() {
    if (document.querySelector(".map-layers-selector-popup")) {
        document.querySelector(".map-layers-selector-popup").remove()
        return
    }
    const options = [
        {
            label: "ESRI",
            value: "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}?blankTile=false",
            about: "https://osm.wiki/Esri",
        },
        {
            label: "ESRI beta (slow layer)",
            value: "https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}?blankTile=false",
            about: "https://osm.wiki/Esri",
        },
        {
            label: "ESRI Wayback 2014",
            value: "https://wayback.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/WMTS/1.0.0/default028mm/MapServer/tile/10/{z}/{y}/{x}?blankTile=false",
            about: "https://osm.wiki/Esri",
        },
        // {
        //     label: "OpenAerialMap Mosaic, by Kontur.io",
        //     value: "https://apps.kontur.io/raster-tiler/oam/mosaic/{z}/{x}/{y}.png",
        //     about: "https://www.kontur.io/solutions/global-orthomosaic-layer/",
        // },
        {
            label: "GeoScribbles",
            value: "https://geoscribble.osmz.ru/wms?FORMAT=image/png&TRANSPARENT=TRUE&VERSION=1.1.1&SERVICE=WMS&REQUEST=GetMap&LAYERS=scribbles&STYLES=&SRS=EPSG:3857&WIDTH=512&HEIGHT=512&BBOX={bbox-epsg-3857}",
            about: "https://osm.wiki/GeoScribble",
            forceVector: true,
        },
        {
            label: "OpenRailwayMap",
            value: "https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png",
            about: "https://www.openrailwaymap.org",
            forceVector: true,
        },
        // {
        //     label: "F4map",
        //     value: "https://tile.f4map.com/tiles/f4_3d/{z}/{x}/{y}.png",
        //     about: "https://demo.f4map.com",
        // },
        {
            label: "Hrvatska GeoPortal",
            value: "https://geoportal.dgu.hr/services/inspire/orthophoto_2021_2022/ows?FORMAT=image/png&TRANSPARENT=TRUE&VERSION=1.3.0&SERVICE=WMS&REQUEST=GetMap&LAYERS=OI.OrthoimageCoverage&STYLES=&CRS=EPSG:3857&WIDTH=256&HEIGHT=256&BBOX={bbox-epsg-3857}",
            about: "",
        },
        // {
        //     label: "Strava Heatmap via freemap.sk",
        //     value: "https://strava-heatmap.tiles.freemap.sk/all/hot/{z}/{x}/{y}.png?px=512",
        //     about: "https://www.freemap.sk",
        //     forceVector: true,
        // },
        {
            label: "Strava Heatmap β",
            value: "https://content-a.strava.com/identified/globalheat/{sport}/{color}/{z}/{x}/{y}@2x.png",
            about: "https://www.strava.com/maps/global-heatmap",
            forceVector: true,
            fields: {
                sport: [
                    { name: "all", value: "all" },
                    { name: "run", value: "run" },
                    { name: "ride", value: "ride" },
                    { name: "water", value: "water" },
                    { name: "winter", value: "winter" },
                ],
                color: [
                    { name: "blue", value: "blue" },
                    { name: "hot", value: "hot" },
                ],
            },
        },
        {
            label: "Waymarked Trails",
            value: "https://tile.waymarkedtrails.org/{type}/{z}/{x}/{y}.png",
            about: "https://waymarkedtrails.org",
            forceVector: true,
            fields: {
                type: [
                    { name: "hiking", value: "hiking" },
                    { name: "cycling", value: "cycling" },
                    { name: "mtb", value: "mtb" },
                    { name: "skating", value: "skating" },
                    { name: "riding", value: "riding" },
                    { name: "slopes", value: "slopes" },
                ],
            },
        },
        // {
        //     label: "OsmAnd HD tiles",
        //     value: "https://tile.osmand.net/hd/{z}/{x}/{y}.png",
        //     about: "https://osmand.net/map",
        // },
    ]
    const popup = document.createElement("div")
    popup.classList.add("map-layers-selector-popup")
    const radioContainer = document.createElement("div")

    injectCSSIntoOSMPage(`
    .map-layers-selector-popup {
        position: relative;
        width: fit-content;
        max-height: calc(100vh - 55px);
        overflow-y: auto;
        background: var(--bs-body-bg);
        border: 1px solid rgba(204,204,204,0.5);
        padding: 12px;
        border-radius: 6px;
        box-shadow: 0 4px 12px rgba(0,0,0,.2);
        font-family: sans-serif;
        z-index: 9999;
    }
    .map-layers-selector-popup label:hover {
        background: light-dark("gray", "gray");
    }
`)

    const h = document.createElement("h3")
    h.style.display = "flex"
    h.style.gap = "25px"
    h.style.marginLeft = "auto"
    h.textContent = t("satellite.setupCustomMapLayers")
    popup.appendChild(h)

    const closeBtn = document.createElement("button")
    closeBtn.classList.add("better-btn-close")
    closeBtn.style.all = "unset"
    closeBtn.style.cursor = "pointer"
    closeBtn.style.marginLeft = "auto"
    closeBtn.innerHTML =
        '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">' +
        '  <path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>' +
        "</svg>"
    closeBtn.querySelector("svg").style.height = "1.5rem"
    closeBtn.onclick = () => popup.remove()
    h.append(closeBtn)

    popup.appendChild(radioContainer)

    const radiosName = `map-layer-url`

    function makeWrapper() {
        const wrapper = document.createElement("label")
        wrapper.style.display = "flex"
        wrapper.style.paddingTop = "2px"
        wrapper.style.paddingBottom = "2px"
        wrapper.style.margin = "2px"
        wrapper.style.gap = "4px"
        return wrapper
    }

    function addRadio({ label, value, about, forceVector, fields }) {
        const wrapper = makeWrapper()

        const input = document.createElement("input")
        input.type = "radio"
        input.name = radiosName
        if ((customLayerInfo.url_template ?? customLayerInfo.url) === value) {
            input.checked = true
        }
        wrapper.title = input.value = value

        async function onChange() {
            if (input.checked) {
                if (forceVector && !vectorLayerEnabled()) {
                    nextVectorLayer()
                    await sleep(100)
                }
                abortTilesAbortController(customLayerUrlOrigin)
                const { url, fieldsValues } = applyFields(input.value, fields, wrapper)
                applyCustomLayer({ url: url, url_template: input.value, label: label, fieldsValues: fieldsValues, forceVector: forceVector })
                switchTiles(currentTilesMode === MAPNIK_MODE)
                setAttributionPrefix(label)
            }
        }
        input.addEventListener("change", onChange)
        input.addEventListener("click", onChange)

        const externalLink = document.createElement("a")
        externalLink.title = t("satellite.openMapLayerHomePage")
        externalLink.setAttribute("href", about)
        externalLink.innerHTML = externalLinkSvg
        externalLink.style.marginLeft = "auto"
        externalLink.style.marginRight = "2px"
        externalLink.style.color = "gray"
        externalLink.tabIndex = -1
        externalLink.target = "_blank"

        const labelSpan = document.createElement("span")
        labelSpan.textContent = label
        wrapper.append(input, labelSpan, externalLink)

        if (fields) {
            Object.entries(fields).forEach(([field_name, values]) => {
                const select = document.createElement("select")
                select.name = field_name
                values.forEach(o => {
                    const opt = document.createElement("option")
                    opt.textContent = o.name
                    opt.value = o.value
                    select.appendChild(opt)
                })
                select.onchange = async () => {
                    if (!input.checked) {
                        input.click()
                    } else {
                        await onChange()
                    }
                }
                if (customLayerInfo.url_template === value && customLayerInfo.fieldsValues?.[field_name]) {
                    select.value = customLayerInfo.fieldsValues[field_name]
                }
                labelSpan.after(select)
            })
        }

        radioContainer.appendChild(wrapper)
    }

    options.forEach(addRadio)
    {
        const wrapper = makeWrapper()

        const input = document.createElement("input")
        input.type = "radio"
        input.name = radiosName
        input.value = ""

        wrapper.append(input)
        const urlInput = document.createElement("input")
        urlInput.type = "text"
        urlInput.placeholder = t("satellite.tileUrlPlaceholder")
        urlInput.style.width = "100%"
        if (lastCustomLayerUrl) {
            urlInput.value = lastCustomLayerUrl
        }
        if (customLayerInfo.url === lastCustomLayerUrl) {
            input.checked = true
        }
        wrapper.append(urlInput)

        function switchCustomLayerUrl() {
            abortTilesAbortController(customLayerUrlOrigin)
            applyCustomLayer({
                url: urlInput.value,
                url_template: urlInput.value,
                label: "Custom map style from " + escapeHtml(new URL(urlInput.value).host),
            })
            void GM.setValue("lastCustomLayerUrl", (lastCustomLayerUrl = urlInput.value))
            switchTiles(currentTilesMode === MAPNIK_MODE)
        }

        input.onchange = async e => {
            if (!e.isTrusted) return
            if (input.checked && urlInput.value.trim() !== "") {
                switchCustomLayerUrl()
            }
        }

        urlInput.onkeydown = async e => {
            if (!e.isTrusted) return
            if (e.key === "Enter" && urlInput.value.trim() !== "") {
                input.click()
                switchCustomLayerUrl()
            }
            if (urlInput.value.trim() === "") {
                void GM.setValue("lastCustomLayerUrl", "")
            }
        }

        radioContainer.appendChild(wrapper)
    }

    const note = document.createElement("span")
    note.style.color = "gray"
    note.innerHTML =
        "For transparent tiles, switch to ShortBread or MapTiler layer<br/>" +
        "You can <a target='_blank' href='https://github.com/deevroman/better-osm-org/issues/new'>suggest</a> other layer. " +
        "One of <a href='https://github.com/osmlab/editor-layer-index'>layers collection</a>"
    popup.appendChild(note)
    document.body.appendChild(popup)
    popup.querySelector("label:has([type=radio]:checked)")?.querySelector("input")?.focus()
    if (currentTilesMode !== SAT_MODE && popup.querySelector("label [type=radio]:checked")) {
        popup.querySelector("label [type=radio]:checked").checked = false
    }
}

async function vectorSwitch() {
    if (customLayerInfo.forceVector && !vectorLayerEnabled()) {
        nextVectorLayer()
        await sleep(100)
    }
    if (!vectorLayerEnabled()) {
        return
    }
    const vectorMap = findVectorMap()
    function addLayer() {
        vectorMap.addSource(
            "satellite",
            intoPage({
                type: "raster",
                tiles: [customLayerInfo.url],
                tileSize: 256,
                attribution: "",
            }),
        )
        vectorMap.addLayer(
            intoPage({
                id: "satellite-layer",
                type: "raster",
                source: "satellite",
            }),
        )
    }
    function removeLayer() {
        try {
            vectorMap.removeLayer("satellite-layer")
            vectorMap.removeSource("satellite")
        } catch (e) {}
    }
    if (currentTilesMode === SAT_MODE) {
        try {
            removeLayer()
            addLayer()
        } catch (e) {
            console.error(e)
            // dirty hack for wait styles downloading
            setTimeout(addLayer, 1000)
        }
    } else {
        removeLayer()
    }
}

let moveAndZoomForEsriDateObserverEnabled = false

function addEsriShotDateCollector() {
    if (moveAndZoomForEsriDateObserverEnabled) {
        return
    }
    moveAndZoomForEsriDateObserverEnabled = true
    try {
        getMap().on(
            "moveend zoomend",
            intoPageWithFun(function () {
                if (customLayerInfo.url.includes(ESRIPrefix) && updateShotEsriDateNeeded()) {
                    addEsriDate()
                }
            }),
        )
    } catch (e) {
        console.error(e)
    }
}

function xyzFromTileElem(elem) {
    if (elem.hasAttribute("x")) {
        return {
            x: elem.getAttribute("x"),
            y: elem.getAttribute("y"),
            z: elem.getAttribute("z"),
        }
    }
}

function replaceToSatTile(imgElem, xyz) {
    const newUrl = makeCustomTileUrl(customLayerInfo.url, xyz)
    if (imgElem.getAttribute("custom-tile-url") === newUrl) {
        return
    }
    // unsafeWindow.L.DomEvent.off(i, "error") // todo добавить перехватчик 404
    try {
        imgElem.onerror = tileErrorHandler
    } catch {
        /* empty */
    }
    imgElem.setAttribute("custom-tile-url", newUrl)
    if (!needBypassSatellite && !customLayerInfo.isWms) {
        imgElem.src = newUrl
    } else {
        bypassCSPForImagesSrc(imgElem, newUrl)
    }
    if (imgElem.complete && !needBypassSatellite) {
        imgElem.classList.add("no-invert")
    } else {
        imgElem.addEventListener(
            "load",
            e => {
                e.target.classList.add("no-invert")
            },
            { once: true },
        )
    }
}

function replaceToBaseTile(imgElem, xyz) {
    const newUrl = makeBaseLayerURL(xyz.x, xyz.y, xyz.z)
    if (!imgElem.getAttribute("custom-tile-url")) {
        return
    }
    imgElem.removeAttribute("custom-tile-url")
    imgElem.src = newUrl
    if (imgElem.complete) {
        imgElem.classList.remove("no-invert")
    } else {
        imgElem.addEventListener(
            "load",
            e => {
                e.target.classList.remove("no-invert")
            },
            { once: true },
        )
    }
}

function addXyzToTile(imgElem) {
    const xyz = parseOSMTileURL(imgElem.src)
    if (!xyz) {
        return
    }
    imgElem.setAttribute("x", xyz.x)
    imgElem.setAttribute("y", xyz.y)
    imgElem.setAttribute("z", xyz.z)
}

function replaceTileSrc(imgElem) {
    if (!imgElem.hasAttribute("x")) {
        addXyzToTile(imgElem)
    }
    const xyz = xyzFromTileElem(imgElem)
    if (!xyz) {
        return
    }
    if (currentTilesMode === SAT_MODE) {
        replaceToSatTile(imgElem, xyz)
    } else {
        replaceToBaseTile(imgElem, xyz)
    }
}

function rasterSwitch() {
    if (currentTilesMode === SAT_MODE) {
        if (!customLayerInfo.url) {
            applyCustomLayer({ url: ESRITemplate, url_template: ESRITemplate, label: "ESRI" })
        }
        addLayerPrefix()
    } else {
        setAttributionPrefix("")
    }
    document.querySelectorAll(".leaflet-tile").forEach(i => {
        if (i.nodeName !== "IMG") {
            return
        }
        replaceTileSrc(i)
    })
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeName !== "IMG") {
                    return
                }
                replaceTileSrc(node)
                if (currentTilesMode === SAT_MODE) {
                    setTimeout(() => {
                        if (updateShotEsriDateNeeded()) {
                            addEsriDate()
                        }
                    })
                }
            })
        })
    })
    tilesObserver = observer
    observer.observe(document.body, { childList: true, subtree: true })
}

function switchTiles(invertMode = true) {
    if (tilesObserver) {
        tilesObserver?.disconnect()
    }
    addEsriShotDateCollector()
    if (invertMode) {
        currentTilesMode = invertTilesMode(currentTilesMode)
        console.log("Current tiles mode", currentTilesMode)
    }
    rasterSwitch()
    void vectorSwitch()
}

function switchTilesAndButtons() {
    switchTiles()
    document.querySelectorAll(".turn-on-satellite").forEach(btn => (btn.textContent = invertTilesMode(currentTilesMode)))
    document.querySelectorAll(".turn-on-satellite-from-pane").forEach(btn => (btn.textContent = invertTilesMode(currentTilesMode)))
}

let osmTilesObserver = undefined

function switchBypassCaches() {
    if (osmTilesObserver) {
        console.log("stop bypassing")
        osmTilesObserver.disconnect()
        osmTilesObserver = undefined
        return
    }
    async function readImgFromBlob(blob) {
        return await new Promise(resolve => {
            const reader = new FileReader()
            reader.onload = () => resolve(reader.result)
            reader.readAsDataURL(blob)
        })
    }
    async function makeRequest(url) {
        return (
            await externalFetchRetry({
                method: "GET",
                url: url,
                headers: {
                    Accept: "image/*",
                    "Cache-Control": "no-cache",
                    Pragma: "no-cache",
                    Referer: "https://www.openstreetmap.org/",
                },
                responseType: "blob",
                nocache: true,
                revalidate: true,
            })
        ).response
    }
    console.log("start bypassing")
    document.querySelectorAll(".leaflet-tile").forEach(i => {
        if (i.nodeName !== "IMG") {
            return
        }
        // todo support multiple press
        const xyz = parseOSMTileURL(i.src)
        if (!xyz) return
        const newUrl = makeOSMURL(xyz.x, xyz.y, xyz.z) // + "?bypassCache=" + new Date().getUTCSeconds();
        makeRequest(newUrl).then(async blob => {
            console.debug("bypassing", newUrl)
            i.src = await readImgFromBlob(blob)
        })
    })
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeName !== "IMG") {
                    return
                }
                const xyz = parseOSMTileURL(node.src)
                if (!xyz) return
                const newUrl = makeOSMURL(xyz.x, xyz.y, xyz.z) // + "?bypassCache=" + new Date().getUTCSeconds();
                makeRequest(newUrl).then(async blob => {
                    console.debug("bypassing", newUrl)
                    node.src = await readImgFromBlob(blob)
                })
            })
        })
    })
    osmTilesObserver = observer
    observer.observe(document.body, { childList: true, subtree: true })
}

function switchOverlayTiles() {
    if (overlayTilesObserver) {
        overlayTilesObserver.disconnect()
    }
    currentOverlayModeIsStrava = invertOverlayMode(currentOverlayModeIsStrava)
    if (currentOverlayModeIsStrava) {
        setAttributionPrefix("Strava")
    } else {
        setAttributionPrefix("")
    }
    document.querySelectorAll(".leaflet-tile").forEach(i => {
        if (i.nodeName !== "IMG") {
            return
        }
        if (currentOverlayModeIsStrava) {
            const xyz = parseOSMGPSTileURL(i.src)
            if (!xyz) return
            // unsafeWindow.L.DomEvent.off(i, "error") // todo добавить перехватчик 404
            try {
                i.onerror = tileErrorHandler
            } catch {
                /* empty */
            }
            const newUrl = makeStravaURL(xyz.x, xyz.y, xyz.z)
            i.setAttribute("custom-tile-url", newUrl)
            bypassCSPForImagesSrc(i, newUrl)
            if (i.complete) {
                i.classList.add("no-invert")
            } else {
                i.addEventListener(
                    "load",
                    e => {
                        e.target.classList.add("no-invert")
                    },
                    { once: true },
                )
            }
        } else {
            const xyz = parseStravaTileURL(i.getAttribute("custom-tile-url") ?? "")
            if (!xyz) return
            i.src = makeOSMGPSURL(xyz.x, xyz.y, xyz.z)
            if (i.complete) {
                i.classList.remove("no-invert")
            } else {
                i.addEventListener(
                    "load",
                    e => {
                        e.target.classList.remove("no-invert")
                    },
                    { once: true },
                )
            }
        }
    })
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeName !== "IMG") {
                    return
                }
                if (currentOverlayModeIsStrava) {
                    const xyz = parseOSMGPSTileURL(node.src)
                    if (!xyz) return
                    // unsafeWindow.L.DomEvent.off(node, "error")
                    try {
                        node.onerror = tileErrorHandler
                    } catch {
                        /* empty */
                    }
                    const newURL = makeStravaURL(xyz.x, xyz.y, xyz.z)
                    node.src = "/dev/null"
                    node.setAttribute("custom-tile-url", newURL)
                    bypassCSPForImagesSrc(node, newURL)
                    if (node.complete) {
                        node.classList.add("no-invert")
                    } else {
                        node.addEventListener(
                            "load",
                            e => {
                                e.target.classList.add("no-invert")
                            },
                            { once: true },
                        )
                    }
                } else {
                    const xyz = parseStravaTileURL(node.getAttribute("custom-tile-url"))
                    if (!xyz) return
                    node.src = makeOSMGPSURL(xyz.x, xyz.y, xyz.z)
                    if (node.complete) {
                        node.classList.remove("no-invert")
                    } else {
                        node.addEventListener(
                            "load",
                            e => {
                                e.target.classList.remove("no-invert")
                            },
                            { once: true },
                        )
                    }
                }
            })
        })
    })
    overlayTilesObserver = observer
    observer.observe(document.body, { childList: true, subtree: true })
}

if (isOsmServer() && new URLSearchParams(location.search).has("sat-tiles")) {
    switchTilesAndButtons()
}

function addSwitchTilesBtnOnNotePage() {
    if (document.querySelector(".turn-on-satellite")) return true
    if (!document.querySelector("#sidebar_content h4")) {
        return
    }
    const btnOnNotePage = document.createElement("span")
    if (!tilesObserver) {
        btnOnNotePage.textContent = "🛰"
    } else {
        btnOnNotePage.textContent = invertTilesMode(currentTilesMode)
    }
    btnOnNotePage.style.cursor = "pointer"
    btnOnNotePage.classList.add("turn-on-satellite")
    btnOnNotePage.title = t("satellite.switchMapAndSatellite")

    document.querySelectorAll("h4")[0].appendChild(document.createTextNode("\xA0"))
    document.querySelectorAll("h4")[0].appendChild(btnOnNotePage)

    btnOnNotePage.onclick = () => {
        enableOverzoom()
        switchTilesAndButtons()
    }
    btnOnNotePage.oncontextmenu = async e => {
        e.preventDefault()
        enableOverzoom()
        await askCustomTileUrl()
    }
}

function createSwitchTilesBtn() {
    const btnOnPane = document.createElement("span")
    if (!tilesObserver) {
        btnOnPane.textContent = "🛰"
    } else {
        btnOnPane.textContent = invertTilesMode(currentTilesMode)
    }
    btnOnPane.style.cursor = "pointer"
    btnOnPane.classList.add("turn-on-satellite-from-pane")
    btnOnPane.title =
        "Switch between map and satellite images (S key)\nPress Shift+S or click with Shift for set custom imagery\nOn mobile use long tap"
    btnOnPane.onclick = async e => {
        e.stopImmediatePropagation()
        enableOverzoom()
        if (e.shiftKey) {
            await askCustomTileUrl()
            return
        }
        switchTilesAndButtons()
    }
    btnOnPane.oncontextmenu = async e => {
        e.preventDefault()
        e.stopImmediatePropagation()
        enableOverzoom()
        await askCustomTileUrl()
    }
    return btnOnPane
}

function createCustomLayerBtn() {
    const btn = document.createElement("span")
    btn.classList.add("set-custom-layer-btn")
    btn.textContent = " ⚙️"
    btn.style.cursor = "pointer"
    btn.title = t("satellite.setCustomLayer")
    btn.onclick = async () => {
        enableOverzoom()
        await askCustomTileUrl()
    }
    return btn
}

function addSwitchTilesButtonsOnPane() {
    if (document.querySelector(".turn-on-satellite-from-pane")) {
        return
    }
    const layers = Array.from(document.querySelectorAll(".layers-ui .base-layers label span"))
    const mapnikBtn = layers[0]
    if (mapnikBtn) {
        const btnOnPane = createSwitchTilesBtn()
        mapnikBtn.appendChild(document.createTextNode("\xA0"))
        mapnikBtn.appendChild(btnOnPane)
    }
    const h2 = document.querySelector(".layers-ui h2")
    if (h2 && !document.querySelector(".set-custom-layer-btn")) {
        h2.appendChild(createCustomLayerBtn())
    }
    const omtBtn = layers.at(-1)
    if (omtBtn) {
        const btnOnPane = document.createElement("span")
        btnOnPane.style.cursor = "pointer"
        btnOnPane.textContent = "🎨"
        btnOnPane.title = t("satellite.setCustomVectorStyle")
        btnOnPane.onmouseover = async () => {
            await mapStylesDatabase.init()
        }
        btnOnPane.onclick = async e => {
            await askCustomStyleUrl()
        }
        btnOnPane.oncontextmenu = async e => {
            e.preventDefault()
            await askCustomStyleUrl()
        }
        omtBtn.appendChild(document.createTextNode("\xA0"))
        omtBtn.appendChild(btnOnPane)
    }
}

function addSatelliteLayers() {
    addSwitchTilesButtonsOnPane()
    if (location.pathname.includes("/note")) {
        addSwitchTilesBtnOnNotePage()
    }
}

function setupSatelliteLayers() {
    if (document.getElementById("map")) {
        tryApplyModule(addSatelliteLayers, 100, 3000)
    }
}

//</editor-fold>

//<editor-fold desc="diff-algorithm" defaultstate="collapsed">

/**
 * Get editorial prescription via modified Levenshtein distance finding algorithm
 * @template T
 * @param {T[]} arg_a
 * @param {T[]} arg_b
 * @param {number} one_replace_cost
 * @return {[T, T][]}
 */
function arraysDiff(arg_a, arg_b, one_replace_cost = 2) {
    const isRelation = arg_a.length && Object.prototype.hasOwnProperty.call(arg_a[0], "role")

    let a = arg_a.map(i => JSON.stringify(i))
    let b = arg_b.map(i => JSON.stringify(i))
    const dp = []
    for (let i = 0; i < a.length + 1; i++) {
        dp[i] = new Uint32Array(b.length + 1)
    }

    for (let i = 0; i <= a.length; i++) {
        dp[i][0] = i
    }

    for (let i = 0; i <= b.length; i++) {
        dp[0][i] = i
    }

    const min = Math.min // fuck Tampermonkey
    // for some fucking reason every math.min call goes through TM wrapper code
    // that is not optimized by the JIT compiler
    if (isRelation) {
        for (let i = 1; i <= a.length; i++) {
            for (let j = 1; j <= b.length; j++) {
                const del_cost = dp[i - 1][j]
                const ins_cost = dp[i][j - 1]
                const replace_cost = dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1]) * one_replace_cost // replacement is not very desirable
                const replace_role_cost =
                    dp[i - 1][j - 1] +
                    (!(arg_a[i - 1].type === arg_b[j - 1].type && arg_a[i - 1].ref === arg_b[j - 1].ref) ||
                        arg_a[i - 1].role === arg_b[j - 1].role) *
                        one_replace_cost
                dp[i][j] = min(min(del_cost, ins_cost) + 1, min(replace_cost, replace_role_cost))
            }
        }
    } else {
        for (let i = 1; i <= a.length; i++) {
            for (let j = 1; j <= b.length; j++) {
                const del_cost = dp[i - 1][j]
                const ins_cost = dp[i][j - 1]
                const replace_cost = dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1]) * one_replace_cost // replacement is not very desirable
                dp[i][j] = min(min(del_cost, ins_cost) + 1, replace_cost)
            }
        }
    }

    a = a.map(i => JSON.parse(i))
    b = b.map(i => JSON.parse(i))

    const answer = []
    let i = a.length
    let j = b.length

    while (true) {
        if (i === 0 || j === 0) {
            if (i === 0 && j === 0) {
                break
            } else if (i === 0) {
                answer.push([null, b[j - 1]])
                j = j - 1
                continue
            } else {
                answer.push([a[i - 1], null])
                i = i - 1
                continue
            }
        }

        const del_cost = dp[i - 1][j]
        const ins_cost = dp[i][j - 1]
        let replace_cost = dp[i - 1][j - 1] + (JSON.stringify(a[i - 1]) !== JSON.stringify(b[j - 1])) * one_replace_cost
        if (isRelation) {
            replace_cost = min(
                replace_cost,
                dp[i - 1][j - 1] +
                    (!(a[i - 1].type === b[j - 1].type && a[i - 1].ref === b[j - 1].ref) || a[i - 1].role === b[j - 1].role) *
                        one_replace_cost,
            )
        }

        if (del_cost <= ins_cost && del_cost + 1 <= replace_cost) {
            answer.push([a[i - 1], null])
            i = i - 1
        } else if (ins_cost <= del_cost && ins_cost + 1 <= replace_cost) {
            answer.push([null, b[j - 1]])
            j = j - 1
        } else {
            answer.push([a[i - 1], b[j - 1]])
            i = i - 1
            j = j - 1
        }
    }
    const result = answer.toReversed()
    if (isRelation) {
        return result
    }

    function insideArray(i) {
        return i >= 0 && i < result.length
    }

    let step = -1

    for (let iter = 0; iter < 2; iter++) {
        step *= -1
        for (let i = step > 0 ? 0 : result.length - 1; step > 0 ? i < result.length : i >= 0; i += step) {
            if (result[i][0] === result[i][1]) {
                continue
            }
            if (result[i][0] !== null && result[i][1] !== null) {
                continue
            }
            const a_or_b = result[i][0] === null ? 0 : 1
            const opposite = 1 - a_or_b
            let j = i
            let was_merged = false
            while (true) {
                while (insideArray(j) && result[j][a_or_b] === null) {
                    j += step
                }
                if (!insideArray(j)) {
                    break
                }

                let try_i = i
                let try_j = j
                while (
                    insideArray(try_j) &&
                    result[try_i][opposite] === result[try_j][opposite] &&
                    result[try_j][a_or_b] === result[try_j][opposite]
                ) {
                    try_i += step
                    try_j += step
                }
                if (!insideArray(try_j) || result[try_j][a_or_b] === null) {
                    was_merged = true
                    while (insideArray(j) && result[i][opposite] === result[j][opposite] && result[j][a_or_b] === result[j][opposite]) {
                        result[i][a_or_b] = result[j][a_or_b]
                        result[j][a_or_b] = null
                        i += step
                        j += step
                    }
                } else {
                    break
                }
            }
            i = j - step
        }
    }
    return result
}

/**
 * @param {string} a
 * @param {string} b
 * @param {number} one_replace_cost
 * @return {[string,string][]}
 */
function stringsDiff(a, b, one_replace_cost = 2) {
    return arraysDiff(Array.from(a), Array.from(b), one_replace_cost)
}

//</editor-fold>

//<editor-fold desc="" defaultstate="collapsed">
function makeElementHistoryCompact(forceState = null) {
    const shouldBeCompact = forceState !== null ? forceState : document.querySelector(".compact-toggle-btn").getAttribute("value") === "><"
    const forToggle = Array.from(document.querySelectorAll("table.browse-tag-list"))
    // workaround for https://github.com/deevroman/better-osm-org/pull/273#issuecomment-2830047660
    forToggle.slice(0, 8).forEach(el => {
        el.classList.toggle("hide-non-modified-tags", shouldBeCompact)
    })
    setTimeout(() => {
        forToggle.slice(8).forEach(el => {
            el.classList.toggle("hide-non-modified-tags", shouldBeCompact)
        })
    })
    document.querySelectorAll(".empty-version").forEach(el => {
        el.classList.toggle("d-none", shouldBeCompact)
    })
    document.querySelectorAll(".preview-img-link img").forEach(i => {
        i.classList.toggle("d-none", shouldBeCompact)
    })
    document.querySelector(".compact-toggle-btn").setAttribute("value", shouldBeCompact ? "<>" : "><")
    document.querySelector(".compact-toggle-btn").innerHTML = shouldBeCompact ? expandModeSvg : compactModeSvg
}

function drawPanoramaxCapturePlace(feature) {
    const lat = feature.geometry?.coordinates?.[1]
    const lon = feature.geometry?.coordinates?.[0]
    if (lat === undefined || lon === undefined) {
        return
    }
    const rawAngle = feature.properties?.exif?.["Exif.GPSInfo.GPSImgDirection"]
    if (rawAngle === undefined) {
        console.warn("no angle in feature", feature)
        return
    }
    const angle = rawAngle?.includes("/") ? rawAngle.split("/")[0] / rawAngle.split("/")[1] : parseFloat(rawAngle)

    showActiveNodeMarker(lat, lon, "#0022ff", true)
    if (angle) {
        drawRay(lat, lon, angle - 30, "#0022ff")
        drawRay(lat, lon, angle + 30, "#0022ff")
    }
}

function appendTitleLine(elem, text) {
    if (!text) {
        return
    }
    if (elem.title && elem.title.length !== 0) {
        elem.title += "\n"
    }
    elem.title += text
}

async function attachPanoramaxHoverCaptureHandler(a, uuid, panoramaxServer) {
    const res = (
        await externalFetchRetry({
            url: `${panoramaxServer}/api/search?limit=1&ids=${uuid}`,
            responseType: "json",
        })
    ).response
    if (res["error"] || res["features"].length === 0) {
        console.error(res)
        return
    }
    a.onmouseenter = () => drawPanoramaxCapturePlace(res["features"][0])
    const author = res["features"][0]?.properties?.["geovisio:producer"]
    const artist = res["features"][0]?.properties?.exif?.["Exif.Image.Artist"]
    if (author) {
        appendTitleLine(a, t("objectPage.photoBy", { author: artist && artist !== author ? `${author} / ${artist}` : author }))
    }
    const date = res["features"][0]?.properties?.exif?.["Exif.Image.DateTime"]
    if (date) {
        appendTitleLine(a, date)
    }
}

function attachMapillaryHoverCaptureHandler(a, res) {
    a.onmouseenter = () => mapillaryMouseEnter(res)
    const author = res["creator"]["username"]
    if (author) {
        appendTitleLine(a, t("objectPage.photoBy", { author }))
    }
    const date = res["captured_at"]
    if (date) {
        const d = new Date()
        d.setTime(date)
        appendTitleLine(a, d.toISOString())
    }
}

function attachWikimediaHoverCaptureHandler(a, res) {
    a.onmouseenter = () => wikimediaMouseEnter(res)
    try {
        const author = res["query"]["pages"]["-1"]["imageinfo"][0]["extmetadata"]["Artist"]["value"]
        if (author) {
            // отдавать HTML придумал гений, блять
            const fixedAuthor = author.endsWith("</a>") ? (author.match(/">(.*)<\/a>$/)[1] ?? author) : author
            appendTitleLine(a, t("objectPage.photoBy", { author: fixedAuthor }))
        }
    } catch (err) {
        console.error(err)
    }
    try {
        const date = res["query"]["pages"]["-1"]["imageinfo"][0]["extmetadata"]["DateTimeOriginal"]["value"]
        if (date) {
            appendTitleLine(a, date)
        }
    } catch (err) {
        console.error(err)
    }
    const license = res["query"]["pages"]["-1"]["imageinfo"][0]["extmetadata"]["LicenseShortName"]["value"]
    if (license) {
        appendTitleLine(a, license)
    }
}

function getInaturalistPhotoUrls(observation) {
    const photo = observation?.observation_photos?.[0]?.photo ?? observation?.photos?.[0]
    return {
        photo,
        thumbSrc: photo?.url,
        zoomSrc: photo?.url?.replace("/square.", "/medium."),
    }
}

function attachInaturalistHoverCaptureHandler(a, observation) {
    a.onmouseenter = () => inaturalistMouseEnter(observation)
    const titleLines = []
    const { photo } = getInaturalistPhotoUrls(observation)
    const commonName = observation?.taxon?.preferred_common_name
    const scientificName = observation?.taxon?.name
    const speciesGuess = observation?.species_guess
    if (commonName && scientificName) {
        titleLines.push(`${commonName} (${scientificName})`)
    } else if (commonName || scientificName || speciesGuess) {
        titleLines.push(commonName ?? scientificName ?? speciesGuess)
    }
    if (observation?.user?.login) {
        titleLines.push(t("objectPage.observedBy", { user: observation.user.login }))
    }
    if (observation?.observed_on) {
        titleLines.push(observation.observed_on)
    }
    if (photo?.attribution) {
        titleLines.push(photo.attribution)
    }
    a.title += titleLines.join("\n")
}

function addPanoramaxPicIntoA(uuid, a, panoramaxServer) {
    const imgSrc = `${panoramaxServer}/api/pictures/${uuid}/sd.jpg`
    if (isSafari) {
        fetchImageWithCache(imgSrc).then(async imgData => {
            const img = document.createElement("img")
            img.src = imgData
            img.style.width = "100%"
            a.appendChild(img)
        })
    } else {
        const img = GM_addElement("img", {
            src: imgSrc,
            width: "100%",
            // crossorigin: "anonymous"
        })
        img.onerror = () => {
            img.style.display = "none"
        }
        img.onload = () => {
            img.style.width = "100%"
        }
        a.appendChild(img)
    }
    setTimeout(() => attachPanoramaxHoverCaptureHandler(a, uuid, panoramaxServer))
}

// https://osm.org/node/12559772251
// https://osm.org/node/10588878341
// https://osm.org/way/1264114016
function makePanoramaxValue(elem) {
    if (!GM_config.get("ImagesAndLinksInTags")) return
    // extracting uuid
    if (elem.classList.contains("panoramaxed")) {
        return
    }
    elem.classList.add("panoramaxed")
    elem.innerHTML = elem.innerHTML.replaceAll(
        /(?<=(^|;))([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(&amp;xyz=-?[0-9]+(\.[0-9]+)?\/-?[0-9]+(\.[0-9]+)?\/-?[0-9]+(\.[0-9]+)?)?/gi,
        function (match) {
            const a = document.createElement("a")
            a.textContent = match.replaceAll("&amp;", "&")
            a.classList.add("preview-img-link")
            a.target = "_blank"
            const browseSection = elem?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
            const lat = browseSection?.querySelector(".latitude")?.textContent?.replace(",", ".")
            const lon = browseSection?.querySelector(".longitude")?.textContent?.replace(",", ".")
            a.href = `${panoramaxDiscoveryServer}/#focus=pic&pic=` + match.replaceAll("&amp;", "&") + (lat ? `&map=16/${lat}/${lon}` : "")
            return a.outerHTML
        },
    )
    elem.querySelectorAll(`a:not(.added-preview-img)[href^="${MAIN_PANORAMAX_DISCOVERY_SERVER}/"]`).forEach(a => {
        a.classList.add("added-preview-img")
        const uuid = a.textContent.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0]
        if (!uuid) {
            return
        }
        if (lastUploadedPanoramaxPicture?.uuid === uuid) {
            console.log(lastUploadedPanoramaxPicture)
            addPanoramaxPicIntoA(uuid, a, lastUploadedPanoramaxPicture.instance)
        } else {
            addPanoramaxPicIntoA(uuid, a, panoramaxDiscoveryServer)
        }
    })
    elem.onclick = e => {
        e.stopImmediatePropagation()
    }
}

// https://www.mapillary.com/dashboard/developers
const MAPILLARY_CLIENT_KEY = "MLY|23980706878196295|56711819158553348b8159429530d931"
const MAPILLARY_URL_PARAMS = new URLSearchParams({
    access_token: MAPILLARY_CLIENT_KEY,
    fields: "id,geometry,computed_geometry,compass_angle,computed_compass_angle,thumb_256_url,thumb_1024_url,creator,captured_at",
})

async function downloadMapillaryPhotoInfo(id) {
    return (
        await externalFetchRetry({
            url: `https://graph.mapillary.com/${id}?${MAPILLARY_URL_PARAMS.toString()}`,
            responseType: "json",
        })
    ).response
}

function mapillaryMouseEnter(info) {
    const lat = info["geometry"]["coordinates"][1]
    const lon = info["geometry"]["coordinates"][0]
    const angle = info["compass_angle"]

    const computed_lat = info["computed_geometry"]["coordinates"][1]
    const computed_lon = info["computed_geometry"]["coordinates"][0]
    const computed_angle = info["computed_compass_angle"]

    showActiveNodeMarker(lat, lon, "#0022ff", true)
    showActiveNodeMarker(computed_lat, computed_lon, "#ee9209", false)

    drawRay(lat, lon, angle - 30, "#0022ff")
    drawRay(computed_lat, computed_lon, computed_angle - 25, "#ee9209")

    drawRay(lat, lon, angle + 30, "#0022ff")
    drawRay(computed_lat, computed_lon, computed_angle + 25, "#ee9209")
}

function wikimediaMouseEnter(info) {
    try {
        const lat = info["query"]["pages"]["-1"]["imageinfo"][0]["extmetadata"]["GPSLatitude"]["value"]
        const lon = info["query"]["pages"]["-1"]["imageinfo"][0]["extmetadata"]["GPSLongitude"]["value"]

        showActiveNodeMarker(lat, lon, "#0022ff", true)
    } catch (err) {
        console.log(err)
    }
}

// https://osm.org/node/7417065297
// https://osm.org/node/6257534611
// https://osm.org/way/682528624/history/3
function makeMapillaryValue(elem) {
    if (!GM_config.get("ImagesAndLinksInTags")) return
    if (elem.classList.contains("mapillared")) {
        return
    }
    elem.classList.add("mapillared")
    elem.innerHTML = elem.innerHTML.replaceAll(
        /(?<=(^|;))([0-9]+)(?=(;|&|$))(&amp;x=-?[0-9]+(\.[0-9]+)?&amp;y=-?[0-9]+(\.[0-9]+)?&amp;zoom=-?[0-9]+(\.[0-9]+)?)?/g,
        function (match) {
            const a = document.createElement("a")
            a.textContent = match.replaceAll("&amp;", "&")
            a.classList.add("preview-mapillary-img-link")
            a.target = "_blank"
            const browseSection = elem?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
            const lat = browseSection?.querySelector(".latitude")?.textContent?.replace(",", ".")
            const lon = browseSection?.querySelector(".longitude")?.textContent?.replace(",", ".")
            a.href =
                `https://www.mapillary.com/app/?focus=photo${lat ? "&lat=" + lat + "&lng=" + lon + "&z=16" : ""}&pKey=` +
                arguments[0].replaceAll("&amp;", "&")
            return a.outerHTML
        },
    )
    setTimeout(async () => {
        for (const a of elem.querySelectorAll('a:not(.added-preview-mapillary-img-link)[href^="https://www.mapillary.com/app/"]')) {
            a.classList.add("added-preview-mapillary-img-link")
            const res = await downloadMapillaryPhotoInfo(a.textContent.match(/[0-9]+/))
            if (res["error"]) {
                a.classList.add("broken-mapillary-link")
                continue
            }
            const imgSrc = res["thumb_1024_url"]
            if (isSafari) {
                fetchImageWithCache(imgSrc).then(async imgData => {
                    const img = document.createElement("img")
                    img.src = imgData
                    img.alt = "image from Mapillary"
                    img.title = t("objectPage.gpsTrackerPosition")
                    img.style.width = "100%"
                    a.appendChild(img)
                })
            } else {
                const img = GM_addElement("img", {
                    src: imgSrc,
                    alt: "image from Mapillary",
                    title: t("objectPage.gpsTrackerPosition"),
                    width: "100%",
                    // crossorigin: "anonymous"
                })
                img.onerror = () => {
                    img.style.display = "none"
                }
                img.onload = () => {
                    img.style.width = "100%"
                }
                a.appendChild(img)
            }
            a.onmouseenter = () => mapillaryMouseEnter(res)
        }
    })
    elem.onclick = e => {
        e.stopImmediatePropagation()
    }
}

async function downloadWikimediaInfo(filename) {
    const response = (
        await externalFetchRetry({
            url:
                `https://commons.wikimedia.org/w/api.php?` +
                new URLSearchParams({
                    action: "query",
                    iiprop: "url|extmetadata",
                    iiurlwidth: "300",
                    prop: "imageinfo",
                    titles: filename,
                    format: "json",
                }).toString(),
            responseType: "json",
        })
    ).response
    const firstPage = Object.values(response?.query?.pages ?? {})?.[0]
    if (!firstPage) {
        return response
    }
    return { query: { pages: { "-1": firstPage } } }
}

async function downloadWikimediaCategoryInfo(categoryName, limit = 8) {
    return (
        await externalFetchRetry({
            url:
                `https://commons.wikimedia.org/w/api.php?` +
                new URLSearchParams({
                    action: "query",
                    list: "categorymembers",
                    cmtitle: categoryName,
                    cmtype: "file",
                    cmlimit: limit,
                    format: "json",
                }).toString(),
            responseType: "json",
        })
    ).response
}

function makeWikimediaCommonsValue(elem, isVersionPage) {
    if (!GM_config.get("ImagesAndLinksInTags")) return
    const items = isVersionPage ? elem.querySelectorAll('a[href^="//commons.wikimedia.org/wiki/"]:not(.preview-img-link)') : [elem]
    items.forEach(a => {
        if (a.classList.contains("preview-img-link")) {
            return
        }
        a.classList.add("preview-img-link")
        setTimeout(async () => {
            for (let item of a.textContent.split(";")) {
                if (item.startsWith("Category:")) {
                    const categoryResponse = await downloadWikimediaCategoryInfo(item)
                    const fileTitles = categoryResponse?.query?.categorymembers?.map(file => file.title) ?? []
                    if (fileTitles.length === 0) {
                        return
                    }
                    const previewsWrap = document.createElement("div")
                    Object.assign(previewsWrap.style, {
                        display: "grid",
                        gridTemplateColumns: "repeat(1, 1fr)",
                        gap: "2px",
                    })
                    a.after(previewsWrap)
                    for (const fileTitle of fileTitles) {
                        const wikimediaResponse = await downloadWikimediaInfo(fileTitle)
                        const imageInfo = wikimediaResponse?.query?.pages?.["-1"]?.imageinfo?.[0]
                        if (!imageInfo?.thumburl) {
                            continue
                        }
                        const previewLink = document.createElement("a")
                        previewLink.href = `https://commons.wikimedia.org/wiki/${fileTitle.replaceAll(" ", "_")}`
                        previewLink.target = "_blank"
                        previewLink.rel = "noreferrer"
                        previewLink.classList.add("preview-img-link")
                        previewLink.style.display = "block"
                        const img = GM_addElement("img", {
                            src: imageInfo["thumburl"],
                            // crossorigin: "anonymous"
                        })
                        Object.assign(img.style, {
                            width: "100%",
                            aspectRatio: "1 / 1",
                            objectFit: "cover",
                        })
                        previewLink.appendChild(img)
                        previewsWrap.appendChild(previewLink)
                        attachWikimediaHoverCaptureHandler(previewLink, wikimediaResponse)
                    }
                    return
                }
                const wikimediaResponse = await downloadWikimediaInfo(item)
                const img = GM_addElement("img", {
                    src: wikimediaResponse["query"]["pages"]["-1"]["imageinfo"][0]["thumburl"],
                    // crossorigin: "anonymous"
                })
                img.style.width = "100%"
                a.appendChild(img)
                attachWikimediaHoverCaptureHandler(img, wikimediaResponse)
            }
        })
    })
}

function makeRoofOrientationValue(elem) {
    if (elem.textContent !== "across" && elem.textContent !== "along") {
        elem.classList.add("fixme-tag")
        elem.title = t("objectPage.roofOrientationAcrossAlong")
    }
}

function makeContactValue(elem, key) {
    try {
        const urlParams = new URL(elem.textContent).searchParams
        const warns = []
        ;[
            "fbclid",
            "gclid",
            "gclsrc",
            "igsh",
            "igshid",
            "mc_id",
            "si",
            "utm_campaign",
            "utm_content",
            "utm_medium",
            "utm_source",
            "utm_term",
            "yclid",
            "ysclid",
        ].forEach(param => {
            if (urlParams.has(param)) {
                warns.push(param)
            }
        })
        if (warns.length > 0) {
            elem.classList.add("warn-tag")
            elem.querySelectorAll("a").forEach(a => a.classList.add("warn-tag"))
            elem.title = warns.map(param => t("objectPage.trackingParamInUrl", { param })).join("\n")
        }
    } catch (e) {
        // todo report about invalid URL
        console.error(e)
    }
    if (key === "website" || key === "contact:website") {
        if (!elem.textContent.startsWith("https://") && !elem.textContent.startsWith("http://")) {
            elem.classList.add("warn-tag")
            elem.querySelectorAll("a").forEach(a => a.classList.add("warn-tag"))
            elem.title = t("objectPage.urlShouldStartWithHttp")
        }
    }
}

function makeConditionalValue(elem) {
    if (!elem.textContent.includes("@")) {
        elem.classList.add("fixme-tag")
        elem.title = t("objectPage.conditionalMustContainAt")
    }
    if (elem.textContent.match(/@\s*$/)) {
        elem.classList.add("fixme-tag")
        elem.title = t("objectPage.emptyPartAfterAt")
    }
    if (elem.textContent.match(/^\s*@/)) {
        elem.classList.add("fixme-tag")
        elem.title = t("objectPage.emptyPartBeforeAt")
    }
    if (elem.textContent.match(/@\s*@/)) {
        elem.classList.add("fixme-tag")
        elem.title = t("objectPage.emptyPartBetweenAt")
    }
}

function makeRefBelpostValue(elem) {
    if (!GM_config.get("ImagesAndLinksInTags")) return
    if (elem.innerHTML.match(/^[0-9]+$/)) {
        const a = document.createElement("a")
        a.href = "https://belpost.by/Pochtovyyeyashchiki/" + elem.textContent
        a.rel = "noreferrer"
        a.textContent = elem.textContent
        elem.innerHTML = a.outerHTML
    }
}

async function downloadInaturalistInfo(id) {
    const ids = (Array.isArray(id) ? id : `${id}`.split(";")).map(i => i.trim()).filter(Boolean)
    if (ids.length === 0) {
        return { results: [] }
    }
    return (
        await externalFetchRetry({
            url:
                `https://api.inaturalist.org/v1/observations?` +
                new URLSearchParams({
                    id: ids.join(","),
                    per_page: ids.length,
                    verifiable: "any",
                }).toString(),
            responseType: "json",
        })
    ).response
}

function inaturalistMouseEnter(info) {
    const lat = parseFloat(info?.geojson?.coordinates?.[1] ?? info?.latitude ?? info?.location?.split(",")?.[0])
    const lon = parseFloat(info?.geojson?.coordinates?.[0] ?? info?.longitude ?? info?.location?.split(",")?.[1])
    if (Number.isNaN(lat) || Number.isNaN(lon)) {
        return
    }
    showActiveNodeMarker(lat, lon, "#0022ff", true)
}

function makeRefInaturalistValue(elem) {
    if (!GM_config.get("ImagesAndLinksInTags")) return
    if (elem.classList.contains("inaturalisted")) {
        return
    }
    elem.classList.add("inaturalisted")
    if (!elem.querySelector("a") && elem.textContent.trim().match(/^[0-9;\s]+$/)) {
        const ids = elem.textContent.split(";")
        if (ids.length !== 0 && ids.every(i => i.match(/^[0-9]+$/))) {
            elem.replaceChildren()
            ids.forEach((id, index) => {
                if (index !== 0) {
                    elem.append(";")
                }
                const a = document.createElement("a")
                a.href = `https://www.inaturalist.org/observations/${id}`
                a.target = "_blank"
                a.rel = "noreferrer"
                a.textContent = id
                elem.appendChild(a)
            })
        }
    }
    setTimeout(async () => {
        const anchors = Array.from(elem.querySelectorAll('a[href^="https://www.inaturalist.org/observations/"]:not(.preview-img-link)'))
        if (anchors.length === 0) {
            return
        }
        anchors.forEach(a => a.classList.add("preview-img-link"))
        const response = await downloadInaturalistInfo(anchors.map(a => a.textContent))
        if (response["error"]) {
            console.error(response)
            return
        }
        const observationsById = new Map(response?.results?.map(observation => [`${observation.id}`, observation]) ?? [])
        for (const a of anchors) {
            const observation = observationsById.get(a.textContent)
            if (!observation) {
                a.classList.add("warn-tag")
                a.title = t("objectPage.observationNotFound")
                continue
            }
            const { zoomSrc: imgSrc } = getInaturalistPhotoUrls(observation)
            if (imgSrc) {
                if (isSafari) {
                    fetchImageWithCache(imgSrc).then(async imgData => {
                        const img = document.createElement("img")
                        img.src = imgData
                        img.alt = "image from iNaturalist"
                        img.style.width = "100%"
                        a.appendChild(img)
                    })
                } else {
                    const img = GM_addElement("img", {
                        src: imgSrc,
                        alt: "image from iNaturalist",
                        width: "100%",
                        // crossorigin: "anonymous"
                    })
                    img.onerror = () => {
                        img.style.display = "none"
                    }
                    img.onload = () => {
                        img.style.width = "100%"
                    }
                    a.appendChild(img)
                }
            }
            attachInaturalistHoverCaptureHandler(a, observation)
        }
    })
    elem.onclick = e => {
        e.stopImmediatePropagation()
    }
}

function makeOpeningHoursValue(valueCell, key, isVersionPage) {
    try {
        new opening_hours(valueCell.textContent, null, { tag_key: key })
        if (isVersionPage) {
            valueCell.title = t("objectPage.noErrorsOpeningHours")
        }
    } catch (e) {
        valueCell.title = e
        valueCell.classList.add("fixme-tag")
        if (isVersionPage) {
            valueCell.querySelectorAll("a").forEach(i => i.classList.add("fixme-tag"))
        }
    }
}

function makePhoneValue(valueCell, key) {
    try {
        if (valueCell.textContent[0] !== "+") {
            valueCell.title = t("objectPage.phoneMustStartPlus")
            valueCell.classList.add("fixme-tag")
            valueCell.querySelectorAll("a").forEach(i => i.classList.add("fixme-tag"))
        } else if (!valueCell.textContent.split(";").every(i => libphonenumber.isPossiblePhoneNumber(i.trim()))) {
            valueCell.title = t("objectPage.invalidPhone", { value: valueCell.textContent })
            valueCell.classList.add("warn-tag")
            valueCell.querySelectorAll("a").forEach(i => i.classList.add("warn-tag"))
        }
    } catch (e) {
        console.error(e)
    }
}

function makeEmailValue(valueCell, key) {
    const email_regex = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i

    if (!valueCell.textContent.split(";").every(i => i.trim().match(email_regex))) {
        valueCell.title = t("objectPage.invalidEmail", { value: valueCell.textContent })
        valueCell.classList.add("warn-tag")
        valueCell.querySelectorAll("a").forEach(i => i.classList.add("warn-tag"))
    }
}

function makeXmasFeatureEasterEgg() {
    const curDate = new Date()
    if ((curDate.getMonth() === 11 && curDate.getDate() >= 18) || (curDate.getMonth() === 0 && curDate.getDate() < 10)) {
        if (!document.querySelector(".egg-snow-tag")) {
            const snowBtn = document.createElement("span")
            snowBtn.classList.add("egg-snow-tag")
            snowBtn.textContent = " ❄️"
            snowBtn.style.cursor = "pointer"
            snowBtn.title = t("objectPage.easterEgg")
            snowBtn.addEventListener(
                "click",
                e => {
                    e.target.style.display = "none"
                    runSnowAnimation()
                },
                {
                    once: true,
                },
            )
            document.querySelector(".browse-tag-list").parentElement.previousElementSibling.appendChild(snowBtn)
        }
    }
}

let buildingViewerIframe = null

let contextMenuCSSInjected = false

const contextMenuCSS = `
    .betterOsmContextMenu {
        position: absolute;
        display: "block";
        background: var(--bs-body-bg);
        border: 1px solid var(--bs-border-color);
        box-shadow: 0 2px 5px var(--bs-body-bg);
        z-index: 10000;
        padding: 5px 0;
        border-radius: 4px;
        min-width: 150px;
    }
    .betterOsmContextMenu div {
        cursor: pointer;
        display: flex;
    }
    .betterOsmContextMenu div:hover {
        cursor: pointer;
        background: var(--bs-secondary-bg);
    }
    .betterOsmContextMenu div a {
        display: block;
        padding-top: 6px;
        padding-right: 12px;
        padding-bottom: 6px;
        padding-left: 6px;
    }
    .betterOsmContextMenu div .pin {
        align-content: center;
        padding-right: 8px;
    }
    .betterOsmContextMenu div .pin:hover {
        cursor: pointer;
        background: var(--bs-secondary-bg);
    }
    .betterOsmContextMenu div .pin {
        display: none;
    }
    .betterOsmContextMenu div .pin + label {
        align-self: center;
        padding-left: 6px;
    }
    .betterOsmContextMenu div .pin:not(:checked) + label {
        filter: grayscale(100%);
        opacity: 20%;
        cursor: pointer;
    }
    .betterOsmContextMenu div .pin:not(:checked) + label:hover {
        filter: initial;
        opacity: initial;
    }
`

function injectContextMenuCSS() {
    if (contextMenuCSSInjected) return
    contextMenuCSSInjected = true
    injectCSSIntoOSMPage(contextMenuCSS)
}

// prettier-ignore
const allowedRelationTypes = new Set([
    "multipolygon", "restriction", "route", "boundary", "associatedStreet", "public_transport", "site", "destination_sign",
    "waterway", "route_master", "building", "enforcement", "street", "power", "connectivity", "provides_feature",
    "turnlanes:turns", "bridge", "TMC", "network", "person", "superroute", "watershed", "collection", "tmc", "junction",
    "tmc:point", "tunnel", "node", "associated_address", "turnlanes:lengths", "dual_carriageway", "level", "railway",
    "tmc:link", "group", "traffic_signals_set", "multilinestring", "pipeline", "stop", "associated_entrance", "traffic_mirror",
    "traffic_control", "manoeuvre", "tracks", "election", "destinationsign", "ridge", "surveillance", "region", "cluster",
    "health", "roadAccess", "canal", "disc_golf_course", "circuit", "restriction:hgv", "give_way", "residential", "link",
    "super-relation", "carriageway", "restriction:on_red", "area", "relation", "gallery", "through_route", "right_of_way",
    "linestring", "unposted_route", "water", "operator", "land_area", "navigation", "golf_course", "traffic_signals_group",
    "proposed", "segmented_tag", "road", "man_made", "transit", "construction", "access", "guidepost_destination", "fixme",
    "label", "defaults", "lanelet", "traffic separation scheme", "station", "kit",
])

function makeTypeValue(elem, objType) {
    if (objType !== "relation") {
        const hint = t("objectPage.typeOnlyForRelations")
        elem.querySelectorAll("a:not(.warn-tag)").forEach(a => {
            a.classList.add("warn-tag")
            a.title = hint + "\n\n" + a.title
        })
        elem.classList.add("warn-tag")
        elem.title = hint
        return
    }
    const value = elem.textContent.replace(/(^was:|^disused:|^abandoned:)/, "")
    if (!allowedRelationTypes.has(value)) {
        const hint = t("objectPage.suspiciousRelationType", { value })
        elem.querySelectorAll("a:not(.warn-tag)").forEach(a => {
            a.classList.add("warn-tag")
            a.title = hint + "\n\n" + a.title
        })
        elem.classList.add("warn-tag")
        elem.title = hint
    }
}

function needValidateOpeningHoursKey(key) {
    return (
        (key.startsWith("opening_hours") || // https://github.com/opening-hours/opening_hours.js/blob/master/scripts/related_tags.txt
            key.startsWith("happy_hours") ||
            ["delivery_hours", "smoking_hours", "collection_times", "service_times"].includes(key)) &&
        key !== "opening_hours:signed" &&
        key !== "opening_hours:url" &&
        key !== "opening_hours:description" &&
        key !== "opening_hours:note" &&
        typeof opening_hours !== "undefined" // todo log this
    )
}

function needValidateConditionalAccessKey(key) {
    return (
        key.endsWith(":conditional") &&
        !key.startsWith("fixme:") &&
        !key.startsWith("note:") &&
        !key.startsWith("source:") &&
        !key.startsWith("check_date:") &&
        !key.startsWith("description:")
    )
}

function needValidatePhoneKey(key) {
    return (
        key === "contact:phone" || key === "contact:mobile" || key === "contact:fax" || key === "phone" || key === "mobile" || key === "fax"
    )
}

function needValidateEmailKey(key) {
    return key === "contact:email" || key === "email" || key === "brand:email"
}

function needValidateTrackingParamsInContact(key) {
    // TODO any https:// ?
    return (
        key === "website" ||
        key.startsWith("website:") ||
        key.endsWith(":website") ||
        key === "contact:instagram" ||
        key === "contact:facebook" ||
        key === "source" ||
        key === "image"
    )
}

function makeRoofDirectionValue(valueCell, row, isVersionPage) {
    if (valueCell.textContent === "across" || valueCell.textContent === "along") {
        // todo more
        valueCell.classList.add("fixme-tag")
        valueCell.title = t("objectPage.needRoofOrientation")
        return
    }
    if (!isVersionPage) {
        return
    }
    setTimeout(async () => {
        // todo
        const metadata = await loadWayMetadata()
        const nodes = metadata.elements.filter(i => i.type === "node")
        let lat = 0.0
        let lon = 0.0
        nodes.forEach(i => {
            lat += i.lat
            lon += i.lon
        })
        lat /= nodes.length
        lon /= nodes.length
        row.onmouseenter = () => {
            cleanObjectsByKey("activeObjects")
            renderDirectionTag(lat, lon, valueCell.textContent, c("#ff00e3"))
        }
        row.onmouseleave = () => {
            cleanObjectsByKey("activeObjects")
        }
    })
}

// example https://osm.org/node/6506618057
function makeLinksInVersionTagClickable(row, objType) {
    const keyCell = row.querySelector("th")
    if (!keyCell) return
    const rawKey = keyCell.textContent
    const key = rawKey.toLowerCase()
    const valueCell = row.querySelector("td .current-value-span") ? row.querySelector("td .current-value-span") : row.querySelector("td")
    if (key === "fixme") {
        valueCell.classList.add("fixme-tag")
    } else if (key === "note") {
        valueCell.classList.add("note-tag")
    } else if (key.startsWith("panoramax")) {
        makePanoramaxValue(valueCell)
    } else if (key.startsWith("mapillary")) {
        makeMapillaryValue(valueCell)
    } else if ((key === "xmas:feature" && !document.querySelector(".egg-snow-tag")) || valueCell.textContent.includes("snow")) {
        makeXmasFeatureEasterEgg()
    } else if (key === "wikimedia_commons") {
        makeWikimediaCommonsValue(valueCell, true)
    } else if (key === "direction" || key === "camera:direction" || key === "light:direction") {
        const coords = row.parentElement.parentElement.parentElement.parentElement.querySelector("span.latitude")
        if (coords) {
            const lat = coords.textContent.replace(",", ".")
            const lon = coords.nextElementSibling.textContent.replace(",", ".")
            const match = location.pathname.match(/(node|way|relation)\/(\d+)\/history\/(\d+)\/?$/)
            if (match || document.querySelector(".browse-tag-list") === row.parentElement.parentElement) {
                cleanObjectsByKey("activeObjects")
                renderDirectionTag(parseFloat(lat), parseFloat(lon), valueCell.textContent, c("#ff00e3"))
            }
            row.onmouseenter = () => {
                cleanObjectsByKey("activeObjects")
                renderDirectionTag(parseFloat(lat), parseFloat(lon), valueCell.textContent, c("#ff00e3"))
            }
        }
    } else if (key === "roof:direction") {
        makeRoofDirectionValue(valueCell, row, true)
    } else if (key === "roof:orientation") {
        makeRoofOrientationValue(valueCell)
    } else if (needValidatePhoneKey(key)) {
        makePhoneValue(valueCell)
    } else if (needValidateEmailKey(key)) {
        makeEmailValue(valueCell)
    } else if (needValidateTrackingParamsInContact(key)) {
        makeContactValue(valueCell, key)
    } else if (needValidateConditionalAccessKey(key)) {
        makeConditionalValue(valueCell)
    } else if (needValidateOpeningHoursKey(key)) {
        makeOpeningHoursValue(valueCell, key, true)
    } else if (["building", "building:part"].includes(key) || (key === "type" && valueCell.textContent === "building")) {
        if (location.pathname.includes("/history") || document.querySelector(".view-3d-link")) {
            return
        }
        const m = location.pathname.match(/\/(way|relation)\/(\d+)/)
        if (!m) {
            return
        }
        const [, type, id] = m
        if (!type) {
            return
        }
        if (type === "way" && ["building", "building:part"].includes(key)) {
            const has3DTags = !Array.from(document.querySelectorAll(".browse-tag-list tr th")).some(i => {
                return (
                    i.textContent.includes("level") ||
                    i.textContent.includes("height") ||
                    i.textContent.includes("roof") ||
                    i.textContent.includes("wikidata")
                )
            })
            if (has3DTags) {
                if (document.querySelectorAll('a[href^="/node/"]').length <= 5) {
                    return
                }
            }
        }
        injectCSSIntoOSMPage(
            `
                    #building-3d-view {
                        position: absolute !important;
                        height: 120% !important;
                        z-index: 9999 !important;
                    }
            ` + contextMenuCSS,
        )
        const parent = document.querySelector(".browse-tag-list").parentElement.previousElementSibling
        parent.style.display = "flex"

        const wrapper = document.createElement("span")
        wrapper.classList.add("btn-group")
        wrapper.style.marginLeft = "auto"
        parent.appendChild(wrapper)

        const viewIn3D = document.createElement("span")
        viewIn3D.classList.add("view-3d-link", "btn", "btn-outline-primary")
        viewIn3D.textContent = "3D"
        viewIn3D.style.cursor = "pointer"
        viewIn3D.style.paddingTop = "3px"
        viewIn3D.style.paddingBottom = "3px"
        viewIn3D.title = t("objectPage.clickShowEmbedded3d")

        async function contextMenuHandler(e) {
            const buildingViewer = (await GM.getValue("3DViewer")) ?? "OSM Building Viewer"
            e.preventDefault()
            e.stopPropagation()

            const menu = makeContextMenuElem({
                ...e,
                pageX: e.target.getBoundingClientRect().right,
                pageY: e.target.getBoundingClientRect().bottom,
            })
            instancesOf3DViewers.forEach(i => {
                const listItem = document.createElement("div")
                const a = document.createElement("a")
                const [x, y, z] = getCurrentXYZ()
                a.href = i.makeURL({ x, y, z, type, id })
                a.textContent = i.name
                a.target = "_blank"
                a.style.width = "100%"

                const pin = document.createElement("input")
                pin.id = i.name
                pin.type = "radio"
                pin.classList.add("pin")
                pin.setAttribute("name", "viewer-selector")
                const pinLabel = document.createElement("label")
                pinLabel.setAttribute("for", i.name)
                pinLabel.classList.add("pin-label")
                pinLabel.textContent = "📌"
                pinLabel.title = t("objectPage.setAsDefaultForClick")
                if (i.name === buildingViewer) {
                    pin.checked = true
                    pinLabel.title = t("objectPage.defaultViewer")
                }
                pin.onchange = async () => {
                    if (pin.checked) {
                        await GM.setValue("3DViewer", i.name)
                        if (viewIn3D.classList.contains("active")) {
                            viewIn3D.click()
                            viewIn3D.click()
                        }
                    }
                }
                listItem.appendChild(pin)
                listItem.appendChild(pinLabel)
                listItem.appendChild(a)
                document.addEventListener(
                    "click",
                    function fn(e) {
                        if (!e.isTrusted) {
                            document.addEventListener("click", fn, { once: true })
                            return
                        }
                        if (e.target.classList.contains("pin-label") || e.target.classList.contains("pin")) {
                            document.addEventListener("click", fn, { once: true })
                            return
                        }
                        menu.remove()
                    },
                    { once: true },
                )
                menu.appendChild(listItem)
            })
            document.body.appendChild(menu)
        }

        viewIn3D.addEventListener("contextmenu", contextMenuHandler)

        async function clickHandler(e) {
            if (buildingViewerIframe) {
                buildingViewerIframe.remove()
                buildingViewerIframe = null
                viewIn3D.classList.toggle("active")
                return
            }
            const [x, y, z] = getCurrentXYZ()
            const buildingViewer = (await GM.getValue("3DViewer")) ?? "OSM Building Viewer"
            const viewer = instancesOf3DViewers.find(i => i.name === buildingViewer)
            const url = viewer.makeURL({ x, y, z, type, id })
            if (isMobile || e.ctrlKey || e.metaKey || e.which === 2 || GM_config.get("3DViewerInNewTab")) {
                window.open(url, "_blank")
                return
            }
            viewIn3D.classList.toggle("active")

            buildingViewerIframe = GM_addElement("iframe", {
                src: url,
                width: document.querySelector("#map").clientWidth,
                height: "100%",
                id: "building-3d-view",
            })
            document.querySelector("#map").before(buildingViewerIframe)
        }

        viewIn3D.addEventListener("click", clickHandler)
        viewIn3D.addEventListener("auxclick", e => {
            if (e.which !== 2) return
            clickHandler(e)
        })
        wrapper.appendChild(viewIn3D)

        const expand = document.createElement("span")
        expand.classList.add("dropdown-toggle", "dropdown-toggle-split", "btn", "btn-outline-primary")
        expand.style.cursor = "pointer"
        expand.style.paddingTop = "3px"
        expand.style.paddingBottom = "3px"
        wrapper.appendChild(expand)
        expand.onclick = contextMenuHandler
    } else if (
        // prettier-ignore
        (key === "route" || key === "superroute") &&
        ["hiking", "foot", "bicycle", "mtb", "horse", "inline_skates", "ski", "piste"].includes(valueCell.textContent)
    ) {
        if (document.querySelector(".route-viewer-link")) {
            return
        }
        const value = valueCell.textContent
        const m = location.pathname.match(/\/(relation)\/(\d+)/)
        if (!m) {
            return
        }
        const [, type, id] = m
        if (!type) {
            return
        }
        const waymarkedtrails_type = {
            hiking: "hiking",
            foot: "hiking",
            bicycle: "cycling",
            mtb: "mtb",
            horse: "riding",
            inline_skates: "skating",
            ski: "slopes",
            piste: "slopes",
        }[value]
        const relationViewer = document.createElement("a")
        relationViewer.innerHTML = externalLinkSvg
        relationViewer.classList.add("route-viewer-link")
        relationViewer.style.cursor = "pointer"
        relationViewer.style.paddingLeft = "5px"
        relationViewer.style.paddingRight = "10px"
        relationViewer.title = t("objectPage.openWebsite", { host: `${waymarkedtrails_type}.waymarkedtrails.org` }) // `\nRight click for select viewer`

        const [x, y, z] = getCurrentXYZ()
        relationViewer.href = waymarkedtrailsLink.makeURL({ x, y, z, type, id, waymarkedtrails_type })
        relationViewer.target = "_blank"
        relationViewer.rel = "noreferrer"

        document.querySelector(".browse-tag-list").parentElement.previousElementSibling.appendChild(relationViewer)
    } else if (
        key === "route" /*|| key === "route_master"*/ &&
        ["bus", "trolleybus", "minibus", "share_taxi", /*"train",*/ "light_rail", "subway", "tram", "ferry"].includes(valueCell.textContent)
    ) {
        if (document.querySelector(".route-viewer-link")) {
            return
        }
        const m = location.pathname.match(/\/(relation)\/(\d+)/)
        if (!m) {
            return
        }
        const [, type, id] = m
        if (!type) {
            return
        }
        const relationViewer = document.createElement("a")
        relationViewer.innerHTML = externalLinkSvg
        relationViewer.classList.add("route-viewer-link")
        relationViewer.style.cursor = "pointer"
        relationViewer.style.paddingLeft = "8px"
        relationViewer.style.paddingRight = "5px"
        relationViewer.title = t("objectPage.openWebsite", { host: "ptna.openstreetmap.de" })

        relationViewer.href = publicTransportNetworkAnalysisLink.makeURL({ id })
        relationViewer.target = "_blank"
        relationViewer.rel = "noreferrer"

        const relationEditor = document.createElement("a")
        relationEditor.innerHTML = pencilLinkSvg
        relationEditor.classList.add("route-viewer-link")
        relationEditor.style.cursor = "pointer"
        relationEditor.style.paddingLeft = "5px"
        relationEditor.style.paddingRight = "5px"
        relationEditor.title = t("objectPage.editWithWebsite", { host: "relatify.monicz.dev" })

        relationEditor.href = relatifyLink.makeURL({ id })
        relationEditor.target = "_blank"
        relationEditor.rel = "noreferrer"

        document.querySelector(".browse-tag-list").parentElement.previousElementSibling.appendChild(relationViewer)
        document.querySelector(".browse-tag-list").parentElement.previousElementSibling.appendChild(relationEditor)
    } else if (key === "type") {
        makeTypeValue(valueCell, objType)
    } else if (key === "ref:belpost") {
        if (!valueCell.querySelector("a")) {
            makeRefBelpostValue(valueCell)
        }
    } else if (key === "ref:inaturalist.org") {
        makeRefInaturalistValue(valueCell)
    } else if (key.length <= 2 && key !== "to" && key !== "tv" && key !== "it") {
        keyCell.classList.add("fixme-tag")
        keyCell.title = t("objectPage.keyTooShort")
    } else if (key.length >= 2 && rawKey[0] >= "A" && rawKey[0] <= "Z" && rawKey[1] >= "a" && rawKey[1] <= "z") {
        keyCell.classList.add("fixme-tag")
        keyCell.title = t("objectPage.suspiciousCapitalKey")
    }
}

function makeLinksInVersionTagsClickable() {
    document
        .querySelectorAll(".browse-tag-list tr")
        .forEach(row => makeLinksInVersionTagClickable(row, location.pathname.match(/\/(node|way|relation)\/(\d+)/)?.[1]))
    const tagsTable = document.querySelector(".browse-tag-list")
    if (tagsTable) {
        tagsTable.parentElement.previousElementSibling.title = t("historyDiff.tagsCount", {
            count: tagsTable.querySelectorAll("tr th").length,
        })
    }
}

function addHistoryLink() {
    if (
        (!location.pathname.startsWith("/node") && !location.pathname.startsWith("/way") && !location.pathname.startsWith("/relation")) ||
        location.pathname.includes("/history")
    )
        return
    if (document.querySelector(".history_button_class")) return true
    const versionInSidebar = document.querySelector("#sidebar_content h4 a")
    if (!versionInSidebar) {
        return
    }
    const a = document.createElement("a")
    const curHref = document.querySelector("#sidebar_content h4 a").href.match(/(.*)\/(\d+)$/)
    a.href = curHref[1]
    a.textContent = "🕒"
    a.title = t("objectPage.openObjectHistory")
    a.classList.add("history_button_class")
    if (curHref[2] !== "1") {
        versionInSidebar.after(a)
        versionInSidebar.after(document.createTextNode("\xA0"))
    }
    resetSearchFormFocus()
    makeTimesSwitchable()
    if (GM_config.get("ResizableSidebar")) {
        document.querySelector("#sidebar").style.resize = "horizontal"
    }
    makeLinksInVersionTagsClickable()
    makeHashtagsClickable()
    document.querySelectorAll("#element_versions_list > div p").forEach(shortOsmOrgLinks)
    injectCSSIntoOSMPage(`
    table.browse-tag-list tr td[colspan="2"] {
        background: var(--bs-body-bg) !important;
    }
    `)
}
//</editor-fold>

//<editor-fold desc="history-diff" defaultstate="collapsed">
/**
 * @typedef {Object} ObjectVersion
 * @property {number} version
 * @property {number} id
 * @property {boolean} visible
 * @property {string} timestamp
 */
/**
 * @typedef {Object} NodeVersion
 * @extends ObjectVersion
 * @property {number} version
 * @property {number} id
 * @property {number} changeset
 * @property {number} uid
 * @property {string} user
 * @property {boolean} visible
 * @property {string} timestamp
 * @property {'node'} type
 * @property {number} lat
 * @property {number} lon
 * @property {Object.<string, string>=} tags
 */

/**
 * @type {Object.<string, NodeHistory>}
 */
const nodesHistories = {}

/**
 * @type {Object.<string, WayVersion[]>}
 */
const waysRedactedVersions = {}

/**
 * @type {Object.<string, WayHistory>}
 */
const waysHistories = {}

/**
 * @type {Object.<string, RelationHistory>}
 */
const relationsHistories = {}

const histories = {
    node: nodesHistories,
    way: waysHistories,
    relation: relationsHistories,
}

/**
 *
 * @type {Object.<number, {
 * data: XMLDocument,
 * nodesWithParentWays: Set<number>,
 * nodesWithOldParentWays: Set<number>
 * }>}
 */
const changesetsCache = {}

/**
 * @param {string|number} id
 */
async function getChangeset(id) {
    if (changesetsCache[id]) {
        return changesetsCache[id]
    }
    const text = await originalFetchTextWithCache(osm_server.apiBase + "changeset" + "/" + id + "/download", {
        signal: getAbortController().signal,
    })
    const parser = new DOMParser()
    const data = /** @type {XMLDocument} **/ parser.parseFromString(text, "application/xml")
    return (changesetsCache[id] = {
        data: data,
        nodesWithParentWays: new Set(Array.from(data.querySelectorAll("way > nd")).map(i => parseInt(i.getAttribute("ref")))),
        nodesWithOldParentWays: new Set(
            Array.from(data.querySelectorAll("way:not([version='1']) > nd")).map(i => parseInt(i.getAttribute("ref"))),
        ),
    })
}

function setupNodeVersionView() {
    const match = location.pathname.match(/\/node\/(\d+)\//)
    if (match === null) return
    const nodeHistoryPath = []
    document.querySelectorAll("#element_versions_list > div span.latitude").forEach(i => {
        const lat = i.textContent.replace(",", ".")
        const lon = i.nextElementSibling.textContent.replace(",", ".")
        nodeHistoryPath.push([lat, lon])
        const versionDiv = i.parentElement.parentElement.parentElement.parentElement
        versionDiv.onmouseenter = async () => {
            await interceptMapManually()
            showActiveNodeMarker(lat, lon, c("#ff00e3"))
            versionDiv.querySelectorAll(".browse-tag-list tr").forEach(row => {
                const key = row.querySelector("th")?.textContent?.toLowerCase()
                if (!key) return
                if (key === "direction" || key === "camera:direction" || key === "light:direction") {
                    renderDirectionTag(parseFloat(lat), parseFloat(lon), row.querySelector("td").textContent, c("#ff00e3"))
                    row.onmouseenter = () => {
                        renderDirectionTag(parseFloat(lat), parseFloat(lon), row.querySelector("td").textContent, c("#ff00e3"))
                    }
                }
            })
        }
        versionDiv.onclick = e => {
            if (e.altKey) return
            if (e.target.tagName === "A" || e.target.tagName === "TIME" || e.target.tagName === "SUMMARY" || e.target.tagName === "BUTTON") {
                return
            }
            panTo(lat, lon)
            showActiveNodeMarker(lat, lon, c("#ff00e3"))
        }
    })
    interceptMapManually().then(() => {
        displayWay(nodeHistoryPath, false, "rgba(251,156,112,0.86)", 2)
    })
    document
        .querySelectorAll("#element_versions_list > div h4:nth-of-type(1):not(:has(.relation-version-view)) a:nth-of-type(1)")
        .forEach(i => {
            const version = i.href.match(/\/(\d+)$/)[1]
            i.parentElement.parentElement.setAttribute("node-version", version)
        })
}

/**
 * @param {number[]} nodes
 * @return {Promise<NodeVersion[][]>}
 */
async function loadNodesViaHistoryCalls(nodes) {
    async function _do(nodes) {
        const targetNodesHistory = []
        for (const nodeID of nodes) {
            targetNodesHistory.push(await getNodeHistory(nodeID))
        }
        return targetNodesHistory
    }

    return (await Promise.all(arraySplit(nodes, 5).map(_do))).flat()
}

/**
 * @typedef {NodeVersion[]} NodeHistory
 * @property {unique symbol} brand_node_history
 */

/**
 * @param {number|string} nodeID
 * @return {Promise<NodeHistory>}
 */
async function getNodeHistory(nodeID) {
    if (nodesHistories[nodeID]) {
        return nodesHistories[nodeID]
    } else {
        const res = await fetchRetry(osm_server.apiBase + "node" + "/" + nodeID + "/history.json", { signal: getAbortController().signal })
        const apiHistory = (await res.json()).elements
        // todo it's dirty
        if (apiHistory[0].version === 1 && !apiHistory.every(n => n.visible === false)) {
            return (nodesHistories[nodeID] = apiHistory)
        }
        return (nodesHistories[nodeID] = await tryToRichObjHistory(apiHistory, "node", nodeID))
    }
}

/**
 * @typedef {WayVersion[]} WayHistory
 * @property {unique symbol} __brand_way_history
 */

const enrichedHistories = new Set()

/**
 * @template T
 * @param apiHistory T[]
 * @param objType
 * @param id {string|number}
 * @return {Promise<T[]>}
 */
async function tryToRichObjHistory(apiHistory, objType, id) {
    if (enrichedHistories.has(`${objType}${id}`)) {
        return histories[objType][id]
    }
    console.debug(`tryToRichObjHistory ${objType}, ${id}`)
    const versionNumbers = new Set()
    const mergedHistory = []
    apiHistory.forEach(v => {
        versionNumbers.add(v.version)
        mergedHistory.push(v)
    })

    const oldVersions = Array.from((await downloadVersionsOfObjectWithRedactionBefore2012(objType, id)) ?? []).map(convertXmlVersionToObject)
    // .visible can be missed
    oldVersions.forEach(v => {
        if (versionNumbers.has(v.version)) {
            return
        }
        versionNumbers.add(v.version)
        mergedHistory.push(v)
    })
    mergedHistory.sort((a, b) => {
        if (a.version < b.version) return -1
        if (a.version > b.version) return 1
        return 0
    })
    enrichedHistories.add(`${objType}${id}`)
    return mergedHistory
}

/**
 * @typedef {Object} WayVersion
 * @property {number} id
 * @property {number} changeset
 * @property {number} uid
 * @property {string} user
 * @property {number[]=} [nodes]
 * @property {number} version
 * @property {boolean} visible
 * @property {string} timestamp
 * @property {'way'} type
 * @property {Object.<string, string>=} [tags]
 */
/**
 * @param {number|string} wayID
 * @return {Promise<WayHistory>}
 */
async function getWayHistory(wayID) {
    if (waysHistories[wayID]) {
        return waysHistories[wayID]
    } else {
        const res = await fetchRetry(osm_server.apiBase + "way" + "/" + wayID + "/history.json", { signal: getAbortController().signal })
        const apiHistory = (await res.json()).elements
        // todo it's dirty
        if (apiHistory[0].version === 1 && !apiHistory.every(w => w.visible === false)) {
            return (waysHistories[wayID] = apiHistory)
        }
        return (waysHistories[wayID] = await tryToRichObjHistory(apiHistory, "way", wayID))
    }
}

const overpassVersionsCache = new Map()

async function loadHiddenWayVersionViaOverpass(wayID, version) {
    if (osm_server !== prod_server) {
        console.warn("Overpass works only for main OSM server")
        return
    }
    if (parseInt(version) < 1) {
        return
    }
    if (overpassVersionsCache.has(`${wayID}${version}`)) {
        return overpassVersionsCache.get(`${wayID}${version}`)
    }
    const query = `
[out:json];
timeline(way, ${wayID}, ${version});
for (t["created"])
{
  retro (_.val)
  {
    way(${wayID});
    out meta;
  }
}
`
    console.time(`download overpass data for way ${wayID} v${version}`)
    const res = await overpassRequest(query, "json")
    console.timeEnd(`download overpass data for way ${wayID} v${version}`)
    if (!res.response.elements[0]) {
        console.log("version not found via overpass. There may be a version created before 2012")
    }
    overpassVersionsCache.set(`${wayID}${version}`, res.response.elements[0])
    return res.response.elements[0]
}

function convertXmlVersionToObject(xmlVersion) {
    if (!xmlVersion) {
        return
    }
    /** @type {NodeVersion|WayVersion|RelationVersion} */
    const resultObj = {
        type: xmlVersion.nodeName,
        id: parseInt(xmlVersion.getAttribute("id")),
        changeset: parseInt(xmlVersion.getAttribute("changeset")),
        uid: parseInt(xmlVersion.getAttribute("uid")),
        user: xmlVersion.getAttribute("user"),
        version: parseInt(xmlVersion.getAttribute("version")),
        timestamp: xmlVersion.getAttribute("timestamp"),
    }
    const tagsInXml = Array.from(xmlVersion.querySelectorAll("tag"))
    const tags = Object.fromEntries(
        tagsInXml.map(tag => {
            const k = tag.getAttribute("k")
            const v = tag.getAttribute("v")
            return [k, v]
        }),
    )
    if (tagsInXml.length) {
        resultObj.tags = tags
    }
    const nodes = Array.from(xmlVersion.querySelectorAll("nd")).map(i => parseInt(i.getAttribute("ref")))
    if (nodes.length) {
        resultObj.nodes = nodes
    }
    if (xmlVersion.getAttribute("visible") === "false") {
        resultObj.visible = false
    }
    if (xmlVersion.getAttribute("lat") !== null) {
        resultObj.lat = parseFloat(xmlVersion.getAttribute("lat"))
        resultObj.lon = parseFloat(xmlVersion.getAttribute("lon"))
    }
    const members = Array.from(xmlVersion.querySelectorAll("member")).map(i => {
        return {
            ref: i.getAttribute("ref"),
            type: i.getAttribute("type"),
            role: i.getAttribute("role"),
        }
    })
    if (members.length) {
        resultObj.members = members
    }
    return resultObj
}

/**
 * @param wayID {number|string}
 * @param version {number|string}
 * @return {Promise<WayVersion>|undefined}
 */
async function loadHiddenWayVersionViaGithub(wayID, version) {
    if (parseInt(version) < 1) {
        return
    }
    const data = await downloadVersionsOfObjectWithRedactionBefore2012("way", wayID)
    const xmlVersion = Array.from(data).find(i => parseInt(i.getAttribute("version")) === parseInt(version))
    return convertXmlVersionToObject(xmlVersion)
}

/**
 * @param {string|number} wayID
 * @param {number} version
 * @param {string|number|null=} changesetID
 * @return {Promise<[WayVersion, NodeHistory[]]>}
 */
async function loadWayVersionNodes(wayID, version, changesetID = null) {
    if (parseInt(version) < 1) {
        throw `invalid version for loadWayVersionNodes failed ${wayID}. Version: ${version}`
    }
    console.debug("Loading way", wayID, version)
    const wayHistory = await getWayHistory(wayID)

    const targetVersion =
        wayHistory.find(v => v.version === version) ??
        (await loadHiddenWayVersionViaOverpass(wayID, version)) ??
        (await loadHiddenWayVersionViaGithub(wayID, version))
    if (!targetVersion) {
        throw `loadWayVersionNodes failed ${wayID}, ${version}`
    }
    if (!targetVersion.nodes || targetVersion.nodes.length === 0) {
        return [targetVersion, []]
    }
    const notCached = targetVersion.nodes.filter(nodeID => !nodesHistories[nodeID])
    // console.debug("Not cached nodes histories for download:", notCached.length, "/", targetVersion.nodes)
    if (notCached.length < 2 || osm_server === local_server) {
        // https://github.com/openstreetmap/openstreetmap-website/issues/5183
        return [targetVersion, await loadNodesViaHistoryCalls(targetVersion.nodes)]
    }
    // todo batchSize должен быть динамический
    // Максимальная длина урла (согласно вики) 8213 символов.
    // 400 взято с запасом, что для точки нужно 20 символов
    // пример точки: 123456789012v1234,
    const batchSize = 410
    const lastVersions = []
    const batches = []
    for (let i = 0; i < notCached.length; i += batchSize) {
        console.debug(`Batch #${i}/${notCached.length}`)
        batches.push(notCached.slice(i, i + batchSize))
    }
    await Promise.all(
        batches.map(async batch => {
            const res = await fetchRetry(osm_server.apiBase + "nodes.json?nodes=" + batch.join(","), { signal: getAbortController().signal })
            if (!res.ok) {
                console.trace(res)
            }
            const nodes = (await res.json()).elements
            lastVersions.push(...nodes)
            nodes.forEach(n => {
                if (n.version === 1) {
                    nodesHistories[n.id] = [n]
                }
            })
        }),
    )

    const longHistoryNodes = lastVersions.filter(n => n?.version !== 1)
    const lastVersionsMap = Object.groupBy(lastVersions, ({ id }) => id)

    console.debug("Nodes with multiple versions: ", longHistoryNodes.length)
    if (longHistoryNodes.length === 0) {
        return [targetVersion, targetVersion.nodes.map(nodeID => nodesHistories[nodeID])]
    }

    const queryArgs = [""]
    // OSM Wiki говорит, что можно 8213 символов
    // на практике можно 8219 символов
    // Однако для OHM можно только 8210 символов
    // upd: 2026: теперь уже OSM падает при 8208
    const maxQueryArgLen = 8208 - (osm_server.apiBase.length + "nodes.json?nodes=".length)
    for (const lastVersion of longHistoryNodes) {
        for (let v = 1; v < lastVersion.version; v++) {
            // todo не нужно загружать версию, которая в текущем пакете правок (если уже успели его загрузить)
            const arg = lastVersion.id + "v" + v
            if (queryArgs[queryArgs.length - 1].length + arg.length + 1 < maxQueryArgLen) {
                if (queryArgs[queryArgs.length - 1] === "") {
                    queryArgs[queryArgs.length - 1] += arg
                } else {
                    queryArgs[queryArgs.length - 1] += "," + arg
                }
            } else {
                queryArgs.push(arg)
            }
        }
    }

    // https://github.com/openstreetmap/openstreetmap-website/issues/5005

    /**
     * @type {NodeVersion[]}
     */
    let versions = []
    // console.debug(`w${wayID}v${version}`)
    // console.groupCollapsed(`w${wayID}v${version}`)
    await Promise.all(
        queryArgs.map(async args => {
            const url = osm_server.apiBase + "nodes.json?nodes=" + args
            const res = await fetchRetry(url, { signal: getAbortController().signal })
            if (res.status === 404) {
                console.log("%c Some nodes was hidden. Start slow fetching :(", "background: #222; color: #bada55")
                const newArgs = args.split(",").map(i => parseInt(i.match(/(\d+)v(\d+)/)[1]))
                // это нарушает инвариант, что versions не содержит последней версии
                // важно также сохранять отсортированность,
                // иначе loadNodesViaHistoryCalls сделает внутри несколько вызовов для одной и той же точки
                ;(await loadNodesViaHistoryCalls(newArgs)).forEach(i => {
                    versions.push(...i)
                })
            } else if (res.status === 414) {
                console.error("hmm, the maximum length of the URI is incorrectly calculated", url.length)
                console.error("url:", url)
                console.trace()
            } else {
                if (!res.ok) {
                    console.trace(res)
                }
                versions.push(...(await res.json()).elements)
            }
        }),
    )
    // console.debug(`end w${wayID}v${version}`)
    // console.groupEnd()
    // из-за возможной ручной докачки историй, нужна дедупликация
    const seen = {}
    versions = versions.filter(function ({ id: id, version: version }) {
        return Object.prototype.hasOwnProperty.call(seen, [id, version]) ? false : (seen[[id, version]] = true)
    })

    Object.entries(Object.groupBy(versions, ({ id }) => id)).forEach(([id, history]) => {
        history.sort((a, b) => {
            if (a.version < b.version) return -1
            if (a.version > b.version) return 1
            return 0
        })
        if (history.length && history[history.length - 1].version !== lastVersionsMap[id][0].version) {
            history.push(lastVersionsMap[id][0])
        }
        nodesHistories[id] = history
    })
    return [targetVersion, targetVersion.nodes.map(nodeID => nodesHistories[nodeID])]
}

/**
 * @template {NodeVersion|WayVersion|RelationVersion} T
 * @param {T[]} history
 * @param {string} timestamp
 * @param {boolean=} alwaysReturn
 * @return {T|null}
 */
function searchVersionByTimestamp(history, timestamp, alwaysReturn = false) {
    const targetTime = new Date(timestamp)
    let cur = history[0]
    if (targetTime < new Date(cur.timestamp) && !alwaysReturn) {
        return null
    }
    for (const v of history) {
        if (new Date(v.timestamp) <= targetTime) {
            cur = v
        }
    }
    return cur
}

/**
 * @template T
 * @param {T[][]} objectList
 * @param {string} timestamp
 * @param {boolean=} alwaysReturn
 * @return {T[]}
 */
function filterObjectListByTimestamp(objectList, timestamp, alwaysReturn = false) {
    return objectList.map(i => searchVersionByTimestamp(i, timestamp, alwaysReturn))
}

async function sortWayNodesByTimestamp(wayID) {
    /** @type {(NodeVersion|WayVersion)[]} */
    const objectsBag = []
    /** @type {Set<string>} */
    const objectsSet = new Set()
    for (const i of document.querySelectorAll(`.way-version-view`)) {
        const versionNum = parseInt(i.getAttribute("way-version"))
        const [targetVersion, nodesHistory] = await loadWayVersionNodes(wayID, versionNum)
        objectsBag.push(targetVersion)
        nodesHistory.forEach(v => {
            if (v.length === 0) {
                console.error(`${wayID}, v${versionNum} has node with empty history`)
            }
            const uniq_key = `${v[0].type} ${v[0].id}`
            if (!objectsSet.has(uniq_key)) {
                objectsBag.push(...v)
                objectsSet.add(uniq_key)
            }
        })
    }
    objectsBag.sort((a, b) => {
        if (a.timestamp < b.timestamp) return -1
        if (a.timestamp > b.timestamp) return 1
        if (a.type === "node" && b.type === "way") return -1
        if (a.type === "way" && b.type === "node") return 1
        return 0
    })
    return objectsBag
}

/**
 * @template T
 * @param {T[]} history
 * @return {Object.<number, T>}
 */
function makeObjectVersionsIndex(history) {
    const wayVersionsIndex = {}
    history.forEach(i => {
        wayVersionsIndex[i.version] = i
    })
    return wayVersionsIndex
}

/**
 * @param {NodeVersion} v1
 * @param {NodeVersion} v2
 * @return {boolean}
 */
function locationChanged(v1, v2) {
    return v1.lat !== v2.lat || v1.lon !== v2.lon
}

/**
 * @param {WayVersion} v1
 * @param {WayVersion} v2
 * @return {boolean}
 */
function nodesChanged(v1, v2) {
    return JSON.stringify(v1.nodes) !== JSON.stringify(v2.nodes)
}

/**
 * @param {RelationVersion} v1
 * @param {RelationVersion} v2
 * @return {boolean}
 */
function membersChanged(v1, v2) {
    return JSON.stringify(v1.members) !== JSON.stringify(v2.members)
}

/**
 * @param {NodeVersion|WayVersion} v1
 * @param {NodeVersion|WayVersion} v2
 * @return {boolean}
 */
function tagsChanged(v1, v2) {
    return JSON.stringify(v1.tags) !== JSON.stringify(v2.tags)
}

/**
 * @param relationID {number}
 * @return {Promise<(NodeVersion|WayVersion|RelationVersion)[]>}
 */
async function sortRelationMembersByTimestamp(relationID) {
    /** @type {(NodeVersion|WayVersion|RelationVersion)[]} */
    const objectsBag = []
    /** @type {Set<string>} */
    const objectsSet = new Set()
    for (const i of document.querySelectorAll(`.relation-version-view`)) {
        const versionNum = parseInt(i.getAttribute("relation-version"))
        const { targetVersion: targetVersion, membersHistory: membersHistory } = await loadRelationVersionMembers(relationID, versionNum)
        objectsBag.push(targetVersion)
        membersHistory.nodes.forEach(nodeHistory => {
            const uniq_key = `${nodeHistory[0].type} ${nodeHistory[0].id}`
            if (!objectsSet.has(uniq_key)) {
                objectsBag.push(...nodeHistory)
                objectsSet.add(uniq_key)
            }
        })
        membersHistory.ways.forEach(([wayVersion, wayHistory, nodesHistories]) => {
            const uniq_key = `${wayVersion.type} ${wayVersion.id}`
            if (!objectsSet.has(uniq_key)) {
                objectsBag.push(...wayHistory)
                objectsSet.add(uniq_key)
            }
            nodesHistories.forEach(history => {
                const uniq_key = `${history[0].type} ${history[0].id}`
                if (!objectsSet.has(uniq_key)) {
                    objectsBag.push(...history)
                    objectsSet.add(uniq_key)
                }
            })
        })
        membersHistory.relations.forEach(([relationVersion, history]) => {
            // todo
            const uniq_key = `${history[0].type} ${history[0].id}`
            if (!objectsSet.has(uniq_key)) {
                objectsBag.push(...history)
                objectsSet.add(uniq_key)
            }
        })
    }
    const lastMembers = await loadRelationMetadata(relationID)
    for (let element of lastMembers.elements) {
        if (element.type === "node") {
            const uniq_key = `${element.type} ${element.id}`
            if (!objectsSet.has(uniq_key)) {
                objectsBag.push(...(await getNodeHistory(element.id)))
                objectsSet.add(uniq_key)
            }
        } else if (element.type === "way") {
            const uniq_key = `${element.type} ${element.id}`
            if (!objectsSet.has(uniq_key)) {
                objectsBag.push(...(await getWayHistory(element.id)))
                objectsSet.add(uniq_key)
            }
        } else if (element.type === "relation") {
            // todo
            await getRelationHistory(element.id)
        }
    }
    objectsBag.sort((a, b) => {
        if (a.timestamp < b.timestamp) return -1
        if (a.timestamp > b.timestamp) return 1
        if (a.type === "node" && b.type !== "node") return -1
        if (a.type === "way" && b.type === "relation") return -1
        if (a.type !== "node" && b.type === "node") return 1
        if (a.type === "relation" && b.type === "way") return 1
        return 0
    })
    return objectsBag
}

function makeVersionUl(timestamp, username, changesetID) {
    const ul = document.createElement("ul")
    ul.classList.add("list-unstyled")
    const li = document.createElement("li")
    ul.appendChild(li)

    const time = document.createElement("time")
    time.setAttribute("datetime", timestamp)
    time.setAttribute("natural_text", timestamp) // it should server side string :(
    time.setAttribute("title", timestamp) // it should server side string :(
    time.textContent = new Date(timestamp).toISOString().slice(0, -5) + "Z"
    li.appendChild(time)
    li.appendChild(document.createTextNode("\xA0"))

    const user_link = document.createElement("a")
    user_link.href = location.origin + "/user/" + username
    user_link.textContent = username
    li.appendChild(user_link)
    li.appendChild(document.createTextNode("\xA0"))

    const changeset_link = document.createElement("a")
    changeset_link.href = location.origin + "/changeset/" + changesetID
    changeset_link.textContent = "#" + changesetID
    li.appendChild(changeset_link)
    return ul
}

function registerObjectCleanerOnHeader() {
    document.querySelector("#sidebar_content h2").addEventListener(
        "mouseleave",
        () => {
            document.querySelector("#sidebar_content h2").onmouseenter = () => {
                cleanAllObjects()
            }
        },
        {
            once: true,
        },
    )
}

function makeVersionsFiltersSelector() {
    const select = document.createElement("select")
    select.id = "versions-filter"
    select.title = t("historyDiff.filterIntermediateChanges")

    const allVersions = document.createElement("option")
    allVersions.value = "all-versions"
    allVersions.textContent = t("historyDiff.allVersions")
    select.appendChild(allVersions)

    const withGeom = document.createElement("option")
    withGeom.value = "with-geom"
    withGeom.textContent = t("historyDiff.withGeometryChanges")
    withGeom.setAttribute("selected", "selected")
    select.appendChild(withGeom)

    const withoutInter = document.createElement("option")
    withoutInter.value = "without-inter"
    withoutInter.textContent = t("historyDiff.withoutIntermediate")
    select.appendChild(withoutInter)
    return select
}

/**
 * @param btn
 * @param {"way"|"relation"} type
 */
function addVersionsFilters(btn, type) {
    if (document.querySelectorAll(`[${type}-version="inter"]`).length <= 20) {
        console.log("don't add filters")
        return
    }
    const select = makeVersionsFiltersSelector()

    select.onchange = e => {
        if (e.target.value === "all-versions") {
            document.querySelectorAll(`[${type}-version="inter"]`).forEach(i => {
                i.removeAttribute("hidden")
            })
        } else if (e.target.value === "with-geom") {
            document.querySelectorAll(`.only-tags-changed[${type}-version="inter"]`).forEach(i => {
                i.setAttribute("hidden", "true")
            })
            document.querySelectorAll(`[${type}-version="inter"]:not(.only-tags-changed)`).forEach(i => {
                i.removeAttribute("hidden")
            })
        } else if (e.target.value === "without-inter") {
            document.querySelectorAll(`[${type}-version="inter"]`).forEach(i => {
                i.setAttribute("hidden", "true")
            })
        }
    }
    document.querySelectorAll(`.only-tags-changed[${type}-version="inter"]`).forEach(i => {
        i.setAttribute("hidden", "true")
    })
    btn.after(select)
}

function reportAboutHiddenMember(i, currentMembers, parentType, btn, divForMembersReplace) {
    console.trace(currentMembers)
    btn.style.background = "yellow"
    btn.title = t("historyDiff.reloadAndReport")
    divForMembersReplace.classList.add("broken-version")
    divForMembersReplace.title = t("historyDiff.someNodesHiddenSad")
    divForMembersReplace.style.cursor = "auto"
}

function makeInterVersionHeader(type) {
    const h = document.createElement("h4")
    const abbr = document.createElement("abbr")
    abbr.textContent = type === "way" ? t("historyDiff.intermediateWayVersion") : t("historyDiff.intermediateRelationVersion")
    abbr.title = type === "way" ? t("historyDiff.intermediateWayVersionTitle") : t("historyDiff.intermediateRelationVersionTitle")
    h.appendChild(abbr)
    return h
}

function makeInterVersionSection(type, changeset, timestamp, user, count) {
    const interVersionDiv = document.createElement("div")
    interVersionDiv.setAttribute(`${type}-version`, "inter")
    interVersionDiv.classList.add("mb-3", "border-bottom", "border-secondary-subtle", "pb-3", "browse-section")

    const interVersionDivHeader = makeInterVersionHeader()
    interVersionDiv.appendChild(interVersionDivHeader)

    const p = document.createElement("p")
    interVersionDiv.appendChild(p)
    loadChangesetMetadata(changeset).then(ch => {
        p.textContent = shortOsmOrgLinksInText(ch.tags["comment"] ?? "")
    })

    interVersionDiv.appendChild(makeVersionUl(timestamp, user, changeset))

    const details = document.createElement("details")
    details.classList.add("way-version-nodes") // TODO
    details.onclick = e => {
        e.stopImmediatePropagation()
    }
    const summary = document.createElement("summary")
    summary.textContent = count
    summary.classList.add("history-diff-modified-tag")
    details.appendChild(summary)
    const ulChildren = document.createElement("ul")
    ulChildren.classList.add("list-unstyled")
    return { interVersionDiv, childrenDetails: details, ulChildren }
}

const emptyComments = Object.freeze({
    nodes: [],
    ways: [],
    relations: [],
})

async function renderWayInterVersion(btn, objectVersionsIndex, objectStates, current, type) {
    /** @type {NodeVersion[]}*/
    const currentChildren = []
    objectVersionsIndex[current.objectVersion.version].nodes.forEach(nodeID => {
        const uniq_key = `node ${nodeID}`
        currentChildren.push(objectStates[uniq_key])
        if (current.changes[uniq_key] !== undefined) return
        const curV = objectStates[uniq_key]
        if (curV) {
            current.changes[uniq_key] = ["", curV, curV]
        } else {
            console.warn(`${uniq_key} not found in states`)
        }
    })
    const count = currentChildren.length
    const {
        interVersionDiv,
        childrenDetails: nodesDetails,
        ulChildren: ulNodes,
    } = makeInterVersionSection(type, current.changeset, current.timestamp, current.user, count)
    for (const i of currentChildren) {
        if (i === undefined) {
            console.trace(currentChildren)
            btn.style.background = "yellow"
            btn.title = t("historyDiff.someNodesHidden")
            continue
        }
        const nodeLi = document.createElement("li")
        const div = document.createElement("div")
        div.classList.add("d-flex", "gap-1")
        const div2 = document.createElement("div")
        div2.classList.add("align-self-center")
        div.appendChild(div2)

        const aHistory = document.createElement("a")
        aHistory.classList.add(i.type)
        aHistory.href = `/${i.type}/${i.id}/history`
        aHistory.textContent = i.id
        div2.appendChild(aHistory)

        div2.appendChild(document.createTextNode(", "))

        const aVersion = document.createElement("a")
        aVersion.classList.add(i.type)
        aVersion.href = `/${i.type}/${i.id}/history/${i.version}`
        aVersion.textContent = "v" + i.version
        div2.appendChild(aVersion)
        nodeLi.appendChild(div)

        const curChange = current.changes[`${i.type} ${i.id}`]
        const nodesHistory = histories[i.type][i.id]
        const tagsTable = await processObject(
            div2,
            i.type,
            curChange[1] ?? curChange[2],
            curChange[2],
            nodesHistory[nodesHistory.length - 1],
            nodesHistory,
        )
        await processObjectInteractions(
            i.id.toString(),
            i.type,
            emptyComments,
            div2,
            ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(div2))),
        )
        if (nodeLi.classList.contains("tags-non-modified")) {
            div2.appendChild(tagsTable)
        }
        // tagsTable.style.borderColor = "var(--bs-body-color)"
        // tagsTable.style.borderStyle = "solid"
        // tagsTable.style.borderWidth = "1px"
        ulNodes.appendChild(nodeLi)
    }
    nodesDetails.appendChild(ulNodes)
    interVersionDiv.appendChild(nodesDetails)

    const tmpChangedNodes = Object.values(current.changes).filter(i => i[2].type === "node")
    if (tmpChangedNodes.every(i => i[0] === "tags")) {
        interVersionDiv.classList.add("only-tags-changed")
    }
    const changedNodes = tmpChangedNodes.filter(i => i[0] !== "location")
    interVersionDiv.onmouseenter = () => {
        resetMapHover()
        cleanAllObjects()
        showWay(currentChildren, "#000000", false, darkModeForMap && isDarkMode())
        currentChildren.forEach(node => {
            if (node.tags && Object.keys(node.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                showNodeMarker(node.lat.toString(), node.lon.toString(), "rgb(161,161,161)", null, "customObjects", 3)
            }
        })
        changedNodes.forEach(i => {
            if (i[0] === "") return
            if (i[2].visible === false) {
                if (i[1].visible !== false) {
                    showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                }
            } else if (i[0] === "new") {
                if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                    showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                }
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
            } else {
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
            }
        })
    }
    interVersionDiv.onclick = e => {
        resetMapHover()
        cleanAllObjects()
        showWay(currentChildren, "#000000", e.isTrusted, darkModeForMap && isDarkMode())
        currentChildren.forEach(node => {
            if (node.tags && Object.keys(node.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                showNodeMarker(node.lat.toString(), node.lon.toString(), "rgb(161,161,161)", null, "customObjects", 3)
            }
        })
        changedNodes.forEach(i => {
            if (i[0] === "") return
            if (i[2].visible === false) {
                if (i[1].visible !== false) {
                    showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                }
            } else if (i[0] === "new") {
                if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                    showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                }
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
            } else {
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
            }
        })
    }
    let insertBeforeThat = document.querySelector(
        `#element_versions_list > :where(div, details)[way-version="${current.objectVersion.version}"]`,
    )
    while (insertBeforeThat.previousElementSibling?.getAttribute("way-version") === "inter") {
        // fixme O(n^2)
        insertBeforeThat = insertBeforeThat.previousElementSibling
    }
    insertBeforeThat.before(interVersionDiv)
}

async function replaceRealWayVersion(it, objectStates, current) {
    let divForNodesReplace = document.querySelector(`#element_versions_list > :where(div, details)[way-version="${it.version}"]`)
    if (Object.keys(current.changes).length > 1 && divForNodesReplace.classList?.contains("empty-version")) {
        divForNodesReplace.querySelector("summary")?.remove()
        const div = document.createElement("div")
        div.innerHTML = divForNodesReplace.innerHTML
        div.setAttribute("way-version", divForNodesReplace.getAttribute("way-version"))
        divForNodesReplace.replaceWith(div)
        divForNodesReplace = div
    }
    current.objectVersion = it
    current.objectObjectsSet = new Set()
    current.objectVersion.nodes?.forEach(nodeID => {
        const uniq_key = `node ${nodeID}`
        current.objectObjectsSet.add(uniq_key)
        if (current.changes[uniq_key] === undefined) {
            const curV = objectStates[uniq_key]
            if (curV) {
                if (curV.version === 1 && current.objectVersion.changeset === curV.changeset) {
                    current.changes[uniq_key] = ["new", emptyVersion, curV]
                } else {
                    current.changes[uniq_key] = ["", curV, curV]
                }
            } else {
                console.warn(`${uniq_key} not found in states`)
            }
        }
    })
    if (divForNodesReplace && current.objectVersion.nodes) {
        /** @type {[HTMLImageElement, NodeVersion][]} */
        const currentNodes = []
        const ulNodes = divForNodesReplace.querySelector("details:not(.empty-version) ul")
        ulNodes.parentElement.classList.add("way-version-nodes")
        ulNodes.querySelectorAll("li").forEach(li => {
            li.style.display = "none"
            const id = li.querySelector("div div a").href.match(/node\/(\d+)/)[1]
            currentNodes.push([li.querySelector("img"), objectStates[`node ${id}`]])
        })
        if (it.version !== 1) {
            const changedNodes = Object.values(current.changes).filter(i => i[2].type === "node" && i[0] !== "location" && i[0] !== "")
            document.querySelector(`#element_versions_list > div[way-version="${it.version}"]`)?.addEventListener("mouseenter", () => {
                changedNodes.forEach(i => {
                    if (i[2].visible === false) {
                        if (i[1].visible !== false) {
                            showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                        }
                    } else if (i[0] === "new") {
                        if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                            showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                        }
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                    } else {
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
                    }
                })
            })
            document.querySelector(`#element_versions_list > div[way-version="${it.version}"]`)?.addEventListener("click", () => {
                changedNodes.forEach(i => {
                    if (i[2].visible === false) {
                        if (i[1].visible !== false) {
                            showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                        }
                    } else if (i[0] === "new") {
                        if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                            showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                        }
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                    } else {
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
                    }
                })
            })
        }
        for (const [img, i] of currentNodes) {
            if (i === undefined) {
                reportAboutHiddenMember(currentNodes, it.type, btn, divForNodesReplace)
                continue
            }
            const nodeLi = document.createElement("li")
            const div = document.createElement("div")
            div.classList.add("d-flex", "gap-1")
            const div2 = document.createElement("div")
            div2.classList.add("align-self-center")
            div.appendChild(div2)

            if (img) {
                div2.before(img.cloneNode(true))
            } else {
                console.warn("img is undefined")
            }

            const aHistory = document.createElement("a")
            aHistory.classList.add(i.type)
            aHistory.href = `/${i.type}/${i.id}/history`
            aHistory.textContent = i.id
            div2.appendChild(aHistory)
            nodeLi.appendChild(div)

            div2.appendChild(document.createTextNode(", "))

            const aVersion = document.createElement("a")
            aVersion.classList.add("node")
            aVersion.href = "/node/" + i.id + "/history/" + i.version
            aVersion.textContent = "v" + i.version
            div2.appendChild(aVersion)
            nodeLi.appendChild(div)

            const curChange = current.changes[`${i.type} ${i.id}`]
            const nodesHistory = histories[i.type][i.id]
            const tagsTable = await processObject(
                div2,
                i.type,
                curChange[1] ?? curChange[2],
                curChange[2],
                nodesHistory[nodesHistory.length - 1],
                nodesHistory,
            )
            await processObjectInteractions(
                i.changeset.toString(),
                i.type,
                emptyComments,
                div2,
                ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(div2))),
            )
            if (nodeLi.classList.contains("tags-non-modified")) {
                div2.appendChild(tagsTable)
            }
            // table.style.borderColor = "var(--bs-body-color)";
            //  table.style.borderStyle = "solid";
            //  table.style.borderWidth = "1px";
            ulNodes.appendChild(nodeLi)
        }
    }
    current.changes = {}
    current.changeset = null
}

/**
 * @param {"way"} type
 * @param btn {HTMLElement}
 * @param objectID {number}
 * @return {Promise<void>}
 */
async function replaceDownloadWayButton(type, btn, objectID) {
    const objectsBag = await sortWayNodesByTimestamp(objectID)
    await preloadChangesetsForObjectsBag(objectsBag)

    /** @type {Object<number, WayVersion>} */
    const objectVersionsIndex = makeObjectVersionsIndex(await getObjectHistory(type, objectID))
    /** @type {Object.<string, NodeVersion|WayVersion>}*/
    const objectStates = {}

    /**
     * @type {{
     * changeset: number|null,
     * currentUser: string|null,
     * timestamp: string|null
     * changes: Object.<string, [string, NodeVersion|WayVersion, NodeVersion|WayVersion]>,
     * objectVersion: WayVersion,
     * objectObjectsSet: Set<string>,
     * }}
     */
    const current = {
        changeset: null,
        user: null,
        timestamp: null,
        changes: {},
        objectVersion: { version: 0, nodes: [] },
        objectObjectsSet: new Set(),
    }

    await processObjectsBag(objectsBag, objectStates, current, type, objectID, objectVersionsIndex, btn)
    registerObjectCleanerOnHeader()
    addVersionsFilters(btn, type)
    btn.remove()
}

async function tryFindWayAncestor(objectId) {
    return
    const way = await getWayHistory(objectId)
    const firstWayVersion = way.at(0)
    if (firstWayVersion.version !== 1) {
        console.warn("missing first version for ancestor finding")
        return
    }
    const firstVersionDate = new Date(firstWayVersion.timestamp)
    const olderThatWay = []

    for (const id of firstWayVersion.nodes) {
        const nodeHistory = await getNodeHistory(id)
        const firstNodeVersion = nodeHistory.at(0)
        if (!firstNodeVersion) {
            return
        }
        if (new Date(firstNodeVersion.timestamp) < firstVersionDate) {
            // todo на самом деле правильнее ещё на пакет правок смотреть
            olderThatWay.push(firstNodeVersion)
        }
    }

    if (olderThatWay.length) {
        console.log("All nodes are new")
        return
    }

    const changeset = await getChangeset(firstWayVersion.changeset)
    changeset.data.querySelectorAll("way")
}

/**
 * @param {"way"|"relation"} type
 * @param objectId {number}
 * @return {Promise<void>}
 */
async function showFullObjectHistory(type, objectId) {
    console.time("full history")
    const btn = document.querySelector("#download-all-versions-btn")
    try {
        if (type === "way") {
            await replaceDownloadWayButton(type, btn, objectId)
        } else {
            await replaceDownloadRelationButton(type, btn, objectId)
        }
    } catch (err) {
        console.error(err)
        btn.title = t("historyDiff.reloadAndReport")
        btn.style.background = "red"
        btn.style.cursor = "auto"
        throw err
    } finally {
        console.timeEnd("full history")
    }
    if (type === "way") {
        await tryFindWayAncestor(objectId)
    }
}

function makeDownloadVersionsButton() {
    const downloadAllVersionsBtn = document.createElement("a")
    downloadAllVersionsBtn.id = "download-all-versions-btn"
    downloadAllVersionsBtn.tabIndex = 0
    downloadAllVersionsBtn.textContent = "⏬"
    downloadAllVersionsBtn.style.cursor = "pointer"
    return downloadAllVersionsBtn
}

function setupWayVersionView() {
    const match = location.pathname.match(/\/way\/(\d+)\//)
    if (match === null) return
    const wayID = match[1]

    async function loadWayVersion(e, loadMore = true, needShowWay = true, needFly = false) {
        const htmlElem = e.target ? e.target : e
        htmlElem.style.cursor = "progress"
        const version = parseInt(htmlElem.getAttribute("way-version"))
        const [targetVersion, nodesHistory] = await loadWayVersionNodes(wayID, version)
        const nodesList = filterObjectListByTimestamp(nodesHistory, targetVersion.timestamp)
        if (nodesList.some(i => i === null)) {
            htmlElem.parentElement.parentElement.classList.add("broken-version")
            htmlElem.title = t("historyDiff.someNodesHidden")
            htmlElem.style.cursor = "auto"
        } else {
            if (needShowWay) {
                cleanAllObjects()
                showWay(nodesList, "#000000", needFly, darkModeForMap && isDarkMode())
                nodesList.forEach(node => {
                    if (node.tags && Object.keys(node.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                        showNodeMarker(node.lat.toString(), node.lon.toString(), "rgb(161,161,161)", null, "customObjects", 3)
                    }
                })
            }
        }
        if (htmlElem.nodeName === "A") {
            const versionDiv = htmlElem.parentNode.parentNode
            versionDiv.onmouseenter = e => {
                resetMapHover()
                loadWayVersion(e)
            }
            versionDiv.onclick = async e => {
                if (
                    e.target.tagName === "A" ||
                    e.target.tagName === "TIME" ||
                    e.target.tagName === "SUMMARY" ||
                    e.target.tagName === "BUTTON"
                ) {
                    return
                }
                await loadWayVersion(versionDiv, true, true, true)
            }
            versionDiv.setAttribute("way-version", version.toString())
            htmlElem.style.cursor = "pointer" // todo finally{}
            htmlElem.setAttribute("hidden", "true")
            // preload next
            if (version !== 1) {
                let prevVersionNum = version - 1
                while (prevVersionNum > 0) {
                    try {
                        console.log(`preloading v${prevVersionNum}`)
                        await loadWayVersionNodes(wayID, prevVersionNum)
                        console.log(`preloaded v${prevVersionNum}`)
                        break
                    } catch {
                        console.log(`Skip v${prevVersionNum}`)
                        prevVersionNum--
                    }
                }
                const loadBtn = document.querySelector(`#sidebar_content a[way-version="${prevVersionNum}"]`)
                if (loadMore && document.querySelector(`#sidebar_content a[way-version="${prevVersionNum}"]`)) {
                    const nodesCount = waysHistories[wayID].filter(v => v.version === prevVersionNum)[0].nodes?.length
                    if (!nodesCount || nodesCount <= 123) {
                        await loadWayVersion(loadBtn, true, false)
                    } else {
                        await loadWayVersion(loadBtn, false, false)
                        if (prevVersionNum > 1) {
                            console.log(`preloading2 v${prevVersionNum - 1}`)
                            await loadWayVersionNodes(wayID, prevVersionNum - 1)
                            console.log(`preloaded v${prevVersionNum - 1}`)
                        }
                    }
                }
            }
        } else {
            try {
                e.target.style.cursor = "auto"
            } catch {
                e.style.cursor = "auto"
            }
        }
    }

    document.querySelectorAll("#element_versions_list > div h4:nth-of-type(1):not(:has(.way-version-view)) a:nth-of-type(1)").forEach(i => {
        const version = i.href.match(/\/(\d+)$/)[1]
        const btn = document.createElement("a")
        btn.classList.add("way-version-view")
        btn.textContent = "📥"
        btn.style.cursor = "pointer"
        btn.setAttribute("way-version", version)
        // fixme mouseenter должен начинать загрузку в фоне
        // но только при клике должна начинаться анимация
        btn.addEventListener("mouseenter", loadWayVersion, {
            once: true,
        })
        i.after(btn)
        i.after(document.createTextNode("\xA0"))
    })

    if (!document.getElementById("download-all-versions-btn")) {
        const downloadAllVersionsBtn = makeDownloadVersionsButton()
        downloadAllVersionsBtn.title = t("historyDiff.downloadAllVersionsWithIntermediate")

        const clickHandler = async () => {
            downloadAllVersionsBtn.style.cursor = "progress"
            await unrollPaginationInHistory()
            for (const i of document.querySelectorAll(`.way-version-view:not([hidden])`)) {
                try {
                    await loadWayVersion(i)
                } catch (e) {
                    console.error(e)
                    console.log("redacted version")
                }
            }
            if (GM_config.get("FullVersionsDiff")) {
                addQuickLookStyles()
                await showFullObjectHistory("way", parseInt(wayID))
            }
        }
        downloadAllVersionsBtn.addEventListener("click", clickHandler, { once: true })
        downloadAllVersionsBtn.addEventListener("keypress", clickHandler, { once: true })
        document.querySelector(".compact-toggle-btn")?.after(downloadAllVersionsBtn)
        document.querySelector(".compact-toggle-btn")?.after(document.createTextNode("\xA0"))
    }
}

//<editor-fold desc="relation types">
/**
 * @typedef {Object} RelationMember
 * @property {number} ref
 * @property {'node'|'way'|'relation'} type
 * @property {string} role
 */

/**
 * @typedef {Object} ExtendedRelationNodeMember
 * @property {number} ref
 * @property {'node'} type
 * @property {string} role
 * @property {number} lat
 * @property {number} lon
 */

/**
 * @typedef {Object} ExtendedRelationWayMember
 * @property {number} ref
 * @property {'way'} type
 * @property {string} role
 * @property {LatLon[]} geometry
 */

// TODO ExtendedRelationRelationMember

/**
 * @typedef {ExtendedRelationNodeMember|ExtendedRelationWayMember} ExtendedRelationMember
 */

/**
 * @typedef {Object} RelationVersion
 * @property {number} id
 * @property {number} changeset
 * @property {number} uid
 * @property {string} user
 * @property {RelationMember[]} members
 * @property {number} version
 * @property {boolean} visible
 * @property {string} timestamp
 * @property {'relation'} type
 * @property {Object.<string, string>=} tags
 */

/**
 * @typedef {Object} ExtendedRelationVersion
 * @property {number} id
 * @property {number} changeset
 * @property {number} uid
 * @property {string} user
 * @property {ExtendedRelationMember[]} members
 * @property {number} version
 * @property {boolean} visible
 * @property {string} timestamp
 * @property {'relation'} type
 * @property {Object.<string, string>=} tags
 */

//</editor-fold>

/**
 * @typedef {RelationVersion[]} RelationHistory
 * @property {unique symbol} __brand_relation_history
 */

/**
 * @param {number|string} relationID
 * @return {Promise<RelationHistory>}
 */
async function getRelationHistory(relationID) {
    if (relationsHistories[relationID]) {
        return relationsHistories[relationID]
    } else {
        const res = await fetchRetry(osm_server.apiBase + "relation" + "/" + relationID + "/history.json")
        return (relationsHistories[relationID] = (await res.json()).elements)
    }
}

async function getObjectHistory(type, objectID) {
    if (type === "node") {
        return await getNodeHistory(objectID)
    } else if (type === "way") {
        return await getWayHistory(objectID)
    } else if (type === "relation") {
        return await getRelationHistory(objectID)
    } else {
        throw `unknown ${type} type`
    }
}

/**
 * @typedef {[number, number]} LatLonPair
 */

/**
 * @typedef {{lat: number, lon: number}} LatLon
 */

/**
 * @typedef {WayVersion & {geometry: LatLon[]}} ExtendedWayVersion
 */

const overpassCache = {}

/**
 * @typedef {{
 *   min_lat: number,
 *   min_lon: number,
 *   max_lat: number,
 *   max_lon: number,
 * }} BBOX
 */

/**
 * @typedef {{
 *   geom: LatLonPair[][],
 *   bbox: BBOX,
 *   isRestriction: boolean,
 *   restrictionRelationErrors: string[]
 * }} CachedRelation
 */

/**
 * @type {Object.<*, CachedRelation>}
 */
const cachedRelations = {}

/**
 *
 * @param {number} id
 * @param {string} timestamp
 * @param {boolean=true} cleanPrevObjects=true
 * @param {string=} color=
 * @param {string=} layer=
 * @param {boolean=} addStroke
 * @return {Promise<CachedRelation>}
 */
async function loadRelationVersionMembersViaOverpass(
    id,
    timestamp,
    cleanPrevObjects = true,
    color = "#000000",
    layer = "activeObjects",
    addStroke = null,
) {
    console.time(`Render ${id} relation`)
    console.log(id, timestamp)

    /**
     * @param id
     * @param timestamp
     * @return {Promise<{
     *   elements: (ExtendedRelationVersion)[]
     * }>}
     */
    async function getRelationViaOverpass(id, timestamp) {
        if (overpassCache[[id, timestamp]]) {
            return overpassCache[[id, timestamp]]
        } else {
            const res = await overpassRequest(
                `
[out:json][date:"${timestamp}"];
relation(${id});
//(._;>;);
out geom;
`,
                "json",
            )
            return (overpassCache[[id, timestamp]] = res.response)
        }
    }

    const overpassGeom = await getRelationViaOverpass(id, timestamp)
    console.log("Data downloaded")
    if (cleanPrevObjects) {
        cleanCustomObjects()
    }
    cleanObjectsByKey("activeObjects")
    if (!layers[layer]) {
        layers[layer] = []
    }

    /**
     * @param overpassGeom
     * @return {{bbox: BBOX, nodesBbox: BBOX}}
     */
    function getBbox(overpassGeom) {
        /** @type {{bbox: BBOX, nodesBbox: BBOX}} */
        const relationInfo = {
            // prettier-ignore
            bbox:      { min_lat: 10000000, min_lon: 10000000, max_lat: -10000000, max_lon: -100000000 },
            nodesBbox: { min_lat: 10000000, min_lon: 10000000, max_lat: -10000000, max_lon: -100000000 },
        }

        const nodesBag = []
        overpassGeom.elements[0]?.members?.forEach(i => {
            if (i.type === "way") {
                nodesBag.push(
                    ...i.geometry.map(p => {
                        return { lat: p.lat, lon: p.lon }
                    }),
                )
            } else if (i.type === "node") {
                nodesBag.push({ lat: i.lat, lon: i.lon })
            } else {
                // ну нинада пожалуйста
            }
        })

        overpassGeom.elements[0]?.members?.forEach(i => {
            if (i.type === "node" && i?.lat) {
                relationInfo.nodesBbox.min_lat = min(relationInfo.nodesBbox.min_lat, i.lat)
                relationInfo.nodesBbox.min_lon = min(relationInfo.nodesBbox.min_lon, i.lon)
                relationInfo.nodesBbox.max_lat = max(relationInfo.nodesBbox.max_lat, i.lat)
                relationInfo.nodesBbox.max_lon = max(relationInfo.nodesBbox.max_lon, i.lon)
            }
        })

        for (const i of nodesBag) {
            if (i?.lat) {
                relationInfo.bbox.min_lat = min(relationInfo.bbox.min_lat, i.lat)
                relationInfo.bbox.min_lon = min(relationInfo.bbox.min_lon, i.lon)
                relationInfo.bbox.max_lat = max(relationInfo.bbox.max_lat, i.lat)
                relationInfo.bbox.max_lon = max(relationInfo.bbox.max_lon, i.lon)
            }
        }
        return relationInfo
    }

    // GC больно, постоянно передавать в контекст страницы больно
    let cache = /** @type {CachedRelation} */ cachedRelations[[id, timestamp]]
    if (!cache) {
        let wayCounts = 0
        /** @type {LatLonPair[][]} */
        const mergedGeometry = []
        overpassGeom.elements[0]?.members?.forEach(i => {
            if (i.type === "way") {
                const w = /** @type {ExtendedWayVersion} */ i
                wayCounts++
                if (w.geometry === undefined || !w.geometry.length) {
                    return
                }
                const nodesList = w.geometry.map(p => [p.lat, p.lon])
                if (mergedGeometry.length === 0) {
                    mergedGeometry.push(nodesList)
                } else {
                    const lastWay = mergedGeometry[mergedGeometry.length - 1]
                    const [lastLat, lastLon] = lastWay[lastWay.length - 1]
                    if (lastLat === nodesList[0][0] && lastLon === nodesList[0][1]) {
                        mergedGeometry[mergedGeometry.length - 1].push(...nodesList.slice(1))
                    } else {
                        mergedGeometry.push(nodesList)
                    }
                }
            } else if (i.type === "node") {
                showNodeMarker(i.lat, i.lon, color, null, layer)
            } else if (i.type === "relation") {
                // todo
            }
        })
        const isRestriction = isRestrictionObj(overpassGeom.elements?.[0]?.tags ?? {})
        const { bbox, nodesBbox } = getBbox(overpassGeom)
        cache = cachedRelations[[id, timestamp]] = {
            geom: mergedGeometry.map(i => intoPage(i)),
            bbox: bbox,
            nodesBbox: nodesBbox,
            isRestriction: isRestriction,
            restrictionRelationErrors: isRestriction ? validateRestriction(overpassGeom.elements[0]) : [],
        }
        console.log(`${cache.geom.length}/${wayCounts} for render`)
    } else {
        overpassGeom.elements[0]?.members?.forEach(i => {
            if (i.type === "node") {
                showNodeMarker(i.lat, i.lon, color, null, layer)
            }
        })
    }

    cache.geom.forEach(nodesList => {
        displayWay(nodesList, false, color, 4, null, layer, null, null, addStroke, true)
    })

    if (cache.isRestriction && cache.restrictionRelationErrors.length === 0) {
        renderRestriction(overpassGeom.elements[0], restrictionColors[overpassGeom.elements[0].tags["restriction"]] ?? color, layer)
    }

    console.timeEnd(`Render ${id} relation`)

    console.log("relation loaded")
    return cache
}

async function getNodeViaOverpassXML(id, timestamp) {
    const res = await overpassRequest(
        `
[out:xml][maxsize:64Mi][date:"${timestamp}"];
node(${id});
out meta;
`,
        "xml",
    )

    if (res.status !== 200) {
        console.trace(res)
    }
    return new DOMParser().parseFromString(res.response, "text/xml").querySelector("node")
}

async function getWayViaOverpassXML(id, timestamp) {
    const res = await overpassRequest(
        `
[out:xml][maxsize:64Mi][date:"${timestamp}"];
way(${id});
//(._;>;);
out meta;
`,
        "xml",
    )
    if (res.status !== 200) {
        console.trace(res)
    }
    return new DOMParser().parseFromString(res.response, "text/xml").querySelector("way")
}

async function getRelationViaOverpassXML(id, timestamp) {
    const res = await overpassRequest(
        `
[out:xml][date:"${timestamp}"];
relation(${id});
//(._;>;);
out meta;
`,
        "xml",
    )
    if (res.status !== 200) {
        console.trace(res)
    }
    return new DOMParser().parseFromString(res.response, "text/xml").querySelector("relation")
}

/**
 * @typedef {NodeVersion[]} NodesBag
 * @property {unique symbol} __brand_nodes_bag
 */

/**
 * @typedef {{
 * nodes: NodeHistory[],
 * ways: [WayVersion, WayHistory, NodeHistory[]][],
 * relations: RelationHistory[]
 * }} RelationMembersVersions
 */

/**
 * Возвращает нужную версию
 * + точки на момент версии
 * + версию линии на момент версии со все необходимые историями точек в линиях
 * @param {string|number} relationID
 * @param {number} version
 * @throws {string}
 * @returns {Promise<{
 * targetVersion: RelationVersion,
 * membersHistory: RelationMembersVersions
 * }>}
 */
async function loadRelationVersionMembers(relationID, version) {
    console.debug("Loading relation", relationID, version)
    const relationHistory = await getRelationHistory(relationID)

    const targetVersion = relationHistory.filter(v => v.version === version)[0]
    if (!targetVersion) {
        throw `loadRelationVersionMembers failed ${relationID}, ${version}`
    }

    /**
     * @type {{
     * nodes: NodeHistory[],
     * ways: [WayVersion, WayHistory, NodeHistory[]][]|Promise<[WayVersion, WayHistory, NodeHistory[]]>[],
     * relations: [RelationVersion, RelationHistory[]]
     * }}
     */
    const membersHistory = {
        nodes: [],
        ways: [],
        relations: [],
    }
    for (const member of targetVersion.members ?? []) {
        if (member.type === "node") {
            membersHistory.nodes.push(await getNodeHistory(member.ref))
        } else if (member.type === "way") {
            async function loadWay() {
                const wayHistory = await getWayHistory(member.ref)
                const targetTime = new Date(targetVersion.timestamp)
                let targetWayVersion = wayHistory[0]
                wayHistory.forEach(history => {
                    if (new Date(history.timestamp) <= targetTime) {
                        targetWayVersion = history
                    }
                })
                const [target, nodes] = await loadWayVersionNodes(member.ref, targetWayVersion.version)
                return [target, wayHistory, nodes]
            }
            membersHistory.ways.push(loadWay())
        } else if (member.type === "relation") {
            // TODO может нинада? :(
            const relationHistory = await getRelationHistory(member.ref)
            const targetTime = new Date(targetVersion.timestamp)
            let targetRelationVersion = relationHistory[0]
            relationHistory.forEach(history => {
                if (new Date(history.timestamp) <= targetTime) {
                    targetRelationVersion = history
                }
            })
            // todo рекурсивный вызов + защита от зацикливания
            // fixme targetRelationVersion может быть другим из-за редакшнов
            membersHistory.relations.push([targetRelationVersion, relationHistory])
        }
    }
    membersHistory.ways = await Promise.all(membersHistory.ways)
    return { targetVersion: targetVersion, membersHistory: membersHistory }
}

async function preloadChangesetsForObjectsBag(objectsBag) {
    const changesetsSet = new Set()
    objectsBag.forEach(o => changesetsSet.add(o.changeset))
    console.log("uniq changesets in versions:", changesetsSet.size)
    await Promise.all(arraySplit(Array.from(changesetsSet).filter(ch => !changesetMetadatas[ch])).map(it => loadChangesetMetadatas(it)))
}

/**
 * @param {Object.<string, NodeVersion|WayVersion|RelationVersion>} objectStates
 * @param {Object.<string, [string, NodeVersion|WayVersion|RelationVersion, NodeVersion|WayVersion|RelationVersion]>} currentChanges
 * @param {string} key
 * @param {NodeVersion|WayVersion|RelationVersion} newVersion
 */
function storeChanges(objectStates, currentChanges, key, newVersion) {
    const prev = objectStates[key]
    if (prev === undefined) {
        currentChanges[key] = ["new", prev, newVersion]
    } else {
        if (newVersion.type !== "node") {
            if (newVersion.type === "way") {
                if (nodesChanged(prev, newVersion)) {
                    currentChanges[key] = ["nodes", prev, newVersion]
                } else {
                    currentChanges[key] = ["", prev, newVersion]
                }
            } else {
                if (membersChanged(prev, newVersion)) {
                    currentChanges[key] = ["members", prev, newVersion]
                } else {
                    currentChanges[key] = ["", prev, newVersion]
                }
            }
            // debugger // todo
        } else if (locationChanged(prev, newVersion) && tagsChanged(prev, newVersion)) {
            currentChanges[key] = ["new", prev, newVersion]
        } else if (locationChanged(prev, newVersion)) {
            currentChanges[key] = ["location", prev, newVersion]
        } else if (tagsChanged(prev, newVersion)) {
            currentChanges[key] = ["tags", prev, newVersion]
        } else {
            currentChanges[key] = ["", prev, newVersion]
        }
    }
}

async function processObjectsBag(objectsBag, objectStates, current, type, objectID, objectVersionsIndex, btn) {
    const renderInterVersion = type === "way" ? renderWayInterVersion : renderRelationInterVersion
    const replaceRealVersion = type === "way" ? replaceRealWayVersion : replaceRealRelationVersion
    for (const it of objectsBag) {
        console.debug(it)
        const uniq_key = `${it.type} ${it.id}`
        if (it.type === "node" && current.objectVersion.version > 0 && !current.objectObjectsSet.has(uniq_key)) {
            objectStates[uniq_key] = it
            continue
        }
        if (type === "relation" && it.type === "way" && current.objectVersion.version > 0 && !current.objectObjectsSet.has(uniq_key)) {
            objectStates[uniq_key] = it
            continue
        }
        if (it.type === "way") {
            // debugger
        } else if (it.type === "relation") {
            // debugger
        }
        if (it.changeset === current.changeset) {
            storeChanges(objectStates, current.changes, uniq_key, it) // todo split if new way version
        } else if (current.changeset === null) {
            current.changeset = it.changeset
            current.user = it.user
            current.timestamp = it.timestamp
            storeChanges(objectStates, current.changes, uniq_key, it)
        } else {
            // debugger
            if (current.objectVersion.version !== 0) {
                await cleanAllPrevAfter(renderInterVersion, btn, objectVersionsIndex, objectStates, current, type)
            }
            current.changes = {}
            storeChanges(objectStates, current.changes, uniq_key, it)
            current.changeset = it.changeset
            current.user = it.user
            current.timestamp = it.timestamp
        }
        objectStates[uniq_key] = it
        if (it.type === type && it.id === objectID) {
            await cleanAllPrevAfter(replaceRealVersion, it, objectStates, current)
        }
    }
    // debugger
    if (Object.entries(current.changes).length) {
        await cleanAllPrevAfter(renderInterVersion, btn, objectVersionsIndex, objectStates, current, type)
    }
}

async function renderRelationInterVersion(btn, objectVersionsIndex, objectStates, current, type) {
    /** @type {(NodeVersion|WayVersion|RelationVersion)[]}*/
    const currentChildren = []
    /** @type {Object.<string, NodesBag>} */
    const currentWaysNodes = {}

    objectVersionsIndex[current.objectVersion.version].members.forEach(member => {
        const uniq_key = `${member.type} ${member.ref}`
        currentChildren.push(objectStates[uniq_key])
        if (member.type === "way") {
            currentWaysNodes[member.ref] = intoPage(
                objectStates[uniq_key].nodes.map(n => {
                    const objectState = objectStates[`node ${n}`]
                    if (!objectState) {
                        console.trace("not found node", n)
                    }
                    return objectState
                }),
            )
        }
        if (current.changes[uniq_key] !== undefined) return
        const curV = objectStates[uniq_key]
        if (curV) {
            current.changes[uniq_key] = ["", curV, curV]
        } else {
            console.warn(`${uniq_key} not found in states`)
        }
    })

    const count = currentChildren.length
    const {
        interVersionDiv,
        childrenDetails: membersDetails,
        ulChildren: ulMembers,
    } = makeInterVersionSection(type, current.changeset, current.timestamp, current.user, count)
    for (const i of currentChildren) {
        if (i === undefined) {
            console.trace(currentChildren)
            btn.style.background = "yellow"
            btn.title = t("historyDiff.someMembersHidden")
            continue
        }
        const memberLi = document.createElement("li")
        const div = document.createElement("div")
        div.classList.add("d-flex", "gap-1")
        const div2 = document.createElement("div")
        div2.classList.add("align-self-center")
        div.appendChild(div2)

        const aHistory = document.createElement("a")
        aHistory.classList.add(i.type)
        aHistory.href = `/${i.type}/${i.id}/history`
        aHistory.textContent = i.id
        div2.appendChild(aHistory)

        div2.appendChild(document.createTextNode(", "))

        const aVersion = document.createElement("a")
        aVersion.classList.add(i.type)
        aVersion.href = `/${i.type}/${i.id}/history/${i.version}`
        aVersion.textContent = "v" + i.version
        div2.appendChild(aVersion)
        memberLi.appendChild(div)

        const curChange = current.changes[`${i.type} ${i.id}`]
        const memberHistory = histories[i.type][i.id]
        const tagsTable = await processObject(
            div2,
            i.type,
            curChange[1] ?? curChange[2],
            curChange[2],
            memberHistory[memberHistory.length - 1],
            memberHistory,
        )
        await processObjectInteractions(
            i.changeset.toString(),
            i.type,
            emptyComments,
            div2,
            ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(div2))),
        )
        if (memberLi.classList.contains("tags-non-modified")) {
            div2.appendChild(tagsTable)
        }
        // table.style.borderColor = "var(--bs-body-color)";
        // table.style.borderStyle = "solid";
        // table.style.borderWidth = "1px";
        ulMembers.appendChild(memberLi)
    }
    membersDetails.appendChild(ulMembers)
    interVersionDiv.appendChild(membersDetails)

    const tmpChangedNodes = Object.values(current.changes).filter(i => i[2].type === "node")
    if (tmpChangedNodes.every(i => i[0] === "tags")) {
        interVersionDiv.classList.add("only-tags-changed")
    }
    const changedNodes = tmpChangedNodes.filter(i => i[0] !== "location") // fixme members lists changes
    interVersionDiv.onmouseenter = () => {
        resetMapHover()
        cleanAllObjects()
        currentChildren.forEach(member => {
            if (member.type === "way") {
                const color = "#000000"
                displayWay(
                    currentWaysNodes[member.id],
                    false,
                    color,
                    4,
                    null,
                    "customObjects",
                    null,
                    null,
                    darkModeForMap && isDarkMode(),
                    true,
                )
            }
        })
        currentChildren.forEach(member => {
            if (member.type !== "node") {
                return
            }
            if (member.tags && Object.keys(member.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                showNodeMarker(member.lat.toString(), member.lon.toString(), "rgb(161,161,161)", null, "customObjects", 3)
            }
        })
        changedNodes.forEach(i => {
            if (i[0] === "") return
            if (i[2].visible === false) {
                if (i[1].visible !== false) {
                    showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                }
            } else if (i[0] === "new") {
                if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                    showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                }
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
            } else {
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
            }
        })
    }
    interVersionDiv.onclick = e => {
        resetMapHover()
        cleanAllObjects()
        currentChildren.forEach(member => {
            if (member.type === "way") {
                displayWay(
                    currentWaysNodes[member.id],
                    false,
                    "#000000",
                    4,
                    null,
                    "customObjects",
                    null,
                    null,
                    darkModeForMap && isDarkMode(),
                    true,
                )
            }
        })
        currentChildren.forEach(member => {
            if (member.type !== "node") {
                return
            }
            if (member.tags && Object.keys(member.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                showNodeMarker(member.lat.toString(), member.lon.toString(), "rgb(161,161,161)", null, "customObjects", 3)
            }
        })
        changedNodes.forEach(i => {
            if (i[0] === "") return
            if (i[2].visible === false) {
                if (i[1].visible !== false) {
                    showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                }
            } else if (i[0] === "new") {
                if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                    showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                }
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
            } else {
                showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
            }
        })
    }
    // fixme slow selector too much object
    let insertBeforeThat = document.querySelector(
        `#element_versions_list > :where(div, details)[relation-version="${current.objectVersion.version}"]`,
    )
    while (insertBeforeThat.previousElementSibling?.getAttribute("relation-version") === "inter") {
        // fixme O(n^2)
        insertBeforeThat = insertBeforeThat.previousElementSibling
    }
    insertBeforeThat.before(interVersionDiv)
}

async function replaceRealRelationVersion(it, objectStates, current) {
    let divForMembersReplace = document.querySelector(`#element_versions_list > :where(div, details)[relation-version="${it.version}"]`)
    if (Object.keys(current.changes).length > 1 && divForMembersReplace.classList?.contains("empty-version")) {
        divForMembersReplace.querySelector("summary")?.remove()
        const div = document.createElement("div")
        div.innerHTML = divForMembersReplace.innerHTML
        div.setAttribute("relation-version", divForMembersReplace.getAttribute("relation-version"))
        divForMembersReplace.replaceWith(div)
        divForMembersReplace = div
    }
    current.objectVersion = it
    current.objectObjectsSet = new Set()
    current.objectVersion.members?.forEach(member => {
        const uniq_key = `${member.type} ${member.ref}`
        current.objectObjectsSet.add(uniq_key)
        if (member.type === "node") {
            current.objectObjectsSet.add(`node ${member.ref}`)
        } else if (member.type === "way") {
            objectStates[uniq_key].nodes.forEach(nodeID => current.objectObjectsSet.add(`node ${nodeID}`))
        } else if (member.type === "relation") {
            objectStates[uniq_key].members.forEach(member => current.objectObjectsSet.add(`${member.type} ${member.ref}`))
            // todo rec
        }
        if (current.changes[uniq_key] === undefined) {
            const curV = objectStates[uniq_key]
            if (curV) {
                if (curV.version === 1 && current.objectVersion.changeset === curV.changeset) {
                    current.changes[uniq_key] = ["new", emptyVersion, curV]
                } else {
                    current.changes[uniq_key] = ["", curV, curV]
                }
            } else {
                console.warn(`${uniq_key} not found in states`)
            }
        }
    })
    if (divForMembersReplace && current.objectVersion.members) {
        /** @type {[HTMLImageElement, (NodeVersion|WayVersion|RelationVersion)][]}*/
        const currentMembers = []
        const ulMembers = divForMembersReplace.querySelector("details:not(.empty-version) ul")
        ulMembers.parentElement.classList.add("way-version-nodes")
        ulMembers.querySelectorAll("li").forEach(li => {
            li.style.display = "none"
            const [, type, id] = li.querySelector("div div a").href.match(/(node|way|relation)\/(\d+)/)
            currentMembers.push([li.querySelector("img"), objectStates[`${type} ${id}`]])
        })
        if (it.version !== 1) {
            const changedNodes = Object.values(current.changes).filter(i => i[2].type === "node" && i[0] !== "location" && i[0] !== "")
            document.querySelector(`#element_versions_list > div[relation-version="${it.version}"]`)?.addEventListener("mouseenter", () => {
                changedNodes.forEach(i => {
                    if (i[2].visible === false) {
                        if (i[1].visible !== false) {
                            showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                        }
                    } else if (i[0] === "new") {
                        if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                            showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                        }
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                    } else {
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
                    }
                })
            })
            document.querySelector(`#element_versions_list > div[relation-version="${it.version}"]`)?.addEventListener("click", () => {
                changedNodes.forEach(i => {
                    if (i[2].visible === false) {
                        if (i[1].visible !== false) {
                            showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, "customObjects", 3)
                        }
                    } else if (i[0] === "new") {
                        if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
                            showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                        }
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, "customObjects", 3)
                    } else {
                        showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, "customObjects", 3)
                    }
                })
            })
        }
        for (const [img, i] of currentMembers) {
            if (i === undefined) {
                reportAboutHiddenMember(currentMembers, it.type, btn, divForMembersReplace)
                continue
            }
            const memberLi = document.createElement("li")
            const div = document.createElement("div")
            div.classList.add("d-flex", "gap-1")
            const div2 = document.createElement("div")
            div2.classList.add("align-self-center")
            div.appendChild(div2)

            if (img) {
                div2.before(img.cloneNode(true))
            } else {
                console.warn("img is undefined")
            }

            const aHistory = document.createElement("a")
            aHistory.classList.add(i.type)
            aHistory.href = `/${i.type}/${i.id}/history`
            aHistory.textContent = i.id
            div2.appendChild(aHistory)
            memberLi.appendChild(div)

            div2.appendChild(document.createTextNode(", "))

            const aVersion = document.createElement("a")
            aVersion.classList.add(i.type)
            aVersion.href = `/${i.type}/${i.id}/history/${i.version}`
            aVersion.textContent = "v" + i.version
            div2.appendChild(aVersion)
            memberLi.appendChild(div)

            const curChange = current.changes[`${i.type} ${i.id}`]
            const memberHistory = histories[i.type][i.id]
            // todo нужна предзагрузка пакетов правок, потому что теперь рендерятся и линии
            const tagsTable = await processObject(
                div2,
                i.type,
                curChange[1] ?? curChange[2],
                curChange[2],
                memberHistory[memberHistory.length - 1],
                memberHistory,
            )
            await processObjectInteractions(
                i.changeset.toString(),
                i.type,
                emptyComments,
                div2,
                ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(div2))),
            )
            if (memberLi.classList.contains("tags-non-modified")) {
                div2.appendChild(tagsTable)
            }
            tagsTable.style.borderColor = "var(--bs-body-color)"
            tagsTable.style.borderStyle = "solid"
            tagsTable.style.borderWidth = "1px"
            ulMembers.appendChild(memberLi)
        }
    }
    current.changes = {}
    current.changeset = null
}

/**
 * @param {"relation"} type
 * @param btn {HTMLElement}
 * @param objectID {number}
 * @return {Promise<void>}
 */
async function replaceDownloadRelationButton(type, btn, objectID) {
    const objectsBag = await sortRelationMembersByTimestamp(objectID)
    await preloadChangesetsForObjectsBag(objectsBag)

    /** @type {Object<number, RelationVersion>} */
    const objectVersionsIndex = makeObjectVersionsIndex(await getObjectHistory(type, objectID))
    /** @type {Object.<string, NodeVersion|WayVersion|RelationVersion>}*/
    const objectStates = {}

    /**
     * @type {{
     * changeset: number|null,
     * currentUser: string|null,
     * timestamp: string|null,
     * changes: Object.<string, [string, NodeVersion|WayVersion|RelationVersion, NodeVersion|WayVersion|RelationVersion]>
     * objectVersion: RelationVersion,
     * objectObjectsSet: Set<string>,
     * }}
     */
    const current = {
        changeset: null,
        user: null,
        timestamp: null,
        changes: {},
        objectVersion: { version: 0, members: [] },
        objectObjectsSet: new Set(),
    }

    await processObjectsBag(objectsBag, objectStates, current, type, objectID, objectVersionsIndex, btn)
    registerObjectCleanerOnHeader()
    addVersionsFilters(btn, type)
    btn.remove()
}

function setupRelationVersionView() {
    const match = location.pathname.match(/\/relation\/(\d+)\//)
    if (match === null) return
    const relationID = match[1]

    async function loadRelationVersion(e, showWay = true) {
        // TODO fly?
        const htmlElem = e.target ? e.target : e
        htmlElem.style.cursor = "progress"

        const version = parseInt(htmlElem.getAttribute("relation-version"))
        console.time(`r${relationID} v${version}`)
        // debugger
        const { targetVersion: targetVersion, membersHistory: membersHistory } = await loadRelationVersionMembers(relationID, version)
        console.timeEnd(`r${relationID} v${version}`)
        if (showWay) {
            cleanCustomObjects()
            let hasBrokenMembers = false
            const nodes = []
            membersHistory.nodes.forEach(nodeHistory => {
                const targetTime = new Date(targetVersion.timestamp)
                /** @type {NodeVersion} */
                let targetNodeVersion = nodeHistory[0]
                nodeHistory.forEach(history => {
                    if (new Date(history.timestamp) <= targetTime) {
                        targetNodeVersion = history
                    }
                })
                nodes.push(targetNodeVersion)
            })
            nodes.forEach(n => {
                showNodeMarker(n.lat, n.lon, "#000")
            })
            membersHistory.ways.forEach(([, , nodesVersionsList]) => {
                try {
                    const nodesList = nodesVersionsList.map(n => {
                        const { lat: lat, lon: lon } = searchVersionByTimestamp(n, targetVersion.timestamp)
                        return [lat, lon]
                    })
                    displayWay(nodesList, false, "#000000", 4, null, "customObjects", null, null, darkModeForMap && isDarkMode())
                } catch {
                    hasBrokenMembers = true
                    // TODO highlight in member list
                }
            })
            if (isRestrictionObj(targetVersion.tags ?? {})) {
                /** @type {Object<number, NodeVersion>}}*/
                const nodeIndex = nodes.reduce((acc, n) => {
                    acc[n.id] = n
                    return acc
                }, {})
                /** @type {Object<number, LatLonPair[]>}}*/
                const wayIndex = membersHistory.ways.reduce((acc, w) => {
                    acc[w[0].id] = w[2].map(n => searchVersionByTimestamp(n, targetVersion.timestamp))
                    return acc
                }, {})
                const extendedRelationVersion = targetVersion
                extendedRelationVersion.members = extendedRelationVersion.members.map(mem => {
                    if (mem.type === "node") {
                        mem["lat"] = nodeIndex[mem.ref].lat
                        mem["lon"] = nodeIndex[mem.ref].lon
                        return /** @type {ExtendedRelationNodeMember} */ mem
                    } else if (mem.type === "way") {
                        mem["geometry"] = wayIndex[mem.ref]
                        return /** @type {ExtendedRelationWayMember} */ mem
                    } else if (mem.type === "relation") {
                        // todo
                        return /** @type {ExtendedRelationMember} */ mem
                    }
                })
                const errors = validateRestriction(/** @type {ExtendedRelationVersion} */ extendedRelationVersion)
                if (errors.length) {
                    showValidationStatus(errors, document.querySelector("#element_versions_list > div details:last-of-type summary"))
                } else {
                    renderRestriction(
                        /** @type {ExtendedRelationVersion} */ extendedRelationVersion,
                        restrictionColors[extendedRelationVersion.tags["restriction"]] ?? "#000000",
                        "customObjects",
                    )
                }
            }
            if (hasBrokenMembers) {
                htmlElem.classList.add("broken-version")
                htmlElem.parentElement.parentElement.classList.add("broken-version")
            }
        }
        if (htmlElem.nodeName === "A") {
            const versionDiv = htmlElem.parentNode.parentNode
            versionDiv.onmouseenter = loadRelationVersion
            versionDiv.onclick = async e => {
                if (
                    e.target.tagName === "A" ||
                    e.target.tagName === "TIME" ||
                    e.target.tagName === "SUMMARY" ||
                    e.target.tagName === "BUTTON"
                ) {
                    return
                }
                await loadRelationVersion(versionDiv) // todo params
            }
            versionDiv.setAttribute("relation-version", version.toString())
            htmlElem.style.cursor = "pointer" // todo finally{}
            htmlElem.setAttribute("hidden", "true")
        } else {
            try {
                e.target.style.cursor = "auto"
            } catch {
                e.style.cursor = "auto"
            }
        }
    }

    document
        .querySelectorAll("#element_versions_list > div h4:nth-of-type(1):not(:has(.relation-version-view)) a:nth-of-type(1)")
        .forEach(i => {
            const version = i.href.match(/\/(\d+)$/)[1]
            const btn = document.createElement("a")
            btn.classList.add("relation-version-view")
            btn.textContent = "📥"
            btn.style.cursor = "pointer"
            btn.setAttribute("relation-version", version)
            btn.addEventListener(
                "mouseenter",
                async e => {
                    i.parentElement.parentElement.querySelectorAll(".browse-tag-list tr").forEach(t => {
                        if (t.querySelector("th")?.textContent?.includes("restriction")) {
                            const key = t.querySelector("td")?.textContent
                            if (restrictionsSignImages[key]) {
                                void fetchTextWithCache(restrictionsSignImages[key])
                            }
                        }
                    })
                    try {
                        await loadRelationVersion(e)
                    } catch (e) {
                        btn.textContent = ":("
                        console.error(e)
                    }
                },
                {
                    once: true,
                },
            )
            i.after(btn)
            i.after(document.createTextNode("\xA0"))
        })

    if (
        document.querySelectorAll(`.relation-version-view:not([hidden])`).length > 1 &&
        !document.getElementById("download-all-versions-btn")
    ) {
        // todo remove check after when would full history
        const downloadAllVersionsBtn = makeDownloadVersionsButton()
        downloadAllVersionsBtn.title = t("historyDiff.downloadAllVersions") // + " (with intermediate versions)"

        const clickHandler = async e => {
            downloadAllVersionsBtn.style.cursor = "progress"
            for (const i of Array.from(document.querySelectorAll(`.relation-version-view:not([hidden])`)).slice(0, -1)) {
                await loadRelationVersion(i)
            }
            await unrollPaginationInHistory()
            for (const i of document.querySelectorAll(`.relation-version-view:not([hidden])`)) {
                await loadRelationVersion(i)
            }
            if (isDebug() && GM_config.get("FullVersionsDiff")) {
                downloadAllVersionsBtn.textContent += " downloading intermediate versions β ..."
                addQuickLookStyles()
                await showFullObjectHistory("relation", parseInt(relationID))
            }
            e.target.remove()
        }
        downloadAllVersionsBtn.addEventListener("click", clickHandler, { once: true })
        downloadAllVersionsBtn.addEventListener("keypress", clickHandler, { once: true })
        document.querySelector(".compact-toggle-btn")?.after(downloadAllVersionsBtn)
        document.querySelector(".compact-toggle-btn")?.after(document.createTextNode("\xA0"))
    }
}

const OVERPASS_NEW_ERA_DATE = new Date("2012-09-12T06:55:00Z")

/**
 * @param objID {number|string}
 * @param type {'node'|'way'|'relation'}
 * @return {Promise<NodeListOf<Element>|undefined>}
 */
async function downloadVersionsOfObjectWithRedactionBefore2012(type, objID) {
    if (osm_server !== prod_server) {
        console.warn("Redaction bypass only for main OSM server")
        return
    }
    if (type === "node" && parseInt(objID) > 1331076658) {
        return
    }
    if (type === "way" && parseInt(objID) > 118351431) {
        return
    }
    if (type === "relation" && parseInt(objID) > 1631294) {
        return
    }
    console.debug(`downloadVersionsOfObjectWithRedactionBefore2012 ${type} ${objID}`)
    let id_prefix = objID
    if (type === "node") {
        id_prefix = Math.floor(id_prefix / 10000)
    } else if (type === "way") {
        id_prefix = Math.floor(id_prefix / 1000)
    } else if (type === "relation") {
        id_prefix = Math.floor(id_prefix / 10)
    }

    async function downloadArchiveData(url, objID, needUnzip = false) {
        try {
            const diffGZ = await fetchBlobWithCache(url, { timeout: 10 * 1000 })
            if (diffGZ.status !== 200) {
                console.warn(diffGZ.status, diffGZ.statusText, url)
                return
            }
            const blob = needUnzip ? await decompressBlob(diffGZ.response) : diffGZ.response
            const diffXML = await blob.text()

            const doc = new DOMParser().parseFromString(diffXML, "application/xml")
            return doc.querySelectorAll(`osm [id='${objID}']`) // todo add type
        } catch {
            return
        }
    }

    const urlPrefix =
        "https://raw.githubusercontent.com/osm-cc-by-sa/data/" +
        "refs/heads/main/versions_affected_by_disagreed_users_and_all_after_with_redaction_period"
    const url = `${urlPrefix}/${type}/${id_prefix}.osm` + (type === "relation" ? ".gz" : "")
    return await downloadArchiveData(url, objID, type === "relation")
}

async function downloadObjectTimeViaOverpass(type, objID) {
    const query = `
[out:json][timeout:25];
timeline(${type}, ${objID});
for (t["created"])
{
  retro (_.val)
  {
    ${type}(${objID});
    out meta;
  }
}
`
    console.time(`download overpass history data for ${type} ${objID} via timeline`)
    const res = await overpassRequest(query, "json")
    console.timeEnd(`download overpass history data for ${type} ${objID} via timeline`)
    return res.response
}

/**
 * @param showUnredactedBtn {HTMLAnchorElement}
 * @return {Promise<void>}
 */
async function restoreObjectHistory(showUnredactedBtn) {
    const m = location.pathname.match(/\/(node|way|relation)\/(\d+)/)
    const type = m[1]
    const objID = parseInt(m[2])
    const data = await downloadVersionsOfObjectWithRedactionBefore2012(type, objID)

    const keysLinks = new Map()
    document.querySelectorAll("#element_versions_list > div table th a").forEach(a => {
        keysLinks.set(a.textContent, a.href)
    })
    const valuesLinks = new Map()
    document.querySelectorAll("#element_versions_list > div table td a").forEach(a => {
        valuesLinks.set(a.textContent, a.href)
    })

    const versionPrefix = document
        .querySelector("#element_versions_list > div h4")
        ?.textContent?.match(/(^.*#)/gms)
        ?.at(0)

    const redactedVersions = Array.from(document.querySelectorAll('#element_versions_list > div:has(a[href*="/redactions/"]:not([rel]))'))

    let versions
    for (const elem of redactedVersions) {
        elem.querySelector(':scope a[href*="/redactions/"]:not([rel])').classList.add("unredacted")
        const version = elem.textContent.match(/(\d+).*(\d+)/)[1]
        console.log(`Processing v${version}`)
        elem.childNodes[0].textContent = elem.childNodes[0].textContent.match(/(\..*$)/gm)[0].slice(1)

        /** @type {NodeVersion|WayVersion|RelationVersion} */
        let target
        try {
            target = convertXmlVersionToObject(Array.from(data).find(i => i.getAttribute("version") === version))
            if (!target) {
                throw "need Overpass"
            }
        } catch {
            if (!versions) {
                versions = makeObjectVersionsIndex((await downloadObjectTimeViaOverpass(type, objID))?.elements ?? [])
            }
            if (versions[version]) {
                target = versions[version]
            }
        }
        if (!target) {
            console.log(`Downloading v${version} via overpass`)
            const prevDatetime = elem.previousElementSibling.querySelector("time").getAttribute(["datetime"])
            const targetDatetime = new Date(new Date(prevDatetime).getTime() - 1).toISOString()
            if (type === "node") {
                target = convertXmlVersionToObject(await getNodeViaOverpassXML(objID, targetDatetime))
            } else if (type === "way") {
                target = convertXmlVersionToObject(await getWayViaOverpassXML(objID, targetDatetime))
            } else if (type === "relation") {
                target = convertXmlVersionToObject(await getRelationViaOverpassXML(objID, targetDatetime))
            }
            if (!target) {
                console.error(`v${version} not found`, objID, targetDatetime)
                continue
            }
        }
        const h4 = document.createElement("h4")
        h4.textContent = versionPrefix ?? "#"
        const versionLink = document.createElement("a")
        versionLink.textContent = version
        versionLink.href = `/${type}/${objID}/history/${version}`
        h4.appendChild(versionLink)

        const comment = document.createElement("p")
        comment.classList.add("fs-6", "overflow-x-auto")
        setTimeout(async () => {
            const res = await fetchRetry(osm_server.apiBase + "changeset" + "/" + target["changeset"] + ".json")
            const jsonRes = await res.json()
            comment.textContent = jsonRes.tags?.comment
        }, 0)

        const metadataDiv = document.createElement("div")
        metadataDiv.classList.add("mb-3")

        const time = document.createElement("time")
        time.setAttribute("datetime", target["timestamp"])
        time.setAttribute("natural_text", target["timestamp"]) // it should server side string :(
        time.setAttribute("title", target["timestamp"]) // it should server side string :(
        time.textContent = new Date(target["timestamp"]).toISOString().slice(0, -5) + "Z"
        metadataDiv.appendChild(time)
        metadataDiv.appendChild(document.createTextNode(" "))

        const user = document.createElement("a")
        user.href = "/user/" + target["user"]
        user.textContent = target["user"]
        metadataDiv.appendChild(user)

        const changesetSpan = document.createElement("span")
        const changeset = document.createElement("a")
        changeset.href = "/changeset/" + target["changeset"]
        changeset.textContent = target["changeset"]
        changesetSpan.appendChild(document.createTextNode(" #"))
        changesetSpan.appendChild(changeset)
        metadataDiv.appendChild(changesetSpan)

        if (type === "node") {
            const locationDiv = document.createElement("div")
            metadataDiv.appendChild(locationDiv)

            const locationA = document.createElement("a")
            locationA.href = "/#map=18/" + target["lat"] + "/" + target["lon"]

            const latSpan = document.createElement("span")
            latSpan.classList.add("latitude")
            latSpan.textContent = target["lat"]
            locationA.appendChild(latSpan)
            locationA.appendChild(document.createTextNode(", "))

            const lonSpan = document.createElement("span")
            lonSpan.classList.add("longitude")
            lonSpan.textContent = target["lon"]
            locationA.appendChild(lonSpan)

            locationDiv.appendChild(locationA)
        }

        const tags = document.createElement("div")
        tags.classList.add("mb-3", "border", "border-secondary-subtle", "rounded", "overflow-hidden")
        const table = document.createElement("table")
        table.classList.add("mb-0", "browse-tag-list", "table", "align-middle")
        const tbody = document.createElement("tbody")
        table.appendChild(tbody)

        Object.entries(target["tags"] ?? {}).forEach(([k, v]) => {
            const tr = document.createElement("tr")

            const th = document.createElement("th")
            th.classList.add("py-1", "border-secondary-subtle", "table-secondary", "fw-normal", "history-diff-modified-key")
            if (keysLinks.has(k)) {
                const wikiLink = document.createElement("a")
                wikiLink.textContent = k
                wikiLink.href = keysLinks.get(k)
                th.appendChild(wikiLink)
            } else {
                th.textContent = k
            }

            const td = document.createElement("td")
            td.classList.add("py-1", "border-secondary-subtle", "border-start")
            if (valuesLinks.has(v)) {
                const wikiLink = document.createElement("a")
                wikiLink.textContent = v
                wikiLink.href = valuesLinks.get(v)
                td.appendChild(wikiLink)
            } else {
                td.textContent = v
            }

            tr.appendChild(th)
            tr.appendChild(td)
            tbody.appendChild(tr)
        })
        tags.appendChild(table)
        elem.prepend(h4)
        elem.appendChild(comment)
        elem.appendChild(metadataDiv)
        elem.appendChild(tags)

        if (type === "way") {
            const nodesDetails = document.createElement("details")
            const summary = document.createElement("summary")
            summary.textContent = target["nodes"].length
            nodesDetails.appendChild(summary)
            const ulNodes = document.createElement("ul")
            ulNodes.classList.add("list-unstyled")
            target["nodes"]?.forEach(nodeID => {
                const nodeLi = document.createElement("li")
                const a = document.createElement("a")
                a.classList.add("node")
                a.href = "/node/" + nodeID
                a.textContent = nodeID
                nodeLi.appendChild(a)
                ulNodes.appendChild(nodeLi)
            })
            nodesDetails.appendChild(ulNodes)
            elem.appendChild(nodesDetails)
        } else if (type === "relation") {
            const members = Array.from(target["members"])?.map(i => {
                return {
                    ref: i["ref"],
                    type: i["type"],
                    role: i["role"],
                }
            })

            const membersDetails = document.createElement("details")
            const summary = document.createElement("summary")
            summary.textContent = members.length
            membersDetails.appendChild(summary)
            const ulMembers = document.createElement("ul")
            ulMembers.classList.add("list-unstyled")
            members.forEach(i => {
                const memberLi = document.createElement("li")
                const a = document.createElement("a")
                a.classList.add(type)
                a.href = "/node/" + i.ref
                a.textContent = i.ref
                memberLi.appendChild(a)
                a.before(document.createTextNode(i.type + " "))
                a.after(document.createTextNode(" " + i.role))
                ulMembers.appendChild(memberLi)
            })
            membersDetails.appendChild(ulMembers)
            elem.appendChild(membersDetails)
        }

        elem.classList.remove("hidden-version")
        // elem.classList.remove("browse-redacted")
        elem.classList.add("browse-unredacted")
        // elem.classList.add("browse-node")
    }
    showUnredactedBtn.remove()
    Array.from(document.querySelectorAll("details.empty-version")).forEach(i => {
        i.querySelector("summary")?.remove()
        const div = document.createElement("div")
        div.innerHTML = i.innerHTML
        i.replaceWith(div)
    })
    const classesForClean = ["processed", "history-diff-new-tag", "history-diff-modified-tag", "non-modified-tag", "empty-version"]
    classesForClean.forEach(className => {
        Array.from(document.getElementsByClassName(className)).forEach(i => {
            i.classList.remove(className)
        })
    })
    const elementClassesForRemove = [
        "history-diff-deleted-tag-tr",
        "history-diff-modified-location",
        "find-user-btn",
        "way-version-view",
        "relation-version-view",
    ]
    elementClassesForRemove.forEach(elemClass => {
        Array.from(document.getElementsByClassName(elemClass)).forEach(i => {
            i.remove()
        })
    })
}

// tests
// https://osm.org/relation/100742/history
// https://osm.org/way/823589563/history
// https://osm.org/node/1920615841/history
// todo https://www.openstreetmap.org/way/217858945/history
function setupViewRedactions() {
    // TODO дозагрузку нужно делать только если есть аргументы в URL?
    // if (!location.pathname.includes("/node")) {
    //     return;
    // }
    if (document.getElementById("show-unredacted-btn")) {
        return
    }
    const showUnredactedBtn = document.createElement("a")
    showUnredactedBtn.id = "show-unredacted-btn"
    showUnredactedBtn.textContent = t("historyDiff.viewUnredactedHistory")
    showUnredactedBtn.style.cursor = "pointer"
    showUnredactedBtn.href = ""
    showUnredactedBtn.onmouseenter = () => {
        resetMapHover()
    }
    showUnredactedBtn.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        await unrollPaginationInHistory()
        showUnredactedBtn.style.cursor = "progress"

        try {
            await restoreObjectHistory(showUnredactedBtn)
        } catch (e) {
            showUnredactedBtn.style.cursor = "not-allowed"
            showUnredactedBtn.textContent = t("historyDiff.errorPleaseReport")
            throw e
        }

        cleanAllObjects()
        document.querySelector(".compact-toggle-btn")?.remove()
        setTimeout(addDiffInHistory, 0)
    }
    if (!document.querySelector('#sidebar .secondary-actions a[href$="show_redactions=true"]')) {
        document.querySelector("#sidebar .secondary-actions").appendChild(document.createElement("br"))
        document.querySelector("#sidebar .secondary-actions").appendChild(showUnredactedBtn)
    }
}

function extractChangesetID(s) {
    return s.match(/\/changeset\/([0-9]+)/)[1]
}

function isVersionPage() {
    return !!location.pathname.match(/\/(node|way|relation)\/[0-9]+\/?(version\/[0-9]+\/?)?/)
}

function addCommentsCount() {
    queueMicrotask(async () => {
        if (isVersionPage()) {
            document.querySelectorAll(".changeset_num_comments").forEach(i => i.style.setProperty("display", "none", "important"))
        }
        const sectionSelector = isVersionPage() ? "#sidebar_content > div:first-of-type" : "#sidebar_content #element_versions_list > div"
        const links = document.querySelectorAll(
            `${sectionSelector} div a[href^="/changeset"]:not(.comments-loaded):not(.comments-link):not([rel])`,
        )
        await loadChangesetMetadatas(
            Array.from(links)
                .map(i => {
                    i.classList.add("comments-loaded")
                    return parseInt(extractChangesetID(i.getAttribute("href")))
                })
                .filter(ch => !changesetMetadatas[ch]),
        )
        links.forEach(i => {
            const changesetID = extractChangesetID(i.getAttribute("href"))
            const comments_count = changesetMetadatas[changesetID].comments_count
            if (comments_count) {
                const a = document.createElement("a")
                a.classList.add("comments-link")
                a.textContent = `${comments_count} 💬`
                a.href = i.getAttribute("href")
                a.tabIndex = 0
                a.style.cursor = "pointer"
                a.style.color = "var(--bs-body-color)"
                i.after(a)
                i.after(document.createTextNode("\xA0"))
                setTimeout(async () => {
                    getChangesetComments(changesetID).then(res => {
                        res.forEach(comment => {
                            const shortText = shortOsmOrgLinksInText(comment["text"])
                            a.title += `${comment["user"]}:\n${shortText}\n\n`
                        })
                        a.title = a.title.trimEnd()
                    })
                })
            }
            setTimeout(async () => {
                await loadChangesetMetadata(changesetID)
                Object.entries(changesetMetadatas[changesetID]?.["tags"] ?? {}).forEach(([k, v]) => {
                    // тегов может не быть
                    if (k === "comment") return
                    i.parentElement.title += `${k}: ${v}\n`
                })
                const user_link = i.parentElement.parentElement.querySelector(`a[href^="/user/"]`)
                if (user_link) {
                    getCachedUserInfo(user_link.textContent).then(res => {
                        user_link.title = makeUsernameTitle(res)
                    })
                }
            })
        })
    })
}

/** @type {MutationObserver | null} **/
let historyPagePaginationDeletingObserver = null
/** @type {MutationObserver | null} **/
let paginationInHistoryStepObserver = null

function monitorHistoryPaginationMoving() {
    if (!document.querySelector("#older_element_versions_navigation a")) {
        return
    }
    if (historyPagePaginationDeletingObserver === null) {
        historyPagePaginationDeletingObserver = new MutationObserver(function (mutationsList, observer) {
            for (let mutationRecord of mutationsList) {
                for (let removedNode of mutationRecord.removedNodes ?? []) {
                    if (removedNode.id === "older_element_versions_navigation") {
                        observer.disconnect()
                        addDiffInHistory("pagination")
                    }
                }
            }
        })
        historyPagePaginationDeletingObserver.observe(document.querySelector("#sidebar_content"), {
            childList: true,
            subtree: true,
            attributes: true,
        })
    }
    paginationInHistoryStepObserver = new MutationObserver(function (mutationsList, observer) {
        observer.disconnect()
        addDiffInHistory("pagination")
    })
    paginationInHistoryStepObserver.observe(document.querySelector("#older_element_versions_navigation"), {
        childList: true,
        subtree: true,
        attributes: true,
    })
}

async function unrollPaginationInHistory() {
    if (!document.querySelector("#older_element_versions_navigation")) {
        return
    }
    console.log("start pagination unrolling")
    await new Promise(resolve => {
        if (!paginationInHistoryStepObserver) {
            paginationInHistoryStepObserver = new MutationObserver(function (mutationsList, observer) {
                observer.disconnect()
                console.log("pagination click")
                document.querySelector("#older_element_versions_navigation a")?.click()
            })
            if (!document.querySelector("#older_element_versions_navigation")) {
                resolve()
                return
            }
            paginationInHistoryStepObserver.observe(document.querySelector("#older_element_versions_navigation"), {
                childList: true,
                subtree: true,
                attributes: true,
            })
        }
        const historyPagePaginationObserver = new MutationObserver(function (mutationsList, observer) {
            for (let mutationRecord of mutationsList) {
                for (let removedNode of mutationRecord.removedNodes ?? []) {
                    if (removedNode.id === "older_element_versions_navigation") {
                        observer.disconnect()
                        console.log("pagination unrolling finished")
                        resolve()
                    }
                }
            }
        })
        historyPagePaginationObserver.observe(document.querySelector("#sidebar_content"), {
            childList: true,
            subtree: true,
            attributes: true,
        })

        const btn = document.querySelector("#older_element_versions_navigation a")
        if (!btn) {
            return
        }
        console.log("pagination first click")
        btn.click()
    })
}

function addDiffInHistoryStyle() {
    const styleText =
        `
    .turbo-progress-bar {
        display: none;
    }

    .compact-toggle-btn {
        position: relative;
        top: -2px;
        cursor: pointer;
        padding: 0px;
        padding-left: 2px;
        padding-right: 2px;
        line-height: initial;
        border-top: none;
        border-bottom: none;
        height: 1rem;
    }
    
    #element_versions_list > div {
        padding-bottom: 0.5rem !important;
    }
    
    #element_versions_list > div > div:has(>table) {
        margin-bottom: 0.5rem !important;
    }

    @media ${mediaQueryForWebsiteTheme} {
        .compact-toggle-btn {
            background: var(--bs-gray-800);
            border-color: var(--bs-gray-800);
        }

        .compact-toggle-btn:hover {
            background: var(--bs-gray-700);
            border-color: var(--bs-gray-700);
        }

        .compact-toggle-btn:active {
            background: var(--bs-gray-700) !important;
            border-color: var(--bs-gray-700) !important;
        }
    }

    .compact-toggle-btn svg {
        display: flex;
    }

    .history-diff-new-tag {
      background: ${c("rgba(17, 238, 9, 0.6)")} !important;
    }
    .history-diff-modified-tag {
      background: rgba(223, 238, 9, 0.6) !important;
    }
    .history-diff-deleted-tag {
      background: ${c("rgba(238, 51, 9, 0.6)")} !important;
    }

    #sidebar_content div.map-hover {
      background-color: rgba(223, 223, 223, 0.6);
    }

    .new-letter {
        background: ${c("rgba(25, 223, 25, 0.6)")};
    }

    .deleted-letter {
        background: ${c("rgba(255, 144, 144, 0.6)")};
    }

    @media ${mediaQueryForWebsiteTheme} {
        .history-diff-new-tag {
          background: ${c("rgba(4, 123, 0, 0.6)", ".history-diff-new-tag")} !important;
        }
        .history-diff-modified-tag {
          color: black !important;
        }
        .history-diff-modified-tag a {
          color: #052894;
        }
        .history-diff-deleted-tag {
          color: lightgray !important;
          background: ${c("rgba(238, 51, 9, 0.4)", ".history-diff-deleted-tag")} !important;
        }

        summary.history-diff-modified-tag {
            background: rgba(223,238,9,0.2) !important;
        }

        /*li.history-diff-modified-tag {*/
        /*     background: rgba(223,238,9,0.2) !important;*/
        /*}*/

        #sidebar_content div.map-hover {
            background-color: rgb(14, 17, 19);
        }

        .new-letter {
            background: ${c("rgba(25, 223, 25, 0.9)")};
        }

        .deleted-letter {
            background: ${c("rgba(253, 83, 83, 0.8)")};
        }
    }
    .non-modified-tag .empty-version {

    }
    .hidden-version, .hidden-h4 {
        display: none;
    }

    table.browse-tag-list.hide-non-modified-tags > tbody > .non-modified-tag {
        display: none;
    }

    table.browse-tag-list.hide-non-modified-tags > tbody > .non-modified-tag + tr:not(:has(th)) {
        display: none;
    }

    #sidebar_content h2:not(.changeset-header){
        font-size: 1rem;
    }

    #element_versions_list h4 {
        font-size: 1rem;
    }

    .copied {
        background-color: rgba(9,238,9,0.6);
        transition:all 0.3s;
    }
    .was-copied {
        background-color: initial;
        transition:all 0.3s;
    }
    @media ${mediaQueryForWebsiteTheme} {
        .copied {
            background-color: rgba(0,255,101,0.6);
            transition:all 0.3s;
        }
        .was-copied {
            background-color: initial;
            transition:all 0.3s;
        }
    }

    @media (max-device-width: 640px) and ${mediaQueryForWebsiteTheme} {
        td.history-diff-new-tag::selection, /*td.history-diff-modified-tag::selection,*/ td.history-diff-deleted-tag::selection {
            background: black;
        }

        th.history-diff-new-tag::selection, /*th.history-diff-modified-tag::selection,*/ th.history-diff-deleted-tag::selection {
            background: black;
        }

        td a.history-diff-new-tag::selection, td a.history-diff-modified-tag::selection, td a.history-diff-deleted-tag::selection {
            background: black;
        }

        th a.history-diff-new-tag::selection, th a.history-diff-modified-tag::selection, th a.history-diff-deleted-tag::selection {
            background: black;
        }
    }

    table.browse-tag-list tr td[colspan="2"] {
        background: var(--bs-body-bg) !important;
    }

    .prev-value-span.hidden {
        display: none !important;
    }

    ` +
        (GM_config.get("ShowChangesetGeometry")
            ? `
    .node-version-view:hover {
        background-color: yellow;
    }

    [node-version]:hover {
        background-color: rgba(244, 244, 244);
    }

    @media ${mediaQueryForWebsiteTheme} {
        [node-version]:hover {
            background-color: rgb(14, 17, 19);
        }
    }

    .way-version-view:hover {
        background-color: yellow;
    }

    [way-version]:hover {
        background-color: rgba(244, 244, 244);
    }

    @media ${mediaQueryForWebsiteTheme} {
        [way-version]:hover {
            background-color: rgb(14, 17, 19);
        }
    }

    [way-version].broken-version details:before {
        color: var(--bs-body-color);
        content: "Some nodes were hidden by moderators";
        font-style: italic;
        font-weight: normal;
        font-size: small;
    }

    .relation-version-view:hover {
        background-color: yellow;
    }

    [relation-version]:hover {
        background-color: rgba(244, 244, 244);
    }

    @media ${mediaQueryForWebsiteTheme} {
        [relation-version]:hover {
            background-color: rgb(14, 17, 19);
        }
    }

    [relation-version].broken-version details:before {
        color: var(--bs-body-color);
        content: "Some members were hidden by moderators";
        font-style: italic;
        font-weight: normal;
        font-size: small;
    }

    @media ${mediaQueryForWebsiteTheme} {
        path.stroke-polyline {
            filter: drop-shadow(1px 1px 0 #7a7a7a) drop-shadow(-1px -1px 0 #7a7a7a) drop-shadow(1px -1px 0 #7a7a7a) drop-shadow(-1px 1px 0 #7a7a7a);
        }
    }
    `
            : ``)
    injectCSSIntoOSMPage(styleText)
}

function historyPaginationClick() {
    const paginationBtn = document.querySelector("#older_element_versions_navigation a")
    console.log("Click by pagination from AddDiffInHistory", paginationBtn?.href)
    paginationBtn?.click()
}

function ogfHistoryFixes() {
    const wrapper = document.createElement("div")
    wrapper.setAttribute("id", "element_versions_list")
    document.querySelector(".browse-section").before(wrapper)
    document.querySelectorAll(".browse-section").forEach(i => {
        wrapper.appendChild(i)
        const versionH = i.querySelector(".details")
        const [, versionNum] = versionH.textContent.match(/#([0-9]+)/)
        versionH.textContent = versionH.textContent.replace(/(#[0-9]+)/, "")
        const a = document.createElement("a")
        a.href = `${location.pathname}/history/${versionNum}`
        a.textContent = `#${versionNum}`
        versionH.appendChild(a)

        const changesetA = i.querySelector('a[href^="/changeset/"]:not([rel])')

        const changesetDiv = document.createElement("div")
        changesetA.parentElement.appendChild(changesetDiv)

        const changesetDiv2 = document.createElement("div")
        changesetDiv2.classList.add("changeset_line")
        changesetDiv.appendChild(changesetDiv2)

        const changesetSpan = document.createElement("span")
        changesetDiv2.appendChild(changesetSpan)

        changesetSpan.appendChild(changesetA.previousSibling)
        changesetSpan.appendChild(changesetA)
    })
}

function transformDiffWithColors() {
    if (isOGFServer()) {
        ogfHistoryFixes()
    }
    const isNode = location.pathname.startsWith("/node")
    const isWay = location.pathname.startsWith("/way")
    const isRelation = location.pathname.startsWith("/relation")

    function convertVersionIntoSpoiler(elem) {
        const spoiler = document.createElement("details")
        const summary = document.createElement("summary")
        summary.textContent = elem.querySelector("a").textContent
        spoiler.innerHTML = elem.innerHTML
        spoiler.prepend(summary)
        spoiler.classList.add("empty-version")
        spoiler.classList.add("browse-" + location.pathname.match(/(node|way|relation)/)[1])
        elem.replaceWith(spoiler)
        return spoiler
    }

    const versions = [
        {
            tags: [],
            coordinates: "",
            wasModified: false,
            nodes: [],
            members: [],
            visible: true,
            membersCount: 0,
            versionNumber: 0,
        },
    ]
    const oldToNewHtmlVersions = Array.from(
        document.querySelectorAll(
            '#element_versions_list > div:not(.processed):not([way-version="inter"]):not(:has(a[href*="/redactions/"]:not([rel]):not(.unredacted)))',
        ),
    ).toReversed()

    for (let verInd = 0; verInd < oldToNewHtmlVersions.length; verInd++) {
        const ver = oldToNewHtmlVersions[verInd]

        ver.classList.add("processed")
        let wasModifiedObject = false
        const version = parseInt(
            ver
                .querySelector("a")
                .getAttribute("href")
                .match(/\/history\/(\d+)$/)[1],
        )
        const kv = ver.querySelectorAll("tbody > tr") ?? []
        const tags = []

        const metainfoHTML = ver.querySelector("div:nth-of-type(1):has(:is(time,abbr))")

        const changesetA = ver.querySelector('div > div a[href^="/changeset/"]:not([rel])')
        const changesetHTML = changesetA?.parentElement
        const changesetID = changesetA.textContent

        const time = metainfoHTML.querySelector("time") ?? metainfoHTML.querySelector("abbr")

        const coordinates = ver.querySelector("div a:has(.latitude)")
        const locationHTML = coordinates?.parentElement
        const locationA = ver.querySelector("div a:has(.latitude)")

        if (metainfoHTML.querySelector('a[href*="/user/"]:not([rel])')) {
            const a = metainfoHTML.querySelector('a[href*="/user/"]:not([rel])')
            metainfoHTML.innerHTML = ""
            metainfoHTML.appendChild(time)
            metainfoHTML.appendChild(document.createTextNode(" "))
            metainfoHTML.appendChild(a)
            metainfoHTML.appendChild(document.createTextNode(" "))
        } else {
            metainfoHTML.innerHTML = ""
            metainfoHTML.appendChild(time)
            const findBtn = document.createElement("span")
            findBtn.classList.add("find-user-btn")
            findBtn.title = t("deletedUsers.tryFindDeletedUser")
            findBtn.textContent = " 🔍 "
            findBtn.value = changesetID
            findBtn.datetime = time.dateTime
            findBtn.style.cursor = "pointer"
            findBtn.onclick = findChangesetInDiff
            metainfoHTML.appendChild(findBtn)
        }

        changesetHTML.innerHTML = ""
        const hashtag = document.createTextNode("#")
        metainfoHTML.appendChild(hashtag)
        const changesetWrapper = document.createElement("span")
        changesetA.classList.remove("comments-loaded")
        changesetWrapper.appendChild(changesetA)
        metainfoHTML.appendChild(changesetWrapper)
        let visible = true

        if (isNode) {
            if (coordinates) {
                locationHTML.innerHTML = ""
                locationHTML.appendChild(locationA)
                metainfoHTML.appendChild(locationHTML)
            } else {
                visible = false
                wasModifiedObject = true // because sometimes deleted object has tags
                time.before(document.createTextNode("🗑 "))
            }
        } else if (isWay) {
            if (!ver.querySelector("details")) {
                time.before(document.createTextNode("🗑 "))
            }
        } else if (isRelation) {
            if (!ver.querySelector("details")) {
                time.before(document.createTextNode("🗑 "))
            }
        }

        const valuesLinks = new Map()
        document.querySelectorAll("#element_versions_list > div table td a").forEach(a => {
            valuesLinks.set(a.textContent, a.href)
        })
        const showPreviousTagValue = GM_config.get("ShowPreviousTagValue", true)
        const lastTags = versions.at(-1).tags
        // add/modification
        kv.forEach(i => {
            const k = i.querySelector("th > a")?.textContent ?? i.querySelector("th")?.textContent
            i.querySelector("td .prev-value-span")?.remove()
            if (i.querySelector("td .current-value-span")) {
                i.querySelector("td .current-value-span").classList.remove("current-value-span")
            }
            i.querySelector(".wdt-preview svg title")?.remove()
            let v = i.querySelector("td .wdplugin")?.textContent ?? i.querySelector("td")?.textContent
            if (k === undefined) {
                // todo support multiple wikidata
                // Human-readable Wikidata extension compatibility
                return
            }
            if (k.includes("colour")) {
                const tmpV = i.querySelector("td").cloneNode(true)
                tmpV.querySelector("svg")?.remove()
                v = tmpV.textContent
            }
            tags.push([k, v])

            let tagWasModified = false
            if (!lastTags.some(elem => elem[0] === k)) {
                i.querySelector("th").classList.add("history-diff-new-tag")
                i.querySelector("td").classList.add("history-diff-new-tag")
                wasModifiedObject = tagWasModified = true
            } else if (lastTags.some(elem => elem[0] === k)) {
                lastTags.forEach(el => {
                    if (el[0] === k && el[1] !== v) {
                        i.querySelector("th").classList.add("history-diff-modified-key")
                        const valCell = i.querySelector("td")
                        if (isRTLLayout) {
                            valCell.dir = ""
                        }
                        valCell.classList.add("history-diff-modified-tag")
                        valCell.innerHTML = "<span class='current-value-span'>" + valCell.innerHTML + "</span>"
                        valCell.onclick = e => {
                            if (e.altKey) return
                            if (window.getSelection().type === "Range") return
                            if (e.target.nodeName === "A") return

                            e.preventDefault()
                            e.stopPropagation()
                            if (valCell.querySelector(".prev-value-span").classList.contains("hidden")) {
                                document.querySelectorAll(".prev-value-span").forEach(span => span.classList.remove("hidden"))
                            } else {
                                document.querySelectorAll(".prev-value-span").forEach(span => span.classList.add("hidden"))
                            }
                        }

                        const currentValueSpan = i.querySelector("td .current-value-span")
                        const prevValueSpan = document.createElement("span")
                        prevValueSpan.classList.add("prev-value-span")

                        const diff = stringsDiff(el[1], v, 1)
                        // todo unify with diff in changesets
                        // todo detect asci -> unicode or less strict cond
                        // prettier-ignore
                        if (!i.querySelector("td a") && v.length > 1 && el[1].length > 1
                            && (
                                diff.length === v.length && el[1].length === v.length
                                && diff.reduce((cnt, b) => cnt + (b[0] !== b[1]), 0) === 1
                                || diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[0] !== null), 0) === 0
                                || diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[1] !== null), 0) === 0
                            )) {
                            const prevText = document.createElement("span")
                            const newText = document.createElement("span")
                            diff.forEach(c => {
                                if (c[0] !== c[1]) {
                                    if (c[1]) {
                                        const colored = document.createElement("span")
                                        colored.classList.add("new-letter")
                                        colored.textContent = c[1]
                                        newText.appendChild(colored)
                                    }
                                    if (c[0]) {
                                        const colored = document.createElement("span")
                                        colored.classList.add("deleted-letter")
                                        colored.textContent = c[0]
                                        prevText.appendChild(colored)
                                    }
                                } else {
                                    prevText.appendChild(document.createTextNode(c[0]))
                                    newText.appendChild(document.createTextNode(c[1]))
                                }
                            })
                            prevText.normalize()
                            newText.normalize()
                            prevValueSpan.appendChild(prevText)
                            prevValueSpan.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
                            newText.classList.add("current-value-span")
                            newText.style.display = "inline-block"
                            if (showPreviousTagValue) {
                                currentValueSpan.replaceWith(newText)
                            } else {
                                currentValueSpan.replaceWith(v)
                            }
                            prevText.dir = "auto"
                            newText.dir = "auto"
                        } else {
                            if (valuesLinks.has(el[1])) {
                                const valueLink = document.createElement("a")
                                valueLink.href = valuesLinks.get(el[1])
                                valueLink.target = "_blank"
                                valueLink.title = ""
                                valueLink.textContent = `${el[1]}`
                                prevValueSpan.appendChild(valueLink)
                                prevValueSpan.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
                            } else {
                                const prevText = document.createElement("span")
                                prevText.appendChild(document.createTextNode(el[1]))
                                const newText = document.createElement("span")
                                newText.appendChild(document.createTextNode(v))

                                prevValueSpan.appendChild(prevText)
                                prevValueSpan.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
                                newText.classList.add("current-value-span")
                                newText.style.display = "inline-block"
                                if (showPreviousTagValue) {
                                    currentValueSpan.replaceWith(newText)
                                } else {
                                    currentValueSpan.replaceWith(v)
                                }
                            }
                        }

                        currentValueSpan.setAttribute("value", v)
                        currentValueSpan.classList.add("current-value-span")
                        currentValueSpan.style.display = "inline-block"
                        prevValueSpan.style.display = "inline-block"
                        valCell.prepend(prevValueSpan)
                        valCell.removeAttribute("dir")
                        if (!showPreviousTagValue) {
                            prevValueSpan.classList.add("hidden")
                        }
                        i.title = t("historyDiff.hidePreviousValue")
                        // i.title = `was: "${el[1]}"`;
                        wasModifiedObject = tagWasModified = true
                    }
                })
            }
            if (!tagWasModified) {
                i.classList.add("non-modified-tag")
                i.querySelector("th").classList.add("non-modified-tag")
                i.querySelector("td").classList.add("non-modified-tag")
            }
        })
        // deletion
        lastTags.forEach(tag => {
            const k = tag[0]
            const v = tag[1]
            const x = ver
            if (tags.some(elem => elem[0] === k)) {
                return
            }
            const tr = document.createElement("tr")
            tr.classList.add("history-diff-deleted-tag-tr")
            const th = document.createElement("th")
            th.textContent = k
            th.classList.add(
                "history-diff-deleted-tag",
                "py-1",
                "border-grey",
                "table-light",
                "fw-normal",
                "border-start",
                "border-secondary-subtle",
            )
            const td = document.createElement("td")
            if (k.includes("colour")) {
                td.innerHTML =
                    '<svg width="14" height="14" class="float-end m-1"><title></title>' +
                    '<rect x="0.5" y="0.5" width="13" height="13" fill="" stroke="#2222"></rect></svg>'
                td.querySelector("svg rect").setAttribute("fill", v)
                td.appendChild(document.createTextNode(v))
            } else {
                td.textContent = v
            }
            td.classList.add(
                "history-diff-deleted-tag",
                "py-1",
                "border-grey",
                "table-light",
                "fw-normal",
                "border-start",
                "border-secondary-subtle",
            )
            tr.appendChild(th)
            tr.appendChild(td)
            if (!x.querySelector("tbody")) {
                const tableDiv = document.createElement("table")
                tableDiv.classList.add("mb-3", "border", "border-secondary-subtle", "rounded", "overflow-hidden")
                const table = document.createElement("table")
                table.classList.add("mb-0", "browse-tag-list", "table", "align-middle")
                const tbody = document.createElement("tbody")
                table.appendChild(tbody)
                tableDiv.appendChild(table)
                x.appendChild(tableDiv)
            }
            const firstNonDeletedTag = x.querySelector("th:not(.history-diff-deleted-tag)")?.parentElement
            if (firstNonDeletedTag) {
                firstNonDeletedTag.before(tr)
            } else {
                x.querySelector("tbody").appendChild(tr)
            }
            wasModifiedObject = true
        })
        const lastCoordinates = versions.at(-1).coordinates
        const lastVisible = versions.at(-1).visible
        if (visible && coordinates && versions.length > 1 && coordinates.href !== lastCoordinates) {
            if (lastCoordinates) {
                const curLat = coordinates.querySelector(".latitude").textContent.replace(",", ".")
                const curLon = coordinates.querySelector(".longitude").textContent.replace(",", ".")
                const lastLat = lastCoordinates.match(/#map=.+\/(.+)\/(.+)$/)[1]
                const lastLon = lastCoordinates.match(/#map=.+\/(.+)\/(.+)$/)[2]
                // prettier-ignore
                const distInMeters = getDistanceFromLatLonInKm(
                    Number.parseFloat(lastLat),
                    Number.parseFloat(lastLon),
                    Number.parseFloat(curLat),
                    Number.parseFloat(curLon)
                ) * 1000
                const distTxt = document.createElement("span")
                distTxt.textContent = `${distInMeters.toFixed(1)}m`
                distTxt.classList.add("history-diff-modified-tag")
                distTxt.classList.add("history-diff-modified-location")
                coordinates.after(distTxt)
                coordinates.after(document.createTextNode(" "))
            }
            wasModifiedObject = true
        }
        let membersCount = 0 // quick workaround for lazy versions
        let childNodes = null
        if (isWay) {
            childNodes = Array.from(ver.querySelectorAll("details ul.list-unstyled li")).map(el => el.textContent.match(/\d+/)[0])
            const lastChildNodes = versions.at(-1).nodes
            // prettier-ignore
            if (version > 1 &&
                (childNodes.length !== lastChildNodes.length
                    || childNodes.some((el, index) => lastChildNodes[index] !== childNodes[index]))) {
                ver.querySelector("details > summary")?.classList.add("history-diff-modified-tag")
                wasModifiedObject = true
            }
            ver.querySelector("details")?.removeAttribute("open")
        } else if (isRelation) {
            membersCount = parseInt(ver.querySelector("details:not(.empty-version) summary")?.textContent?.match(/(\d+)/)?.[0]) ?? 0
            childNodes = Array.from(ver.querySelectorAll("details:not(.empty-version) ul.list-unstyled li")).map(el => el.textContent)

            const olderMembersCount = versions.at(-1).membersCount
            const olderMembers = versions.at(-1).members

            const unloadedMembersList = ver.querySelector("turbo-frame:has(.spinner-border)")
            const olderUnloadedMembersList = oldToNewHtmlVersions[verInd - 1]?.querySelector("turbo-frame:has(.spinner-border)")
            // https://osm.org/relation/9425522/history
            // https://osm.org/relation/17542348/history
            if (version > 1 && membersCount !== olderMembersCount) {
                ver.querySelector("details:not(.empty-version) > summary")?.classList.add("history-diff-modified-tag")
                wasModifiedObject = true
            } else if (version > 1 && !unloadedMembersList && !olderUnloadedMembersList) {
                if (childNodes.length !== olderMembers.length || childNodes.some((el, index) => olderMembers[index] !== childNodes[index])) {
                    ver.querySelector("details:not(.empty-version) > summary")?.classList.add("history-diff-modified-tag")
                    wasModifiedObject = true
                } else if (!wasModifiedObject) {
                    oldToNewHtmlVersions[verInd] = convertVersionIntoSpoiler(ver)
                }
            }
            if (unloadedMembersList) {
                function repairVersions() {
                    const ver = oldToNewHtmlVersions[verInd]
                    const olderVersion = oldToNewHtmlVersions[verInd - 1]
                    const nextVersion = oldToNewHtmlVersions[verInd + 1]
                    if (olderVersion && !olderVersion.querySelector("turbo-frame:has(.spinner-border)")) {
                        const curChildNodes = Array.from(ver.querySelectorAll("details ul.list-unstyled li")).map(el => el.textContent)
                        const oldChildMembers = Array.from(olderVersion.querySelectorAll("details ul.list-unstyled li")).map(
                            el => el.textContent,
                        )
                        if (
                            version > 1 &&
                            (curChildNodes.length !== oldChildMembers.length ||
                                curChildNodes.some((el, index) => curChildNodes[index] !== oldChildMembers[index]))
                        ) {
                            ver.querySelector("details:not(.empty-version) > summary")?.classList.add("history-diff-modified-tag")
                            wasModifiedObject = true
                        } else if (!wasModifiedObject) {
                            oldToNewHtmlVersions[verInd] = convertVersionIntoSpoiler(ver)
                        }
                    }
                    if (nextVersion && !nextVersion.querySelector("turbo-frame:has(.spinner-border)")) {
                        const nextChildMembers = Array.from(nextVersion.querySelectorAll("details ul.list-unstyled li")).map(
                            el => el.textContent,
                        )
                        const curChildNodes = Array.from(ver.querySelectorAll("details ul.list-unstyled li")).map(el => el.textContent)

                        if (
                            nextChildMembers.length !== curChildNodes.length ||
                            curChildNodes.some((el, index) => curChildNodes[index] !== nextChildMembers[index])
                        ) {
                            nextVersion.querySelector("details:not(.empty-version) > summary")?.classList.add("history-diff-modified-tag")
                        } else {
                            const nextVersionNumber = parseInt(
                                nextVersion
                                    .querySelector("a")
                                    .getAttribute("href")
                                    .match(/\/history\/(\d+)$/)[1],
                            )
                            if (versions.find(i => i.versionNumber === nextVersionNumber)?.wasModified === false) {
                                oldToNewHtmlVersions[verInd + 1] = convertVersionIntoSpoiler(nextVersion)
                            }
                        }
                    }
                }

                const lazyMembersObserver = new MutationObserver(function (mutationsList, observer) {
                    for (let mutationRecord of mutationsList) {
                        for (let newNode of mutationRecord.addedNodes ?? []) {
                            if (newNode.nodeName === "UL") {
                                console.log("lazy version loaded")
                                observer.disconnect()
                                repairVersions()
                            }
                        }
                    }
                })
                lazyMembersObserver.observe(unloadedMembersList, {
                    childList: true,
                    subtree: true,
                })
            }
            ver.querySelector("details")?.removeAttribute("open")
        }
        if (!wasModifiedObject && !isRelation && verInd !== 0) {
            oldToNewHtmlVersions[verInd] = convertVersionIntoSpoiler(ver)
        }
        versions.push({
            versionNumber: version,
            tags: tags,
            coordinates: coordinates?.href ?? lastCoordinates,
            wasModified: wasModifiedObject || (visible && !lastVisible),
            nodes: childNodes,
            membersCount: membersCount,
            members: childNodes,
            visible: visible,
        })
        ver.querySelectorAll("h4").forEach((el, index) => (index !== 0 ? el.classList.add("hidden-h4") : null))
        ver.title = t("historyDiff.tagsCount", { count: tags.length })
    }
    if (document.querySelector("#older_element_versions_navigation a")) {
        oldToNewHtmlVersions[0]?.classList?.remove("processed")
        oldToNewHtmlVersions[0]?.querySelectorAll(".history-diff-new-tag, .history-diff-modified-tag")?.forEach(elem => {
            elem.classList.remove("history-diff-new-tag")
            elem.classList.remove("history-diff-modified-tag")
        })
    }
    let hasRedacted = false
    Array.from(document.querySelectorAll('#element_versions_list > div:has(a[href*="/redactions/"]:not([rel]):not(.unredacted))')).forEach(
        x => {
            x.classList.add("hidden-version")
            hasRedacted = true
        },
    )
    if (hasRedacted) {
        try {
            setupViewRedactions()
        } catch (e) {
            console.error(e)
        }
    }
}

// hard cases:
// https://www.openstreetmap.org/node/1/history
// https://www.openstreetmap.org/node/2/history
// https://www.openstreetmap.org/node/9286365017/history
// https://www.openstreetmap.org/relation/72639/history
// https://www.openstreetmap.org/node/10173297169/history
// https://www.openstreetmap.org/relation/16022751/history
// https://www.openstreetmap.org/node/12084992837/history
// https://www.openstreetmap.org/way/1329437422/history
function addDiffInHistory(reason = "url_change") {
    makeHeaderPartsClickable()
    addHistoryLink()
    externalizeLinks(document.querySelectorAll("#sidebar_content p a"))
    externalizeLinks(document.querySelectorAll("#sidebar_content table a"))
    if (!location.pathname.match(/\/(node|way|relation)\/[0-9]+\/history\/?$/)) {
        return
    }
    if (document.querySelector(".compact-toggle-btn") && reason !== "pagination") {
        return
    }
    // костыль для KeyK/L и OSM tags editor
    document.querySelectorAll("#element_versions_list > div").forEach(i => i.classList.add("browse-section"))
    cleanAllObjects()
    hideSearchForm()
    if (document.activeElement?.nodeName !== "A" && document.activeElement?.nodeName !== "SVG") {
        // в хроме фокус не выставляется
        document.querySelector("#sidebar").focus({ focusVisible: false }) // focusVisible работает только в Firefox
        document.querySelector("#sidebar").blur()
    }
    if (!document.querySelector(".compact-toggle-btn")) {
        const compactToggle = document.createElement("button")
        compactToggle.title = t("historyDiff.toggleCompactTagsDiff")
        compactToggle.setAttribute("value", "><")
        compactToggle.innerHTML = compactModeSvg
        compactToggle.classList.add("compact-toggle-btn")
        compactToggle.classList.add("btn", "btn-primary", "btn-sm")
        compactToggle.onclick = () => makeElementHistoryCompact()
        const sidebar = document.querySelector("#sidebar_content h2")
        if (!sidebar) {
            return
        }
        sidebar.appendChild(document.createTextNode("\xA0"))
        sidebar.appendChild(compactToggle)
    }

    addDiffInHistoryStyle()

    if (!document.querySelector(".tag-added") && !document.querySelector(".tag-unmodified")) {
        transformDiffWithColors()
    }
    if (reason === "pagination") {
        makeElementHistoryCompact(document.querySelector(".compact-toggle-btn").getAttribute("value") !== "><")
    } else {
        makeElementHistoryCompact()
    }
    makeLinksInVersionTagsClickable()
    makeHashtagsClickable()
    makeTimesSwitchable()
    document.querySelectorAll("#element_versions_list > div p").forEach(shortOsmOrgLinks)
    addCommentsCount()
    setupNodeVersionView()
    setupWayVersionView()
    setupRelationVersionView()
    expandWikidata()
    addCopyCoordinatesButtons()
    addRelationHistoryViewerLinks()
    monitorHistoryPaginationMoving()
    if (location.pathname.startsWith("/relation")) {
        const maxVersion = parseInt(
            document
                .querySelector("#element_versions_list h4 a")
                .getAttribute("href")
                .match(/\/relation\/[0-9]+\/history\/([0-9]+)/)[1],
        )
        if (maxVersion < 500) {
            historyPaginationClick()
        }
    } else {
        historyPaginationClick()
    }
}

function setupVersionsDiff() {
    const path = location.pathname
    // prettier-ignore
    if (!path.includes("/history")
        || !path.startsWith("/node")
        && !path.startsWith("/way")
        && !path.startsWith("/relation")) {
        return;
    }
    tryApplyModule(addDiffInHistory, 500, 25000)
}

//</editor-fold>

//<editor-fold desc="changeset-quicklook" defaultstate="collapsed">

// Perf test:                                           https://osm.org/changeset/155712128
// Check way 695574090:                                 https://osm.org/changeset/71014890
// Check deleted relation                               https://osm.org/changeset/155923052
// Heavy ways and deleted relation                      https://osm.org/changeset/153431079
// Downloading parents:                                 https://osm.org/changeset/156331065
// Restored objects                                     https://osm.org/changeset/156515722
// Check ways with version=1                            https://osm.org/changeset/155689740
// Many changes in the coordinates of the intersections https://osm.org/changeset/156331065
// Deleted and restored objects                         https://osm.org/changeset/155160344
// Old edits with unusual objects                       https://osm.org/changeset/1000
// Parent ways only in future                           https://osm.org/changeset/156525401
// Restored tags                                        https://osm.org/changeset/141362243
let quickLookInjectingStarted = false
let allTagsOfObjectsVisible = true

/**
 * @typedef {{
 * closed_at: string,
 * max_lon: number,
 * maxlon: number,
 * created_at: string,
 * type: string,
 * changes_count: number,
 * tags: {},
 * min_lon: number,
 * minlon: number,
 * uid: number,
 * max_lat: number,
 * maxlat: number,
 * minlat: number,
 * comments_count: number,
 * id: number,
 * min_lat: number,
 * user: string,
 * open: boolean}} ChangesetMetadata
 */

// /**
//  * @type ChangesetMetadata|null
//  **/
// let prevChangesetMetadata = null;
/**
 * @type {Object.<string, ChangesetMetadata>}|null
 **/
const changesetMetadatas = {}

/**
 * @param {number|null=} changeset_id
 * @return {Promise<ChangesetMetadata>}
 */
async function loadChangesetMetadata(changeset_id = null) {
    console.debug(`Loading changeset metadata`)
    if (!changeset_id) {
        const match = location.pathname.match(/changeset\/(\d+)/)
        if (!match) {
            // console.trace("loadChangesetMetadata called without changeset_id and on not /changeset page")
            return
        }
        changeset_id = parseInt(match[1])
    }
    console.debug(`Loading metadata of changeset #${changeset_id}`)
    if (changesetMetadatas[changeset_id] && changesetMetadatas[changeset_id].id === changeset_id) {
        return changesetMetadatas[changeset_id]
    }
    // prevChangesetMetadata = changesetMetadatas[changeset_id]
    const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "changeset" + "/" + changeset_id + ".json")
    if (jsonRes.changeset) {
        return (changesetMetadatas[changeset_id] = jsonRes.changeset)
    }
    changesetMetadatas[changeset_id] = jsonRes.elements[0]
    changesetMetadatas[changeset_id].min_lat = changesetMetadatas[changeset_id].minlat
    changesetMetadatas[changeset_id].min_lon = changesetMetadatas[changeset_id].minlon
    changesetMetadatas[changeset_id].max_lat = changesetMetadatas[changeset_id].maxlat
    changesetMetadatas[changeset_id].max_lon = changesetMetadatas[changeset_id].maxlon
    return changesetMetadatas[changeset_id]
}

/**
 * @param {number[]} changeset_ids
 */
async function loadChangesetMetadatas(changeset_ids) {
    if (!changeset_ids.length) {
        return
    }
    const batchSize = 100
    for (let i = 0; i < changeset_ids.length; i += batchSize) {
        const res = await fetchRetry(osm_server.apiBase + "changesets.json?changesets=" + changeset_ids.slice(i, i + batchSize).join(","))
        const jsonRes = await res.json()
        jsonRes["changesets"].forEach(i => {
            changesetMetadatas[i.id] = i
        })
    }
}

let noteMetadata = null

async function loadNoteMetadata() {
    const match = location.pathname.match(/note\/(\d+)/)
    if (!match) {
        return
    }
    const note_id = parseInt(match[1])
    if (noteMetadata !== null && noteMetadata.id === note_id) {
        return
    }
    const res = await fetchRetry(osm_server.apiBase + "notes" + "/" + note_id + ".json", { signal: getAbortController().signal })
    noteMetadata = await res.json()
}

let nodeMetadata = null

async function loadNodeMetadata() {
    const match = location.pathname.match(/node\/(\d+)/)
    if (!match) {
        return
    }
    const node_id = parseInt(match[1])
    const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "node" + "/" + node_id + ".json", {}, res => {
        if (res.status === 410) {
            console.warn(`node ${node_id} was deleted`)
        } else {
            return true
        }
    })
    if (!jsonRes) return
    nodeMetadata = jsonRes.elements[0]
    return jsonRes
}

let wayMetadata = null

/**
 * @param {number|null=} way_id
 * @return {Promise<void|{elements: (NodeVersion|WayVersion)[]}>}
 */
async function loadWayMetadata(way_id = null) {
    console.log(`Loading way metadata`)
    if (!way_id) {
        const match = location.pathname.match(/way\/(\d+)/)
        if (!match) {
            return
        }
        way_id = parseInt(match[1])
    }
    /*** @type {{elements: (NodeVersion|WayVersion)[]}|undefined}*/
    const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "way" + "/" + way_id + "/full.json", {}, res => {
        if (res.status === 410) {
            console.warn(`way ${way_id} was deleted`)
        } else {
            return true
        }
    })
    if (!jsonRes) return
    wayMetadata = jsonRes.elements.filter(i => i.type === "node")
    wayMetadata.bbox = {
        min_lat: Math.min(...wayMetadata.map(i => i.lat)),
        min_lon: Math.min(...wayMetadata.map(i => i.lon)),
        max_lat: Math.max(...wayMetadata.map(i => i.lat)),
        max_lon: Math.max(...wayMetadata.map(i => i.lon)),
    }
    return jsonRes
}

/**
 * @type {{
 *     relation: RelationVersion,
 *     bbox: {
 *         min_lat: number,
 *         min_lon: number,
 *         max_lat: number,
 *         max_lon: number,
 *     }
 * } | null}
 */
let relationMetadata = null

/**
 * @param {number|null=} relation_id
 * @return {Promise<{elements: (NodeVersion|WayVersion|RelationVersion)[]}| undefined>}
 */
async function loadRelationMetadata(relation_id = null) {
    console.log(`Loading relation metadata`)
    if (!relation_id) {
        const match = location.pathname.match(/relation\/(\d+)/)
        if (!match) {
            return
        }
        relation_id = parseInt(match[1])
    }
    const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "relation" + "/" + relation_id + "/full.json", {}, res => {
        if (res.status === 410) {
            console.warn(`relation ${relation_id} was deleted`)
        } else {
            return true
        }
    })
    if (!jsonRes) return
    const nodes = /** @type {NodeVersion[]} */ jsonRes.elements.filter(i => i.type === "node")
    relationMetadata = {
        relation: jsonRes.elements.find(i => i.type === "relation" && i.id === relation_id),
        bbox: {
            min_lat: Math.min(...nodes.map(i => i.lat)),
            min_lon: Math.min(...nodes.map(i => i.lon)),
            max_lat: Math.max(...nodes.map(i => i.lat)),
            max_lon: Math.max(...nodes.map(i => i.lon)),
        },
    }
    return jsonRes
}

const cachedNominatimRequests = new Set()

async function geocodeCurrentView(attempts = 5) {
    if (isOGFServer()) {
        return
    }
    if (!GM_config.get("AddLocationFromNominatim")) return
    if (location.search.includes("changesets")) return
    await interceptMapManually()
    if (getZoom() <= 10) {
        setAttributionPrefix("")
        if (attempts > 0) {
            console.log(`Attempt №${7 - attempts} for geocoding`)
            setTimeout(geocodeCurrentView, 100, attempts - 1)
        } else {
            console.log("Skip geocoding")
        }
        return
    }
    const center = getMapCenter()
    const precision = getZoom() <= 15 ? 5 : 6
    const latStr = center.lat.toString().slice(0, precision)
    const lngStr = center.lng.toString().slice(0, precision)
    const url = `https://nominatim.openstreetmap.org/reverse.php?lat=${latStr}&lon=${lngStr}&format=jsonv2&zoom=10`

    console.time(`Geocoding ${latStr}, ${lngStr}`)
    if (!cachedNominatimRequests.has(url)) {
        await globalRateLimitByKey("last-geocoder-request-time", 1000)
    } else {
        console.debug(`%c${url} should be cached`, "background: #222; color: #00ff00")
    }

    fetchJSONWithCache(url, {
        signal: getAbortController().signal,
        retryCount: 5,
    })
        .then(r => {
            cachedNominatimRequests.add(url)
            if (r?.address?.state) {
                setAttributionPrefix(`${r.address.state}`)
                console.timeEnd(`Geocoding ${latStr}, ${lngStr}`)
            }
        })
        .catch(e => {
            console.debug("Nominatim fail")
            console.debug(e)
        })
}

let iconsList = null

async function loadIconsList() {
    const url = `https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/refs/heads/master/config/browse_icons.yml`
    const yml = (
        await externalFetchRetry({
            url: url,
        })
    ).responseText
    iconsList = {}
    // не, ну а почему бы и нет
    yml.match(/[\w_-]+:\s*(([\w_-]|:\*)+:(\s+{.*}\s+))*/g).forEach(tags => {
        const lines = tags.split("\n")
        lines.slice(1).forEach(i => {
            const line = i.trim()
            if (line === "") return
            const [, value, jsonValue] = line.match(/(:\*|\w+): (\{.*})/)
            iconsList[lines[0].slice(0, -1) + "=" + value] = JSON.parse(jsonValue.replaceAll(/(\w+):/g, '"$1":'))
        })
    })
    await GM.setValue("poi-icons", JSON.stringify({ icons: iconsList, cacheTime: new Date() }))
    return iconsList
}

async function initPOIIcons() {
    const cache = await GM.getValue("poi-icons", "")
    if (cache) {
        console.log("poi icons cached")
        const json = JSON.parse(cache)
        const cacheTime = new Date(json["cacheTime"])
        const oneDayLater = new Date(cacheTime.getTime() + 24 * 60 * 60 * 1000)
        if (oneDayLater < new Date()) {
            console.log("but cache outdated")
            setTimeout(loadIconsList, 0)
        }
        iconsList = json["icons"]
        return
    }
    console.log("loading icons")
    await loadIconsList()
}

/** @type {null | Map}*/
let corporatesLinks = null
/** @type {null | Map}*/
let corporateMappers = null

const corporationContributorsURL =
    "https://raw.githubusercontent.com/deevroman/openstreetmap-statistics/refs/heads/master/config/organised_teams_contributors.json"
const corporationContributorsSource =
    "https://github.com/deevroman/openstreetmap-statistics/blob/master/config/organised_teams_contributors.json"

/**
 * @param {Object} raw_data
 */
function makeCorporateMappersData(raw_data) {
    corporatesLinks = new Map()
    corporateMappers = new Map()
    for (let [kontora, { url, usernames }] of Object.entries(raw_data)) {
        corporatesLinks.set(kontora, url)
        usernames.forEach(username => {
            if (corporateMappers.has(username)) {
                corporateMappers.get(username).push(kontora)
            } else {
                corporateMappers.set(username, [kontora])
            }
        })
    }
}

async function loadAndMakeCorporateMappersList() {
    const raw_data = (
        await externalFetchRetry({
            url: corporationContributorsURL,
            responseType: "json",
        })
    ).response
    makeCorporateMappersData(raw_data)
    await GM.setValue(
        "corporate-mappers",
        JSON.stringify({
            raw_data: raw_data,
            cacheTime: new Date(),
        }),
    )
}

async function initCorporateMappersList() {
    if (corporatesLinks) return
    const cache = await GM.getValue("corporate-mappers", "")
    if (corporatesLinks) return
    if (cache) {
        console.log("corporate mappers cached")
        const json = JSON.parse(cache)
        const cacheTime = new Date(json["cacheTime"])
        const threeDaysLater = new Date(cacheTime.getTime() + 2 * 24 * 60 * 60 * 1000)
        if (threeDaysLater < new Date()) {
            console.log("but cache outdated")
            setTimeout(loadAndMakeCorporateMappersList, 0)
        }
        makeCorporateMappersData(JSON.parse(cache)["raw_data"])
        return
    }
    console.log("loading corporate mappers")
    try {
        await loadAndMakeCorporateMappersList()
    } catch (e) {
        console.log("loading corporate mappers list failed", e)
    }
}

const nodeFallback = "https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/node.svg"
const wayFallback = "https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/way.svg"
const relationFallback = "https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/relation.svg"

/**
 *
 * @param {string} type
 * @param {[[string, string]]}tags
 * @return {[string, boolean]}
 */
function getPOIIconURL(type, tags) {
    if (!iconsList) {
        return ["", false]
    }

    function getFallback(type) {
        if (type === "node") {
            return nodeFallback
        } else if (type === "way") {
            return wayFallback
        } else if (type === "relation") {
            return relationFallback
        }
    }

    let result = undefined
    tags.forEach(([key, value]) => {
        function makeIconURL(filename) {
            return `https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/` + filename
        }

        if (iconsList[key + "=" + value] === undefined) {
            if (iconsList[key + "=:*"] && !result) {
                result = [makeIconURL(iconsList[key + "=:*"]["filename"]), iconsList[key + "=:*"]["invert"]]
            }
        } else {
            result = [makeIconURL(iconsList[key + "=" + value]["filename"]), iconsList[key + "=" + value]["invert"]]
        }
    })
    return result ?? [getFallback(type), false]
}

function makeTagRow(key, value, addTd = false) {
    const tagRow = document.createElement("tr")
    const tagTh = document.createElement("th")
    const tagTd = document.createElement("td")
    tagRow.appendChild(tagTh)
    tagRow.appendChild(tagTd)
    if (addTd) {
        const td = document.createElement("td")
        td.classList.add("tag-flag")
        tagRow.appendChild(td)
    }
    tagTh.textContent = key
    tagTd.textContent = value
    return tagRow
}

function makeLinksInChangesetObjectRowClickable(row, objType) {
    if (row.querySelector("td").textContent.match(/^https?:\/\//)) {
        const a = document.createElement("a")
        a.textContent = row.querySelector("td").textContent
        a.href = row.querySelector("td").textContent
        row.querySelector("td").textContent = ""
        a.target = "_blank"
        a.onclick = e => {
            e.stopPropagation()
            e.stopImmediatePropagation()
        }
        row.querySelector("td").appendChild(a)
    } else {
        const keyCell = row.querySelector("th")
        const rawKey = keyCell.textContent
        const key = rawKey
        const valueCell = row.querySelector("td")
        if (key.startsWith("panoramax")) {
            makePanoramaxValue(valueCell)
        } else if (key.startsWith("mapillary")) {
            makeMapillaryValue(valueCell)
        } else if (key.startsWith("wikimedia_commons")) {
            makeWikimediaCommonsValue(valueCell, false)
        } else if (key.startsWith("ref:inaturalist.org")) {
            makeRefInaturalistValue(valueCell)
        } else if (needValidateOpeningHoursKey(key)) {
            makeOpeningHoursValue(valueCell, key, false)
        } else if (key === "roof:direction") {
            makeRoofDirectionValue(valueCell, row, false)
        } else if (key === "roof:orientation") {
            makeRoofOrientationValue(valueCell)
        } else if (needValidatePhoneKey(key)) {
            makePhoneValue(valueCell)
        } else if (needValidateEmailKey(key)) {
            makeEmailValue(valueCell)
        } else if (needValidateTrackingParamsInContact(key)) {
            makeContactValue(valueCell, key)
        } else if (needValidateConditionalAccessKey(key)) {
            makeConditionalValue(valueCell)
        } else if (key === "type") {
            makeTypeValue(valueCell, objType)
        } else if (key.length <= 2 && key !== "to" && key !== "tv" && key !== "it") {
            keyCell.classList.add("fixme-tag")
            keyCell.title = t("objectPage.keyTooShort")
        } else if (key.length >= 2 && rawKey[0] >= "A" && rawKey[0] <= "Z" && rawKey[1] >= "a" && rawKey[1] <= "z") {
            keyCell.classList.add("fixme-tag")
            keyCell.title = t("objectPage.suspiciousCapitalKey")
        }
    }
}

function detectEditsWars(prevVersion, targetVersion, objHistory, row, key) {
    let revertsCounter = 0
    const warLog = document.createElement("table")
    warLog.style.borderColor = "var(--bs-body-color)"
    warLog.style.borderStyle = "solid"
    warLog.style.borderWidth = "1px"
    warLog.title = ""
    warLog.classList.add("edits-wars-log")
    for (let j = 0; j < objHistory.length; j++) {
        const it = objHistory[j]

        const prevIt = (objHistory[j - 1]?.tags ?? {})[key]
        const targetIt = (it.tags ?? {})[key]
        const prevTag = (prevVersion.tags ?? {})[key]
        // const targetTag = (targetVersion.tags ?? {})[key]

        if (prevIt === targetIt) {
            continue
        }

        if (prevTag === targetIt) {
            revertsCounter++
        }
        if (targetIt === undefined) {
            const tr = document.createElement("tr")
            tr.classList.add("quick-look-deleted-tag")
            const th_ver = document.createElement("th")
            const ver_link = document.createElement("a")
            ver_link.textContent = `v${it.version}`
            ver_link.href = `/${it.type}/${it.id}/history/${it.version}`
            ver_link.target = "_blank"
            ver_link.style.color = "unset"
            th_ver.appendChild(ver_link)
            const td_user = document.createElement("td")
            const user_link = document.createElement("a")
            user_link.textContent = `${it.user}`
            user_link.href = `/user/${it.user}`
            user_link.target = "_blank"
            user_link.style.color = "unset"
            td_user.appendChild(user_link)
            const td_tag = document.createElement("td")
            td_tag.textContent = "<deleted>"
            tr.appendChild(th_ver)
            tr.appendChild(td_user)
            tr.appendChild(td_tag)
            warLog.appendChild(tr)
        } else {
            const tr = document.createElement("tr")
            const th_ver = document.createElement("th")
            const ver_link = document.createElement("a")
            ver_link.textContent = `v${it.version}`
            ver_link.href = `/${it.type}/${it.id}/history/${it.version}`
            ver_link.target = "_blank"
            ver_link.style.color = "unset"
            th_ver.appendChild(ver_link)
            const td_user = document.createElement("td")
            const user_link = document.createElement("a")
            user_link.textContent = `${it.user}`
            user_link.href = `/user/${it.user}`
            user_link.target = "_blank"
            user_link.style.color = "unset"
            td_user.appendChild(user_link)
            const td_tag = document.createElement("td")
            td_tag.textContent = getValue(it.tags, key)
            tr.appendChild(th_ver)
            tr.appendChild(td_user)
            tr.appendChild(td_tag)
            warLog.appendChild(tr)
        }
    }
    if (revertsCounter > 3) {
        row.classList.add("edits-wars-tag")
        row.title = t("changesetQuicklook.editsWarTitle", { details: row.title })
    }
    const tr = document.createElement("tr")
    const td = document.createElement("td")
    td.appendChild(warLog)
    td.colSpan = 3
    tr.style.display = "none"
    tr.appendChild(td)

    row.after(tr)
    row.querySelector("td.tag-flag").style.cursor = "pointer"
    row.querySelector("td.tag-flag").onclick = e => {
        e.stopPropagation()
        e.stopImmediatePropagation()
        if (e.target.getAttribute("open")) {
            tr.style.display = "none"
            e.target.removeAttribute("open")
        } else {
            tr.style.removeProperty("display")
            e.target.setAttribute("open", "true")
        }
    }
}

const emptyVersion = {
    tags: {},
    version: 0,
    lat: null,
    lon: null,
    visible: false,
    timestamp: new Date(0).toISOString().slice(0, -5) + "Z",
}

/**
 * @param {string} targetTimestamp
 * @param {string|number} wayID
 * @return {Promise<(*[])[]>}
 */
async function getWayNodesByTimestamp(targetTimestamp, wayID) {
    const targetVersion = searchVersionByTimestamp(await getWayHistory(wayID), targetTimestamp)
    if (targetVersion === null) {
        return
    }
    if (targetVersion.visible === false) {
        return [targetVersion, []]
    }
    const [, wayNodesHistories] = await loadWayVersionNodes(wayID, targetVersion.version)
    const targetNodes = filterObjectListByTimestamp(wayNodesHistories, targetTimestamp)

    const nodesMap = {}
    targetNodes.forEach(elem => {
        nodesMap[elem.id] = [elem.lat, elem.lon]
    })

    const currentNodesList = []
    targetVersion.nodes?.forEach(node => {
        if (node in nodesMap) {
            currentNodesList.push(nodesMap[node])
        } else {
            console.error("not found target nodes", wayID, node)
        }
    })
    return [targetVersion, currentNodesList]
}

let pinnedRelations = new Set()

/**
 * @param {Object<string, string>} tags
 * @param {string} key
 * @return {string|undefined}
 */
function getValue(tags, key) {
    return Object.getOwnPropertyDescriptor(tags, key)?.value
}

/**
 * @param {Object<string, string>} tags
 * @param {string} key
 * @return {boolean}
 */
function hasKey(tags, key) {
    return tags.hasOwnProperty(key)
}

/**
 * @param {Element} i
 * @param {string} objType
 * @param {NodeVersion|WayVersion|RelationVersion} prevVersion
 * @param {NodeVersion|WayVersion|RelationVersion} targetVersion
 * @param {NodeVersion|WayVersion|RelationVersion} lastVersion
 * @param {NodeVersion[]|WayVersion[]|RelationVersion[]} objHistory
 */
async function processObject(i, objType, prevVersion, targetVersion, lastVersion, objHistory) {
    if (i.parentElement.parentElement.classList.contains("tags-processed-object")) {
        return
    }
    const tagsTable = document.createElement("table")
    tagsTable.classList.add("quick-look")
    tagsTable.classList.toggle("hide-non-modified-tags", !allTagsOfObjectsVisible)

    const tbody = document.createElement("tbody")
    tagsTable.appendChild(tbody)

    let tagsWasChanged = false
    let changedOnlyUninterestedTags = true

    const uninterestedTags = new Set(["check_date", "check_date:opening_hours", "check_date:opening_hours", "survey:date"])

    // tags deletion
    if (prevVersion.version !== 0) {
        for (const [key, value] of Object.entries(prevVersion?.tags ?? {})) {
            if (targetVersion.tags === undefined || !hasKey(targetVersion.tags, key)) {
                const row = makeTagRow(key, value, true)
                row.classList.add("quick-look-deleted-tag")
                tbody.appendChild(row)
                tagsWasChanged = true
                changedOnlyUninterestedTags = false
                if (lastVersion.tags && getValue(lastVersion.tags, key) === getValue(prevVersion.tags, key)) {
                    row.classList.add("restored-tag")
                    row.title += t("changesetQuicklook.tagRestored")
                }
                makeLinksInChangesetObjectRowClickable(row, objType)
                detectEditsWars(prevVersion, targetVersion, objHistory, row, key)
            }
        }
    }
    // tags add/modification
    for (const [key, value] of Object.entries(targetVersion.tags ?? {})) {
        const row = makeTagRow(key, value, true)
        if (prevVersion.tags === undefined || !hasKey(prevVersion.tags, key)) {
            tagsWasChanged = true
            if (!uninterestedTags.has(key)) {
                changedOnlyUninterestedTags = false
            }
            row.classList.add("quick-look-new-tag")
            if (!lastVersion.tags || getValue(lastVersion.tags, key) !== getValue(targetVersion.tags, key)) {
                if (lastVersion.tags && hasKey(lastVersion.tags, key)) {
                    row.classList.add("replaced-tag")
                    row.title = t("changesetQuicklook.nowIsTag", { key, value: getValue(lastVersion.tags, key) })
                } else if (lastVersion.visible !== false) {
                    row.classList.add("removed-tag")
                    row.title = t("changesetQuicklook.tagDeleted")
                }
            }
            makeLinksInChangesetObjectRowClickable(row, objType)
            tbody.appendChild(row)
            detectEditsWars(prevVersion, targetVersion, objHistory, row, key)
        } else if (getValue(prevVersion.tags, key) !== value) {
            // todo reverted changes
            const valCell = row.querySelector("td")
            if (!uninterestedTags.has(key)) {
                changedOnlyUninterestedTags = false
            }
            row.classList.add("quick-look-modified-tag")
            // toReversed is dirty hack for group inserted/deleted symbols https://osm.org/changeset/157338007
            const diff = stringsDiff(getValue(prevVersion.tags, key), valCell.textContent, 1)
            // for one character diff
            // example: https://osm.org/changeset/157002657
            // prettier-ignore
            if (valCell.textContent.length > 1 && getValue(prevVersion.tags, key).length > 1
                && (
                    diff.length === valCell.textContent.length && getValue(prevVersion.tags, key).length === valCell.textContent.length
                    && diff.reduce((cnt, b) => cnt + (b[0] !== b[1]), 0) === 1
                    || diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[0] !== null), 0) === 0
                    || diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[1] !== null), 0) === 0
                )) {
                const prevText = document.createElement("span")
                const newText = document.createElement("span")
                prevText.dir = "auto"
                newText.dir = "auto"
                diff.forEach(c => {
                    if (c[0] !== c[1]) {
                        if (c[1]) {
                            const colored = document.createElement("span")
                            colored.classList.add("new-letter")
                            colored.textContent = c[1]
                            newText.appendChild(colored)
                        }
                        if (c[0]) {
                            const colored = document.createElement("span")
                            colored.classList.add("deleted-letter")
                            colored.textContent = c[0]
                            prevText.appendChild(colored)
                        }
                    } else {
                        prevText.appendChild(document.createTextNode(c[0]))
                        newText.appendChild(document.createTextNode(c[1]))
                    }
                })
                prevText.normalize()
                newText.normalize()
                valCell.textContent = ""
                valCell.appendChild(prevText)
                valCell.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
                valCell.appendChild(newText)
            } else {
                const prevSpan = document.createElement("span")
                prevSpan.dir = "auto"
                prevSpan.textContent = getValue(prevVersion.tags, key)
                const newSpan = document.createElement("span")
                newSpan.dir = "auto"
                newSpan.textContent = valCell.textContent
                valCell.textContent = ""
                valCell.appendChild(prevSpan)
                valCell.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
                valCell.appendChild(newSpan)
            }
            valCell.title = t("changesetQuicklook.wasValue", { value: getValue(prevVersion.tags, key) })
            tagsWasChanged = true
            if (!lastVersion.tags || getValue(lastVersion.tags, key) !== getValue(targetVersion.tags, key)) {
                if (lastVersion.tags && prevVersion.tags && getValue(lastVersion.tags, key) === getValue(prevVersion.tags, key)) {
                    row.classList.add("reverted-tag")
                    row.title = t("changesetQuicklook.tagReverted")
                } else if (lastVersion.tags && getValue(lastVersion.tags, key)) {
                    row.classList.add("replaced-tag")
                    row.title = t("changesetQuicklook.nowIsTag", { key, value: getValue(lastVersion.tags, key) })
                } else if (lastVersion.visible !== false) {
                    row.classList.add("removed-tag")
                    row.title = t("changesetQuicklook.tagDeleted")
                }
            }
            tbody.appendChild(row)
            detectEditsWars(prevVersion, targetVersion, objHistory, row, key)
        } else {
            row.classList.add("non-modified-tag-in-quick-view")
            makeLinksInChangesetObjectRowClickable(row, objType)
            tbody.appendChild(row)
        }
    }
    if (targetVersion.visible !== false && prevVersion?.nodes && prevVersion.nodes.toString() !== targetVersion.nodes?.toString()) {
        const geomChangedFlag = document.createElement("span")
        geomChangedFlag.textContent = " 📐"
        geomChangedFlag.tabIndex = 0
        geomChangedFlag.classList.add("nodes-changed")
        geomChangedFlag.title = t("changesetQuicklook.listWayNodesChanged")
        geomChangedFlag.style.userSelect = "none"
        geomChangedFlag.style.background = "rgba(223, 238, 9, 0.6)"
        geomChangedFlag.style.cursor = "pointer"

        const nodesTable = document.createElement("table")
        nodesTable.classList.add("way-nodes-table")
        nodesTable.style.display = "none"
        const tbody = document.createElement("tbody")
        nodesTable.style.borderWidth = "2px"
        nodesTable.onclick = e => {
            e.stopPropagation()
        }
        tbody.style.borderWidth = "2px"
        nodesTable.appendChild(tbody)

        function makeWayDiffRow(left, right) {
            const row = document.createElement("tr")
            const tagTd = document.createElement("td")
            const tagTd2 = document.createElement("td")
            tagTd.style.borderWidth = "2px"
            tagTd2.style.borderWidth = "2px"
            row.style.borderWidth = "2px"
            row.appendChild(tagTd)
            row.appendChild(tagTd2)
            tagTd.textContent = left
            tagTd2.textContent = right
            tagTd.style.textAlign = "right"
            tagTd2.style.textAlign = "right"

            if (typeof left === "number") {
                tagTd.onmouseenter = async e => {
                    e.stopPropagation() // fixme
                    e.target.classList.add("way-version-node")
                    const targetTimestamp = new Date(new Date(targetVersion.timestamp).getTime() - 1).toISOString()
                    const version = searchVersionByTimestamp(await getNodeHistory(left), targetTimestamp)
                    showActiveNodeMarker(version.lat.toString(), version.lon.toString(), c("#ff00e3"))
                }
                tagTd.onclick = async e => {
                    e.stopPropagation() // fixme
                    const targetTimestamp = new Date(new Date(targetVersion.timestamp).getTime() - 1).toISOString()
                    const version = searchVersionByTimestamp(await getNodeHistory(left), targetTimestamp)
                    panTo(version.lat.toString(), version.lon.toString())
                }
                tagTd.onmouseleave = e => {
                    e.target.classList.remove("way-version-node")
                }
            } else {
                tagTd.onclick = e => {
                    e.stopPropagation()
                }
            }

            if (typeof right === "number") {
                function calcTimestamp() {
                    // нужно лениво считать, ибо информация о пакете правок может быть ещё не загружена
                    let targetTimestamp = changesetMetadatas[targetVersion.changeset].closed_at ?? new Date().toISOString()
                    const nextVersion = upperBoundVersion(objHistory, targetVersion.version)
                    if (nextVersion && nextVersion.changeset === targetVersion.changeset) {
                        targetTimestamp = minusSecondInStringTimestamp(nextVersion.timestamp)
                    }
                    return targetTimestamp
                }
                tagTd2.onmouseenter = async e => {
                    e.stopPropagation() // fixme
                    e.target.classList.add("way-version-node")
                    const version = searchVersionByTimestamp(await getNodeHistory(right), calcTimestamp())
                    showActiveNodeMarker(version.lat.toString(), version.lon.toString(), c("#ff00e3"))
                }
                tagTd2.onclick = async e => {
                    e.stopPropagation() // fixme
                    e.target.classList.add("way-version-node")
                    const version = searchVersionByTimestamp(await getNodeHistory(right), calcTimestamp())
                    panTo(version.lat.toString(), version.lon.toString())
                }
                tagTd2.onmouseleave = e => {
                    e.target.classList.remove("way-version-node")
                }
            } else {
                tagTd2.onclick = e => {
                    e.stopPropagation()
                }
            }

            return row
        }

        let haveOnlyInsertion = true
        let haveOnlyDeletion = true
        const lineWasReversed = JSON.stringify(prevVersion.nodes.toReversed()) === JSON.stringify(targetVersion.nodes)
        if (lineWasReversed) {
            const row = makeWayDiffRow("", "🔃")
            row.querySelectorAll("td").forEach(i => (i.style.textAlign = "center"))
            row.querySelector("td:nth-of-type(2)").title = t("changesetQuicklook.nodesReversed")
            tbody.appendChild(row)

            prevVersion.nodes.forEach((i, index) => {
                const row = makeWayDiffRow(i, targetVersion.nodes[index])
                row.querySelector("td:nth-of-type(2)").style.background = "rgba(223, 238, 9, 0.6)"
                row.style.fontFamily = "monospace"
                tbody.appendChild(row)
            })
            haveOnlyInsertion = false
            haveOnlyDeletion = false
        } else {
            arraysDiff(prevVersion.nodes ?? [], targetVersion.nodes ?? []).forEach(i => {
                const row = makeWayDiffRow(i[0], i[1])
                if (i[0] === null) {
                    row.style.background = c("rgba(17, 238, 9, 0.6)", ".nodes-changed-row")
                    haveOnlyDeletion = false
                } else if (i[1] === null) {
                    row.style.background = c("rgba(238, 51, 9, 0.6)", ".nodes-changed-row")
                    haveOnlyInsertion = false
                } else if (i[0] !== i[1]) {
                    row.style.background = "rgba(223, 238, 9, 0.6)" // never executed?
                    haveOnlyInsertion = false
                    haveOnlyDeletion = false
                }
                row.style.fontFamily = "monospace"
                tbody.appendChild(row)
            })
        }
        if (haveOnlyInsertion) {
            if (isDarkMode()) {
                geomChangedFlag.style.background = c("rgba(17, 238, 9, 0.3)", ".nodes-changed-flag")
            } else {
                geomChangedFlag.style.background = c("rgba(101, 238, 9, 0.6)", ".nodes-changed-flag")
            }
        } else if (haveOnlyDeletion) {
            if (isDarkMode()) {
                geomChangedFlag.style.background = c("rgba(238, 51, 9, 0.4)", ".nodes-changed-flag")
            } else {
                geomChangedFlag.style.background = c("rgba(238, 9, 9, 0.42)", ".nodes-changed-flag")
            }
        }

        const tagsTable = document.createElement("table")
        tagsTable.style.display = "none"
        const tbodyForTags = document.createElement("tbody")
        tagsTable.appendChild(tbodyForTags)

        Object.entries(targetVersion.tags ?? {}).forEach(([k, v]) => {
            const row = makeTagRow(k, v)
            makeLinksInChangesetObjectRowClickable(row, objType)
            tbodyForTags.appendChild(row)
        })

        geomChangedFlag.onkeypress = geomChangedFlag.onclick = e => {
            if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
                e.preventDefault()
            } else if (e.type === "keypress") {
                return
            }
            e.stopPropagation()
            if (nodesTable.style.display === "none") {
                nodesTable.style.display = ""
                tagsTable.style.display = ""
            } else {
                nodesTable.style.display = "none"
                tagsTable.style.display = "none"
            }
        }

        i.appendChild(geomChangedFlag)

        geomChangedFlag.after(nodesTable)
        geomChangedFlag.after(tagsTable)
        if (lineWasReversed) {
            geomChangedFlag.after(document.createTextNode(t("changesetQuicklook.lineWasReversed")))
        }
    }
    if (objType === "way" && targetVersion.visible !== false) {
        if (prevVersion.nodes && prevVersion.nodes.length !== targetVersion.nodes?.length) {
            i.title +=
                (i.title === "" ? "" : "\n") +
                t("changesetQuicklook.nodesCountChanged", { prevCount: prevVersion.nodes.length, nextCount: targetVersion.nodes.length })
        } else {
            i.title += (i.title === "" ? "" : "\n") + t("changesetQuicklook.nodesCount", { count: targetVersion.nodes.length })
        }
    }
    if (prevVersion.visible === false && targetVersion?.visible !== false && targetVersion.version !== 1) {
        const restoredElemFlag = document.createElement("span")
        restoredElemFlag.textContent = " ♻️"
        restoredElemFlag.title = t("changesetQuicklook.objectRestored")
        restoredElemFlag.style.userSelect = "none"
        i.appendChild(restoredElemFlag)
    }
    if (objType === "relation") {
        const memChangedFlag = document.createElement("span")
        memChangedFlag.textContent = " 👥"
        memChangedFlag.tabIndex = 0
        memChangedFlag.classList.add("members-changed")
        memChangedFlag.style.userSelect = "none"
        let membersChanged = false
        if (JSON.stringify(prevVersion?.members ?? []) !== JSON.stringify(targetVersion.members) && targetVersion.version !== 1) {
            memChangedFlag.style.background = "rgba(223, 238, 9, 0.6)"
            memChangedFlag.title = t("changesetQuicklook.relationMembersChanged")
            membersChanged = true
        } else {
            memChangedFlag.title = t("changesetQuicklook.showRelationMembers")
        }
        memChangedFlag.style.cursor = "pointer"

        const membersTable = document.createElement("table")
        membersTable.classList.add("relation-members-table")
        membersTable.style.display = "none"
        const tbody = document.createElement("tbody")
        membersTable.style.borderWidth = "2px"
        tbody.style.borderWidth = "2px"
        membersTable.appendChild(tbody)

        /**
         * @param {RelationMember} member
         */
        function getIcon(member) {
            if (member?.type === "node") {
                return nodeIcon
            } else if (member?.type === "way") {
                return wayIcon
            } else if (member?.type === "relation") {
                return relationIcon
            } else {
                console.error(member)
                console.trace()
            }
        }

        /**
         * @param {string|RelationMember} left
         * @param {string|RelationMember} right
         */
        function makeRelationDiffRow(left, right) {
            const row = document.createElement("tr")
            const tagTd = document.createElement("td")
            const tagTd2 = document.createElement("td")
            tagTd.style.borderWidth = "2px"
            tagTd2.style.borderWidth = "2px"
            row.style.borderWidth = "2px"
            row.appendChild(tagTd)
            row.appendChild(tagTd2)

            const leftRefSpan = document.createElement("span")
            leftRefSpan.classList.add("rel-ref")
            leftRefSpan.textContent = `${left?.ref ?? ""} `
            const leftRoleSpan = document.createElement("span")
            leftRoleSpan.classList.add("rel-role")
            leftRoleSpan.textContent = `${left?.role ?? ""}`

            tagTd.appendChild(leftRefSpan)
            tagTd.appendChild(leftRoleSpan)

            if (left && typeof left === "object") {
                const icon = document.createElement("img")
                icon.src = getIcon(left)
                icon.style.height = "1em"
                icon.style.marginLeft = "1px"
                icon.style.marginTop = "-3px"
                tagTd.appendChild(icon)
            }

            const rightRefSpan = document.createElement("span")
            rightRefSpan.textContent = `${right?.ref ?? ""} `
            rightRefSpan.classList.add("rel-ref")
            const rightRoleSpan = document.createElement("span")
            rightRoleSpan.textContent = `${right?.role ?? ""}`
            rightRoleSpan.classList.add("rel-role")

            tagTd2.appendChild(rightRefSpan)
            tagTd2.appendChild(rightRoleSpan)

            if (right && typeof right === "object") {
                const icon = document.createElement("img")
                icon.src = getIcon(right)
                icon.style.height = "1em"
                icon.style.marginLeft = "1px"
                icon.style.marginTop = "-3px"
                tagTd2.appendChild(icon)
            }
            tagTd2.style.cursor = ""
            tagTd.style.textAlign = "right"
            tagTd2.style.textAlign = "right"

            if (left && typeof left === "object") {
                tagTd.onmouseenter = async e => {
                    e.stopPropagation()
                    e.target.classList.add("relation-version-node")
                    const targetTimestamp = new Date(
                        new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1,
                    ).toISOString()
                    if (left.type === "node") {
                        const version = searchVersionByTimestamp(await getNodeHistory(left.ref), targetTimestamp)
                        showActiveNodeMarker(version.lat.toString(), version.lon.toString(), c("#ff00e3"))
                        tagTd.title = ""
                        for (let tagsKey in version.tags ?? {}) {
                            tagTd.title += `${tagsKey}=${getValue(version.tags, tagsKey)}\n`
                        }
                    } else if (left.type === "way") {
                        const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, left.ref)
                        showActiveWay(currentNodesList)
                        const version = searchVersionByTimestamp(await getWayHistory(left.ref), targetTimestamp)
                        tagTd.title = ""
                        for (let tagsKey in version.tags ?? {}) {
                            tagTd.title += `${tagsKey}=${getValue(version.tags, tagsKey)}\n`
                        }
                    } else if (left.type === "relation") {
                        // todo
                    }
                }

                tagTd.onmouseleave = e => {
                    e.target.classList.remove("relation-version-node")
                }
                tagTd.onclick = async e => {
                    e.stopPropagation()
                    const targetTimestamp = new Date(
                        new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1,
                    ).toISOString()
                    if (left.type === "node") {
                        const version = searchVersionByTimestamp(await getNodeHistory(left.ref), targetTimestamp)
                        panTo(version.lat.toString(), version.lon.toString())
                    } else if (left.type === "way") {
                        const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, left.ref)
                        showActiveWay(currentNodesList, c("#ff00e3"), true)
                    }
                }
            }

            if (right && typeof right === "object") {
                tagTd2.onmouseenter = async e => {
                    e.stopPropagation() // fixme
                    e.target.classList.add("relation-version-node")
                    const targetTimestamp = new Date(changesetMetadatas[targetVersion.changeset].closed_at ?? new Date()).toISOString()
                    if (right.type === "node") {
                        const version = searchVersionByTimestamp(await getNodeHistory(right.ref), targetTimestamp)
                        showActiveNodeMarker(version.lat.toString(), version.lon.toString(), c("#ff00e3"))
                        tagTd2.title = ""
                        for (let tagsKey in version.tags ?? {}) {
                            tagTd2.title += `${tagsKey}=${getValue(version.tags, tagsKey)}\n`
                        }
                    } else if (right.type === "way") {
                        const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, right.ref)
                        showActiveWay(currentNodesList)
                        const version = searchVersionByTimestamp(await getWayHistory(right.ref), targetTimestamp)
                        tagTd2.title = ""
                        for (let tagsKey in version.tags ?? {}) {
                            tagTd2.title += `${tagsKey}=${getValue(version.tags, tagsKey)}\n`
                        }
                    } else if (right.type === "relation") {
                        // todo
                    }
                }
                tagTd2.onmouseleave = e => {
                    e.target.classList.remove("relation-version-node")
                }
                tagTd2.onclick = async e => {
                    e.stopPropagation()
                    const targetTimestamp = new Date(changesetMetadatas[targetVersion.changeset].closed_at ?? new Date()).toISOString()
                    if (right.type === "node") {
                        const version = searchVersionByTimestamp(await getNodeHistory(right.ref), targetTimestamp)
                        panTo(version.lat.toString(), version.lon.toString())
                    } else if (right.type === "way") {
                        const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, right.ref)
                        showActiveWay(currentNodesList, c("#ff00e3"), true)
                    }
                }
            }

            return row
        }

        let haveOnlyInsertion = true
        let haveOnlyDeletion = true

        function colorizeFlag() {
            if (haveOnlyInsertion && membersChanged && targetVersion.version !== 1) {
                if (isDarkMode()) {
                    memChangedFlag.style.background = c("rgba(17, 238, 9, 0.3)", ".members-changed-flag")
                } else {
                    memChangedFlag.style.background = c("rgba(101, 238, 9, 0.6)", ".members-changed-flag")
                }
            } else if (haveOnlyDeletion && membersChanged) {
                if (isDarkMode()) {
                    memChangedFlag.style.background = c("rgba(238, 51, 9, 0.4)", ".members-changed-flag")
                } else {
                    memChangedFlag.style.background = c("rgba(238, 9, 9, 0.42)", ".members-changed-flag")
                }
            }
        }

        if (JSON.stringify((prevVersion?.members ?? []).toReversed()) === JSON.stringify(targetVersion.members)) {
            // members reversed
            const row = makeRelationDiffRow("", "🔃")
            row.querySelectorAll("td").forEach(i => (i.style.textAlign = "center"))
            row.querySelector("td:nth-of-type(2)").title = t("changesetQuicklook.relationMembersReversed")
            tbody.appendChild(row)

            prevVersion?.members?.forEach((i, index) => {
                const row = makeRelationDiffRow(i, targetVersion.members[index])
                row.querySelector("td:nth-of-type(2)").style.background = "rgba(223, 238, 9, 0.6)"
                row.style.fontFamily = "monospace"
                tbody.appendChild(row)
            })
            haveOnlyInsertion = false
            haveOnlyDeletion = false
            colorizeFlag()
        } else {
            memChangedFlag.style.display = "none"
            setTimeout(() => {
                arraysDiff(prevVersion?.members ?? [], targetVersion.members ?? []).forEach(i => {
                    const row = makeRelationDiffRow(i[0], i[1])
                    if (i[0] === null) {
                        row.style.background = c("rgba(17, 238, 9, 0.6)", ".members-changed-row")
                        haveOnlyDeletion = false
                    } else if (i[1] === null) {
                        row.style.background = c("rgba(238, 51, 9, 0.6)", ".members-changed-row")
                        haveOnlyInsertion = false
                    } else if (JSON.stringify(i[0]) !== JSON.stringify(i[1])) {
                        if (i[0].ref === i[1].ref && i[0].type === i[1].type) {
                            row.querySelectorAll(".rel-role").forEach(i => {
                                i.style.background = "rgba(223, 238, 9, 0.6)"
                                if (isDarkMode()) {
                                    i.style.color = "black"
                                }
                            })
                        } else {
                            row.style.background = "rgba(223, 238, 9, 0.6)"
                            if (isDarkMode()) {
                                row.style.color = "black"
                            }
                        }
                        haveOnlyInsertion = false
                        haveOnlyDeletion = false
                    }
                    row.style.fontFamily = "monospace"
                    tbody.appendChild(row)
                })
                memChangedFlag.style.display = ""
                colorizeFlag()
            })
        }

        const tagsTable = document.createElement("table")
        tagsTable.style.display = "none"
        const tbodyForTags = document.createElement("tbody")
        tagsTable.appendChild(tbodyForTags)

        Object.entries(targetVersion.tags ?? {}).forEach(([k, v]) => {
            const row = makeTagRow(k, v)
            makeLinksInChangesetObjectRowClickable(row, objType)
            tbodyForTags.appendChild(row)
        })

        memChangedFlag.onkeypress = memChangedFlag.onclick = e => {
            if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
                e.preventDefault()
            } else if (e.type === "keypress") {
                return
            }
            // todo preload first elements
            e.stopPropagation()
            if (membersTable.style.display === "none") {
                membersTable.style.display = ""
                tagsTable.style.display = ""
            } else {
                membersTable.style.display = "none"
                tagsTable.style.display = "none"
            }
        }

        i.appendChild(memChangedFlag)
        const pinRelation = document.createElement("span")
        pinRelation.textContent = "📌"
        pinRelation.tabIndex = 0
        pinRelation.classList.add("pin-relation")
        pinRelation.style.cursor = "pointer"
        pinRelation.style.display = "none"
        pinRelation.title = t("changesetQuicklook.pinRelationOnMap")
        pinRelation.onkeypress = pinRelation.onclick = async e => {
            if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
                e.preventDefault()
            } else if (e.type === "keypress") {
                return
            }
            e.stopImmediatePropagation()
            if (!pinRelation.classList.contains("pinned")) {
                pinnedRelations.add(targetVersion.id)
                pinRelation.style.cursor = "progress"
                const color = darkModeForMap && isDarkMode() ? "#000" : "#373737"
                await loadRelationVersionMembersViaOverpass(
                    targetVersion.id,
                    targetVersion.timestamp,
                    false,
                    color,
                    `customObjects/${targetVersion.id}`,
                    darkModeForMap && isDarkMode(),
                )
                pinRelation.style.cursor = "pointer"
                pinRelation.classList.add("pinned")
                pinRelation.textContent = "📍"
                pinRelation.title = t("changesetQuicklook.unpinRelationFromMap")
            } else {
                pinRelation.title = t("changesetQuicklook.pinRelationOnMap")
                pinRelation.classList.remove("pinned")
                pinRelation.textContent = "📌"
                cleanObjectsByKey(`customObjects/${targetVersion.id}`)
                pinnedRelations.delete(targetVersion.id)
            }
        }
        memChangedFlag.after(pinRelation)

        pinRelation.after(membersTable)
        if (membersChanged) {
            pinRelation.after(tagsTable)
        }
    }
    if (targetVersion.lat && prevVersion.lat && (prevVersion.lat !== targetVersion.lat || prevVersion.lon !== targetVersion.lon)) {
        i.parentElement.parentElement.classList.add("location-modified")
        const locationChangedFlag = document.createElement("span")
        // prettier-ignore
        const distInMeters = getDistanceFromLatLonInKm(
            prevVersion.lat,
            prevVersion.lon,
            targetVersion.lat,
            targetVersion.lon,
        ) * 1000;
        locationChangedFlag.textContent = ` 📍${distInMeters.toFixed(1)}m`
        locationChangedFlag.title = t("changesetQuicklook.nodeCoordinatesChanged")
        locationChangedFlag.classList.add("location-modified-marker")
        // if (distInMeters > 100) {
        //     locationChangedFlag.classList.add("location-modified-marker-warn")
        // }
        locationChangedFlag.style.userSelect = "none"
        if (isDarkMode()) {
            locationChangedFlag.style.background = "rgba(223, 238, 9, 0.6)"
            locationChangedFlag.style.color = "black"
        } else {
            locationChangedFlag.style.background = "rgba(223, 238, 9, 0.6)"
        }
        i.appendChild(locationChangedFlag)
        locationChangedFlag.onmouseover = e => {
            e.stopPropagation()
            e.stopImmediatePropagation()
            showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), c("#ff00e3"))
            showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), c("#0022ff"), false)
        }
        locationChangedFlag.onclick = e => {
            e.stopPropagation()
            e.stopImmediatePropagation()
            fitBoundsWithPadding(
                [
                    [prevVersion.lat.toString(), prevVersion.lon.toString()],
                    [targetVersion.lat.toString(), targetVersion.lon.toString()],
                ],
                30,
            )
        }
        if (lastVersion.visible !== false && prevVersion.lat === lastVersion.lat && prevVersion.lon === lastVersion.lon) {
            locationChangedFlag.classList.add("reverted-coordinates")
            locationChangedFlag.title += ",\nbut now they have been restored."
        }
    }
    if (targetVersion.visible === false) {
        i.parentElement.parentElement.classList.add("removed-object")
    }
    // https://osm.org/changeset/169708866
    if (targetVersion.version !== lastVersion.version && lastVersion.visible === false) {
        const objDeletedBadge = document.createElement("span")
        if (targetVersion.user === lastVersion.user) {
            objDeletedBadge.textContent = t("changesetQuicklook.objectDeletedByAuthor")
        } else {
            objDeletedBadge.textContent = t("changesetQuicklook.objectDeleted")
        }
        objDeletedBadge.title = t("changesetQuicklook.objectDeletedTitle", { user: lastVersion.user })
        i.appendChild(objDeletedBadge)
    }
    if (targetVersion.visible === false && lastVersion.visible !== false) {
        const objRestoredBadge = document.createElement("span")
        objRestoredBadge.textContent = t("changesetQuicklook.objectRestored")
        let lastRestoredVersion
        for (let versionInd = 1; versionInd < objHistory.length; versionInd++) {
            if (objHistory[versionInd].version <= targetVersion.version) {
                continue
            }
            if (objHistory[versionInd - 1].visible === false && objHistory[versionInd].visible !== false) {
                lastRestoredVersion = objHistory[versionInd]
            }
        }
        if (lastRestoredVersion) {
            if (lastRestoredVersion.user === targetVersion.user) {
                objRestoredBadge.textContent = t("changesetQuicklook.objectRestoredByAuthor")
            } else {
                objRestoredBadge.title = t("changesetQuicklook.objectRestoredTitle", { user: lastVersion.user })
            }
        }
        i.appendChild(objRestoredBadge)
    }
    // if (objType === "node") {
    //     i.appendChild(tagsTable)
    // }
    if (targetVersion.tags?.["type"] === "restriction") {
        const key =
            Object.keys(targetVersion.tags).find(k => k === "restriction") ??
            Object.keys(targetVersion.tags).find(k => k.startsWith("restriction"))
        if (key && restrictionsSignImages[key]) {
            void fetchTextWithCache(restrictionsSignImages[key])
        }
    }
    if (tagsWasChanged) {
        i.appendChild(tagsTable)
        if (changedOnlyUninterestedTags) {
            i.parentElement.parentElement.classList.add("tags-uninterested-modified")
        }
    } else {
        i.parentElement.parentElement.classList.add("tags-non-modified")
    }
    i.parentElement.parentElement.classList.add("tags-processed-object")
    return tagsTable
}

function minusSecondInStringTimestamp(t) {
    if (!t) {
        return t
    }
    const res = new Date(t)
    res.setTime(res.getTime() - 1000)
    return res.toISOString().slice(0, -5) + "Z"
}

/**
 * @param t1 {string|Date}
 * @param t2 {string|Date}
 * @return {Date}
 */
function maxDate(t1, t2) {
    const d1 = new Date(t1)
    const d2 = new Date(t2)
    return d1 > d2 ? d1 : d2
}

/**
 * @typedef {{
 * nodes: Array<HTMLElement>,
 * ways: Array<HTMLElement>,
 * relations: Array<HTMLElement>
 * }} ObjectsInComments
 */

/**
 * @template T
 * @param {T[]} objHistory
 * @param {number} version
 * @return {T|undefined}
 */
function upperBoundVersion(objHistory, version) {
    let index = version - 1
    if (objHistory[index]?.version === version) {
        return objHistory[index + 1]
    }
    index--
    while (index >= 0) {
        if (objHistory[index]?.version === version) {
            return objHistory[index + 1]
        }
        index--
    }
    return undefined
}

/**
 * @param {string} changesetID
 * @param {string} objType
 * @param {ObjectsInComments} objectsInComments
 * @param {Element} i
 * @param {NodeVersion|WayVersion|RelationVersion} prevVersion
 * @param {NodeVersion|WayVersion|RelationVersion} targetVersion
 * @param {NodeVersion|WayVersion|RelationVersion} lastVersion
 * @param {NodeVersion[]|WayVersion[]|RelationVersion[]} objHistory
 */
async function processObjectInteractions(changesetID, objType, objectsInComments, i, prevVersion, targetVersion, lastVersion, objHistory) {
    if (!GM_config.get("ShowChangesetGeometry")) {
        i.parentElement.parentElement.classList.add("processed-object")
        return
    }
    /**
     * @type {[string, string, string, string]}
     */
    const m = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/)
    const [, , objID, strVersion] = m
    const version = parseInt(strVersion)
    i.parentElement.parentElement.ondblclick = e => {
        if (e.altKey) return
        if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return
        if (changesetMetadatas[targetVersion.changeset]) {
            fitBounds([
                [changesetMetadatas[targetVersion.changeset].min_lat, changesetMetadatas[targetVersion.changeset].min_lon],
                [changesetMetadatas[targetVersion.changeset].max_lat, changesetMetadatas[targetVersion.changeset].max_lon],
            ])
        }
    }

    function processNode() {
        i.id = `${changesetID}n${objID}v${version}`

        function mouseoverHandler(e) {
            if (e.relatedTarget?.parentElement === e.target) {
                return
            }
            if (targetVersion.visible === false) {
                if (prevVersion.visible !== false) {
                    showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), c("#0022ff"))
                    const direction =
                        prevVersion.tags?.["direction"] ?? prevVersion.tags?.["camera:direction"] ?? prevVersion.tags?.["light:direction"]
                    if (direction) {
                        renderDirectionTag(prevVersion.lat, prevVersion.lon, direction, c("#0022ff"))
                    }
                }
            } else {
                showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), c("#ff00e3"))
                const direction =
                    targetVersion.tags?.["direction"] ?? targetVersion.tags?.["camera:direction"] ?? targetVersion.tags?.["light:direction"]
                if (direction) {
                    renderDirectionTag(targetVersion.lat, targetVersion.lon, direction, c("#ff00e3"))
                }
            }
            resetMapHover()
        }

        i.parentElement.parentElement.onmouseover = mouseoverHandler
        if ((prevVersion.tags && Object.keys(prevVersion.tags).length) || (targetVersion.tags && Object.keys(targetVersion.tags).length)) {
            // todo temp hack for potential speed up // fixme remove comment
            objectsInComments.nodes
                .filter(i => i.href.includes(`node/${objID}`))
                .forEach(link => {
                    // link.title = "Alt + click for scroll into object list"
                    link.onmouseenter = mouseoverHandler
                    link.onclick = e => {
                        if (!e.altKey) return
                        i.scrollIntoView()
                    }
                })
        }
        i.parentElement.parentElement.onclick = e => {
            if (e.altKey) return
            if (window.getSelection().type === "Range") return
            if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return

            document.querySelector("#element_versions_list > div.active-object")?.classList?.remove()
            i.parentElement.parentElement.classList.add("active-object")

            if (prevVersion.visible !== false && targetVersion.visible !== false) {
                fitBoundsWithPadding(
                    [
                        [prevVersion.lat.toString(), prevVersion.lon.toString()],
                        [targetVersion.lat.toString(), targetVersion.lon.toString()],
                    ],
                    30,
                )
                showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), c("#0022ff"), true)
                showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), c("#ff00e3"), false)
                const direction =
                    prevVersion.tags?.["direction"] ?? prevVersion.tags?.["camera:direction"] ?? prevVersion.tags?.["light:direction"]
                if (direction) {
                    renderDirectionTag(prevVersion.lat, prevVersion.lon, direction, c("#0022ff"))
                }
                const newDirection =
                    targetVersion.tags?.["direction"] ?? targetVersion.tags?.["camera:direction"] ?? targetVersion.tags?.["light:direction"]
                if (direction) {
                    renderDirectionTag(targetVersion.lat, targetVersion.lon, newDirection, c("#ff00e3"))
                }
            } else if (targetVersion.visible === false) {
                panTo(prevVersion.lat.toString(), prevVersion.lon.toString(), 18, false)
                showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), c("#0022ff"), true)
                const direction =
                    prevVersion.tags?.["direction"] ?? prevVersion.tags?.["camera:direction"] ?? prevVersion.tags?.["light:direction"]
                if (direction) {
                    renderDirectionTag(prevVersion.lat, prevVersion.lon, direction, c("#0022ff"))
                }
            } else {
                if (!repeatedEvent && trustedEvent) {
                    // todo
                    panTo(targetVersion.lat.toString(), targetVersion.lon.toString(), 18, false)
                } else {
                    /*
                    const bounds = getMap().getBounds()
                    const lat1 = bounds.getNorthWest().lat
                    const lng1 = bounds.getNorthWest().lng
                    const lat2 = bounds.getSouthEast().lat
                    const lng2 = bounds.getSouthEast().lng

                    const delta_lat = (lat2 - lat1) / 5.0
                    const delta_lng = (lng2 - lng1) / 5.0

                    const newBounds = getWindow().L.latLngBounds(
                        intoPage([lat1 + delta_lat, lng1 + delta_lng]),
                        intoPage([lat2 - delta_lat, lng2 - delta_lng])
                    )

                    getWindow().L.rectangle(
                        intoPage([
                            [newBounds.getSouth(), newBounds.getWest()],
                            [newBounds.getNorth(), newBounds.getEast()]
                        ]),
                        intoPage({color: "#0022ff", weight: 3, fillOpacity: 0})
                    ).addTo(getMap());

                    if (!newBounds.contains(getWindow().L.latLng(intoPage([targetVersion.lat.toString(), targetVersion.lon.toString()])))) {
                        panTo(targetVersion.lat.toString(), targetVersion.lon.toString(), 18, false)
                    }
                    */
                    // panInside(targetVersion.lat.toString(), targetVersion.lon.toString(), false, [70, 70])
                    panTo(targetVersion.lat.toString(), targetVersion.lon.toString(), 18, false)
                }
                showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), c("#ff00e3"), true)
                const direction =
                    targetVersion.tags?.["direction"] ?? targetVersion.tags?.["camera:direction"] ?? targetVersion.tags?.["light:direction"]
                if (direction) {
                    renderDirectionTag(targetVersion.lat, targetVersion.lon, direction, c("#ff00e3"))
                }
            }
        }
        if (!location.pathname.includes("changeset")) {
            return
        }
        if (targetVersion.visible === false) {
            if (targetVersion.version !== 1 && prevVersion.visible !== false) {
                // даа, такое есть https://www.openstreetmap.org/node/300524/history
                if (prevVersion.tags) {
                    showNodeMarker(
                        prevVersion.lat.toString(),
                        prevVersion.lon.toString(),
                        c("#ff0000", ".deleted-node-geom"),
                        `${changesetID}n${prevVersion.id}v${targetVersion.version}`,
                    )
                } else {
                    showNodeMarker(
                        prevVersion.lat.toString(),
                        prevVersion.lon.toString(),
                        c("#ff0000", ".deleted-node-geom"),
                        `${changesetID}n${prevVersion.id}v${targetVersion.version}`,
                        "customObjects",
                        2,
                    )
                    // todo show prev parent ways
                }
            }
        } else if (targetVersion.version === 1) {
            if (targetVersion.tags) {
                showNodeMarker(
                    targetVersion.lat.toString(),
                    targetVersion.lon.toString(),
                    c("#00a500", ".first-node-version"),
                    `${changesetID}n${targetVersion.id}v${targetVersion.version}`,
                )
            }
            setTimeout(async () => {
                if ((await getChangeset(parseInt(changesetID))).nodesWithOldParentWays.has(parseInt(objID))) {
                    showNodeMarker(
                        targetVersion.lat.toString(),
                        targetVersion.lon.toString(),
                        c("#00a500", ".first-node-version"),
                        `${changesetID}n${targetVersion.id}v${targetVersion.version}`,
                    )
                }
            }, 0) // dirty hack for https://osm.org/changeset/162017882
        } else if (prevVersion?.visible === false && targetVersion?.visible !== false) {
            showNodeMarker(
                targetVersion.lat.toString(),
                targetVersion.lon.toString(),
                c("rgba(89, 170, 9, 0.6)", ".restored-node-version"),
                `${changesetID}n${targetVersion.id}v${targetVersion.version}`,
                "customObjects",
                2,
            )
        } else {
            showNodeMarker(
                targetVersion.lat.toString(),
                targetVersion.lon.toString(),
                "rgb(255,245,41)",
                `${changesetID}n${targetVersion.id}v${targetVersion.version}`,
            )
        }
    }

    // old changeset with redactions https://osm.org/changeset/10934800
    async function processWay() {
        i.id = `${changesetID}w${objID}v${version}`

        // TODO для полной истории кеш нужен, а вот для правок сомнительно, если нужно перемещаться между ними
        // хотя при отображении нескольких правок разом тоже полезно
        const res = await fetchJSONorResWithCache(osm_server.apiBase + objType + "/" + objID + "/full.json", {
            signal: getAbortController().signal,
        })
        // todo по-хорошему нужно проверять, а не успела ли измениться история линии
        // будет более актуально после добавление предзагрузки
        let changesetMetadata = changesetMetadatas[targetVersion.changeset]
        const nowDeleted = res instanceof Response
        const dashArray = nowDeleted ? "4, 4" : null
        let lineWidth = nowDeleted ? 4 : 3

        if (!nowDeleted) {
            const lastElements = res.elements
            lastElements.forEach(n => {
                if (n.type !== "node") return
                if (n.version === 1) {
                    nodesHistories[n.id] = [n]
                }
            })
            if (!changesetMetadata) {
                changesetMetadata = await loadChangesetMetadata(targetVersion.changeset)
            }
        }

        const [, wayNodesHistories] = await loadWayVersionNodes(objID, version)

        const currentNodesList = []
        if (targetVersion.visible !== false) {
            // https://osm.org/changeset/174173815
            // if changeset was long opened and nodes changed after way
            let hasInterChanges = false
            /**
             * @type {
             * (function(NodeVersion, string, string, number): NodeVersion|null) |
             * (function(WayVersion, string, string, number): WayVersion|null) |
             * (function(RelationVersion, string, string, number): RelationVersion|null)
             * }
             */
            const searchFinalVersion = (history, timestamp, notLater, currentChangeset) => {
                const targetTime = new Date(timestamp)
                let cur = history[0]
                if (targetTime < new Date(cur.timestamp)) {
                    return null
                }
                for (const v of history) {
                    if (new Date(v.timestamp) <= targetTime) {
                        cur = v
                    } else {
                        // Вообще v.changeset === currentChangeset не очень хорошо
                        // Однако я не могу представить когда конкурирующий пакет правок может возникнуть непреднамеренно
                        if (new Date(v.timestamp) <= new Date(notLater) && v.changeset === currentChangeset) {
                            cur = v
                            hasInterChanges = true
                            if (cur.visible === false) {
                                console.trace(history, timestamp, notLater, currentChangeset)
                            }
                        } else {
                            break
                        }
                    }
                }
                return cur
            }

            /**
             * @type {
             * (function(NodeHistory[], string, string, number): NodeHistory) |
             * (function(WayHistory[], string, string, number): WayHistory) |
             * (function(RelationHistory[], string, string, number): RelationHistory)
             * }
             */
            const filterFinalObjectState = (objectList, timestamp, notLater, currentChangeset) => {
                return objectList.map(i => searchFinalVersion(i, timestamp, notLater, currentChangeset))
            }

            const nextVersion = upperBoundVersion(objHistory, targetVersion.version)
            let nextVersionTimestamp
            // https://osm.org/changeset/178767978
            if (nextVersion?.visible === false || nextVersion?.changeset === parseInt(changesetID)) {
                nextVersionTimestamp = minusSecondInStringTimestamp(nextVersion?.timestamp)
            } else {
                nextVersionTimestamp = nextVersion?.timestamp
            }
            if (!changesetMetadata) {
                changesetMetadata = await loadChangesetMetadata(parseInt(changesetID))
            }
            const notLater =
                !nextVersionTimestamp || new Date(nextVersionTimestamp) > new Date(changesetMetadata.closed_at)
                    ? changesetMetadata.closed_at
                    : nextVersionTimestamp
            // notLater важен для правок от StreetComplete. Там часто линия обновляется несколько раз в правке
            const targetNodes = filterFinalObjectState(wayNodesHistories, targetVersion.timestamp, notLater, changesetMetadata.id)
            if (hasInterChanges) {
                const hasInterChangesWarn = document.createElement("a")
                hasInterChangesWarn.textContent = "…"
                hasInterChangesWarn.setAttribute("href", `/way/${objID}/history`)
                hasInterChangesWarn.setAttribute("target", `_blank`)
                hasInterChangesWarn.title = t("changesetQuicklook.intermediateNodeChangesTitle")
                i.querySelector("a ~ table.quick-look")?.before(hasInterChangesWarn)
            }
            const nodesMap = {}
            let broken = false
            targetNodes.forEach(elem => {
                if (!elem) {
                    console.error(targetNodes, objID, targetVersion)
                }
                if (!elem.lon) {
                    broken = true
                    console.error(elem, targetNodes, objID, targetVersion)
                }
                nodesMap[elem.id] = [elem.lat, elem.lon]
            })
            targetVersion.nodes?.forEach(node => {
                if (node in nodesMap) {
                    currentNodesList.push(nodesMap[node])
                } else {
                    console.error("not found target nodes", objID, node)
                }
            })
            setTimeout(() => {
                if (broken) {
                    return
                }
                const nodesMap = new Map(Object.entries(Object.groupBy(targetNodes, i => i.id)).map(([k, v]) => [k, v[0]]))
                const lineGeometryIssues = validateWayGeometry(targetVersion, nodesMap)
                showValidationStatus(
                    lineGeometryIssues.map(i => i.description),
                    i,
                )
            }, 0)
        }

        i.parentElement.parentElement.onclick = async e => {
            if (e.altKey) return
            if (window.getSelection().type === "Range") return
            if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return

            document.querySelector("#element_versions_list > div.active-object")?.classList?.remove()
            i.parentElement.parentElement.classList.add("active-object")

            showActiveWay(currentNodesList, c("#ff00e3"), currentNodesList.length !== 0, `${changesetID}w${objID}v${targetVersion.version}`)

            if (version > 1) {
                // show prev version
                const [, nodesHistory] = await loadWayVersionNodes(objID, version - 1)
                const targetTimestamp = maxDate(
                    prevVersion.timestamp,
                    new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1),
                ).toISOString()
                const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
                showActiveWay(
                    nodesList,
                    "rgb(238,146,9)",
                    currentNodesList.length === 0,
                    `${changesetID}w${objID}v${targetVersion.version}`,
                    false,
                    4,
                    "4, 4",
                )

                showActiveWay(currentNodesList, c("#ff00e3"), false, `${changesetID}w${objID}v${targetVersion.version}`, false)
            } else {
                const targetTimestamp = new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1).toISOString()
                const prevVersion = searchVersionByTimestamp(await getWayHistory(objID), targetTimestamp)
                if (prevVersion) {
                    const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version)
                    const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
                    showActiveWay(
                        nodesList,
                        "rgb(238,146,9)",
                        currentNodesList.length === 0,
                        `${changesetID}w${objID}v${targetVersion.version}`,
                        false,
                        4,
                        "4, 4",
                    )
                }
                showActiveWay(currentNodesList, c("#ff00e3"), false, `${changesetID}w${objID}v${targetVersion.version}`, false)
            }
        }
        if (!changesetMetadata) {
            changesetMetadata = await loadChangesetMetadata(targetVersion.changeset)
        }
        if (targetVersion.visible === false) {
            const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version)
            const targetTimestamp = new Date(maxDate(changesetMetadata.created_at, prevVersion.timestamp).getTime() - 1).toISOString()
            const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
            if (!nodesList.some(i => i.visible === false)) {
                const closedTime = new Date(changesetMetadata.closed_at ?? new Date()).toISOString()
                const nodesAfterChangeset = filterObjectListByTimestamp(nodesHistory, closedTime)
                if (nodesAfterChangeset.some(i => i.visible === false)) {
                    displayWay(
                        nodesList,
                        false,
                        c("#ff0000", ".deleted-way-geom"),
                        3,
                        `${changesetID}w${objID}v${targetVersion.version}`,
                        "customObjects",
                        dashArray,
                    )
                } else {
                    // скорее всего это объединение линий, поэтому это удаление линии нужно отправить на задний план
                    const layer = displayWay(
                        nodesList,
                        false,
                        c("#ff0000", ".deleted-way-geom"),
                        7,
                        `${changesetID}w${objID}v${targetVersion.version}`,
                        "customObjects",
                        dashArray,
                    )
                    layer.bringToBack()
                    lineWidth = 8
                }
            } else {
                console.error(`broken way: ${objID}`, nodesList) // todo retry
            }
        } else if (version === 1 && targetVersion.changeset === parseInt(changesetID)) {
            displayWay(
                currentNodesList,
                false,
                c("rgba(0, 128, 0, 0.6)"),
                lineWidth,
                `${changesetID}w${objID}v${targetVersion.version}`,
                "customObjects",
                dashArray,
            )
        } else if (prevVersion?.visible === false) {
            displayWay(
                currentNodesList,
                false,
                c("rgba(120, 238, 9, 0.6)"),
                lineWidth,
                `${changesetID}w${objID}v${targetVersion.version}`,
                "customObjects",
                dashArray,
            )
        } else {
            displayWay(
                currentNodesList,
                false,
                nowDeleted ? "rgb(0,0,0)" : "#373737",
                lineWidth,
                `${changesetID}w${objID}v${targetVersion.version}`,
                "customObjects",
                null,
                null,
                darkModeForMap && isDarkMode(),
            )
        }

        async function mouseenterHandler() {
            showActiveWay(currentNodesList)
            resetMapHover()
            if (version > 1) {
                // show prev version
                const [, nodesHistory] = await loadWayVersionNodes(objID, version - 1)
                const targetTimestamp = maxDate(
                    prevVersion.timestamp,
                    new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1),
                ).toISOString()
                const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
                showActiveWay(nodesList, "rgb(238,146,9)", false, `${changesetID}w${objID}v${targetVersion.version}`, false, 4, "4, 4")

                showActiveWay(currentNodesList, c("#ff00e3"), false, `${changesetID}w${objID}v${targetVersion.version}`, false, lineWidth)
            } else {
                const targetTimestamp = new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1).toISOString()
                const prevVersion = searchVersionByTimestamp(await getWayHistory(objID), targetTimestamp)
                if (prevVersion) {
                    const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version)
                    const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
                    showActiveWay(nodesList, "rgb(238,146,9)", false, `${changesetID}w${objID}v${targetVersion.version}`, false, 4, "4, 4")
                }
                showActiveWay(currentNodesList, c("#ff00e3"), false, `${changesetID}w${objID}v${targetVersion.version}`, false, lineWidth)
            }
        }

        i.parentElement.parentElement.onmouseenter = mouseenterHandler
        objectsInComments.ways
            .filter(i => i.href.includes(`way/${objID}`))
            .forEach(link => {
                // link.title = "Alt + click for scroll into object list"
                link.onmouseenter = mouseenterHandler
                link.onclick = e => {
                    if (!e.altKey) return
                    i.scrollIntoView()
                }
            })
    }

    function processRelation() {
        i.id = `${changesetID}r${objID}v${version}`
        const btn = document.createElement("a")
        btn.textContent = "📥"
        btn.classList.add("load-relation-version")
        btn.title = t("changesetQuicklook.downloadThisRelation")
        btn.tabIndex = 0
        btn.style.cursor = "pointer"

        async function clickForDownloadHandler(e) {
            if (e.altKey) return
            if (window.getSelection().type === "Range") return
            if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return
            if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
                e.preventDefault()
            } else if (e.type === "keypress") {
                return
            }
            e.preventDefault()

            document.querySelector("#element_versions_list > div.active-object")?.classList?.remove()
            i.parentElement.parentElement.classList.add("active-object")

            btn.style.cursor = "progress"
            let targetTimestamp = new Date(changesetMetadatas[targetVersion.changeset].closed_at ?? new Date()).toISOString()
            if (targetVersion.visible === false) {
                targetTimestamp = new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1).toISOString()
            }
            try {
                const relationMetadata = await loadRelationVersionMembersViaOverpass(parseInt(objID), targetTimestamp, false, c("#ff00e3"))
                if (relationMetadata.restrictionRelationErrors.length) {
                    showValidationStatus(relationMetadata.restrictionRelationErrors, i.parentElement)
                } else {
                    if (relationMetadata.isRestriction) {
                        i.parentElement.parentElement.title = t("changesetQuicklook.shiftClickZoomVia")
                    }
                }
                i.parentElement.parentElement.onclick = e => {
                    if (e.altKey) return
                    if (e.shiftKey && relationMetadata.isRestriction) {
                        fitBounds([
                            [relationMetadata.nodesBbox.min_lat, relationMetadata.nodesBbox.min_lon],
                            [relationMetadata.nodesBbox.max_lat, relationMetadata.nodesBbox.max_lon],
                        ])
                    } else {
                        fitBounds([
                            [relationMetadata.bbox.min_lat, relationMetadata.bbox.min_lon],
                            [relationMetadata.bbox.max_lat, relationMetadata.bbox.max_lon],
                        ])
                    }
                }

                async function mouseenterHandler() {
                    if (!pinnedRelations.has(parseInt(objID))) {
                        await loadRelationVersionMembersViaOverpass(parseInt(objID), targetTimestamp, false, c("#ff00e3"))
                    }
                }

                i.parentElement.parentElement.onmouseenter = mouseenterHandler
                objectsInComments.relations
                    .filter(i => i.href.includes(`relation/${objID}`))
                    .forEach(link => {
                        // link.title = "Alt + click for scroll into object list"
                        link.onmouseenter = mouseenterHandler
                        link.onclick = e => {
                            if (!e.altKey) return
                            i.scrollIntoView()
                        }
                    })

                i.parentElement.parentElement.classList.add("downloaded")
                i.parentElement.querySelector(".pin-relation").style.display = ""
            } catch (e) {
                btn.style.cursor = "pointer"
                throw e
            }
            btn.style.visibility = "hidden"
            // todo нужна кнопка с глазом чтобы можно было скрывать
        }

        btn.addEventListener("click", clickForDownloadHandler)
        btn.addEventListener("keypress", clickForDownloadHandler)
        i.querySelector("a:nth-of-type(2)").after(btn)
        i.querySelector("a:nth-of-type(2)").after(document.createTextNode("\xA0"))
    }

    if (objType === "node") {
        processNode()
    } else if (objType === "way") {
        await processWay()
    } else if (objType === "relation") {
        processRelation()
    }
    i.parentElement.parentElement.classList.add("processed-object")
}

async function processObjectsInteractions(objType, uniqTypes, changesetID) {
    const objects = document.querySelectorAll(
        `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object):not(.object-in-process)`,
    )
    if (objects.length === 0) {
        return
    }
    objects.forEach(i => {
        i.classList.add("object-in-process")
    })

    const objectsLinksInComments = {
        // todo can be optimized
        nodes: Array.from(
            document.querySelectorAll(
                `#element_versions_list > div > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="node/"]`,
            ),
        ),
        ways: Array.from(
            document.querySelectorAll(
                `#element_versions_list > div > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="way/"]`,
            ),
        ),
        relations: Array.from(
            document.querySelectorAll(
                `#element_versions_list > div > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="relation/"]`,
            ),
        ),
    }

    try {
        const needFetch = []

        if (objType === "relation" && objects.length >= 2) {
            for (let i of document.querySelectorAll(
                `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
            )) {
                const [, , objID, strVersion] = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/)
                const version = parseInt(strVersion)
                if (version === 1) {
                    needFetch.push(objID + "v" + version)
                    needFetch.push(objID)
                } else {
                    needFetch.push(objID + "v" + (version - 1))
                    needFetch.push(objID + "v" + version)
                    needFetch.push(objID)
                }
            }
            const res = await fetchRetry(osm_server.apiBase + `${objType}s.json?${objType}s=` + needFetch.join(","), {
                signal: getAbortController().signal,
            })
            if (res.status === 404) {
                for (let i of document.querySelectorAll(
                    `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                )) {
                    await processObjectInteractions(
                        changesetID,
                        objType,
                        objectsLinksInComments,
                        i,
                        ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(i))),
                    )
                }
            } else {
                /**
                 * @type {RelationVersion[]}
                 */
                const versions = (await res.json()).elements
                /**
                 * @type {Object.<number, Object.<number, RelationVersion>>}
                 */
                const objectsVersions = {}
                Object.entries(Object.groupBy(Array.from(versions), i => i.id)).forEach(([id, history]) => {
                    objectsVersions[id] = Object.fromEntries(
                        Object.entries(Object.groupBy(history, i => i.version)).map(([version, val]) => [version, val[0]]),
                    )
                })
                for (let i of document.querySelectorAll(
                    `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                )) {
                    const [, , objID, strVersion] = i
                        .querySelector("a:nth-of-type(2)")
                        .href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/)
                    const version = parseInt(strVersion)
                    await processObjectInteractions(
                        changesetID,
                        objType,
                        objectsLinksInComments,
                        i,
                        ...getPrevTargetLastVersions(Object.values(objectsVersions[objID]), version),
                    )
                }
            }
        } else {
            await Promise.all(
                Array.from(
                    document.querySelectorAll(
                        `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                    ),
                ).map(async function (i) {
                    await processObjectInteractions(
                        changesetID,
                        objType,
                        objectsLinksInComments,
                        i,
                        ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(i))),
                    )
                }),
            )
        }
    } finally {
        objects.forEach(i => {
            i.classList.remove("object-in-process")
        })
    }

    await getChangeset(changesetID)
}

/**
 * @param {HTMLElement} elem
 * @return {Promise<[NodeHistory|WayHistory|RelationHistory, number]>}
 */
async function getHistoryAndVersionByElem(elem) {
    const [, objType, objID, versionStr] = elem.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/)
    const version = parseInt(versionStr)
    if (histories[objType][objID]) {
        return [histories[objType][objID], version]
    }
    let history
    if (objType === "node") {
        history = await getNodeHistory(objID)
    } else if (objType === "way") {
        history = await getWayHistory(objID)
    } else if (objType === "relation") {
        history = await getRelationHistory(objID)
    }
    if (history[version - 1]?.version === version) {
        return [history, version]
    }
    for (let i = min(version - 1, history.length - 1); i > 0; i--) {
        let versionObj = history[i]
        if (versionObj.version === version) {
            return [history, version]
        }
        if (versionObj.version < version) {
            break
        }
    }
    return [(histories[objType][objID] = await tryToRichObjHistory(history, objType, objID)), version]
}

/**
 * @template T
 * @param {T[]} objHistory
 * @param {number} version
 * @return {[T, T, T, T[]]}
 */
function getPrevTargetLastVersions(objHistory, version) {
    let prevVersion = emptyVersion
    let targetVersion = prevVersion
    let lastVersion = objHistory.at(-1)

    for (const objVersion of objHistory) {
        prevVersion = targetVersion
        targetVersion = objVersion
        // FIXME а если версия скрыта?
        if (objVersion.version === version) {
            break
        }
    }
    return [prevVersion, targetVersion, lastVersion, objHistory]
}

let quickLookStylesInjected = false

function addQuickLookStyles() {
    if (quickLookStylesInjected) return
    quickLookStylesInjected = true
    const styleText =
        `
        .edits-wars-log tr:nth-child(even) td, .edits-wars-log tr:nth-child(even) th {
            background-color: color-mix(in srgb, var(--bs-body-bg), black 25%);
        }

        @media ${mediaQueryForWebsiteTheme} {

        .edits-wars-log tr:nth-child(even) td, .edits-wars-log tr:nth-child(even) th {
            background-color: color-mix(in srgb, var(--bs-body-bg), white 5%);
        }

        }

        tr.quick-look-new-tag th {
            background: ${c("rgba(17, 238, 9, 0.6)")};
        }

        table[contenteditable] th:not(.tag-flag) {
            border: solid 2px black;
        }

        table[contenteditable] td:not(.tag-flag) {
            border: solid 2px black;
        }

        tr.quick-look-modified-tag td:nth-of-type(1){
            background: ${c("rgba(223, 238, 9, 0.6)")};
        }
        
        tr.quick-look-deleted-tag th {
            background: ${c("rgba(238, 51, 9, 0.6)")};
            color: ${c("#000", ".quick-look-deleted-tag")};
        }

        tr.quick-look-new-tag td:not(.tag-flag) {
            background: ${c("rgba(17, 238, 9, 0.6)")};
        }

        tr.quick-look-deleted-tag td:not(.tag-flag) {
            background: ${c("rgba(238, 51, 9, 0.6)")};
            color: ${c("#000", ".quick-look-deleted-tag")};
        }

        table.quick-look.hide-non-modified-tags > tbody > .non-modified-tag-in-quick-view {
            display: none;
        }

        .new-letter {
            background: ${c("rgba(25, 223, 25, 0.6)")};
        }

        .deleted-letter {
            background: ${c("rgba(255, 144, 144, 0.6)")};
        }

        @media ${mediaQueryForWebsiteTheme} {
            tr.quick-look-new-tag th{
                /*background: #0f540fde;*/
                background: ${c("rgba(17, 238, 9, 0.3)")};
                /*background: rgba(87, 171, 90, 0.3);*/
            }

            tr.quick-look-new-tag td:not(.tag-flag){
                /*background: #0f540fde;*/
                background: ${c("rgba(17, 238, 9, 0.3)")};
                /*background: rgba(87, 171, 90, 0.3);*/
            }

            tr.quick-look-modified-tag td {
                color: black;
            }

            tr.quick-look-deleted-tag th:not(.tag-flag) { /* dirty hack for zebra colors override */
                /*background: #692113;*/
                background: ${c("rgba(238, 51, 9, 0.4)")};
                /*background: rgba(229, 83, 75, 0.3);*/
                color: ${c("#fff", ".quick-look-deleted-tag")};
            }

            tr.quick-look-deleted-tag td:not(.tag-flag) {
                /*background: #692113;*/
                background: ${c("rgba(238, 51, 9, 0.4)")};
                /*background: rgba(229, 83, 75, 0.3);*/
                color: ${c("#fff", ".quick-look-deleted-tag")};
            }

            tr.quick-look-new-tag th::selection {
                background: black !important;
            }

            tr.quick-look-modified-tag th::selection {
                background: black !important;
            }

            tr.quick-look-deleted-tag th::selection {
                background: black !important;
            }

            tr.quick-look-new-tag td::selection {
                background: black !important;
            }

            /*tr.quick-look-modified-tag td::selection {*/
            /*    background: black !important;*/
            /*}*/

            tr.quick-look-deleted-tag td::selection {
                background: black !important;
            }

            .new-letter {
                background: ${c("rgba(25, 223, 25, 0.9)")};
            }

            .deleted-letter {
                background: ${c("rgba(253, 83, 83, 0.8)")};
            }
        }
        .edits-wars-tag td:nth-of-type(2)::after{
          content: " ⚔️";
          margin-top: 2px
        }
        tr.restored-tag td:nth-of-type(2)::after {
          content: " ♻️";
          margin-top: 2px
        }
        tr.restored-tag.edits-wars-tag td:nth-of-type(2)::after {
          content: " ♻️⚔️";
          margin-top: 2px
        }
        tr.removed-tag td:nth-of-type(2)::after {
          content: " 🗑";
          margin-top: 2px
        }
        tr.removed-tag.edits-wars-tag td:nth-of-type(2)::after {
          content: " 🗑⚔️";
          margin-top: 2px
        }
        tr.replaced-tag td:nth-of-type(2)::after {
          content: " ⇄";
          color: var(--bs-body-color);
        }
        tr.replaced-tag.edits-wars-tag td:nth-of-type(2)::after {
          content: " ⇄⚔️";
          color: var(--bs-body-color);
        }
        tr.reverted-tag td:nth-of-type(2)::after {
          content: " ↻";
          color: var(--bs-body-color);
        }

        tr.reverted-tag.edits-wars-tag td:nth-of-type(2)::after {
          content: " ↻⚔️";
          color: var(--bs-body-color);
        }
        span.reverted-coordinates::after {
          content: " ↻";
          position: absolute;
          color: var(--bs-body-color);
        }


        table.browse-tag-list tr td[colspan="2"]{
            background: var(--bs-body-bg) !important;
        }

        ul:has(li[hidden]):after {
            color: var(--bs-body-color);
            content: attr(hidden-nodes-count) ' uninteresting nodes hidden';
            font-style: italic;
            font-weight: normal;
            font-size: small;
            opacity: 0.5;
        }

        ` +
        (GM_config.get("ShowChangesetGeometry")
            ? `
        #sidebar_content #changeset_nodes ul:not(.pagination) li:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #changeset_ways ul:not(.pagination) li:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #changeset_nodes ul:not(.pagination) li.map-hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #changeset_ways ul:not(.pagination) li.map-hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #changeset_relations ul:not(.pagination) li.map-hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #changeset_relations ul:not(.pagination) li.downloaded:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }

        .location-modified-marker-warn::after:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }

        #sidebar_content #element_versions_list > div details.way-version-nodes li:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #element_versions_list > div details.way-version-nodes li:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #element_versions_list > div details.way-version-nodes li.map-hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #element_versions_list > div details.way-version-nodes li.map-hover {
            background-color: rgba(223, 223, 223, 0.6);
        }
        #sidebar_content #element_versions_list > div details.way-version-nodes li.downloaded:hover {
            background-color: rgba(223, 223, 223, 0.6);
        }

        .location-modified-marker-warn::after:hover {
              background-color: rgba(223, 223, 223, 0.6);;
        }

        @media ${mediaQueryForWebsiteTheme} {
            #sidebar_content #changeset_nodes ul:not(.pagination) li:hover {
                background-color: rgb(14, 17, 19);
            }
            #sidebar_content #changeset_ways ul:not(.pagination) li:hover {
                background-color: rgb(14, 17, 19);
            }
            #sidebar_content #changeset_nodes ul:not(.pagination) li.map-hover {
                background-color: rgb(14, 17, 19);
            }
            #sidebar_content #changeset_ways ul:not(.pagination) li.map-hover {
                background-color: rgb(14, 17, 19);
            }
            #sidebar_content #changeset_relations ul:not(.pagination) li.map-hover {
                background-color: rgb(14, 17, 19);
            }
            #sidebar_content #changeset_relations ul:not(.pagination) li.downloaded:hover {
                background-color: rgb(14, 17, 19);
            }

            #sidebar_content #element_versions_list > div details.way-version-nodes li:hover {
                background-color: rgb(52,61,67);
            }
            #sidebar_content #element_versions_list > div details.way-version-nodes li:hover {
                background-color: rgb(52,61,67);
            }
            #sidebar_content #element_versions_list > div details.way-version-nodes li.map-hover {
                background-color: rgb(52,61,67);
            }
            #sidebar_content #element_versions_list > div details.way-version-nodes li.map-hover {
                background-color: rgb(52,61,67);
            }
            #sidebar_content #element_versions_list > div details.way-version-nodes li.downloaded:hover {
                background-color: rgb(52,61,67);
            }

            .location-modified-marker-warn::after:hover {
                background-color: rgb(14, 17, 19);
            }
        }
        .location-modified-marker-warn::after {
          content: " ⚠️";
          background: var(--bs-body-bg);
        }
        .location-modified-marker:hover {
            background: #0022ff82 !important;
        }
        .way-version-node:hover {
            background-color: #ff00e3 !important;
        }
        .relation-version-node:hover {
            background-color: #ff00e3 !important;
        }
        .leaflet-fade-anim .leaflet-popup {
            transition: none;
        }

        @media (prefers-color-scheme: dark) {
            path.stroke-polyline {
                filter: drop-shadow(1px 1px 0 #7a7a7a) drop-shadow(-1px -1px 0 #7a7a7a) drop-shadow(1px -1px 0 #7a7a7a) drop-shadow(-1px 1px 0 #7a7a7a);
            }
        }
        `
            : "")
    try {
        injectCSSIntoOSMPage(styleText)
    } catch {
        /* empty */
    }
}

function removeEditTagsButton() {
    if (location.pathname.startsWith("/changeset/")) {
        if (!document.querySelector(".secondary-actions .edit_tags_class")) {
            const tagsEditorExtensionWaiter = new MutationObserver(() => {
                document.querySelector(".edit_tags_class")?.previousSibling?.remove()
                document.querySelector(".edit_tags_class")?.remove()
            })
            tagsEditorExtensionWaiter.observe(document.querySelector(".secondary-actions"), {
                childList: true,
                subtree: true,
            })
            setTimeout(() => tagsEditorExtensionWaiter.disconnect(), 3000)
        } else {
            document.querySelector(".edit_tags_class")?.previousSibling?.remove()
            document.querySelector(".edit_tags_class")?.remove()
        }
    }
}

async function preloadChangeset(changesetID) {
    console.log(`c${changesetID} preloading`)
    performance.mark("PRELOADING_" + changesetID)
    const ways = (await getChangeset(changesetID)).data.querySelectorAll("way")
    Array.from(ways)
        .slice(0, 5)
        .forEach(way => {
            getWayHistory(way.id)
        })
    performance.mark("END_PRELOADING_" + changesetID)
    console.log(`c${changesetID} preloaded`)
}

function preloadPrevNextChangesets() {
    console.debug("Preloading changesets")
    const prevLink = getPrevChangesetLink()
    if (prevLink) {
        const changesetID = prevLink.href.match(/\/changeset\/(\d+)/)[1]
        setTimeout(preloadChangeset, 0, changesetID)
    }
    const nextLink = getNextChangesetLink()
    if (nextLink) {
        const changesetID = nextLink.href.match(/\/changeset\/(\d+)/)[1]
        setTimeout(preloadChangeset, 0, changesetID)
    }
    needPreloadChangesets = false
}

/**
 * @param {number|string} nodeID
 * @return {Promise<WayVersion[]>}
 */
async function getParentWays(nodeID) {
    const rawRes = await fetchRetry(osm_server.apiBase + "node/" + nodeID + "/ways.json", { signal: getAbortController().signal })
    if (!rawRes.ok) {
        console.warn(`fetching parent ways for ${nodeID} failed`)
        console.trace()
        return []
    }
    return (await rawRes.json()).elements
}

async function safeCallForSafari(fn) {
    try {
        await fn()
    } catch (e) {
        console.error(e)
        if (!isSafari) {
            throw e
        } else {
            console.trace("suppressing errors for safari")
        }
    }
}

function makeCrashReportText(err) {
    return `
  **Page:** ${location.origin}${location.pathname}

  **Error:** \`${err?.toString()?.replace("`", "\\`")}\`

  **StackTrace:**

  \`\`\`
  ${err?.stack?.replace("`", "\\`")?.replaceAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gm, "<hidden>")}
  \`\`\`

  **Script handler:** \`${GM_info.scriptHandler} v${GM_info.version}\`

  **UserAgent:** \`${JSON.stringify(GM_info.userAgentData)}\`

                      `
}

function isAbortError(err) {
    return [ABORT_ERROR_PREV, ABORT_ERROR_NEXT, ABORT_ERROR_USER_CHANGESETS, ABORT_ERROR_WHEN_PAGE_CHANGED].includes(err)
}

async function processQuickLookInSidebar(changesetID) {
    const interceptMapManuallyPromise = interceptMapManually()
    const multipleChangesets = location.search.includes("changesets=")

    function addCompactModeToggles(objType, uniqTypes) {
        const compactToggle = document.createElement("button")
        compactToggle.title = t("historyDiff.toggleCompactTagsDiff")
        compactToggle.textContent = allTagsOfObjectsVisible ? "><" : "<>"
        compactToggle.classList.add("quick-look-compact-toggle-btn")
        compactToggle.classList.add("btn", "btn-sm", "btn-primary")
        compactToggle.classList.add("quick-look")
        compactToggle.onclick = e => {
            const needHideNodes = location.search.includes("changesets=")
            const state = e.target.textContent === "><"
            document.querySelectorAll(".quick-look-compact-toggle-btn").forEach(i => {
                if (state) {
                    i.textContent = "<>"
                } else {
                    i.textContent = "><"
                }
            })
            allTagsOfObjectsVisible = !allTagsOfObjectsVisible
            const shouldBeHidden = e.target.textContent === "<>"
            document.querySelectorAll("table.quick-look").forEach(el => {
                el.classList.toggle("hide-non-modified-tags", shouldBeHidden)
            })
            if (needHideNodes) {
                if (e.target.textContent === "<>") {
                    document.querySelectorAll("#changeset_nodes .tags-non-modified:not(.location-modified)").forEach(i => {
                        i.setAttribute("hidden", "true")
                    })
                    document.querySelectorAll("#changeset_nodes").forEach(i => {
                        if (!i.querySelector("li:not([hidden])")) {
                            i.setAttribute("hidden", "true")
                        }
                    })
                } else {
                    document.querySelectorAll("#changeset_nodes .tags-non-modified:not(.location-modified)").forEach(i => {
                        i.removeAttribute("hidden")
                    })
                    document.querySelectorAll("#changeset_nodes").forEach(i => {
                        i.removeAttribute("hidden")
                    })
                }
            }
            if (e.target.textContent === "><") {
                if (!e.altKey) {
                    document.querySelectorAll(".preview-img-link img").forEach(i => {
                        i.style.display = ""
                    })
                }
            } else {
                if (!e.altKey) {
                    document.querySelectorAll(".preview-img-link img").forEach(i => {
                        i.style.display = "none"
                    })
                }
            }
        }
        const objectListSection = document
            .querySelector(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li`)
            .parentElement.parentElement.querySelector("h4")
        if (!objectListSection.querySelector(".quick-look-compact-toggle-btn")) {
            objectListSection.appendChild(compactToggle)
        }
        compactToggle.before(document.createTextNode("\xA0"))
        if (
            uniqTypes === 1 &&
            document.querySelectorAll(
                `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li .non-modified-tag-in-quick-view`,
            ).length < 5
        ) {
            compactToggle.style.display = "none"
            document.querySelectorAll(".non-modified-tag-in-quick-view").forEach(i => {
                i.removeAttribute("hidden")
            })
        }
        if (multipleChangesets && compactToggle.style.display !== "none") {
            document.querySelectorAll(`[changeset-id="${changesetID}"]`).forEach(changeset => {
                const forHide = document.querySelectorAll(
                    `[changeset-id="${changeset.getAttribute("changeset-id")}"]#changeset_nodes .tags-non-modified:not(.location-modified)`,
                )
                forHide.forEach(i => {
                    i.setAttribute("hidden", "true")
                })
                document.querySelectorAll(`[changeset-id="${changeset.getAttribute("changeset-id")}"]#changeset_nodes`).forEach(i => {
                    if (!i.querySelector("li:not([hidden])")) {
                        i.setAttribute("hidden", "true")
                    }
                })
            })
        }
    }

    /**
     * Загружает историю объектов и показывает дифф тегов. Не использует Overpass API
     * @param {"node"|"way"|"relation"} objType
     * @param {0|1|2|3} uniqTypes - сколько различных типов OSM объектов пришло
     * @return {Promise<void>}
     */
    async function processObjects(objType, uniqTypes) {
        pinnedRelations = new Set()
        const objects = document.querySelectorAll(
            `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object):not(.object-in-process)`,
        )
        if (objects.length === 0) {
            return
        }
        objects.forEach(i => {
            i.classList.add("object-in-process")
        })

        const needFetch = []

        try {
            if (objType === "relation") {
                for (let i of document.querySelectorAll(
                    `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                )) {
                    const [, , objID, strVersion] = i
                        .querySelector("a:nth-of-type(2)")
                        .href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/)
                    const version = parseInt(strVersion)
                    if (version === 1) {
                        needFetch.push(objID + "v" + version)
                        needFetch.push(objID)
                    } else {
                        needFetch.push(objID + "v" + (version - 1))
                        needFetch.push(objID + "v" + version)
                        needFetch.push(objID)
                    }
                }
                const res = await fetchRetry(osm_server.apiBase + `${objType}s.json?${objType}s=` + needFetch.join(","), {
                    signal: getAbortController().signal,
                })
                if (res.status === 404) {
                    for (let i of document.querySelectorAll(
                        `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                    )) {
                        await processObject(i, objType, ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(i))))
                    }
                } else {
                    /**
                     * @type {RelationVersion[]}
                     */
                    const versions = (await res.json()).elements
                    /**
                     * @type {Object.<number, Object.<number, RelationVersion>>}
                     */
                    const objectsVersions = {}
                    Object.entries(Object.groupBy(Array.from(versions), i => i.id)).forEach(([id, history]) => {
                        objectsVersions[id] = Object.fromEntries(
                            Object.entries(Object.groupBy(history, i => i.version)).map(([version, val]) => [version, val[0]]),
                        )
                    })
                    for (let i of document.querySelectorAll(
                        `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                    )) {
                        const [, , objID, strVersion] = i
                            .querySelector("a:nth-of-type(2)")
                            .href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/)
                        const version = parseInt(strVersion)
                        await processObject(i, objType, ...getPrevTargetLastVersions(Object.values(objectsVersions[objID]), version))
                    }
                }
            } else {
                const elems = Array.from(
                    document.querySelectorAll(
                        `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`,
                    ),
                )
                for (const elem of arraySplit(elems, elems.length > 520 ? 10 : 1)) {
                    await Promise.all(
                        elem.map(async function (i) {
                            await processObject(i, objType, ...getPrevTargetLastVersions(...(await getHistoryAndVersionByElem(i))))
                        }),
                    )
                }
            }
        } finally {
            objects.forEach(i => {
                i.classList.remove("object-in-process")
            })
        }

        // reorder non-interesting-objects
        // todo potential crash
        const objectsList = document.querySelector(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li`).parentElement
        Array.from(
            document.querySelectorAll(
                `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-uninterested-modified.location-modified`,
            ),
        ).forEach(i => {
            objectsList.appendChild(i)
        })
        Array.from(
            document.querySelectorAll(
                `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-uninterested-modified:not(.location-modified)`,
            ),
        ).forEach(i => {
            objectsList.appendChild(i)
        })
        Array.from(
            document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-non-modified`),
        ).forEach(i => {
            objectsList.appendChild(i)
        })
        Array.from(
            document.querySelectorAll(
                `[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-non-modified:not(.location-modified)`,
            ),
        ).forEach(i => {
            objectsList.appendChild(i)
        })

        if (multipleChangesets) {
            document.querySelectorAll("#changeset_nodes .tags-non-modified:not(.location-modified)").forEach(i => {
                i.setAttribute("hidden", "true")
            })
        }

        addCompactModeToggles(objType, uniqTypes)
        console.debug("tags processing stage finished")
    }

    try {
        console.time(`QuickLook ${changesetID}`)
        console.log(`%cQuickLook for ${changesetID}`, "background: #222; color: #bada55")

        /** @type {("way" | "node" | "relation")[]} */
        const osmTypesOrder = ["way", "node", "relation"]

        /** @type {0|1|2|3} */
        let uniqTypes = 0
        for (const objType of osmTypesOrder) {
            if (document.querySelectorAll(`#changeset_${objType}s`).length > 0) {
                uniqTypes++
            }
        }
        if (uniqTypes === 0) {
            console.warn("found 0 uniqTypes")
        }

        for (const objType of osmTypesOrder) {
            await processObjects(objType, uniqTypes)
        }
        const changesetDataPromise = getChangeset(changesetID)
        await interceptMapManuallyPromise
        await safeCallForSafari(async () => {
            for (const objType of osmTypesOrder) {
                await processObjectsInteractions(objType, uniqTypes, changesetID)
            }
        })
        const changesetData = (await changesetDataPromise).data
        const paginationSelector = document.querySelector(".numbered_pagination") ? ".numbered_pagination" : ".pagination"

        // osm.org/changeset/170309417
        function dropNodesPagination(changesetData) {
            const pagination = Array.from(
                document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_nodes ${paginationSelector}`),
            ).find(i => {
                return Array.from(i.querySelectorAll("a.page-link")).some(a => a.href?.includes("node"))
            })
            if (!pagination) {
                return false
            }
            const nodesUl =
                pagination.parentElement.querySelector("ul.list-unstyled") ??
                pagination.parentElement.parentElement.querySelector("ul.list-unstyled")
            const nodes = Array.from(changesetData.querySelectorAll("node"))
            const other = changesetData.querySelectorAll("way,relation").length
            if (nodes.length > 1200 && !isDebug()) {
                if (other > 20 || isMobile) {
                    // fixme bump
                    return false
                }
                if (nodes.length > 3500 && isMobile) {
                    return false
                }
                if (nodes.length > 6000) {
                    return false
                }
            }
            pagination.remove()
            try {
                document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes h4 .count-number`).textContent = `1-${nodes.length}`
            } catch (e) {
                console.error(e)
            }
            return { nodes, nodesUl }
        }

        function insertPOIIcon(parentElem, objType, tags) {
            try {
                const [iconSrc, invert] = getPOIIconURL(objType, tags)
                if (isSafari) {
                    const tmpElem = document.createElement("span")
                    parentElem.appendChild(tmpElem)
                    fetchImageWithCache(iconSrc).then(async imgData => {
                        const img = document.createElement("img")
                        img.src = imgData
                        img.height = 20
                        img.width = 20
                        img.classList.add("align-bottom", "object-fit-none", "browse-icon")
                        if (invert) {
                            img.classList.add("browse-icon-invertible")
                        }
                        tmpElem.replaceWith(img)
                    })
                } else {
                    parentElem.appendChild(
                        GM_addElement("img", {
                            src: iconSrc,
                            height: 20,
                            width: 20,
                            class: "align-bottom object-fit-none browse-icon" + (invert ? " browse-icon-invertible" : ""),
                        }),
                    )
                }
            } catch (e) {
                console.error(e)
                const img = document.createElement("img")
                img.height = 20
                img.width = 20
                img.style.visibility = "hidden"
                parentElem.appendChild(img)
            }
        }

        function replaceNodes(nodes, nodesUl) {
            nodes.forEach(node => {
                if (document.getElementById(`${changesetID}n${node.id}v${node.getAttribute("version")}`)) {
                    return
                }
                const ulItem = document.createElement("li")
                const div1 = document.createElement("div")
                div1.classList.add("d-flex", "gap-1")
                ulItem.appendChild(div1)

                insertPOIIcon(
                    div1,
                    "node",
                    Array.from(node.querySelectorAll("tag[k]")).map(i => [i.getAttribute("k"), i.getAttribute("v")]),
                )

                const div2 = document.createElement("div")
                div2.classList.add("align-self-center")
                div1.appendChild(div2)

                div2.classList.add("node")
                div2.id = `${changesetID}n${node.id}v${node.getAttribute("version")}`

                const nodeLink = document.createElement("a")
                nodeLink.rel = "nofollow"
                nodeLink.href = `/node/${node.id}`
                if (node.querySelector('tag[k="name"]')?.getAttribute("v")) {
                    nodeLink.textContent = `${node.querySelector('tag[k="name"]')?.getAttribute("v")} (${node.id})`
                } else {
                    nodeLink.textContent = node.id
                }
                div2.appendChild(nodeLink)

                div2.appendChild(document.createTextNode(", "))

                const versionLink = document.createElement("a")
                versionLink.rel = "nofollow"
                versionLink.href = `/node/${node.id}/history/${node.getAttribute("version")}`
                versionLink.textContent = "v" + node.getAttribute("version")
                div2.appendChild(versionLink)

                Array.from(node.children).forEach(i => {
                    // todo
                    if (mainTags.includes(i.getAttribute("k"))) {
                        div2.classList.add(i.getAttribute("k"))
                        try {
                            div2.classList.add(i.getAttribute("v"))
                        } catch {
                            console.log(`skip tag with value: ${i.getAttribute("v")}`)
                        }
                    }
                })
                if (node.getAttribute("visible") === "false") {
                    div2.innerHTML = "<s>" + div2.innerHTML + "</s>"
                }
                nodesUl.appendChild(ulItem)
            })
        }

        function dropWaysPagination(changesetData) {
            const pagination = Array.from(
                document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_ways ${paginationSelector}`),
            ).find(i => {
                return Array.from(i.querySelectorAll("a.page-link")).some(a => a.href?.includes("way"))
            })
            if (!pagination) {
                return false
            }
            const waysUl =
                pagination.parentElement.querySelector("ul.list-unstyled") ??
                pagination.parentElement.parentElement.querySelector("ul.list-unstyled")
            const ways = Array.from(changesetData.querySelectorAll("way"))
            if (ways.length > 50 && !isDebug()) {
                if (ways.length > 200 && changesetData.querySelectorAll("node") > 40) {
                    return false
                }
                if (ways.length > 520 && isMobile) {
                    return false
                }
                if (ways.length > 5000) {
                    return false
                }
            }
            pagination.remove()
            try {
                document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways h4 .count-number`).textContent = `1-${ways.length}`
            } catch (e) {
                console.error(e)
            }
            return { ways, waysUl }
        }

        // todo unify
        function replaceWays(ways, waysUl) {
            ways.forEach(way => {
                if (document.getElementById(`${changesetID}w${way.id}v${way.getAttribute("version")}`)) {
                    return
                }
                const ulItem = document.createElement("li")
                const div1 = document.createElement("div")
                div1.classList.add("d-flex", "gap-1")
                ulItem.appendChild(div1)

                insertPOIIcon(
                    div1,
                    "way",
                    Array.from(way.querySelectorAll("tag[k]")).map(i => [i.getAttribute("k"), i.getAttribute("v")]),
                )

                const div2 = document.createElement("div")
                div2.classList.add("align-self-center")
                div1.appendChild(div2)

                div2.classList.add("way")
                div2.id = `${changesetID}w${way.id}v${way.getAttribute("version")}`

                const wayLink = document.createElement("a")
                wayLink.rel = "nofollow"
                wayLink.href = `/way/${way.id}`
                if (way.querySelector('tag[k="name"]')?.getAttribute("v")) {
                    wayLink.textContent = `${way.querySelector('tag[k="name"]')?.getAttribute("v")} (${way.id})`
                } else {
                    wayLink.textContent = way.id
                }
                div2.appendChild(wayLink)

                div2.appendChild(document.createTextNode(", "))

                const versionLink = document.createElement("a")
                versionLink.rel = "nofollow"
                versionLink.href = `/way/${way.id}/history/${way.getAttribute("version")}`
                versionLink.textContent = "v" + way.getAttribute("version")
                div2.appendChild(versionLink)

                Array.from(way.children).forEach(i => {
                    if (mainTags.includes(i.getAttribute("k"))) {
                        div2.classList.add(i.getAttribute("k"))
                        try {
                            div2.classList.add(i.getAttribute("v"))
                        } catch {
                            console.log(`skip tag with value: ${i.getAttribute("v")}`)
                        }
                    }
                })
                if (way.getAttribute("visible") === "false") {
                    div2.innerHTML = "<s>" + div2.innerHTML + "</s>"
                }
                waysUl.appendChild(ulItem)
            })
        }

        try {
            await initPOIIcons()
        } catch (e) {
            console.log(e)
            console.trace()
        }

        const waysRes = dropWaysPagination(changesetData)
        if (waysRes) {
            const batchSize = 300
            for (let i = 0; i < waysRes.ways.length; i += batchSize) {
                console.log(`Ways batch ${i}-${i + batchSize} / ${waysRes.ways.length}`)
                replaceWays(waysRes.ways.slice(i, i + batchSize), waysRes.waysUl)
                await processObjects("way", uniqTypes)
                await safeCallForSafari(async () => {
                    await processObjectsInteractions("way", uniqTypes, changesetID)
                })
            }
        }

        const nodesRes = dropNodesPagination(changesetData)
        if (nodesRes) {
            const batchSize = 3000
            for (let i = 0; i < nodesRes.nodes.length; i += batchSize) {
                console.log(`Nodes batch ${i}-${i + batchSize} / ${nodesRes.nodes.length}`)
                replaceNodes(nodesRes.nodes.slice(i, i + batchSize), nodesRes.nodesUl)
                await processObjects("node", uniqTypes)
                await safeCallForSafari(async () => {
                    await processObjectsInteractions("node", uniqTypes, changesetID)
                })
            }
        }

        function observePagination(obs) {
            const paginationSelector = document.querySelector(".numbered_pagination") ? ".numbered_pagination" : ".pagination"

            if (document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes ${paginationSelector}`)) {
                obs.observe(document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes`), {
                    attributes: true,
                })
            }
            if (document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways ${paginationSelector}`)) {
                obs.observe(document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways`), {
                    attributes: true,
                })
            }
            if (document.querySelector(`[changeset-id="${changesetID}"]#changeset_relations ${paginationSelector}`)) {
                obs.observe(document.querySelector(`[changeset-id="${changesetID}"]#changeset_relations`), {
                    attributes: true,
                })
            }
        }

        const obs = new MutationObserver(async (mutationList, observer) => {
            observer.disconnect()
            observer.takeRecords()
            for (const objType of ["way", "node", "relation"]) {
                await processObjects(objType, uniqTypes)
            }
            for (const objType of ["way", "node", "relation"]) {
                await processObjectsInteractions(objType, uniqTypes, changesetID)
            }
            observePagination(obs)
        })
        observePagination(obs)

        // try to find parent ways

        async function findParents() {
            performance.mark("FIND_PARENTS_BEGIN_" + changesetID)
            const nodesInChangesets = {}
            document
                .querySelectorAll(`[changeset-id="${changesetID}"]#changeset_nodes li:has(a[href^="/node/"]) > div > div`)
                .forEach(div => {
                    const prefix = div.id.match(/^([0-9]+n[0-9]+)/)[1]
                    if (!nodesInChangesets[prefix]) {
                        nodesInChangesets[prefix] = div
                    }
                })
            const nodesCount = changesetData.querySelectorAll(`node`)
            for (const i of changesetData.querySelectorAll(`node[version="1"]`)) {
                const nodeID = i.getAttribute("id")
                const nodeVersion = i.getAttribute("version")
                if (!i.querySelector("tag")) {
                    if (i.getAttribute("visible") === "false") {
                        // todo
                    } else if (
                        i.getAttribute("version") === "1" &&
                        !(await getChangeset(parseInt(changesetID))).nodesWithParentWays.has(parseInt(nodeID))
                    ) {
                        showNodeMarker(i.getAttribute("lat"), i.getAttribute("lon"), "#00a500", `${changesetID}n${nodeID}v${nodeVersion}`)
                    }
                }
            }
            /**
             * @type {Set<number>}
             */
            const processedNodes = new Set()
            /**
             * @type {Set<number>}
             */
            const processedWays = new Set()
            // fixme
            const changesetWaysSet = new Set(Array.from(changesetData.querySelectorAll(`way`)).map(i => parseInt(i.id)))
            const loadNodesParents = async nodes => {
                for (const nodeID of nodes) {
                    if (
                        ((await getChangeset(parseInt(changesetID))).nodesWithParentWays.has(nodeID) && nodesCount > 30) ||
                        processedNodes.has(parseInt(nodeID))
                    ) {
                        continue
                    }
                    const parents = await getParentWays(nodeID)

                    await Promise.all(
                        parents.map(async way => {
                            if (processedWays.has(way.id) || changesetWaysSet.has(way.id)) {
                                return
                            }
                            processedWays.add(way.id)
                            way.nodes.forEach(node => {
                                processedNodes.add(node)
                            })
                            const objID = way.id

                            const res = await fetchRetry(osm_server.apiBase + "way" + "/" + way.id + "/full.json", {
                                signal: getAbortController().signal,
                            })
                            if (!res.ok) {
                                // крааайне маловероятно
                                return
                            }
                            const lastElements = (await res.json()).elements
                            lastElements.forEach(n => {
                                if (n.type !== "node") return
                                if (n.version === 1) {
                                    nodesHistories[n.id] = [n]
                                }
                            })

                            if (!changesetMetadatas[changesetID]) {
                                console.log(`changesetMetadata[${changesetID}] not ready. Wait second...`)
                                await abortableSleep(1000, getAbortController()) // todo нужно поретраить
                            }

                            const res2 = await getWayNodesByTimestamp(changesetMetadatas[changesetID].closed_at, objID)
                            if (!res2) {
                                // если линия создана после правки
                                console.log(`skip parent w${objID} for ${nodeID}`)
                                return
                            }
                            const [targetVersion, currentNodesList] = res2
                            if (targetVersion.visible === false) {
                                console.log(`skip parent w${objID} for ${nodeID} because version not visible`)
                                return
                            }

                            const popup = document.createElement("span")
                            const link = document.createElement("a")
                            link.href = `/way/${way.id}`
                            link.target = "_blank"
                            link.textContent = "w" + way.id

                            const tagsTable = document.createElement("table")
                            const tbody = document.createElement("tbody")
                            Object.entries(way.tags ?? {}).forEach(tag => {
                                const row = document.createElement("tr")
                                const tagTd = document.createElement("th")
                                const tagTd2 = document.createElement("td")
                                tagTd.style.borderWidth = "2px"
                                tagTd2.style.borderWidth = "2px"
                                row.style.borderWidth = "2px"
                                row.appendChild(tagTd)
                                row.appendChild(tagTd2)
                                tagTd.textContent = tag[0]
                                tagTd2.textContent = tag[1]
                                tagTd.style.textAlign = "right"
                                tagTd2.style.textAlign = "right"
                                tbody.appendChild(row)
                            })
                            tagsTable.appendChild(tbody)
                            popup.appendChild(link)
                            popup.appendChild(tagsTable)
                            // todo показать по ховеру прошлую версию?
                            // prettier-ignore
                            const line = displayWay(
                                currentNodesList,
                                false,
                                "rgba(55,55,55,0.5)",
                                4,
                                `${changesetID}n${nodeID}v${targetVersion.version}`,
                                "customObjects",
                                null,
                                popup.outerHTML,
                                darkModeForMap && isDarkMode(),
                            )
                            if (layersHidden) {
                                line.getElement().style.visibility = "hidden"
                            }

                            // ховер в списке объектов, который показывает родительскую линию
                            way.nodes.forEach(n => {
                                if (!nodesInChangesets[`${changesetID}n${n}`]) return
                                nodesInChangesets[`${changesetID}n${n}`].parentElement.parentElement.addEventListener(
                                    "mouseover",
                                    async e => {
                                        if (e.relatedTarget?.parentElement === e.target) {
                                            return
                                        }
                                        showActiveWay(currentNodesList)
                                        resetMapHover()
                                        const targetTimestamp = new Date(
                                            new Date(changesetMetadatas[changesetID].created_at).getTime() - 1,
                                        ).toISOString()
                                        if (targetVersion.version > 1) {
                                            // show prev version
                                            const prevVersion = searchVersionByTimestamp(await getWayHistory(way.id), targetTimestamp)
                                            const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version)
                                            const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
                                            showActiveWay(
                                                nodesList,
                                                "rgb(238,146,9)",
                                                false,
                                                `${changesetID}w${objID}v${targetVersion.version}`,
                                                false,
                                                4,
                                                "4, 4",
                                            )

                                            // showActiveWay(currentNodesList, "rgba(55,55,55,0.5)", false, objID, false)
                                        } else {
                                            const prevVersion = searchVersionByTimestamp(await getWayHistory(way.id), targetTimestamp)
                                            if (prevVersion) {
                                                const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version)
                                                const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
                                                showActiveWay(
                                                    nodesList,
                                                    "rgb(238,146,9)",
                                                    false,
                                                    `${changesetID}w${objID}v${targetVersion.version}`,
                                                    false,
                                                    4,
                                                    "4, 4",
                                                )

                                                // showActiveWay(currentNodesList, "rgba(55,55,55,0.5)", false, objID, false)
                                            }
                                        }
                                        const curVersion = searchVersionByTimestamp(
                                            await getNodeHistory(n),
                                            changesetMetadatas[changesetID].closed_at ?? new Date(),
                                        )
                                        if (curVersion.version > 1) {
                                            const prevVersion = searchVersionByTimestamp(await getNodeHistory(n), targetTimestamp)
                                            showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), c("#0022ff"), false)
                                        }
                                        showActiveNodeMarker(curVersion.lat.toString(), curVersion.lon.toString(), c("#ff00e3"), false)
                                    },
                                )
                            })
                        }),
                    )
                }
            }
            const nodesWithModifiedLocation = Array.from(document.querySelectorAll("#changeset_nodes .location-modified div div")).map(i =>
                parseInt(i.id.match(/n(\d+)/)[1]),
            )
            await Promise.all(arraySplit(nodesWithModifiedLocation, 4).map(loadNodesParents))
            // fast hack
            // const someInterestingNodes = Array.from(changesetData.querySelectorAll("node")).filter(i => i.querySelector("tag[k=power],tag[k=entrance]")).map(i => parseInt(i.id))
            // await Promise.all(arraySplit(someInterestingNodes, 4).map(loadNodesParents))
            performance.mark("FIND_PARENTS_END_" + changesetID)
            console.debug(
                performance.measure("FIND_PARENTS", {
                    start: "FIND_PARENTS_BEGIN_" + changesetID,
                }),
                "FIND_PARENTS_END_" + changesetID,
            )
        }

        if (GM_config.get("ShowChangesetGeometry")) {
            console.log("%cTry find parents ways", "background: #222; color: #bada55")
            if (multipleChangesets) {
                // todo не стоит если пакетов мало?
                findParents().then(() => {
                    console.log(`Parents with ${changesetID} loaded`)
                })
            } else {
                await findParents()
            }
        }
    } catch (err) {
        // TODO notify user
        if (err.name === "AbortError") {
            console.debug("Some requests was aborted")
        } else {
            console.error(err)
            console.log("%cSetup QuickLook finished with error ⚠️", "background: #222; color: #bada55")

            function makeGithubIssueLink(text) {
                const a = document.createElement("a")
                a.classList.add("crash-report-link")
                a.href =
                    "https://github.com/deevroman/better-osm-org/issues/new?" +
                    new URLSearchParams({
                        body: text,
                        title: "Crash Report",
                        labels: "bug,crash",
                    }).toString()
                a.target = "_blank"
                a.appendChild(document.createTextNode(t("changesetQuicklook.sendBugReport")))
                a.title = t("changesetQuicklook.unableDisplaySomeData")
                return a
            }

            if (!isAbortError(err) && getMap()?.getZoom) {
                // eslint-disable-next-line no-debugger
                debugger
                try {
                    const reportText = makeCrashReportText(err)
                    if (!document.querySelector(".crash-report-link")) {
                        document.querySelector("#sidebar_content .secondary-actions").appendChild(document.createTextNode(" · "))
                        document.querySelector("#sidebar_content .secondary-actions").appendChild(makeGithubIssueLink(reportText))
                    }
                    if (isDebug()) {
                        setAttributionPrefix("⚠️")
                    }
                } catch {
                    /* empty */
                }
                if (isDebug()) {
                    alert(t("changesetQuicklook.debugAlert"))
                }
                // eslint-disable-next-line no-debugger
                debugger
                throw err
            }
        }
    } finally {
        quickLookInjectingStarted = false
        console.timeEnd(`QuickLook ${changesetID}`)
        console.log("%cSetup QuickLook finished", "background: #222; color: #bada55")
        // todo mark changeset as reviewed
    }
}

const currentChangesets = []

function drawBBox(bbox, options = { color: "#ff7800", weight: 1, fillOpacity: 0 }) {
    try {
        const bottomLeft = getMap().project(getWindow().L.latLng(bbox.min_lat, bbox.min_lon))
        const topRight = getMap().project(getWindow().L.latLng(bbox.max_lat, bbox.max_lon))
        const width = topRight.x - bottomLeft.x
        const height = bottomLeft.y - topRight.y
        const minSize = 10

        if (width < minSize) {
            bottomLeft.x -= (minSize - width) / 2
            topRight.x += (minSize - width) / 2
        }

        if (height < minSize) {
            bottomLeft.y += (minSize - height) / 2
            topRight.y -= (minSize - height) / 2
        }

        const b = getWindow().L.latLngBounds(getMap().unproject(intoPage(bottomLeft)), getMap().unproject(intoPage(topRight)))

        const bound = getWindow().L.rectangle(
            intoPage([
                [b.getSouth(), b.getWest()],
                [b.getNorth(), b.getEast()],
            ]),
            intoPage(options),
        )
        bound.on(
            "click",
            intoPageWithFun(function () {
                const elementById = document.getElementById(bbox.id)
                elementById?.scrollIntoView()
                resetMapHover()
                elementById?.parentElement?.parentElement?.classList.add("map-hover")
                cleanObjectsByKey("activeObjects")
            }),
        )
        bound.addTo(getMap())
        bound.bringToBack()
        layers["changesetBounds"].push(bound)
        return bound
    } catch (err) {
        console.trace(err)
    }
}

async function processQuickLookForCombinedChangesets(changesetID, changesetIDs) {
    await loadChangesetMetadatas(changesetIDs)
    await zoomToChangesets()
    for (let curID of changesetIDs) {
        currentChangesets.push(changesetMetadatas[curID])
    }

    for (let bbox of currentChangesets) {
        drawBBox(bbox)
    }
    drawBBox(changesetMetadatas[changesetID])
    getMap().on(
        "moveend zoomend",
        intoPageWithFun(function () {
            if (layersHidden) return
            for (let bound of layers["changesetBounds"]) {
                bound.remove()
            }
            layers["changesetBounds"] = []
            for (let bbox of currentChangesets) {
                drawBBox(bbox)
            }
            drawBBox(changesetMetadatas[changesetID])
        }),
    )

    const step = 10
    const changesetsQueue = []
    if (changesetIDs.length) {
        // preloading
        changesetIDs.slice(0, step).forEach(i => {
            changesetsQueue.push(fetchRetry(osm_server.url + "/changeset/" + i).then(async res => await res.text()))
        })
    }
    // MORE PRELOADING
    const waysForPreload = []
    await Promise.all(
        changesetIDs.map(async i => {
            const data = (await getChangeset(i)).data
            Array.from(data.querySelectorAll("way:not([version='1'])")).map(i => waysForPreload.push(parseInt(i.getAttribute("id"))))
        }),
    )
    await Promise.all(Array.from(new Set(waysForPreload)).map(i => getWayHistory(i)))

    for (let i = 0; i < changesetIDs.length; i++) {
        console.log(`${i + 1} / ${changesetIDs.length}`)
        const curID = changesetIDs[i]

        const res = await changesetsQueue.shift()

        const doc = new DOMParser().parseFromString(res, "text/html")
        const newPrevLink = getPrevChangesetLink(doc)
        if (newPrevLink) {
            const prevLink = getPrevChangesetLink()
            const prevID = extractChangesetID(prevLink.href)

            const newPrevID = extractChangesetID(newPrevLink.href)
            prevLink.childNodes[2].textContent = prevLink.childNodes[2].textContent.replace(prevID, newPrevID)
            prevLink.href = "/changeset/" + newPrevID
        }

        const divID = document.createElement("a")
        divID.id = curID
        divID.textContent = "#" + curID
        divID.href = "/changeset/" + curID
        divID.style.color = "var(--bs-body-color)"
        // todo add comment
        document.querySelector("turbo-frame:is(#changeset_nodes,#changeset_ways,#changeset_relations):last-of-type").after(divID)
        let prevFrame = null
        doc.querySelectorAll("turbo-frame:is(#changeset_nodes,#changeset_ways,#changeset_relations)").forEach(frame => {
            frame.setAttribute("changeset-id", curID)
            if (prevFrame) {
                prevFrame.after(frame)
            } else {
                divID.after(frame)
                prevFrame = frame
            }
        })
        setTimeout(async () => {
            await loadChangesetMetadata(parseInt(curID))
            const span = document.createElement("span")
            span.textContent = " " + shortOsmOrgLinksInText(changesetMetadatas[curID].tags["comment"] ?? "") // todo trim
            span.title = " " + (changesetMetadatas[curID].tags["comment"] ?? "")
            span.style.color = "gray"
            divID.after(span)
        })

        const promise = processQuickLookInSidebar(curID)
        if (i + step < changesetIDs.length) {
            changesetsQueue.push(fetchRetry(osm_server.url + "/changeset/" + changesetIDs[i + step]).then(async res => await res.text()))
        }
        await promise
    }
}

/*
function interceptRectangle() {
    console.log("intercept rectangle");
    injectJSIntoPage(`
    var layers = {}

    function makeColor(username) {
        let hash = 0;
        for (let i = 0; i < username.length; i++) {
            hash = username.charCodeAt(i) + 42 + ((hash << 5) - hash);
        }
        return '#' + ((hash >> 16) & 0xFFFFFF).toString(16).padStart(6, '0');
    }

    if (!window.rectangleIntercepted) {
        L.Rectangle.addInitHook((function () {
                return
                this.better_id = -1
                const layer = this
                Object.defineProperty(
                    this,
                    'id',
                    {
                        get: function () {
                            try {
                                const username = document.querySelector('#changeset_' + this.better_id + ' a[href^="/user/"]').textContent
                                // debugger
                                this.options.color = makeColor(username)
                            } catch (e) {
                            }

                            return this.better_id
                        },
                        set: function (val) {
                            if (location.pathname !== "/history") {
                                this.better_id = val;
                                return;
                            }
                            const username = document.querySelector('#changeset_' + val + ' a[href^="/user/"]').textContent
                            this.options.color = makeColor(username)

                            this.better_id = val
                        }
                    }
                );
                Object.defineProperty(
                    this.options,
                    'color',
                    {
                        get: function () {
                            if (location.pathname !== "/history") {
                                this.better_id = val;
                                return;
                            }
                            const username = document.querySelector('#changeset_' + layer.better_id + ' a[href^="/user/"]')?.textContent
                            if (!username) return "#000"
                            return makeColor(username)

                            // return this.better_options
                        },
                        set: function (color) {
                            // debugger
                            const username = document.querySelector('#changeset_' + layer.better_id + ' a[href^="/user/"]')?.textContent
                            if (!username) return color
                            return makeColor(username)
                        }
                    }
                );
            })
        )
        window.rectangleIntercepted = true
    }
    `)
}
*/

async function interceptMapManually() {
    if (!document.getElementById("map")) {
        console.log("#map for manually intercepting not found")
        return
    }
    if (getWindow().mapIntercepted) return
    try {
        console.warn("try intercept map manually")
        getWindow().scriptHandler = GM_info.scriptHandler
        injectJSIntoPage(`
        L.Layer.addInitHook(function () {
                if (window.mapIntercepted) return
                try {
                    this.addEventListener("add", (e) => {
                        if (window.mapIntercepted) return;
                        console.log("%cMap intercepted with workaround", 'background: #000; color: #0f0')
                        window.mapIntercepted = true
                        window.map = e.target._map;
                        if (!window.scriptInstance) {
                            window.scriptInstance = window.scriptHandler;
                        } else if (window.scriptInstance !== window.scriptHandler) {
                            console.error(\`Two copies of the script were running simultaneously via ${window.scriptInstance} and ${window.scriptInstance}. Turn off one of them\`)
                        }
                    })
                } catch (e) {
                    console.error(e)
                }
            }
        )
        `)
        // trigger Layer creation
        let exportImageBtn = document.querySelector("#export-image #image_filter")
        if (!exportImageBtn) {
            await sleep(10)
            exportImageBtn = document.querySelector("#export-image #image_filter")
            if (!exportImageBtn) {
                await sleep(10)
                exportImageBtn = document.querySelector("#export-image #image_filter")
            }
        }
        if (getWindow().mapIntercepted) {
            console.log("skip manual intercepting: already intercepted")
            return
        }
        exportImageBtn.click()
        exportImageBtn.click()
        if (!getWindow().mapIntercepted) {
            console.warn("wait for map intercepting")
            await sleep(9)
        }
    } catch (e) {
        console.error(e)
    }
}

function ogfFixes(changeset_id) {
    for (let type of ["node", "way", "relation"]) {
        const wrapper = document.createElement("turbo-frame")
        wrapper.setAttribute("id", `changeset_${type}s`)
        const ul = document.querySelector(`ul:has(.${type})`)
        if (!ul) {
            continue
        }
        ul.before(wrapper)
        wrapper.appendChild(ul)
        wrapper.prepend(wrapper.previousElementSibling)

        wrapper.querySelectorAll(`li a[href^="/${type}"]`).forEach(a => {
            const div1 = document.createElement("div")
            const div2 = document.createElement("div")
            div1.appendChild(div2)
            a.parentElement.prepend(div1)
            div2.appendChild(a)
            const [, id] = a.getAttribute("href").match(/\/([0-9]+)/)
            const [, v] = a.textContent.match(/v([0-9]+)/)
            div2.setAttribute("id", `${changeset_id}${type.slice(0, 1)}${id}v${v}`)

            const versionLink = document.createElement("a")
            versionLink.setAttribute("href", `/${type}/${id}/history/${v}`)
            versionLink.textContent = `v${v}`
            a.after(versionLink)

            a.textContent = a.textContent.replace(`v${v}`, "")
        })

        wrapper.querySelector(".paginate")?.classList?.add("pagination")
        wrapper.querySelectorAll(`.paginate a[href*="?${type}_page"]`).forEach(a => {
            a.classList.add("page-link")
        })
    }
}

async function addChangesetQuickLook() {
    if (quickLookInjectingStarted) return
    if (!location.pathname.startsWith("/changeset")) {
        allTagsOfObjectsVisible = true
        return
    }
    addQuickLookStyles()
    if (document.querySelector(".quick-look")) return true
    const sidebar = document.querySelector("#sidebar_content h2")
    if (!sidebar) {
        return
    }
    if (!document.querySelector("turbo-frame:is(#changeset_nodes,#changeset_ways,#changeset_relations)")) {
        if (!isOGFServer()) {
            console.log("changeset is empty")
            return
        }
    }
    quickLookInjectingStarted = true
    resetSearchFormFocus()
    void geocodeCurrentView()
    makeTimesSwitchable()
    if (GM_config.get("ResizableSidebar")) {
        document.querySelector("#sidebar").style.resize = "horizontal"
    }
    addSwipes()
    removeEditTagsButton()

    const changesetID = location.pathname.match(/changeset\/(\d+)/)[1]
    if (isOGFServer() && !document.querySelector("turbo-frame")) {
        ogfFixes(changesetID)
    }
    document
        .querySelectorAll("turbo-frame:is(#changeset_nodes,#changeset_ways,#changeset_relations)")
        .forEach(i => i.setAttribute("changeset-id", changesetID))

    const params = new URLSearchParams(location.search)
    let changesetIDs = []
    if (params.get("changesets")) {
        changesetIDs =
            params
                .get("changesets")
                ?.split(",")
                ?.filter(i => i !== changesetID) ?? []
    }

    if (!GM_config.get("NavigationViaHotkeys")) {
        setTimeout(loadChangesetMetadata, 0)
    }
    await processQuickLookInSidebar(changesetID)

    if (changesetIDs.length) {
        await processQuickLookForCombinedChangesets(changesetID, changesetIDs)
    }

    if (needPreloadChangesets) {
        preloadPrevNextChangesets(changesetID)
    }
}

function setupChangesetQuickLook() {
    if (!location.pathname.startsWith("/changeset")) return
    tryApplyModule(addChangesetQuickLook, 100, 4000)
}

//</editor-fold>

//<editor-fold desc="photos-gallery" defaultstate="collapsed">

let photosHoverZoomToken = 0

function hidePhotosHoverZoomPopup() {
    const hoverZoomPopup = document.getElementById("photos-hover-zoom-popup")
    if (!hoverZoomPopup) {
        return
    }
    hoverZoomPopup.style.display = "none"
    hoverZoomPopup.style.visibility = "visible"
}

function getOrCreatePhotosHoverZoomPopup() {
    let hoverZoomPopup = document.getElementById("photos-hover-zoom-popup")
    if (!hoverZoomPopup) {
        hoverZoomPopup = document.createElement("div")
        hoverZoomPopup.id = "photos-hover-zoom-popup"
        Object.assign(hoverZoomPopup.style, {
            position: "fixed",
            display: "none",
            zIndex: "100000",
            pointerEvents: "auto",
            padding: "6px",
            background: "var(--bs-body-bg)",
            borderRadius: "10px",
            boxShadow: "0 8px 24px rgba(0, 0, 0, 0.25)",
            maxWidth: "calc(100vw - 16px)",
            overflowX: "auto",
            overflowY: "hidden",
            touchAction: "pan-x",
            overscrollBehaviorX: "contain",
            WebkitOverflowScrolling: "touch",
        })
        hoverZoomPopup.addEventListener("pointerdown", e => {
            e.stopPropagation()
        })
        hoverZoomPopup.addEventListener(
            "touchstart",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        hoverZoomPopup.addEventListener(
            "touchmove",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        hoverZoomPopup.addEventListener(
            "touchend",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        hoverZoomPopup.addEventListener(
            "touchcancel",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        hoverZoomPopup.addEventListener("contextmenu", e => {
            e.stopPropagation()
            e.preventDefault()
        })
        document.body.append(hoverZoomPopup)
    }
    return hoverZoomPopup
}

function placePhotosHoverZoomPopup(previewEl, hoverZoomPopup) {
    const previewRect = previewEl.getBoundingClientRect()
    const popupRect = hoverZoomPopup.getBoundingClientRect()
    const popupWidth = popupRect.width || 494
    const popupHeight = popupRect.height || 374
    const viewportWidth = window.innerWidth
    const viewportHeight = window.innerHeight
    const edgePadding = 8
    const gap = 10

    let left = previewRect.left + previewRect.width / 2 - popupWidth / 2
    left = Math.max(edgePadding, Math.min(left, viewportWidth - popupWidth - edgePadding))

    let top = previewRect.top - gap - popupHeight
    if (top < edgePadding) {
        top = previewRect.bottom + gap
    }
    if (top + popupHeight > viewportHeight - edgePadding) {
        top = Math.max(edgePadding, viewportHeight - popupHeight - edgePadding)
    }

    hoverZoomPopup.style.left = `${left}px`
    hoverZoomPopup.style.top = `${top}px`
    hoverZoomPopup.style.transform = "none"
}

function attachPhotosCarouselHoverZoom(previewEl) {
    if (previewEl.getAttribute("data-hover-zoom-bound")) {
        return
    }
    previewEl.setAttribute("data-hover-zoom-bound", "1")

    const hoverZoomPopup = getOrCreatePhotosHoverZoomPopup()
    const hideHoverZoom = () => {
        hidePhotosHoverZoomPopup()
    }

    previewEl.addEventListener("mouseenter", () => {
        const hoverToken = ++photosHoverZoomToken
        hoverZoomPopup.style.display = "none"
        hoverZoomPopup.replaceChildren()
        if (hoverToken !== photosHoverZoomToken) {
            return
        }
        if (!document.body.contains(previewEl)) {
            return
        }
        const zoomImgSrc = previewEl.getAttribute("data-photo-zoom-src")
        if (!zoomImgSrc) {
            return
        }
        const thumbImgSrc = previewEl.getAttribute("data-photo-thumb-src")
        if (!thumbImgSrc) {
            return
        }
        hoverZoomPopup.replaceChildren()
        const zoomWrapper = document.createElement("div")
        Object.assign(zoomWrapper.style, {
            position: "relative",
            width: "480px",
            height: "360px",
            borderRadius: "6px",
            overflow: "hidden",
            background: "var(--bs-body-bg)",
        })
        const thumbImg = GM_addElement("img", {
            src: thumbImgSrc,
            width: "480",
        })
        Object.assign(thumbImg.style, {
            display: "block",
            width: "480px",
            height: "360px",
            objectFit: "cover",
            borderRadius: "6px",
        })
        const zoomImg = GM_addElement("img", {
            src: zoomImgSrc,
            width: "480",
        })
        Object.assign(zoomImg.style, {
            display: "block",
            position: "absolute",
            inset: "0",
            opacity: "0",
            width: "480px",
            height: "360px",
            objectFit: "cover",
            borderRadius: "6px",
        })
        zoomImg.onerror = () => {
            zoomImg.style.display = "none"
        }
        zoomImg.onload = () => {
            if (hoverToken !== photosHoverZoomToken) {
                return
            }
            zoomImg.style.opacity = "1"
        }
        zoomWrapper.append(thumbImg)
        zoomWrapper.append(zoomImg)
        hoverZoomPopup.append(zoomWrapper)
        hoverZoomPopup.style.visibility = "hidden"
        hoverZoomPopup.style.display = "block"
        placePhotosHoverZoomPopup(previewEl, hoverZoomPopup)
        hoverZoomPopup.style.visibility = "visible"
    })
    previewEl.addEventListener("mousemove", () => {
        if (hoverZoomPopup.style.display !== "none") {
            placePhotosHoverZoomPopup(previewEl, hoverZoomPopup)
        }
    })
    previewEl.addEventListener("mouseleave", e => {
        const nextEl = e.relatedTarget
        if (nextEl && nextEl.closest?.("#photos-preview-gallery")) {
            return
        }
        photosHoverZoomToken += 1
        hideHoverZoom()
    })
    previewEl.addEventListener("click", hideHoverZoom)
}

/**
 * @param {{tags, type, id}[]} withPhotos
 */
function renderPhotosPreview(withPhotos) {
    const mapEl = document.getElementById("map")
    if (!mapEl) {
        return
    }
    Object.assign(mapEl.style, {
        position: "relative",
    })

    let photosPreviewGallery = document.getElementById("photos-preview-gallery")
    if (!photosPreviewGallery) {
        photosPreviewGallery = document.createElement("div")
        photosPreviewGallery.id = "photos-preview-gallery"
        Object.assign(photosPreviewGallery.style, {
            position: "absolute",
            left: "0",
            transform: "none",
            bottom: "0",
            width: "100%",
            maxWidth: "100%",
            height: "96px",
            padding: "10px 12px",
            boxSizing: "border-box",
            display: "flex",
            gap: "8px",
            overflowX: "auto",
            overflowY: "hidden",
            zIndex: "99999",
            background: "transparent",
            touchAction: "pan-x",
            overscrollBehaviorX: "contain",
            WebkitOverflowScrolling: "touch",
        })
        photosPreviewGallery.addEventListener(
            "wheel",
            e => {
                e.stopPropagation()
                if (e.deltaX !== 0 || e.deltaY !== 0) {
                    photosPreviewGallery.scrollLeft += e.deltaX + e.deltaY
                    e.preventDefault()
                }
            },
            { passive: false },
        )
        photosPreviewGallery.addEventListener("pointerdown", e => {
            e.stopPropagation()
        })
        photosPreviewGallery.addEventListener(
            "touchstart",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        photosPreviewGallery.addEventListener(
            "touchmove",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        photosPreviewGallery.addEventListener(
            "touchend",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        photosPreviewGallery.addEventListener(
            "touchcancel",
            e => {
                e.stopPropagation()
            },
            { passive: true },
        )
        photosPreviewGallery.addEventListener("contextmenu", e => {
            e.stopPropagation()
            e.preventDefault()
        })
        photosPreviewGallery.addEventListener("mouseleave", () => {
            hidePhotosHoverZoomPopup()
        })
        mapEl.append(photosPreviewGallery)
    }

    photosPreviewGallery.replaceChildren()
    function makePlaceholder() {
        const placeholder = document.createElement("div")
        placeholder.classList.add("photo-preview")
        Object.assign(placeholder.style, {
            flex: "0 0 72px",
            height: "72px",
            border: "1px solid #c7c7c7",
            borderRadius: "8px",
            background: "#f3f3f3",
            overflow: "hidden",
            pointerEvents: "auto",
            cursor: "pointer",
        })
        return placeholder
    }
    withPhotos.forEach(photoObj => {
        Object.entries(photoObj.tags ?? {}).forEach(([k, v]) => {
            if (k.startsWith("panoramax")) {
                v.split(";").forEach(i => {
                    const uuid = i?.match?.(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0]
                    const placeholder = makePlaceholder()
                    if (uuid) {
                        placeholder.setAttribute("data-panoramax-uuid", uuid.toLowerCase())
                    }
                    placeholder.setAttribute("data-osm-path", "/" + photoObj.type + "/" + photoObj.id)
                    photosPreviewGallery.append(placeholder)
                })
            } else if (k.startsWith("mapillary")) {
                v.split(";").forEach(i => {
                    const id = i?.match?.(/[0-9]+/)?.[0]
                    const placeholder = makePlaceholder()
                    if (id) {
                        placeholder.setAttribute("data-mapillary-id", id)
                    }
                    placeholder.setAttribute("data-osm-path", "/" + photoObj.type + "/" + photoObj.id)
                    photosPreviewGallery.append(placeholder)
                })
            } else if (k.startsWith("wikimedia_commons")) {
                v.split(";").forEach(i => {
                    const placeholder = makePlaceholder()
                    placeholder.setAttribute("data-wikimedia-id", i)
                    placeholder.setAttribute("data-osm-path", "/" + photoObj.type + "/" + photoObj.id)
                    photosPreviewGallery.append(placeholder)
                })
            } else if (k.startsWith("ref:inaturalist.org")) {
                v.split(";").forEach(i => {
                    const placeholder = makePlaceholder()
                    placeholder.setAttribute("data-inaturalist-id", i)
                    placeholder.setAttribute("data-osm-path", "/" + photoObj.type + "/" + photoObj.id)
                    photosPreviewGallery.append(placeholder)
                })
            }
        })
    })

    function addClick(elem) {
        const osmPath = elem.getAttribute("data-osm-path")
        if (osmPath && !elem.getAttribute("data-route-bound")) {
            elem.setAttribute("data-route-bound", "1")
            elem.onclick = e => {
                e.stopPropagation()
                getWindow().OSM.router.route(osmPath)
            }
        }
    }

    function addImg(thumbSrc, previewEl) {
        const img = GM_addElement("img", {
            src: thumbSrc,
            width: "100%",
        })
        Object.assign(img.style, {
            height: "100%",
            objectFit: "cover",
        })
        img.onerror = () => {
            img.style.display = "none"
        }
        previewEl.append(img)
    }

    document.querySelectorAll("#photos-preview-gallery .photo-preview[data-panoramax-uuid]").forEach(previewEl => {
        addClick(previewEl)
        const uuid = previewEl.getAttribute("data-panoramax-uuid")
        if (!uuid) {
            return
        }
        const thumbSrc = `${panoramaxDiscoveryServer}/api/pictures/${uuid}/thumb.jpg`
        previewEl.setAttribute("data-photo-thumb-src", thumbSrc)
        previewEl.setAttribute("data-photo-zoom-src", `${panoramaxDiscoveryServer}/api/pictures/${uuid}/sd.jpg`)
        attachPhotosCarouselHoverZoom(previewEl)
        if (previewEl.querySelector("img")) {
            return
        }
        addImg(thumbSrc, previewEl)
        void attachPanoramaxHoverCaptureHandler(previewEl, uuid, panoramaxDiscoveryServer)
    })

    document.querySelectorAll("#photos-preview-gallery .photo-preview[data-mapillary-id]").forEach(previewEl => {
        addClick(previewEl)
        setTimeout(async () => {
            const id = previewEl.getAttribute("data-mapillary-id")
            const res = await downloadMapillaryPhotoInfo(id)
            if (res["error"]) {
                return
            }
            const thumbSrc = res["thumb_256_url"]
            previewEl.setAttribute("data-photo-thumb-src", thumbSrc)
            previewEl.setAttribute("data-photo-zoom-src", res["thumb_1024_url"])
            attachPhotosCarouselHoverZoom(previewEl)
            if (previewEl.querySelector("img")) {
                return
            }
            addImg(thumbSrc, previewEl)
            attachMapillaryHoverCaptureHandler(previewEl, res)
        })
    })

    document.querySelectorAll("#photos-preview-gallery .photo-preview[data-wikimedia-id]").forEach(previewEl => {
        addClick(previewEl)
        setTimeout(async () => {
            const id = previewEl.getAttribute("data-wikimedia-id")
            let resolvedId = id
            if (id?.startsWith("Category:")) {
                const categoryResponse = await downloadWikimediaCategoryInfo(id, 1)
                resolvedId = categoryResponse?.query?.categorymembers?.[0]?.title
            }
            if (!resolvedId) {
                return
            }
            const res = await downloadWikimediaInfo(resolvedId)
            const thumbSrc = res["query"]["pages"]["-1"]["imageinfo"][0]["thumburl"]

            previewEl.setAttribute("data-photo-thumb-src", thumbSrc)
            previewEl.setAttribute("data-photo-zoom-src", res["query"]["pages"]["-1"]["imageinfo"][0]["url"])
            attachPhotosCarouselHoverZoom(previewEl)
            if (previewEl.querySelector("img")) {
                return
            }
            addImg(thumbSrc, previewEl)
            attachWikimediaHoverCaptureHandler(previewEl, res)
        })
    })

    document.querySelectorAll("#photos-preview-gallery .photo-preview[data-inaturalist-id]").forEach(previewEl => {
        addClick(previewEl)
        setTimeout(async () => {
            const id = previewEl.getAttribute("data-inaturalist-id")
            const response = await downloadInaturalistInfo(id)
            if (response["error"]) {
                return
            }
            const observation = response?.results?.[0]
            if (!observation) {
                return
            }
            const { thumbSrc, zoomSrc } = getInaturalistPhotoUrls(observation)
            if (!thumbSrc || !zoomSrc) {
                return
            }

            previewEl.setAttribute("data-photo-thumb-src", thumbSrc)
            previewEl.setAttribute("data-photo-zoom-src", zoomSrc)
            attachPhotosCarouselHoverZoom(previewEl)
            if (previewEl.querySelector("img")) {
                return
            }
            addImg(thumbSrc, previewEl)
            attachInaturalistHoverCaptureHandler(previewEl, observation)
        })
    })
}

//</editor-fold>

//<editor-fold desc="panoramax-upload" defaultstate="collapsed">

async function getPanoramaxToken() {
    const res = await externalFetch({
        url: `${panoramaxInstance}/api/users/me/tokens`,
        responseType: "json",
    })
    if (!res.response?.[0]?.links?.[0]?.href) {
        console.error(res.responseText)
        alert(t("panoramax.loginRequiredAlert"))
        window.open(panoramaxInstance, "_blank")
        return
    }
    const res1 = await externalFetch({
        url: res.response[0].links[0].href,
        responseType: "json",
    })
    console.log("Panoramax token obtained")
    return res1.response.jwt_token
}

async function createUploadSet(apiUrl, token, title) {
    const response = await externalFetch({
        url: `${apiUrl}/api/upload_sets`,
        method: "POST",
        headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
            "User-Agent": `better osm.org v${GM_info.script.version}`,
        },
        data: JSON.stringify({
            title: title,
            estimated_nb_files: 1,
        }),
        responseType: "json",
    })

    if (response.status !== 200) {
        throw new Error(`Failed to create upload set: HTTP ${response.status}\n\n${response.response?.details?.error}`)
    }

    const data = await response.response
    return data.id || data.upload_set_id
}

async function uploadPhotoToSet(apiUrl, token, uploadSetId, file, isBlurred) {
    const formData = new FormData()
    formData.append("isBlurred", isBlurred)
    formData.append("file", file)

    const response = await externalFetch({
        url: `${apiUrl}/api/upload_sets/${uploadSetId}/files`,
        method: "POST",
        headers: {
            Authorization: `Bearer ${token}`,
        },
        responseType: "json",
        data: formData,
    })

    if (response.status === 409) {
        return {
            picture_id: (await response.response).upload_sets[0].existing_item_id,
        }
    }

    if (response.status < 200 || response.status >= 300) {
        console.error(response)
        throw new Error(
            `Photo upload failed, HTTP ${response.status}:\n\n${response.response?.details?.error}\n\nFull log in browser console (F12)`,
        )
    }

    return await response.response
}

async function deleteUploadSet(apiUrl, token, uploadSetId) {
    const response = await externalFetch({
        url: `${apiUrl}/api/upload_sets/${uploadSetId}`,
        method: "DELETE",
        headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
        },
        responseType: "json",
    })

    if (response.status !== 204) {
        throw new Error("Failed to delete upload set: " + response.status)
    }

    return await response.response
}

async function completeUploadSet(apiUrl, token, uploadSetId) {
    const response = await externalFetchRetry({
        url: `${apiUrl}/api/upload_sets/${uploadSetId}/complete`,
        method: "POST",
        headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
        },
        responseType: "json",
    })

    if (response.status !== 200) {
        throw new Error("Failed to complete upload set: " + response.status)
    }

    return await response.response
}

let panoramaxInstance = "https://panoramax.openstreetmap.fr"
let lastUploadedPanoramaxPicture

GM.getValue("panoramaxInstance").then(res => {
    panoramaxInstance = res ?? "https://panoramax.openstreetmap.fr"
})
GM.getValue("lastUploadedPanoramaxPicture").then(res => {
    if (res) {
        lastUploadedPanoramaxPicture = JSON.parse(res)
    }
})

async function uploadImage(token, file, title, needBlur) {
    const uploadSetId = await createUploadSet(panoramaxInstance, token, title)
    console.log(`Upload set created:${uploadSetId}. ${needBlur ? "Upload with blurring" : "Upload without blurring"}. Uploading...`)

    let picture_id
    try {
        const uploadRes = await uploadPhotoToSet(panoramaxInstance, token, uploadSetId, file, !needBlur)
        picture_id = uploadRes.picture_id
        console.log("Uploaded file:", uploadRes, "Finishing...")
    } catch (e) {
        console.log("failed upload. Cleaning uploadSet...")
        try {
            await deleteUploadSet(panoramaxInstance, token, uploadSetId)
        } catch (e) {
            console.error("Cleaning uploadSet failed", e)
        }
        console.log("uploadSet cleaned")
        throw e
    }

    try {
        const completeRes = await completeUploadSet(panoramaxInstance, token, uploadSetId)
        console.log("Upload set completed:", completeRes)
    } catch (e) {}

    return picture_id
}

async function addPanoramaxTag(pictureId, object_type, object_id) {
    const objectInfo = await getOsmObjectInfo(object_type, object_id)
    const newTag = objectInfo.createElement("tag")
    newTag.setAttribute("k", "panoramax")
    newTag.setAttribute("v", pictureId)
    objectInfo.querySelector("tag").after(newTag)

    const changesetId = await openOsmChangeset("Add Panoramax image")
    try {
        objectInfo.children[0].children[0].setAttribute("changeset", changesetId)

        const objectInfoStr = new XMLSerializer().serializeToString(objectInfo).replace(/xmlns="[^"]+"/, "")
        console.log(objectInfoStr)
        await osmEditAuth
            .fetch(osm_server.apiBase + object_type + "/" + object_id, {
                method: "PUT",
                prefix: false,
                body: objectInfoStr,
            })
            .then(async res => {
                const text = await res.text()
                if (res.ok) return text
                alert(text)
                throw new Error(text)
            })
    } finally {
        await closeOsmChangeset(changesetId)
    }
}

function addUploadPanoramaxBtn() {
    if (!isDebug() && !GM_config.get("PanoramaxUploader") && !isMobile) return
    if (!document.querySelector("#sidebar_content nav")) {
        return
    }
    if (!document.querySelector(".secondary-actions") || document.querySelector(".restore_object_button_class")) {
        return
    }
    if (!location.pathname.match(/\/(node|way|relation)\/[0-9]+\/?$/)) {
        return
    }
    if (document.querySelector(".upload-to-panoramax")) {
        return
    }

    if (!document.querySelector('a[href*="https://wiki.openstreetmap.org/wiki/"]')) {
        return
    }
    // if (!document.querySelector(':where(a[href*="Key:shop"], a[href*="Key:amenity"], a[href*="Key:tourism"], a[href*="Key:utility"], a[href*="Tag:natural=tree"])')) {
    //     return
    // }
    if (document.querySelector('a[href^="https://wiki.openstreetmap.org/wiki/Key:panoramax"]')) {
        return
    }
    const wrapper = document.createElement("div")
    wrapper.style.setProperty("padding-bottom", "1px", "important")
    const firstBlock = document.createElement("div")
    const secondBlock = document.createElement("div")
    secondBlock.style.paddingTop = "5px"
    const thirdBlock = document.createElement("div")
    thirdBlock.style.paddingTop = "5px"
    wrapper.appendChild(firstBlock)
    wrapper.appendChild(secondBlock)
    wrapper.appendChild(thirdBlock)

    firstBlock.appendChild(document.createTextNode(t("panoramax.uploadPhoto")))

    const fileInput = document.createElement("input")
    fileInput.type = "file"
    if (!isMobile) {
        fileInput.accept = "image/*"
    }
    const preview = document.createElement("img")
    fileInput.onchange = async () => {
        uploadImgBtn.style.removeProperty("display")
        instanceInput.style.removeProperty("display")
        needBlueLabel.style.removeProperty("display")
        instanceInput.value = panoramaxInstance
        const fr = new FileReader()
        fr.onload = function () {
            preview.style.width = "100%"
            preview.src = fr.result
            thirdBlock.after(preview)
        }
        fr.readAsDataURL(fileInput.files[0])
    }
    firstBlock.appendChild(fileInput)

    const uploadImgBtn = document.createElement("button")
    uploadImgBtn.style.all = "unset"
    uploadImgBtn.style.cursor = "pointer"
    uploadImgBtn.classList.add("upload-to-panoramax", "bi", "bi-upload")
    uploadImgBtn.style.display = "none"
    uploadImgBtn.style.paddingLeft = "10px"
    uploadImgBtn.onclick = async () => {
        initOsmAuth()
        new URL(instanceInput.value)
        void GM.setValue("panoramaxInstance", (panoramaxInstance = instanceInput.value))
        if (!fileInput.files.length) {
            return alert(t("panoramax.selectFileAlert"))
        }
        wrapper.classList.add("is-loading")
        const file = fileInput.files[0]
        let metadata
        if (file.type.startsWith("image/jpeg")) {
            metadata = EXIF.readFromBinaryFile(await file.arrayBuffer())
            console.log("Metadata from EXIF", metadata)
            console.log(metadata.DateTime, metadata.GPSLatitude, metadata.GPSLongitude)
        }
        // TODO add client side validation
        try {
            const [, object_type, object_id] = location.pathname.match(/\/(node|way|relation)\/([0-9]+)/)
            const token = await getPanoramaxToken()
            if (!token) {
                return
            }
            uploadImgBtn.style.display = "none"
            const uuid = await uploadImage(token, file, `Upload from better-osm-org for ${object_type}/${object_id}`, needBlur.checked)
            await addPanoramaxTag(uuid, object_type, object_id)
            await GM.setValue("lastUploadedPanoramaxPicture", JSON.stringify({ uuid: uuid, instance: panoramaxInstance }))
            await sleep(1000)
            location.reload()
        } catch (err) {
            console.error(err)
            alert(
                t("panoramax.uploadError", { message: err.message }) +
                    "\n\n" +
                    (metadata
                        ? t("panoramax.exifInfo", {
                              dateTime: metadata.DateTime,
                              lat: metadata.GPSLatitude,
                              lon: metadata.GPSLongitude,
                          })
                        : ""),
            )
        } finally {
            wrapper.classList.remove("is-loading")
        }
    }

    const datalistInstances = document.createElement("datalist")
    datalistInstances.id = "panoramax-instances"
    ;[
        "https://panoramax.openstreetmap.fr",
        "https://panoramax.ign.fr",
        "https://panoramax.mapcomplete.org",
        "https://panoramax.multimob.be",
        "https://panoramax.liswu.me",
        "https://panoramax.osm-hr.org",
        "https://panoramax.osm.tw",
        "https://panoramax.libre.net.ar",
        "https://panoramax.koenhabets.nl",
        "https://panoramax.basi.re",
    ].forEach(i => {
        const opt = document.createElement("option")
        opt.value = i
        datalistInstances.appendChild(opt)
    })

    const instanceInput = document.createElement("input")
    instanceInput.type = "url"
    instanceInput.style.display = "none"
    instanceInput.style.width = "300px"
    instanceInput.setAttribute("list", "panoramax-instances")
    secondBlock.appendChild(instanceInput)
    secondBlock.appendChild(datalistInstances)
    secondBlock.appendChild(uploadImgBtn)

    const needBlur = document.createElement("input")
    needBlur.type = "checkbox"
    needBlur.checked = false
    needBlur.style.marginRight = "5px"

    const needBlueLabel = document.createElement("label")
    needBlueLabel.textContent = t("panoramax.blurFaces")
    needBlueLabel.style.display = "none"
    needBlueLabel.prepend(needBlur)

    thirdBlock.appendChild(needBlueLabel)

    document.querySelector("#sidebar_content nav").appendChild(wrapper)
}

//</editor-fold>

//<editor-fold desc="object-version-page" defaultstate="collapsed">

let addHoverForNodesParentsLock = false

async function addHoverForNodesParents() {
    if (!location.pathname.match(/^\/node\/(\d+)\/?$/)) {
        return
    }
    if (addHoverForNodesParentsLock) return
    addHoverForNodesParentsLock = true
    try {
        document.querySelectorAll(`details [href^="/way/"]:not(.hover-added)`).forEach(elem => {
            elem.classList.add("hover-added")
            setTimeout(async () => {
                const wayID = parseInt(elem.href.match(/way\/(\d+)/)[1])
                const wayData = await loadWayMetadata(wayID)
                const wayInfo = wayData.elements.find(i => i.id === wayID)
                /*** @type {Map<string, NodeVersion>}*/
                const nodesMap = new Map(
                    Object.entries(
                        Object.groupBy(
                            wayData.elements.filter(i => i.type === "node"),
                            i => i.id,
                        ),
                    ).map(([k, v]) => [k, v[0]]),
                )
                const wayLi = elem?.parentElement?.parentElement?.parentElement
                wayLi.classList.add("node-last-version-parent")
                wayLi.onmouseenter = () => {
                    const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon])
                    const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                    showActiveWay(currentNodesList, color)
                }
                wayLi.onclick = e => {
                    if (e.altKey) return
                    if (e.target.tagName === "A") return
                    const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon])
                    const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                    showActiveWay(currentNodesList, color, true)
                }
                wayLi.ondblclick = zoomToCurrentObject
                if (wayInfo.tags) {
                    Object.entries(wayInfo.tags).forEach(([k, v]) => {
                        wayLi.title += `\n${k}=${v}`
                    })
                    wayLi.title = wayLi.title.trim()
                }
            })
        })
        // prettier-ignore
        document.querySelector(".node-last-version-parent")?.parentElement?.parentElement?.querySelector("summary")?.addEventListener("mouseenter", () => {
            cleanObjectsByKey("activeObjects")
        })
        document.querySelector(".secondary-actions")?.addEventListener("mouseenter", () => {
            cleanObjectsByKey("activeObjects")
        })
        console.log("addHoverForWayNodes finished")
    } finally {
        addHoverForNodesParentsLock = false
    }
}

function showBboxMenu(e, bbox) {
    injectContextMenuCSS()
    document.querySelectorAll(".betterOsmContextMenu").forEach(i => i.remove())
    const menu = makeContextMenuElem(e)
    ;[
        `${bbox.min_lat.toFixed(6)} ${bbox.min_lon.toFixed(6)} ${bbox.max_lat.toFixed(6)} ${bbox.max_lon.toFixed(6)}`,
        `${bbox.min_lon.toFixed(6)} ${bbox.min_lat.toFixed(6)} ${bbox.max_lon.toFixed(6)} ${bbox.max_lat.toFixed(6)}`,
        `${bbox.min_lat.toFixed(6)},${bbox.min_lon.toFixed(6)},${bbox.max_lat.toFixed(6)},${bbox.max_lon.toFixed(6)}`,
        `${bbox.min_lon.toFixed(6)},${bbox.min_lat.toFixed(6)},${bbox.max_lon.toFixed(6)},${bbox.max_lat.toFixed(6)}`,
    ].forEach(text => {
        const listItem = document.createElement("div")
        const a = document.createElement("a")
        a.textContent = text
        a.title = t("copying.clickToCopyValue", { value: text })
        a.style.width = "100%"
        a.style.fontVariantNumeric = "tabular-nums"
        a.onclick = e => {
            e.preventDefault()
            e.stopPropagation()
            navigator.clipboard.writeText(text)
            document.querySelectorAll(".betterOsmContextMenu").forEach(i => i.remove())
        }
        listItem.appendChild(a)
        document.addEventListener(
            "click",
            function fn(e) {
                menu.remove()
            },
            { once: true },
        )
        menu.appendChild(listItem)
    })
    document.body.appendChild(menu)
}

/**
 * @param {number[]} nodesIds
 * @param {Map} nodesMap
 * @param {"way"|"relation"} osm_type
 * @return {HTMLDivElement}
 */
function makePolygonMeasureButtons(nodesIds, nodesMap, osm_type) {
    const nodes = nodesIds.map(i => nodesMap.get(i.toString()) ?? nodesMap.get(i)) // todo dirty hack
    const bbox = {
        min_lat: Math.min(...nodes.map(i => i.lat)),
        min_lon: Math.min(...nodes.map(i => i.lon)),
        max_lat: Math.max(...nodes.map(i => i.lat)),
        max_lon: Math.max(...nodes.map(i => i.lon)),
    }

    let wayLength = 0.0
    for (let i = 1; i < nodes.length; i++) {
        wayLength += getDistanceFromLatLonInKm(nodes[i - 1].lat, nodes[i - 1].lon, nodes[i].lat, nodes[i].lon)
    }
    wayLength *= 1000

    let wayArea = null
    if (nodesIds.length > 2 && nodesIds[0] === nodesIds.at(-1)) {
        wayArea = Math.abs(ringArea(nodes))
    }

    const center = { lat: (bbox.max_lat + bbox.min_lat) / 2, lng: (bbox.max_lon + bbox.min_lon) / 2 }
    // TODO
    // geojson

    const infos = document.createElement("div")
    infos.style.display = "none"
    infos.style.paddingBottom = "5px"

    if (osm_type === "way") {
        const lengthElem = document.createElement("span")
        const lengthText = wayLength < 1000 ? wayLength.toFixed(2) + " m" : wayLength.toFixed(0) + " m"
        lengthElem.textContent = t("objectVersionPage.length", { value: lengthText })
        infos.appendChild(lengthElem)

        if (wayArea !== null) {
            infos.appendChild(document.createTextNode(",\xA0"))
            const areaElem = document.createElement("span")
            const areaText = wayLength < 1000 ? wayArea.toFixed(2) + " m²" : wayArea.toFixed(0) + " m²"
            areaElem.textContent = t("objectVersionPage.area", { value: areaText })
            infos.appendChild(areaElem)
        }
    }

    const svg1 =
        '<svg viewBox="0 0 24 24" width="36" height="36" stroke="currentColor" fill="none" stroke-width="1">\n' +
        "  <!-- Внешний квадрат -->\n" +
        '  <rect x="4" y="4" width="16" height="16" stroke-width="1"/>\n' +
        "  <!-- Центральные линии -->\n" +
        '  <line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="1"/>\n' +
        '  <line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1"/>\n' +
        "  <!-- Толстые верхняя и левая стороны -->\n" +
        '  <line x1="3" y1="4" x2="20.5" y2="4" stroke="red" stroke-width="2"/>\n' +
        '  <line x1="4" y1="3" x2="4" y2="20.5" stroke="red" stroke-width="2"/>\n' +
        "</svg>\n"
    const icon1 = document.createElement("span")
    icon1.innerHTML = svg1
    icon1.style.cursor = "pointer"
    const text1 = `${bbox.max_lat.toString()} ${bbox.min_lon.toString()}`
    icon1.title = t("objectVersionPage.clickToCopyTopLeft", { value: text1 })
    icon1.onmouseenter = () => {
        showActiveNodeMarker(bbox.max_lat.toString(), bbox.min_lon.toString(), "red", true)
        showActiveNodeIconMarker(bbox.max_lat.toString(), bbox.min_lon.toString(), null, false)
    }
    icon1.onclick = e => {
        navigator.clipboard.writeText(text1).then(() => copyAnimation(e, text1))
    }

    const svg2 =
        '<svg viewBox="0 0 24 24" width="36" height="36" stroke="currentColor" fill="none" stroke-width="1">\n' +
        "  <!-- Внешний квадрат -->\n" +
        '  <rect x="4" y="4" width="16" height="16" stroke-width="1"/>\n' +
        "  <!-- Центральные линии -->\n" +
        '  <line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="1"/>\n' +
        '  <line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1"/>\n' +
        "  <!-- Жирная точка в центре -->\n" +
        '  <circle cx="12" cy="12" r="2" fill="red" stroke="red"/>\n' +
        "</svg>\n"
    const icon2 = document.createElement("span")
    icon2.innerHTML = svg2
    icon2.style.cursor = "pointer"
    const text2 = `${center.lat.toFixed(6)} ${center.lng.toFixed(6)}`
    icon2.title = t("objectVersionPage.clickToCopyCenter", { value: text2 })
    icon2.onmouseenter = () => {
        showActiveNodeMarker(center.lat.toString(), center.lng.toString(), "red")
        showActiveNodeIconMarker(center.lat.toString(), center.lng.toString(), null, false)
    }
    icon2.onclick = e => {
        navigator.clipboard.writeText(text2).then(() => copyAnimation(e, text2))
    }

    const svg3 =
        '<svg viewBox="0 0 24 24" width="36" height="36" stroke="currentColor" fill="none" stroke-width="1">\n' +
        "  <!-- Внешний квадрат -->\n" +
        '  <rect x="4" y="4" width="16" height="16" stroke-width="1"/>\n' +
        "  <!-- Центральные линии -->\n" +
        '  <line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="1"/>\n' +
        '  <line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1"/>\n' +
        "  <!-- Толстые правая и нижняя стороны -->\n" +
        '  <line x1="3.5" y1="20" x2="21" y2="20" stroke="red" stroke-width="2"/>\n' +
        '  <line x1="20" y1="3.5" x2="20" y2="20.5" stroke="red" stroke-width="2"/>\n' +
        "</svg>\n"
    const icon3 = document.createElement("span")
    icon3.innerHTML = svg3
    icon3.style.cursor = "pointer"
    const text3 = `${bbox.min_lat.toString()} ${bbox.max_lon.toString()}`
    icon3.title = t("objectVersionPage.clickToCopyRightBottom", { value: text3 })
    icon3.onmouseenter = () => {
        showActiveNodeMarker(bbox.min_lat.toString(), bbox.max_lon.toString(), "red")
        showActiveNodeIconMarker(bbox.min_lat.toString(), bbox.max_lon.toString(), null, false)
    }
    icon3.onclick = e => {
        navigator.clipboard.writeText(text3).then(() => copyAnimation(e, text3))
    }
    // prettier-ignore
    const svg4 = '<svg viewBox="0 0 24 24" width="36" height="36" stroke="currentColor" fill="none">\n' +
        '  <!-- Внешний прямоугольник жирный -->\n' +
        '  <!-- Центральные линии -->\n' +
        '  <line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="1"/>\n' +
        '  <line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1"/>\n' +
        '  <rect x="4" y="4" width="16" height="16" stroke="red" stroke-width="2"/>\n' +
        '</svg>\n';
    const icon4 = document.createElement("span")
    icon4.innerHTML = svg4
    icon4.style.cursor = "pointer"
    icon4.title = t("objectVersionPage.clickToCopyBbox")
    icon4.onmouseenter = () => {
        cleanObjectsByKey("activeObjects")
        const rect = getWindow()
            .L.rectangle(
                intoPage([
                    [bbox.max_lat, bbox.max_lon],
                    [bbox.min_lat, bbox.min_lon],
                ]),
                intoPage({ color: "red", weight: 3, fillOpacity: 0 }),
            )
            .addTo(getMap())
        layers["activeObjects"].push(rect)
    }
    icon4.onclick = e => {
        e.preventDefault()
        e.stopPropagation()
        showBboxMenu(e, bbox)
    }
    // todo нужно больше форматов bbox
    const icons = document.createElement("div")
    icons.style.paddingTop = "5px"
    infos.appendChild(icons)

    icons.appendChild(icon1)
    icons.appendChild(icon2)
    icons.appendChild(icon3)
    icons.appendChild(icon4)

    document.querySelector("#sidebar_content h4:last-of-type").after(infos)

    infos.onmouseleave = () => {
        cleanAllObjects()
    }

    return infos
}

function isDarkTiles() {
    const layers = new URLSearchParams(location.hash).get("layers")
    return layers && (layers.includes("S") || layers.includes("T")) && isDarkMode()
}

let addHoverForWayNodesLock = false

async function addHoverForWayNodes() {
    if (!location.pathname.match(/^\/way\/(\d+)\/?$/)) {
        return
    }
    if (addHoverForWayNodesLock) return
    addHoverForWayNodesLock = true
    try {
        // todo relations
        let infoBtn
        if (!document.querySelector(".way-info-btn") && document.querySelector("#sidebar_content h4:last-of-type")) {
            infoBtn = document.createElement("span")
            infoBtn.textContent = "📐"
            infoBtn.classList.add("way-info-btn")
            infoBtn.classList.add("completed")
            infoBtn.style.fontSize = "large"
            infoBtn.style.cursor = "pointer"

            document.querySelector("#sidebar_content h4:last-of-type").appendChild(document.createTextNode("\xA0"))
            document.querySelector("#sidebar_content h4:last-of-type").appendChild(infoBtn)
        }

        const wayData = await loadWayMetadata().catch(() => {
            if (infoBtn) {
                infoBtn.style.display = "none"
            }
        })
        if (!wayData || !wayData.elements) {
            if (infoBtn) {
                infoBtn.style.display = "none"
            }
            return
        }
        const wayInfo = wayData.elements.find(i => i.type === "way")
        if (!wayInfo) {
            if (infoBtn) {
                infoBtn.style.display = "none"
            }
            return
        }
        /*** @type {Map<string, NodeVersion>}*/
        const nodesMap = new Map(
            Object.entries(
                Object.groupBy(
                    wayData.elements.filter(i => i.type === "node"),
                    i => i.id,
                ),
            ).map(([k, v]) => [k, v[0]]),
        )
        const lineGeometryIssues = validateWayGeometry(wayInfo, nodesMap)
        const affectedNodeIds = collectWayGeometryAffectedNodeIds(lineGeometryIssues)
        const wayMembersSummary = document.querySelector("#sidebar_content > div:first-of-type details summary")
        if (wayMembersSummary) {
            showValidationStatus(
                lineGeometryIssues.map(i => i.description),
                wayMembersSummary,
            )
        }
        document.querySelectorAll(`details [href^="/node/"]:not(.hover-added)`).forEach(elem => {
            elem.classList.add("hover-added")
            const nodeID = parseInt(elem.href.match(/node\/(\d+)/)[1])
            const nodeInfo = nodesMap.get(nodeID.toString())
            const nodeLi = elem?.parentElement?.parentElement?.parentElement
            nodeLi.classList.add("way-last-version-node")
            if (affectedNodeIds.has(nodeID)) {
                nodeLi.querySelector("div > div").style.backgroundColor = "rgba(255, 80, 80, 0.28)"
            }
            nodeLi.onmouseenter = () => {
                const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), color, true, 6, 3)
            }
            nodeLi.onclick = e => {
                if (e.altKey) return
                panTo(nodeInfo.lat.toString(), nodeInfo.lon.toString())
                const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), color, true, 6, 3)
            }
            nodeLi.ondblclick = zoomToCurrentObject
            if (nodeInfo.tags) {
                Object.entries(nodeInfo.tags).forEach(([k, v]) => {
                    nodeLi.title += `\n${k}=${v}`
                })
                nodeLi.title = nodeLi.title.trim()
            }
        })
        // prettier-ignore
        document.querySelector(".way-last-version-node")?.parentElement?.parentElement?.querySelector("summary")?.addEventListener("mouseenter", () => {
            cleanObjectsByKey("activeObjects")
        })
        document.querySelector(".secondary-actions")?.addEventListener("mouseenter", () => {
            cleanObjectsByKey("activeObjects")
        })

        if (infoBtn) {
            const nodesIds = wayInfo.nodes
            const infos = makePolygonMeasureButtons(nodesIds, nodesMap, "way")
            document.querySelector("#sidebar_content h4:last-of-type").after(infos)

            infoBtn.onclick = () => {
                infos.style.display = infos.style.display === "none" ? "" : "none"
            }
        }
        console.log("addHoverForWayNodes finished")
    } finally {
        addHoverForWayNodesLock = false
    }
}

/**
 * @param {string[]} errors
 * @param {HTMLElement} targetElem
 */
function showValidationStatus(errors, targetElem) {
    if (errors.length === 0) {
        return
    }
    if (!targetElem.querySelector(".validation-status")) {
        const validationStatus = document.createElement("span")
        validationStatus.classList.add("validation-status")
        validationStatus.textContent = " ⚠️"
        validationStatus.title = errors.join("\n")
        validationStatus.style.cursor = "help"
        targetElem.appendChild(validationStatus)
    }
}

let addHoverForRelationMembersLock = false

async function addHoverForRelationMembers() {
    // todo make async safe
    const match = location.pathname.match(/^\/relation\/(\d+)\/?$/)
    if (!match) {
        return
    }
    const relation_id = parseInt(match[1])
    if (addHoverForRelationMembersLock) return
    addHoverForRelationMembersLock = true
    try {
        let infoBtn
        if (!document.querySelector(".relation-info-btn")) {
            infoBtn = document.createElement("span")
            infoBtn.textContent = "📐"
            infoBtn.classList.add("relation-info-btn")
            infoBtn.classList.add("completed")
            infoBtn.style.fontSize = "large"
            infoBtn.style.cursor = "progress"

            document.querySelector("#sidebar_content h4:last-of-type").appendChild(document.createTextNode("\xA0"))
            document.querySelector("#sidebar_content h4:last-of-type").appendChild(infoBtn)
        }
        /** @type {{elements: (NodeVersion|WayVersion|RelationVersion)[]}} */
        const topRelationData = await fetchJSONWithCache(osm_server.apiBase + "relation" + "/" + relation_id + "/full.json")

        if (!topRelationData) return
        while (document.querySelector("details turbo-frame .spinner-border")) {
            console.log("wait members list loading")
            await sleep(1000)
        }
        /*** @type {Map<number, NodeVersion>}*/
        const nodesMap = new Map(
            Object.entries(
                Object.groupBy(
                    topRelationData.elements.filter(i => i.type === "node"),
                    i => i.id,
                ),
            ).map(([k, v]) => [parseInt(k), v[0]]),
        )
        /*** @type {Map<number, WayVersion>}*/
        const waysMap = new Map(
            Object.entries(
                Object.groupBy(
                    topRelationData.elements.filter(i => i.type === "way"),
                    i => i.id,
                ),
            ).map(([k, v]) => [parseInt(k), v[0]]),
        )
        let restrictionArrows = []
        const pinSign = document.createElement("span")

        function bringRestrictionArrowsToFront() {
            restrictionArrows.forEach(i => i.bringToFront())
        }

        let isRestriction = false
        document.querySelectorAll(`details [href^="/node/"]:not(.hover-added)`).forEach(elem => {
            elem.classList.add("hover-added")
            const nodeInfo = nodesMap.get(parseInt(elem.href.match(/node\/(\d+)/)[1]))
            const nodeLi = elem?.parentElement?.parentElement?.parentElement
            nodeLi.classList.add("relation-last-version-member")
            nodeLi.onmouseenter = () => {
                const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), color, true, 6, 3)
                bringRestrictionArrowsToFront()
                if (isRestriction && pinSign.classList.contains("pinned")) {
                    // https://github.com/deevroman/better-osm-org/pull/324#issuecomment-2993733516
                    injectJSIntoPage(`
                    (() => {
                        let tmp = document.createElement("span")
                        tmp.innerHTML = '<svg id="kek" width="100" height="100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="6" stroke="black" stroke-width="3" fill="none" /></svg>'
                        window.prevdo = {
                            maxWidth: 10,
                            maxHeight: 10,
                            className: "restriction-via-node-workaround",
                            icon: L.icon({
                                iconUrl: "data:image/svg+xml;base64," + btoa(tmp.innerHTML),
                                iconSize: [100, 100],
                                iconAnchor: [50, 50]
                            })
                        };
                    })()
                    `)
                    const m = getWindow().L.marker(getWindow().L.latLng(nodeInfo.lat, nodeInfo.lon), getWindow().prevdo)
                    layers["activeObjects"].push(m)
                    m.addTo(getMap())
                }
            }
            nodeLi.onclick = e => {
                if (e.altKey) return
                panTo(nodeInfo.lat.toString(), nodeInfo.lon.toString())
                const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), color, true, 6, 3)
                bringRestrictionArrowsToFront()
            }
            nodeLi.ondblclick = zoomToCurrentObject
            if (nodeInfo.tags) {
                Object.entries(nodeInfo.tags).forEach(([k, v]) => {
                    nodeLi.title += `\n${k}=${v}`
                })
                nodeLi.title = nodeLi.title.trim()
            }
        })
        document.querySelectorAll(`details [href^="/way/"]:not(.hover-added)`).forEach(elem => {
            elem.classList.add("hover-added")
            const wayInfo = waysMap.get(parseInt(elem.href.match(/way\/(\d+)/)[1]))
            const wayLi = elem?.parentElement?.parentElement?.parentElement
            wayLi.classList.add("relation-last-version-member")
            const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
            wayLi.onmouseenter = () => {
                const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i).lat, nodesMap.get(i).lon])
                showActiveWay(currentNodesList, color)
                bringRestrictionArrowsToFront()
            }
            wayLi.onclick = e => {
                if (e.altKey) return
                if (e.target.tagName === "A") return
                const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i).lat, nodesMap.get(i).lon])
                showActiveWay(currentNodesList, color, true)
                bringRestrictionArrowsToFront()
            }
            wayLi.ondblclick = zoomToCurrentObject
            if (wayInfo.tags) {
                Object.entries(wayInfo.tags).forEach(([k, v]) => {
                    wayLi.title += `\n${k}=${v}`
                })
                wayLi.title = wayLi.title.trim()
            }
        })

        /** @type {RelationVersion[]}*/
        const childRelations = topRelationData.elements.filter(i => i.type === "relation")
        /*** @type {Map<number, RelationVersion>}*/
        const relationsMap = new Map(Object.entries(Object.groupBy(childRelations, i => i.id)).map(([k, v]) => [parseInt(k), v[0]]))
        const downloadedRelations = new Set([relation_id])

        for (const elem of document.querySelectorAll(`details:last-of-type [href^="/relation/"]:not(.hover-added)`)) {
            elem.classList.add("hover-added")
            const childRelationId = parseInt(elem.href.match(/relation\/(\d+)/)[1])
            if (!downloadedRelations.has(childRelationId)) {
                elem.parentElement.style.cursor = "progress"
                console.debug("downloading child relation", childRelationId)
                downloadedRelations.add(childRelationId)
                /** @type {{elements: (NodeVersion|WayVersion|RelationVersion)[]}} */
                const relationData = await fetchJSONWithCache(osm_server.apiBase + "relation" + "/" + childRelationId + "/full.json")
                elem.parentElement.style.cursor = ""
                relationData?.elements?.forEach(i => {
                    if (i.type === "node") {
                        if (!nodesMap.has(i.id)) {
                            nodesMap.set(i.id, i)
                        }
                    } else if (i.type === "way") {
                        if (!waysMap.has(i.id)) {
                            waysMap.set(i.id, i)
                        }
                    } else if (i.type === "relation") {
                        if (!relationsMap.has(i.id)) {
                            relationsMap.set(i.id, i)
                        }
                    }
                })
            }

            const relationInfo = relationsMap.get(childRelationId)
            const relationLi = elem?.parentElement?.parentElement?.parentElement
            relationLi.classList.add("relation-last-version-member")

            let cache

            function renderRelation(relationInfo) {
                const addStroke = darkModeForMap && isDarkMode()
                const layer = "activeObjects"
                const color = darkModeForMap || isDarkTiles() ? c("#ff00e3") : "#000000"
                if (!cache) {
                    let wayCounts = 0
                    /** @type {LatLonPair[][]} */
                    const mergedGeometry = []
                    relationInfo.members?.forEach(i => {
                        if (i.type === "way") {
                            const w = waysMap.get(i.ref)
                            wayCounts++
                            const nodesList = w.nodes.map(i => nodesMap.get(i)).map(p => [p.lat, p.lon])
                            if (mergedGeometry.length === 0) {
                                mergedGeometry.push(nodesList)
                            } else {
                                const lastWay = mergedGeometry[mergedGeometry.length - 1]
                                const [lastLat, lastLon] = lastWay[lastWay.length - 1]
                                if (lastLat === nodesList[0][0] && lastLon === nodesList[0][1]) {
                                    mergedGeometry[mergedGeometry.length - 1].push(...nodesList.slice(1))
                                } else {
                                    mergedGeometry.push(nodesList)
                                }
                            }
                        } else if (i.type === "node") {
                            showNodeMarker(nodesMap.get(i.ref).lat, nodesMap.get(i.ref).lon, color, null, layer)
                        } else if (i.type === "relation") {
                            // todo
                        }
                    })
                    cache = {
                        geom: mergedGeometry.map(i => intoPage(i)),
                    }
                    console.log(`${cache.geom.length}/${wayCounts} for render`)
                } else {
                    relationInfo.members?.forEach(i => {
                        if (i.type === "node") {
                            showNodeMarker(nodesMap.get(i.ref).lat, nodesMap.get(i.ref).lon, color, null, layer)
                        }
                    })
                }

                cache.geom.forEach(nodesList => {
                    displayWay(nodesList, false, color, 4, null, layer, null, null, addStroke, true)
                })
            }
            relationLi.onmouseenter = () => {
                cleanObjectsByKey("activeObjects")
                renderRelation(relationInfo)
            }
            relationLi.onclick = e => {
                if (e.altKey) return
                if (e.target.tagName === "A") return
                cleanObjectsByKey("activeObjects")
                renderRelation(relationInfo)

                function zoomToRelation(relationInfo) {
                    const nodes = []
                    relationInfo.members.forEach(i => {
                        if (i.type === "node") {
                            nodes.push(nodesMap.get(i.ref))
                        } else if (i.type === "way") {
                            waysMap.get(i.ref).nodes.map(i => nodes.push(nodesMap.get(i)))
                        } else {
                            // todo
                        }
                    })
                    const bbox = {
                        min_lat: Math.min(...nodes.map(i => i.lat)),
                        min_lon: Math.min(...nodes.map(i => i.lon)),
                        max_lat: Math.max(...nodes.map(i => i.lat)),
                        max_lon: Math.max(...nodes.map(i => i.lon)),
                    }
                    fitBounds([
                        [bbox.min_lat, bbox.min_lon],
                        [bbox.max_lat, bbox.max_lon],
                    ])
                }

                zoomToRelation(relationInfo)
            }
            relationLi.ondblclick = () => {
                const nodes = Array.from(nodesMap.values())
                const bbox = {
                    min_lat: Math.min(...nodes.map(i => i.lat)),
                    min_lon: Math.min(...nodes.map(i => i.lon)),
                    max_lat: Math.max(...nodes.map(i => i.lat)),
                    max_lon: Math.max(...nodes.map(i => i.lon)),
                }
                fitBounds([
                    [bbox.min_lat, bbox.min_lon],
                    [bbox.max_lat, bbox.max_lon],
                ])
            }
            if (relationInfo.tags) {
                Object.entries(relationInfo.tags).forEach(([k, v]) => {
                    relationLi.title += `\n${k}=${v}`
                })
                relationLi.title = relationLi.title.trim()
            }
        }
        // prettier-ignore
        document.querySelector(".relation-last-version-member")?.parentElement?.parentElement?.querySelector("summary")?.addEventListener("mouseenter", () => {
            cleanObjectsByKey("activeObjects")
        })
        document.querySelector(".secondary-actions")?.addEventListener("mouseenter", () => {
            cleanObjectsByKey("activeObjects")
        })
        if (
            document.querySelector("#sidebar_content h2:not(.restriction-rendered)") &&
            isRestrictionObj(relationsMap.get(relation_id).tags ?? {})
        ) {
            isRestriction = true
            document.querySelector("#sidebar_content h2").classList.add("restriction-rendered")
            const extendedRelationVersion = relationsMap.get(relation_id)
            extendedRelationVersion.members = extendedRelationVersion.members.map(mem => {
                if (mem.type === "node") {
                    mem["lat"] = nodesMap.get(mem.ref).lat
                    mem["lon"] = nodesMap.get(mem.ref).lon
                    return /** @type {ExtendedRelationNodeMember} */ mem
                } else if (mem.type === "way") {
                    mem["geometry"] = waysMap.get(mem.ref).nodes.map(n => ({
                        lat: nodesMap.get(n).lat,
                        lon: nodesMap.get(n).lon,
                    }))
                    return /** @type {ExtendedRelationWayMember} */ mem
                } else if (mem.type === "relation") {
                    // todo
                    return /** @type {ExtendedRelationMember} */ mem
                }
            })
            const errors = validateRestriction(/** @type {ExtendedRelationVersion} */ extendedRelationVersion)
            if (errors.length) {
                showValidationStatus(errors, document.querySelector("#sidebar_content > div:first-of-type details:last-of-type summary"))
            } else {
                restrictionArrows = renderRestriction(
                    /** @type {ExtendedRelationVersion} */ extendedRelationVersion,
                    restrictionColors[extendedRelationVersion.tags["restriction"]] ?? "#000",
                    "customObjects",
                )
                pinSign.classList.add("pinned")
                pinSign.textContent = "📌"
                pinSign.tabIndex = 0
                pinSign.title = t("objectVersionPage.pinRestrictionSign")
                pinSign.style.cursor = "pointer"
                pinSign.onkeypress = pinSign.onclick = async e => {
                    e.preventDefault()
                    e.stopImmediatePropagation()
                    if (pinSign.classList.contains("pinned")) {
                        pinSign.style.cursor = "pointer"
                        pinSign.classList.remove("pinned")
                        pinSign.style.filter = "grayscale(1)"
                        pinSign.title = t("objectVersionPage.hideRestrictionSign")
                        restrictionArrows.forEach(i => (i.getElement().style.display = "none"))
                    } else {
                        pinSign.title = t("objectVersionPage.hideRestrictionSign")
                        pinSign.classList.add("pinned")
                        pinSign.style.filter = ""
                        restrictionArrows.forEach(i => (i.getElement().style.display = ""))
                    }
                }
                document
                    .querySelector("#sidebar_content > div:first-of-type details:last-of-type summary")
                    .appendChild(document.createTextNode(" "))
                document.querySelector("#sidebar_content > div:first-of-type details:last-of-type summary").appendChild(pinSign)
            }
        }

        if (infoBtn) {
            const nodesIds = topRelationData.elements.filter(i => i.type === "way").flatMap(i => i.nodes)
            const infos = makePolygonMeasureButtons(nodesIds, nodesMap, "relation")
            document.querySelector("#sidebar_content h4:last-of-type").after(infos)

            infoBtn.onclick = () => {
                infos.style.display = infos.style.display === "none" ? "" : "none"
            }
        }
        console.log("addHoverForRelationMembers finished")
    } finally {
        addHoverForRelationMembersLock = false
        document.querySelector(".relation-info-btn").style.cursor = "pointer"
    }
}

function makeHeaderPartsClickable() {
    function makeElemCopyable(elem, url = "") {
        if (/^\d+$/.test(elem.textContent)) {
            elem.title = t("objectVersionPage.clickToCopyIdOrUrl", { modifier: CtrlKeyName })
        } else {
            elem.title = t("copying.clickForCopy")
        }
        elem.style.cursor = "pointer"
        elem.classList.add("copyable")
        elem.addEventListener("click", e => {
            if (e.altKey) return
            if (window.getSelection().type === "Range") return
            if ((e.ctrlKey || e.metaKey) && url !== "") {
                navigator.clipboard.writeText(url).then(() => copyAnimation(e, url))
            } else {
                navigator.clipboard.writeText(elem.textContent).then(() => copyAnimation(e, elem.textContent))
            }
        })
    }

    document.querySelectorAll("#sidebar_content h2 bdi:not(.copyable)").forEach(i => {
        if (i.textContent.match(/^\d+$/)) {
            const url = shortOsmOrgLinksInText(location.origin + location.pathname)
            if (url.startsWith("http")) {
                makeElemCopyable(i, url)
            } else {
                makeElemCopyable(i, "https://" + url)
            }
        } else {
            makeElemCopyable(i)
        }
    })
    document.querySelectorAll("#sidebar_content h2:not(:has(.copyable))").forEach(i => {
        if (i.childNodes.length >= 1 && i.childNodes[0].nodeName === "#text") {
            const match = /\d+/g.exec(i.childNodes[0].textContent)
            if (!match) return
            i.childNodes[0].splitText(match.index + match[0].length)
            i.childNodes[0].splitText(match.index)
            const span = document.createElement("bdi")
            span.textContent = i.childNodes[1].textContent
            i.childNodes[1].replaceWith(span)
            const url = shortOsmOrgLinksInText(location.origin + location.pathname)
            if (url.startsWith("http")) {
                makeElemCopyable(span, url)
            } else {
                makeElemCopyable(span, "https://" + url)
            }
        }
    })
}

function expandWikidata() {
    const links = Array.from(document.querySelectorAll(".wdt-preview:not([disabled])"))
    // fuck Bootstrap. When page contains many HTML elements, him querySelector iterates over all elements
    // This is especially noticeable on the relationship pages.
    if (links.length > 50) {
        return
    }
    console.debug("Wikilinks count:", links.length)
    ;(
        links.find(
            i =>
                i.parentElement.classList.contains("history-diff-new-tag") || i.parentElement.classList.contains("history-diff-modified-tag"),
        ) ?? links?.[0]
    )?.click()
    setTimeout(() => {
        links.slice(0, 3).forEach(i => i.click())
    }, 100)
    setTimeout(() => {
        links.slice(3).forEach(i => i.click())
    }, 1000)
}

/**
 * @param {number} lat
 * @param {number} lon
 * @param {number} precision
 * @return {Object.<string, {getter: function(): string}>}
 */
function makeCoordinatesFormatters(lat, lon, precision) {
    const latStr = lat.toFixed(precision)
    const lonStr = lon.toFixed(precision)
    return {
        "Lat Lon": { getter: () => `${latStr} ${lonStr}` },
        "Lon Lat": { getter: () => `${lonStr} ${latStr}` },
        "geo:": { getter: () => `geo:${latStr},${lonStr}` },
        "osm.org": { getter: () => `osm.org#map=${getZoom()}/${latStr}/${lonStr}` },
    }
}

function addCopyCoordinatesButtons() {
    const m = location.pathname.match(/^\/(node|way)\/(\d+)/)
    if (!m) {
        return
    }
    const type = m[1]
    if (type === "way") {
        return
    }

    function addCopyButton(coordsElem, lat, lon) {
        const coordinatesFormatters = makeCoordinatesFormatters(parseFloat(lat), parseFloat(lon), 7)

        coordsElem.onclick = async e => {
            e.preventDefault()
            e.stopPropagation()
            e.stopImmediatePropagation()

            const coordinatesFormat = (await GM.getValue("CoordinatesFormat")) ?? "Lat Lon"
            const text = coordinatesFormatters[coordinatesFormat]["getter"]()
            navigator.clipboard.writeText(text).then(() => copyAnimation({ target: coordsElem }, text))
        }
        setTimeout(async () => {
            const coordinatesFormat = (await GM.getValue("CoordinatesFormat")) ?? "Lat Lon"
            coordsElem.title = t("copying.clickToCopyValue", { value: coordinatesFormatters[coordinatesFormat]["getter"]() })
        })
        coordsElem.classList.add("copyable")
        const copyButton = document.createElement("span")
        copyButton.classList.add("copy-coords-btn")
        copyButton.textContent = "📄"
        copyButton.title = t("objectVersionPage.selectCoordinatesFormat")
        copyButton.innerHTML = copyBtnSvg
        copyButton.style.height = "0.9rem"
        copyButton.style.position = "relative"
        if (location.pathname.endsWith("/history")) {
            copyButton.style.top = "-2px"
        } else {
            copyButton.style.top = "-1px"
        }
        copyButton.style.left = "-1px"
        copyButton.style.color = "gray"
        copyButton.style.cursor = "pointer"

        async function contextMenuHandler(e) {
            const coordinatesFormat = (await GM.getValue("CoordinatesFormat")) ?? "Lat Lon"
            e.preventDefault()
            e.stopPropagation()
            e.stopImmediatePropagation()

            injectContextMenuCSS()
            document.querySelectorAll(".betterOsmContextMenu").forEach(i => i.remove())
            const menu = makeContextMenuElem(e)
            Object.entries(coordinatesFormatters).forEach(([format, formatter], ind) => {
                const listItem = document.createElement("div")
                const text = formatter.getter()
                const a = document.createElement("a")
                a.textContent = text
                a.title = t("copying.clickToCopyValue", { value: text })
                a.style.width = "100%"
                a.onclick = e => {
                    e.preventDefault()
                    navigator.clipboard.writeText(text)
                }

                const pin = document.createElement("input")
                pin.id = "CoordFormat_" + ind
                pin.type = "radio"
                pin.classList.add("pin")
                pin.setAttribute("name", "viewer-selector")
                const pinLabel = document.createElement("label")
                pinLabel.setAttribute("for", "CoordFormat_" + ind)
                pinLabel.classList.add("pin-label")
                pinLabel.textContent = "📌"
                pinLabel.title = t("objectVersionPage.setDefaultCopyFormat")
                if (format === coordinatesFormat) {
                    pin.checked = true
                    pinLabel.title = t("objectVersionPage.defaultCopyFormat")
                }
                pin.onchange = async () => {
                    if (pin.checked) {
                        await GM.setValue("CoordinatesFormat", format)
                        coordsElem.title = t("copying.clickToCopyValue", { value: coordinatesFormatters[format]["getter"]() })
                    }
                }
                listItem.appendChild(pin)
                listItem.appendChild(pinLabel)
                listItem.appendChild(a)
                document.addEventListener(
                    "click",
                    function fn(e) {
                        if (!e.isTrusted) {
                            document.addEventListener("click", fn, { once: true })
                            return
                        }
                        if (e.target.classList.contains("pin-label") || e.target.classList.contains("pin")) {
                            document.addEventListener("click", fn, { once: true })
                            return
                        }
                        menu.remove()
                    },
                    { once: true },
                )
                menu.appendChild(listItem)
            })
            document.body.appendChild(menu)
        }

        copyButton.addEventListener("click", contextMenuHandler)
        coordsElem.after(copyButton)
        coordsElem.after(document.createTextNode("\xA0"))
    }

    document.querySelectorAll("#sidebar_content a:has(.longitude):not(.copyable)").forEach(coordsElem => {
        const lat = coordsElem.querySelector(".latitude").textContent.replace(",", ".")
        const lon = coordsElem.querySelector(".longitude").textContent.replace(",", ".")
        addCopyButton(coordsElem, lat, lon)
    })
    if (type === "way") {
    }
    // try {
    //     const wrapper = document.createElement("span")
    //     document.querySelector(".share-geo-uri h4:not(.copyable)")?.appendChild(wrapper)
    //     document.querySelector(".share-geo-uri h4").classList.add("copyable")
    //
    //     const lat = getMap().getCenter().lat.toFixed(6)
    //     const lon = getMap().getCenter().lat.toFixed(6)
    //     addCopyButton(wrapper, lat, lon)
    // } catch (e) {
    //     console.error(e)
    // }
}

function addRelationHistoryViewerLinks() {
    if (!location.pathname.match(/\/relation\/[0-9]+\/history\/?$/)) {
        return
    }
    if (document.querySelector(".relation-viewer-link")) {
        return
    }
    const id = parseInt(location.pathname.match(/\/relation\/(\d+)/)[1])
    const type = "relation"
    injectCSSIntoOSMPage(contextMenuCSS)
    const viewInExternal = document.createElement("a")
    viewInExternal.classList.add("relation-viewer-link")
    viewInExternal.innerHTML = externalLinkSvg
    viewInExternal.style.cursor = "pointer"
    viewInExternal.style.position = "relative"
    viewInExternal.style.top = "-2px"
    viewInExternal.style.paddingLeft = "5px"
    viewInExternal.title = t("objectVersionPage.openExternalRelationViewer")
    viewInExternal.querySelector("svg").tabIndex = 0

    async function contextMenuHandler(e) {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        const relationViewer = (await GM.getValue("relationHistoryViewer")) ?? mapkiLink.name

        const menu = makeContextMenuElem(e)
        instancesOfRelationViewers.forEach(i => {
            const listItem = document.createElement("div")
            const a = document.createElement("a")
            const [x, y, z] = getCurrentXYZ()
            a.href = i.makeURL({ x, y, z, type, id })
            a.textContent = i.name
            a.target = "_blank"
            a.classList.add("relation-viewer-a")

            const pin = document.createElement("input")
            pin.id = i.name
            pin.type = "radio"
            pin.classList.add("pin")
            pin.setAttribute("name", "viewer-selector")
            const pinLabel = document.createElement("label")
            pinLabel.setAttribute("for", i.name)
            pinLabel.classList.add("pin-label")
            pinLabel.textContent = "📌"
            pinLabel.title = t("objectPage.setAsDefaultForClick")
            if (i.name === relationViewer) {
                pin.checked = true
                pinLabel.title = t("objectPage.defaultViewer")
            }
            pin.onchange = async () => {
                if (pin.checked) {
                    await GM.setValue("relationHistoryViewer", i.name)
                }
            }
            listItem.appendChild(pin)
            listItem.appendChild(pinLabel)
            listItem.appendChild(a)
            document.addEventListener(
                "click",
                function fn(e) {
                    if (!e.isTrusted) {
                        document.addEventListener("click", fn, { once: true })
                        return
                    }
                    if (e.target.classList.contains("pin-label") || e.target.classList.contains("pin")) {
                        document.addEventListener("click", fn, { once: true })
                        return
                    }
                    menu.remove()
                },
                { once: true },
            )
            menu.appendChild(listItem)
        })
        document.body.appendChild(menu)
        if (e.type === "keypress" || (e.type === "click" && !e.isTrusted)) {
            menu.querySelector("input:checked ~ a").focus()
        }
    }

    viewInExternal.addEventListener("click", contextMenuHandler)
    viewInExternal.addEventListener("keypress", contextMenuHandler)
    viewInExternal.addEventListener("contextmenu", contextMenuHandler)
    document.querySelector("#sidebar_content h2").appendChild(viewInExternal)
}

function makeVersionPageBetter() {
    const match = location.pathname.match(/(node|way|relation)\/(\d+)(\/history\/(\d+)\/?$|\/?$)/)
    if (!match) {
        return
    }
    addCompactSidebarStyle()
    externalizeLinks(document.querySelectorAll("#sidebar_content p a"))
    externalizeLinks(document.querySelectorAll("#sidebar_content table a"))
    const browseSectionSelector = document.querySelector("#element_versions_list")
        ? '#element_versions_list > div:not(:has(a[href*="/redactions/"]:not([rel]):not(.unredacted)))'
        : "#sidebar_content > div:first-of-type"
    if (!document.querySelector(".find-user-btn")) {
        try {
            const ver = document.querySelector(browseSectionSelector)
            const tagsCount = ver.querySelectorAll("#sidebar_content tr:has(th):has(td)").length
            if (tagsCount > 5) {
                ver.title = t("historyDiff.tagsCount", { count: tagsCount })
            }

            const metainfoHTML = ver?.querySelector(":scope > div > div:has(time)")
            if (metainfoHTML && !metainfoHTML.querySelector('a[href*="/user/"]:not([rel])')) {
                const time = metainfoHTML.querySelector("time")
                const changesetID = ver.querySelector(':scope div a[href^="/changeset/"]:not([rel])').textContent

                metainfoHTML.lastChild.remove()
                const findBtn = document.createElement("span")
                findBtn.classList.add("find-user-btn")
                findBtn.title = t("deletedUsers.tryFindDeletedUser")
                findBtn.textContent = " 🔍 "
                findBtn.value = changesetID
                findBtn.datetime = time.dateTime
                findBtn.style.cursor = "pointer"
                findBtn.onclick = findChangesetInDiff
                metainfoHTML.appendChild(findBtn)
            }
        } catch {
            /* empty */
        }
    }
    makeHeaderPartsClickable()
    addHistoryLink()
    makeLinksInVersionTagsClickable()
    makeHashtagsClickable()
    makeTimesSwitchable()
    document.querySelectorAll(`${browseSectionSelector} p`).forEach(shortOsmOrgLinks)
    addCommentsCount()
    expandWikidata()
    addCopyCoordinatesButtons()
    void addHoverForNodesParents()
    void addHoverForWayNodes()
    void addHoverForRelationMembers()
    addUploadPanoramaxBtn()
    // костыль для KeyK/L и OSM tags editor
    document.querySelector("#sidebar_content > div:first-of-type")?.classList?.add("browse-section")
    document.querySelectorAll("#element_versions_list > div").forEach(i => i.classList.add("browse-section"))
}

function setupMakeVersionPageBetter() {
    const match = location.pathname.match(/(node|way|relation)\/(\d+)(\/history\/(\d+)\/?$|\/?$)/)
    if (!match) {
        return
    }
    tryApplyModule(makeVersionPageBetter, 500, 3000)
}

//</editor-fold>

//<editor-fold desc="in-osm-page-code" defaultstate="collapsed">

function allowedCspBridgeOrigin(origin) {
    return origin === prod_server.origin || origin === dev_server.origin || origin === ohm_prod_server.origin
}

function initCspBridge() {
    window.addEventListener("message", async e => {
        if (!customLayerUrlOrigin && !vectorLayerStyleUrlOrigin) {
            return
        }
        if (e.origin !== location.origin) return
        if (!allowedCspBridgeOrigin(e.origin)) return
        if (e.data.type !== "bypass_csp") {
            return
        }
        // console.log(e.data)
        const opt = {}
        if (e.data.url.startsWith(customLayerUrlOrigin)) {
            opt.signal = tilesAbortController.signal
        }
        if (e.data.url.startsWith("https://tiles.openrailwaymap.org")) {
            opt.headers = { Referer: "https://www.openrailwaymap.org/" }
        }
        if (e.data.url.startsWith("https://tile.openstreetmap.org")) {
            opt.headers = { Accept: "image/*", Referer: "https://www.openstreetmap.org/" }
        }
        if (e.data.url.startsWith("https://tiles.indoorequal.org")) {
            opt.headers = { Referer: "https://www.openstreetmap.org/" }
        }
        if (e.data.url.startsWith("https://tile.tracestrack.com")) {
            opt.headers = { Referer: "https://www.openstreetmap.org/" }
        }
        // fuck TM, need imitate Response
        try {
            const res = await fetchBlobWithCache(e.data.url, opt)
            if (res.status === 403 && e.data.url.includes("strava")) {
                stravaAuthNotify()
            }
            window.postMessage(
                {
                    type: "bypass_csp_response",
                    url: e.data.url,
                    data: {
                        status: res.status,
                        statusText: res.statusText,
                        response: res.response,
                    },
                },
                e.origin,
            )
        } catch (err) {
            window.postMessage(
                {
                    type: "bypass_csp_response",
                    url: e.data.url,
                    data: {
                        status: err.status,
                        statusText: err.statusText,
                        response: err.response,
                        aborted: opt.signal?.aborted,
                    },
                },
                e.origin,
            )
        }
    })
}

let initCustomFetch

function initMaplibreWorkerOverrider() {
    window.addEventListener("message", async e => {
        if (e.origin !== location.origin) {
            return
        }
        if (e.data.type !== "create_worker") {
            return
        }
        console.log("create_worker message")
        const worker = boWindowObject.maplibreOverriddenWorker
        worker.onmessage = intoPageWithFun(async e => {
            if (e.data.type !== "bypass_csp") {
                return
            }
            // console.log(e.data)
            if (!customLayerUrlOrigin && !vectorLayerStyleUrlOrigin) {
            } else {
                const res = await GM.xmlHttpRequest({
                    url: e.data.url,
                    responseType: "blob",
                    headers: {
                        Origin: location.origin,
                        Referer: location.origin + "/",
                    },
                })
                worker.postMessage(
                    cloneInto(
                        {
                            type: "bypass_csp_response",
                            url: e.data.url,
                            data: {
                                status: res.status,
                                statusText: res.statusText,
                                response: res.response,
                            },
                        },
                        boWindowObject,
                    ),
                )
            }
        })
        initCustomFetch = () => {
            worker.postMessage(intoPage({ type: "init_custom_fetch" }))
        }
        delete boWindowObject.maplibreOverriddenWorker
        // лучше убрать воркер из глобального скоупа
    })
}
if ([prod_server.origin, dev_server.origin, local_server.origin, ohm_prod_server.origin].includes(location.origin)) {
    initCspBridge()
    initMaplibreWorkerOverrider()
}

function initPhotosPreviewMessageHandler() {
    window.addEventListener("message", e => {
        if (e.origin !== location.origin) {
            return
        }
        if (e.data?.type !== "photos_filtered_objects") {
            return
        }
        renderPhotosPreview(e.data?.withPhotos || [])
    })
}

function runInOsmPageCode() {
    injectJSIntoPage(`
    const OriginalBlob = window.Blob;
    window.Blob = function Blob(parts = [], options = {}) {
        if (parts?.[0]?.startsWith?.("var sharedModule = {};")) {
            console.debug('Blob intercepted:', options, "len", parts[0].length);
            window.maplibreWorkerSourceCode = parts[0]
            window.Blob = OriginalBlob
        }
        return new OriginalBlob(parts, options);
    };
    window.Blob.prototype = OriginalBlob.prototype;

    const OriginalWorker = window.Worker
    window.Worker = function (url, options) {
        const fetchCode = "const originalFetch = self.fetch;" +
            "let customFetchEnabled = false;" +
            "self.addEventListener('message', (e) => {" +
            "    if (e.data.type !== 'init_custom_fetch') { return; } " +
            "    customFetchEnabled = true;" +
            "});" +
            "self.fetch = async (...args) => {" +
            "   if (!customFetchEnabled || args.length > 1) return await originalFetch(...args);" +
            "   const url = args[0].url;" +
            "   const resultCallback = new Promise((resolve, reject) => {" +
            "       console.log('fetch in worker', url);" +
            "       self.addEventListener('message', (e) => {" +
            "           if (e.data.type !== 'bypass_csp_response') { return; } " +
            "           if (e.data.url !== url) { return; }" +
            "           resolve(e.data.data);" +
            "       });" +
            "   });" +
            "   self.postMessage({ type: 'bypass_csp', url: url });" +
            "   const res = await resultCallback;" +
            "   return new Response(res.status === 204 ? null : res.response, { status: res.status, statusText: res.statusText });" +
            "};"
        const proxyUrl = URL.createObjectURL(
            new OriginalBlob([fetchCode + window.maplibreWorkerSourceCode], { type: "application/javascript" }),
        )
        // TODO FIXME proxyUrl = URL.createObjectURL — memory leak?
        console.count("newWorkerCounter")
        const maplibreOverriddenWorker = new OriginalWorker(proxyUrl, options);
        window.maplibreOverriddenWorker = maplibreOverriddenWorker
        window.postMessage({ type: "create_worker" }, location.origin)

        return maplibreOverriddenWorker
    }
        
    const originalFetch = window.fetch;

    window.fetchIntercepterScriptInited = true;
    window.needClearLoadMoreRequest = 0;
    window.needPatchLoadMoreRequest = null;
    window.hiddenChangesetsCount = null;
    window.spyGlassMode = false;
    window.photosMode = false;

    window.notesDisplayName = "";
    window.notesQFilter = "";
    window.notesClosedFilter = "";
    window.notesCommentsFilter = "";
    window.notesIDsFilter = new Set();

    window.customLayerOrigin = null
    window.customVectorStyleLayerOrigin = null

    // const cache = new Map();

    window.mapDataIDsFilter = new Set();

    console.log('Fetch intercepted');
    window.fetch = async (...args) => {
        try {
            if (args[0]?.includes?.("notes.json")) {
                if (!(window.notesDisplayName !== ""
                    || window.notesQFilter !== ""
                    || (window.notesClosedFilter !== "" && window.notesClosedFilter !== "7")
                    || window.notesCommentsFilter !== ""
                    || window.notesIDsFilter.size)) {
                    const response = await originalFetch(...args);
                    if (response.status !== 200) {
                        return response
                    }
                    const originalJSON = await response.json();
                    window.visibleNotes = originalJSON.features;
                    return new Response(JSON.stringify(originalJSON), {
                        status: response.status,
                        statusText: response.statusText,
                        headers: response.headers
                    });
                }
                const url = new URL(args[0], location.origin);
                url.pathname = url.pathname.replace("notes.json", "notes/search.json")
                url.searchParams.set("limit", "1000")
                if (window.notesDisplayName && !window.invertDisplayName && !window.notesDisplayName.includes(",")) {
                    if (window.notesDisplayName !== "anon" && window.notesDisplayName !== "анон" && window.notesDisplayName !== "anonymous") {
                        url.searchParams.set("display_name", window.notesDisplayName)
                    }
                }
                // if (window.notesQFilter && !window.invertQ && !window.notesQFilter.includes(",")) {
                //     url.searchParams.set("q", window.notesQFilter)
                // }
                if (window.notesClosedFilter) {
                    if (window.notesClosedFilter < -1) {
                        if (window.notesClosedFilter < -365) {
                            url.searchParams.set("from", new Date("2000-01-01T00:00:00Z").toISOString().slice(0, -5) + "Z")
                            const d = new Date()
                            d.setTime(d.getTime() - 1000 * 86400 * -notesClosedFilter)
                            url.searchParams.set("to", d.toISOString().slice(0, -5) + "Z")
                        } else {
                            const d = new Date()
                            d.setTime(d.getTime() - 1000 * 86400 * -notesClosedFilter)
                            url.searchParams.set("from", d.toISOString().slice(0, -5) + "Z")
                        }
                    } else {
                        url.searchParams.set("closed", window.notesClosedFilter)
                    }
                }
                args[0] = url.toString()
                const response = await originalFetch(...args);
                if (response.status !== 200) {
                    return response
                }
                /** @type import('geojson').GeoJSON */
                const originalJSON = await response.json();
                window.visibleNotes = originalJSON.features = originalJSON.features?.filter(note => {
                    if (window.notesCommentsFilter) {
                        const currentUserID = document.head.getAttribute("data-user")
                        switch (window.notesCommentsFilter) {
                            case "only with comments":
                                if (note.properties.comments.length <= 1) {
                                    return false
                                }
                                break
                            case "only with my comments":
                                if (currentUserID) {
                                    if (!note.properties.comments.slice(1).some(c => c.uid === currentUserID)) {
                                        return false
                                    }
                                }
                                break
                            case "without comments":
                                if (note.properties.comments.length > 1) {
                                    return false
                                }
                                break
                            case "without my comments":
                                if (currentUserID) {
                                    if (note.properties.comments.slice(1)?.some(c => c.uid === currentUserID)) {
                                        return false
                                    }
                                }
                                break
                            case "commented by other users":
                                if (currentUserID) {
                                    if (note.properties.comments.length <= 1) {
                                        return false
                                    }
                                    if (!note.properties.comments.slice(1).some(c => c.uid === currentUserID)) {
                                        return false
                                    }
                                }
                                break
                            default:
                                console.error("unsupported comments filter", window.notesCommentsFilter)
                        }
                    }
                    if (window.notesDisplayName) {
                        if (window.invertDisplayName) {
                            const usernames = window.notesDisplayName.split(",")
                            for (const username of usernames) {
                                if ((username === "anon" || username === "анон" || username === "anonymous") && !note.properties.comments?.[0]?.user) {
                                    return false
                                } else if (note.properties.comments?.[0]?.user === username) {
                                    return false
                                }
                            }
                        } else {
                            const usernames = window.notesDisplayName.split(",")
                            let found = false
                            for (const username of usernames) {
                                if ((username === "anon" || username === "анон" || username === "anonymous") && !note.properties.comments?.[0]?.user) {
                                    found = true
                                } else if (note.properties.comments?.[0]?.user === username) {
                                    found = true
                                }
                            }
                            if (!found) {
                                return false
                            }
                        }
                    }
                    if (window.notesQFilter) {
                        if (window.invertQ) {
                            const words = window.notesQFilter.split(",").map(i => i.trim()).filter(i => i !== "")
                            for (const word of words) {
                                for (const comment of note.properties.comments ?? []) {
                                    if (comment.text?.toLowerCase()?.includes(word.toLowerCase())) {
                                        return false
                                    }
                                }
                            }
                        } else {
                            const words = window.notesQFilter.split(",").map(i => i.trim()).filter(i => i !== "")
                            let found = false
                            for (const word of words) {
                                for (const comment of note.properties.comments ?? []) {
                                    if (comment.text?.toLowerCase()?.includes(word.toLowerCase())) {
                                        found = true
                                    }
                                }
                            }
                            if (!found && words.length) {
                                return false
                            }
                        }
                    }
                    if (window.notesIDsFilter.size) {
                        if (window.notesIDsFilter.has(note.properties.id)) {
                            return false
                        }
                    }
                    return true
                })

                return new Response(JSON.stringify(originalJSON), {
                    status: response.status,
                    statusText: response.statusText,
                    headers: response.headers
                });
            } else if (args[0]?.startsWith?.("/history?bbox") && (needClearLoadMoreRequest || needPatchLoadMoreRequest)) {
                const response = await originalFetch(...args);
                const originalText = await response.text();
                if (needClearLoadMoreRequest) {
                    console.log("new changesets cleared")
                    needClearLoadMoreRequest--;
                    const doc = (new DOMParser()).parseFromString(originalText, "text/html");
                    doc.querySelectorAll("ol > li").forEach(i => i.remove())
                    doc.querySelector('.changeset_more:has([href*="before"]) a.page-link').href = window.lastLoadMoreURL
                    window.lastLoadMoreURL = ""
                    return new Response(doc.documentElement.outerHTML, {
                        status: response.status,
                        statusText: response.statusText,
                        headers: response.headers
                    });
                } else if (needPatchLoadMoreRequest) {
                    console.log("new changesets patched")
                    const doc = (new DOMParser()).parseFromString(originalText, "text/html");
                    filterChangesets(doc)
                    setTimeout(() => {
                        const changesetsCount = document.querySelectorAll("ol > li").length
                        document.querySelector("#hidden-changeset-counter").textContent = t("changesetsHistory.displayedCount", {
                            displayed: changesetsCount - getWindow().hiddenChangesetsCount,
                            total: changesetsCount,
                        })
                    }, 100)
                    return new Response(doc.documentElement.outerHTML, {
                        status: response.status,
                        statusText: response.statusText,
                        headers: response.headers
                    });
                }
            } else if (window.mapGLIntercepted && window.customLayerOrigin && args?.[0]?.url?.startsWith?.(window.customLayerOrigin)
            || window.mapGLIntercepted && window.customVectorStyleLayerOrigin && (
                    window.customVectorStyleLayerOrigin.startsWith("https://www.openstreetmap.org") 
                    || window.customVectorStyleLayerOrigin.startsWith("http://localhost") 
                    || window.customVectorStyleLayerOrigin.startsWith("https://raw.githubusercontent.com") 
                    || args?.[0]?.url?.startsWith?.(window.customVectorStyleLayerOrigin)
                    || args?.[0]?.url?.startsWith?.("https://demotiles.maplibre.org/")
                )) {
                const resourceUrl = args?.[0]?.url
                console.log("overridden fetch in page", window.customLayerOrigin, window.customVectorStyleLayerOrigin, resourceUrl)
                const resultCallback = new Promise((resolve, reject) => {
                    window.addEventListener("message", e => {
                        if (e.origin !== location.origin) return
                        if (e.data.type !== "bypass_csp_response") {
                            return
                        }
                        if (e.data.url !== resourceUrl) {
                            return
                        }
                        resolve(e.data.data)
                    })
                })
                window.postMessage({ "type": "bypass_csp", url: resourceUrl }, location.origin)
                const res = await resultCallback
                if (res.aborted) {
                    throw "AbortError"
                } else {
                    return new Response(res.status === 204 ? null : res.response, {
                        status: res.status,
                        statusText: res.statusText,
                    });
                }
            } else if (args?.[0]?.url === "https://vector.openstreetmap.org/styles/shortbread/colorful.json"
                || args?.[0]?.url === "https://vector.openstreetmap.org/styles/shortbread/eclipse.json") {
                return originalFetch(...args);
                // const response = await originalFetch(...args);
                // const originalJSON = await response.json();
                // originalJSON.layers[originalJSON.layers.findIndex(i => i.id === "water-river")].paint['line-color'] = "red"
                // originalJSON.layers.forEach(i => {
                //     if (i.paint && i.paint['line-color']) {
                //         i.paint['line-color'] = "red"
                //     }
                //     if (i.paint && i.paint['fill-color']) {
                //         i.paint['fill-color'] = "black"
                //     }
                // })
                // console.log(originalJSON)
                // return new Response(JSON.stringify(originalJSON));
            } else if (args[0]?.includes?.("/members")) {
                // console.log("freeeeeze", args[0])
                // await new Promise(() => setTimeout(() => true, 1000 * 1000))
            } else if (args[0]?.includes?.("https://www.wikidata.org/w/api.php")) {
                // if (cache.has(args[0])) {
                //     console.log("force cache for", args[0])
                //     return new Response(cache.get(args[0]), {
                //         status: response.status,
                //         statusText: response.statusText,
                //         headers: response.headers
                //     });
                // }
                // const response = await originalFetch(...args);
                // const jsonStr = JSON.stringify(await response.json());
                // cache.set(args[0], jsonStr)
                // return new Response(jsonStr, {
                //     status: response.status,
                //     statusText: response.statusText,
                //     headers: response.headers
                // });
            } else if ((false || photosMode) && args[0]?.includes?.("/map.json")) {
                console.debug("replacing Map Data overlay")
                const response = await originalFetch(...args);
                const originalJSON = await response.json();
                const withPhotos = originalJSON.elements.filter(i => {
                    return Object.keys(i.tags || {}).some(k => k.startsWith("panoramax"))
                })
                window.postMessage({ type: "photos_filtered_objects", withPhotos: withPhotos }, location.origin)
                return new Response(JSON.stringify(originalJSON), {
                    status: response.status,
                    statusText: response.statusText,
                    headers: response.headers
                });
            } else if (spyGlassMode && args[0]?.includes?.("/map.json")) {
                console.debug("replacing Map Data overlay")
                const response = await originalFetch(...args);
                const originalJSON = await response.json();
                if (false) {
                    const reverseIndex = {}
                    originalJSON.elements?.forEach(i => {
                        reverseIndex[i.type + i.id] = i
                    })
                    map.dataLayer.on('layeradd', e => {
                        // if (e.layer.feature.tags["name"] && e.layer.feature.tags["landuse"]) {
                        //     e.layer.bindTooltip(
                        //         e.layer.feature.tags["name"],
                        //         {
                        //             content: e.layer.feature.tags["name"],
                        //             sticky: true,
                        //             permanent: true,
                        //             offset: L.point(10, 0)
                        //         },
                        //     )
                        //         .openTooltip()
                        // }
                        const layer = e.layer
                        const f = e.layer.feature
                        const t = f.tags || {};
                        const palette = {
                            background: "#ffeef7",
                            border: "#ffb6c1",
                            text: "#d81b60",
                            pink1: "#f8bbd0",
                            pink2: "#f48fb1",
                            pink3: "#ec407a",
                            pink4: "#ad1457",
                            white: "#fff"
                        };

                        if (t.building) {
                            layer.setStyle({
                                color: palette.pink3,
                                fillColor: palette.pink1,
                                fillOpacity: 0.8,
                                weight: 1
                            });
                        } else if (t.landuse) {
                            if (["forest", "meadow", "grass", "farmland"].includes(t.landuse)) {
                                layer.setStyle({
                                    color: palette.pink2,
                                    fillColor: palette.pink1,
                                    fillOpacity: 0.6
                                });
                            } else if (["residential", "commercial", "industrial"].includes(t.landuse)) {
                                layer.setStyle({
                                    color: palette.pink4,
                                    fillColor: palette.pink3,
                                    fillOpacity: 0.5
                                });
                            } else {
                                layer.setStyle({
                                    color: palette.border,
                                    fillColor: palette.pink1,
                                    fillOpacity: 0.4
                                });
                            }
                        } else if (t.highway) {
                            layer.setStyle({
                                color: palette.pink4,
                                weight: 2,
                                opacity: 0.9
                            });
                        } else if (t.natural === "water" || t.waterway) {
                            layer.setStyle({
                                color: "#ff80ab",
                                fillColor: "#ffbcd9",
                                fillOpacity: 0.5
                            });
                        } else {
                            layer.setStyle({
                                color: palette.border,
                                fillColor: palette.background,
                                fillOpacity: 0.2
                            });
                        }
                        if (reverseIndex[f.type + f.id].user === "TrickyFoxy") {
                            debugger
                            layer.setStyle({
                                color: "green"
                            });
                        }
                    })
                }
                let rightPopup = document.getElementById("spy-glass-popup")
                if (!rightPopup) {
                    rightPopup = document.createElement("div")
                    rightPopup.id = "spy-glass-popup"
                    rightPopup.style.position = "absolute"
                    rightPopup.style.top = "0px"
                    rightPopup.style.width = "max(350px, 30%)"
                    rightPopup.style.height = "fit-content"
                    rightPopup.style.background = "white"
                    document.getElementById("map").after(rightPopup)
                }
                rightPopup.style.zIndex = "99"
                // todo clean prev handler
                map.dataLayer.clearLayers()

                const features = map.dataLayer.buildFeatures(originalJSON)
                const nodes = features.filter(i => i.type === "node")
                const other = features.filter(i => i.type !== "node")
                for (const feature of [...other, ...nodes]) {
                    if (window.mapDataIDsFilter.has(feature.type + feature.id)) {
                        continue
                    }
                    let layer, layerShadow;

                    if (feature.type === "node") {
                        layerShadow = L.circleMarker(feature.latLng, {
                            color: "white",
                            weight: 4,
                            radius: 4,
                            opacity: 1,
                            fillOpacity: 1
                        });
                        layer = L.circleMarker(feature.latLng, {
                            color: "black",
                            weight: 2,
                            radius: 2,
                            opacity: 1,
                            fillOpacity: 1
                        });
                    } else {
                        const latLngs = new Array(feature.nodes.length);

                        for (let j = 0; j < feature.nodes.length; j++) {
                            latLngs[j] = feature.nodes[j].latLng;
                        }

                        if (map.dataLayer.isWayArea(feature)) {
                            latLngs.pop();
                            layer = L.polygon(latLngs, {
                                color: "black",
                                weight: 2,
                                opacity: 1,
                                fillOpacity: 0.5
                            });
                        } else {
                            layer = L.polyline(latLngs, {
                                color: "black",
                                weight: 2,
                                radius: 4,
                                opacity: 1,
                                fillOpacity: 0.5
                            });
                        }
                    }

                    function attachMouseHandlers(layer) {
                        layer.on("mouseover", function(e) {
                            rightPopup.textContent = ""
                            layer.setStyle({
                                color: "red",
                                weight: 2,
                                radius: 2,
                                opacity: 1,
                                fillOpacity: 1
                            })
                            layer._path.classList.add("spy-glass-stroke-polyline")
                            layer.bringToFront()

                            const t = document.createElement("table")
                            t.classList.add("spy-glass")
                            t.style.margin = "4px"
                            t.style.border = "solid 1px red"
                            t.style.borderRadius = "4px"
                            t.style.width = "97%"
                            const tB = document.createElement("tbody")
                            t.appendChild(tB)

                            const thead = document.createElement("thead")
                            thead.style.color = "black"
                            thead.style.background = "yellow"
                            thead.style.fontWeight = "bold"
                            thead.style.fontSize = "large"
                            thead.style.textAlign = "center"
                            thead.style.borderBottom = "solid 1px gray"
                            t.appendChild(thead)

                            const th = document.createElement("th")
                            th.colSpan = 2
                            th.textContent = feature.type + " " + feature.id
                            thead.appendChild(th)

                            Object.entries(feature.tags ?? {}).forEach(([k, v]) => {
                                const tr = document.createElement("tr")
                                const tdKey = document.createElement("td")
                                const tdValue = document.createElement("td")
                                tdKey.textContent = k
                                tdKey.style.color = "black"
                                tdKey.style.fontWeight = "bold"
                                tdValue.textContent = v
                                tdValue.style.color = "black"
                                tr.appendChild(tdKey)
                                tr.appendChild(tdValue)
                                tB.appendChild(tr)
                            })

                            rightPopup.appendChild(t)

                            console.log(feature)
                        })
                        layer.on("mouseout", function(e) {
                            layer.setStyle({
                                color: "black",
                                weight: 2,
                                radius: 2,
                                opacity: 1,
                                fillOpacity: 1
                            })
                            layer._path.classList.remove("spy-glass-stroke-polyline")
                            if (feature.type === "way") {
                                layer.bringToBack()
                            }
                        })
                        layer.on('click', e => {
                            if (!layer.feature) {
                                return
                            }
                            if (!e.originalEvent.altKey) {
                                OSM.router.route("/" + layer.feature.type + "/" + layer.feature.id)
                                rightPopup.style.zIndex = "0"
                                return
                            }
                            window.mapDataIDsFilter.add(layer.feature.type + layer.feature.id)
                            layer.remove()
                            layerShadow.remove()
                        })
                    }

                    attachMouseHandlers(layer)
                    if (feature.type === "node") {
                        layerShadow.addTo(map);
                        queueMicrotask(() => {
                            layer.addTo(map);
                            layer.feature = feature;
                            layerShadow.feature = feature;
                            layer._path.classList.add("spy-glass-" + feature.type)
                            layer.bringToFront()
                        })
                    } else {
                        layer.addTo(map);
                        layer.feature = feature;
                        layer._path.classList.add("spy-glass-" + feature.type)
                    }
                }
                throw "PreventMapData"
            } else if (args[0]?.includes?.("graphhopper.com/api/1/route")) {
                const response = await originalFetch(...args);
                if (response.status !== 200) {
                    return response
                }
                const originalJSON = await response.json();
                window.postMessage({type: "add_router_data_date", url: args[0], time: originalJSON["info"]["road_data_timestamp"]})
                return new Response(JSON.stringify(originalJSON), {
                    status: response.status,
                    statusText: response.statusText,
                    headers: response.headers
                });
            } else if (args[0]?.startsWith?.("https://routing.openstreetmap.de")) { 
                window.postMessage({type: "add_router_data_date", url: args[0]})
            } else if (args[0]?.startsWith?.("https://valhalla1.openstreetmap.de")) {
                window.postMessage({type: "add_router_data_date", url: args[0]})
            }
            else {
                // console.log("other requests", args[0])
                // debugger
            }
        } catch (e) {
            if (e === "PreventMapData" || e === "AbortError") {
                throw { name: "AbortError" }
            }
            return originalFetch(...args);
        } finally {
            document.querySelectorAll(".wait-fetch").forEach(elem => elem.classList.remove("wait-fetch"))
        }
        return originalFetch(...args);
    }
    `)
}

if (isOsmServer()) {
    initPhotosPreviewMessageHandler()
    runInOsmPageCode()
}

//</editor-fold>

//<editor-fold desc="/history, /user/*/history">
let sidebarObserverForMassActions = null
let massModeForUserChangesetsActive = null
let massModeActive = null
let currentMassDownloadedPages = null
let needHideBigChangesets = true

function openCombinedChangesetsMap() {
    const batchSize = 500

    function openIDs(ids) {
        const forOpen = []
        for (let i = 0; i < ids.length; i += batchSize) {
            const idsStr = ids.slice(i, i + batchSize).join(",")
            forOpen.push(osm_server.url + `/changeset/${ids[i]}?changesets=` + idsStr)
        }
        forOpen.toReversed().forEach(url => open(url, "_blank"))
    }

    const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value)
    if (ids.length) {
        openIDs(ids)
    } else {
        const ids = Array.from(document.querySelectorAll(".mass-action-checkbox")).map(i => i.value)
        if (ids.length) {
            openIDs(ids)
        } else {
            const ids = Array.from(document.querySelectorAll(`a[href^="/changeset/"].custom-changeset-id-click`)).map(
                i => i.getAttribute("href").match(/\/changeset\/([0-9]+)/)[1],
            )
            openIDs(ids)
        }
    }
}

function onClickByCopyIds() {
    const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked"))
        .map(i => i.value)
        .join(",")
    if (ids !== "") {
        navigator.clipboard.writeText(ids).then(() => {
            console.log(ids, "ids copied")
        })
    } else {
        const ids = Array.from(document.querySelectorAll(".mass-action-checkbox"))
            .map(i => i.value)
            .join(",")
        navigator.clipboard.writeText(ids).then(() => {
            console.log(ids, "ids copied")
        })
    }
}

function makeTopActionBar() {
    const actionsBar = document.createElement("div")
    actionsBar.classList.add("actions-bar")
    const copyIds = document.createElement("button")
    copyIds.textContent = t("changesetsHistory.copyIds")
    copyIds.classList.add("copy-changesets-ids-btn")
    copyIds.onclick = onClickByCopyIds
    const revertButton = document.createElement("button")
    revertButton.textContent = "↩️"
    revertButton.title = t("changesetsHistory.revertViaOsmRevert", { name: osm_revert_name })
    revertButton.onclick = () => {
        const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked"))
            .map(i => i.value)
            .join(",")
        open(`${osm_revert_origin}/?changesets=` + ids, "_blank")
    }
    const revertViaJOSMButton = document.createElement("button")
    revertViaJOSMButton.textContent = "↩️ via JOSM"
    revertViaJOSMButton.onclick = () => {
        const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked"))
            .map(i => i.value)
            .join(",")
        open("http://127.0.0.1:8111/revert_changeset?id=" + ids, "_blank")
    }
    const viewChangesetsButton = document.createElement("button")
    viewChangesetsButton.textContent = "🔍"
    viewChangesetsButton.title = t("changesetsHistory.displayOnOneMapAll")
    viewChangesetsButton.onclick = openCombinedChangesetsMap
    actionsBar.appendChild(copyIds)
    actionsBar.appendChild(document.createTextNode("\xA0"))
    actionsBar.appendChild(revertButton)
    actionsBar.appendChild(document.createTextNode("\xA0"))
    actionsBar.appendChild(revertViaJOSMButton)
    actionsBar.appendChild(document.createTextNode("\xA0"))
    actionsBar.appendChild(viewChangesetsButton)
    return actionsBar
}

function makeBottomActionBar() {
    if (document.querySelector(".buttom-btn")) return

    const copyIds = document.createElement("button")
    const selectedIDsCount = document.querySelectorAll(".mass-action-checkbox:checked").length
    if (selectedIDsCount) {
        copyIds.textContent = t("changesetsHistory.copyIdsCount", { count: selectedIDsCount })
    } else {
        copyIds.textContent = t("changesetsHistory.copyIds")
    }
    copyIds.classList.add("copy-changesets-ids-btn")
    copyIds.classList.add("buttom-btn")
    copyIds.classList.add("page-link")
    copyIds.onclick = onClickByCopyIds
    const revertButton = document.createElement("button")
    revertButton.textContent = "↩️"
    revertButton.title = t("changesetsHistory.revertViaOsmRevert", { name: osm_revert_name })
    revertButton.onclick = () => {
        const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked"))
            .map(i => i.value)
            .join(",")
        window.location = `${osm_revert_origin}/?changesets=` + ids
    }
    revertButton.classList.add("page-link")
    const viewChangesetsButton = document.createElement("button")
    viewChangesetsButton.textContent = "🔍"
    viewChangesetsButton.title = t("changesetsHistory.displayOnOneMap")
    viewChangesetsButton.onclick = openCombinedChangesetsMap
    viewChangesetsButton.classList.add("page-link")
    const changesetMore = document.querySelector('#sidebar_content div.changeset_more:has([href*="before"]) li')
    if (changesetMore) {
        changesetMore.style.display = "inline-flex"
        changesetMore.appendChild(copyIds)
        changesetMore.appendChild(document.createTextNode("\xA0"))
        changesetMore.appendChild(revertButton)
        changesetMore.appendChild(document.createTextNode("\xA0"))
        changesetMore.appendChild(viewChangesetsButton)
    } else {
        const changesetsList = document.querySelector("#sidebar_content ol")
        const actionBarWrapper = document.createElement("ul")
        actionBarWrapper.classList.add("pagination", "justify-content-center")
        const actionBarWrapperLi = document.createElement("li")
        actionBarWrapperLi.classList.add("page-item")
        actionBarWrapperLi.style.display = "inline-flex"
        actionBarWrapper.appendChild(actionBarWrapperLi)

        actionBarWrapperLi.classList.add("action-bar-wrapper")
        actionBarWrapperLi.classList.add("text-center")
        actionBarWrapperLi.appendChild(copyIds)
        actionBarWrapperLi.appendChild(document.createTextNode("\xA0"))
        actionBarWrapperLi.appendChild(revertButton)
        actionBarWrapperLi.appendChild(document.createTextNode("\xA0"))
        actionBarWrapperLi.appendChild(viewChangesetsButton)
        changesetsList.after(actionBarWrapper)
    }
}

function makeOsmchaLinkForUsername(username) {
    // example: https://osmcha.org?filters={"users":[{"label":"TrickyFoxy","value":"TrickyFoxy"}]}
    const osmchaFilter = {
        users: [{ label: username, value: username }],
        date__gte: [{ label: "", value: "" }],
    }
    return `${osmcha_server_origin}?${new URLSearchParams({ filters: JSON.stringify(osmchaFilter) }).toString()}`
}

function addMassActionForUserChangesets() {
    if (!location.pathname.includes("/user/") || document.querySelector("#mass-action-btn")) {
        return
    }
    setTimeout(() => void geocodeCurrentView(), 100) // todo without timeout
    const a = document.createElement("a")
    a.title = t("changesetsHistory.addCheckboxesMassActions")
    a.textContent = " 📋"
    a.style.cursor = "pointer"
    a.id = "mass-action-btn"
    a.onclick = () => {
        if (massModeForUserChangesetsActive === null) {
            massModeForUserChangesetsActive = true
            document.querySelector("#sidebar div.changesets").before(makeTopActionBar())
            document.querySelector('#sidebar div.changeset_more:has([href*="before"])').after(document.createTextNode("   "))
            makeBottomActionBar()
            document.querySelectorAll("ol li").forEach(addChangesetCheckbox)
        } else {
            massModeForUserChangesetsActive = !massModeForUserChangesetsActive
            document.querySelectorAll(".actions-bar").forEach(i => i.toggleAttribute("hidden"))
            document.querySelectorAll(".mass-action-checkbox").forEach(i => {
                i.toggleAttribute("hidden")
            })
        }
    }
    const username = decodeURI(location.pathname.match(/\/user\/(.*)\/history$/)[1])
    const osmchaLink = document.createElement("a")
    osmchaLink.innerHTML = osmchaSvgLogo
    osmchaLink.id = "osmcha_link"
    osmchaLink.title = t("osmcha.openProfileInOsmcha")
    osmchaLink.href = makeOsmchaLinkForUsername(username)
    osmchaLink.target = "_blank"
    osmchaLink.rel = "noreferrer"

    const osmchaIcon = osmchaLink.querySelector("svg")
    osmchaIcon.style.height = "1em"
    osmchaIcon.style.cursor = "pointer"
    osmchaIcon.style.marginTop = "-3px"
    if (isDarkMode()) {
        osmchaIcon.style.filter = "invert(0.7)"
    }
    osmchaLink.appendChild(osmchaIcon)

    document.querySelector("#sidebar_content h2").appendChild(a)
    document.querySelector("#sidebar_content h2").appendChild(document.createTextNode("\xA0"))
    document.querySelector("#sidebar_content h2").appendChild(osmchaLink)
    // setTimeout(async () => {
    //     const res = await fetchJSONWithCache(
    //         osm_server.apiBase +
    //         "notes/search.json?" +
    //         new URLSearchParams({
    //             display_name: username,
    //             closed: -1,
    //             order: "newest",
    //         }).toString(),
    //     )
    //     debugger
    // })
}

function addChangesetCheckbox(changesetElem) {
    if (changesetElem.querySelector(".mass-action-checkbox")) {
        return
    }
    const a = document.createElement("a")
    a.classList.add("mass-action-wrapper")
    const checkbox = document.createElement("input")
    checkbox.type = "checkbox"
    checkbox.classList.add("mass-action-checkbox")
    checkbox.value = changesetElem.querySelector(".changeset_id").href.match(/\/(\d+)/)[1]
    checkbox.style.cursor = "pointer"
    checkbox.title = t("selection.shiftClickSelectRange")
    checkbox.onclick = e => {
        if (e.shiftKey) {
            let currentCheckboxFound = false
            for (const cBox of Array.from(document.querySelectorAll(".mass-action-checkbox")).toReversed()) {
                if (!currentCheckboxFound) {
                    if (cBox.value === checkbox.value) {
                        currentCheckboxFound = true
                    }
                } else {
                    if (cBox.checked) {
                        break
                    }
                    cBox.checked = true
                }
            }
        }
        const selectedIDsCount = document.querySelectorAll(".mass-action-checkbox:checked").length
        document.querySelectorAll(".copy-changesets-ids-btn").forEach(i => {
            if (selectedIDsCount) {
                i.textContent = t("changesetsHistory.copyIdsCount", { count: selectedIDsCount })
            } else {
                i.textContent = t("changesetsHistory.copyIds")
            }
        })
    }
    a.appendChild(checkbox)
    changesetElem.querySelector("p").prepend(a)
    changesetElem.querySelectorAll("a.changeset_id").forEach(i => {
        i.onclick = e => {
            if (massModeActive) {
                e.preventDefault()
            }
        }
    })
}

function filterChangesets(htmlDocument = document) {
    const usernameFilters = document
        .querySelector("#filter-by-user-input")
        .value.trim()
        .split(",")
        .map(i => i.trim())
        .filter(i => i !== "")
    const commentFilters = document
        .querySelector("#filter-by-comment-input")
        .value.trim()
        .split(",")
        .filter(i => i.trim() !== "")
    const changesetsLi = htmlDocument.querySelectorAll("ol li")
    let newHiddenChangesetsCount = 0
    let newChangesetsCount = changesetsLi.length
    changesetsLi.forEach(li => {
        const changesetComment = li.querySelector("p a bdi").textContent
        const changesetAuthorLink = li.querySelector("div > a")
        const changesetAuthor = changesetAuthorLink?.textContent ?? ""
        let bbox
        if (li.getAttribute("data-changeset")) {
            bbox = Object.values(JSON.parse(li.getAttribute("data-changeset")).bbox)
        } else {
            bbox = Object.values(JSON.parse(li.getAttribute("hidden-data-changeset")).bbox)
        }
        bbox = bbox.map(parseFloat)
        const deltaLon = bbox[2] - bbox[0]
        const deltaLat = bbox[3] - bbox[1]
        const bboxSizeLimit = 1
        let wasHidden = false

        function hideLi(li) {
            if (li.getAttribute("data-changeset")) {
                li.setAttribute("hidden-data-changeset", li.getAttribute("data-changeset"))
                li.removeAttribute("data-changeset")
                li.setAttribute("hidden", true)
            } else {
                // FIXME
            }
            wasHidden = true
        }

        function unhideLi(li) {
            if (li.getAttribute("hidden-data-changeset")) {
                li.setAttribute("data-changeset", li.getAttribute("hidden-data-changeset"))
                li.removeAttribute("hidden-data-changeset")
                li.removeAttribute("hidden")
            } else {
                // FIXME
            }
        }

        if (needHideBigChangesets && (deltaLat > bboxSizeLimit || deltaLon > bboxSizeLimit)) {
            hideLi(li)
        }
        if (!wasHidden) {
            const invert = document.querySelector("#invert-user-filter-checkbox").getAttribute("checked") === "true"
            if (invert) {
                let needHide = true
                usernameFilters.forEach(username => {
                    if (changesetAuthor.includes(username)) {
                        needHide = false
                    } else if (username === CORPORATE_EMOJI && corporateMappers?.has(changesetAuthor)) {
                        needHide = false
                    } else if (username === BAN_EMOJI && changesetAuthorLink?.previousElementSibling?.classList?.contains("banned-badge")) {
                        needHide = false
                    } else if (username === REVIEW_REQUESTED_EMOJI && li.classList.contains("review-requested-changeset")) {
                        needHide = false
                    } else if (
                        username === NEWBIE_EMOJI &&
                        changesetAuthorLink?.previousElementSibling?.classList?.contains("newbie-badge")
                    ) {
                        needHide = false
                    }
                })
                if (usernameFilters.length && needHide) {
                    hideLi(li)
                }
            } else {
                usernameFilters.forEach(username => {
                    // prettier-ignore
                    if (changesetAuthor.includes(username)
                        || username === CORPORATE_EMOJI && corporateMappers?.has(changesetAuthor)
                        || username === BAN_EMOJI && changesetAuthorLink?.previousElementSibling?.classList?.contains("banned-badge")
                        || username === REVIEW_REQUESTED_EMOJI && li?.classList?.contains("review-requested-changeset")
                        || username === NEWBIE_EMOJI && li?.querySelector(".newbie-badge")
                    ) {
                        hideLi(li)
                    }
                })
            }
        }
        if (!wasHidden) {
            const invert = document.querySelector("#invert-comment-filter-checkbox").getAttribute("checked") === "true"
            commentFilters.forEach(comment => {
                if (changesetComment.includes(comment.trim()) ^ invert) {
                    hideLi(li)
                }
            })
        }
        if (!wasHidden) {
            unhideLi(li)
        } else {
            newHiddenChangesetsCount++
        }
    })

    const counter = document.querySelector("#hidden-changeset-counter")
    const needUpdateCounter =
        (getWindow().hiddenChangesetsCount !== newHiddenChangesetsCount ||
            parseInt(counter.getAttribute("changesets-count")) !== newChangesetsCount) &&
        htmlDocument === document

    if (needUpdateCounter) {
        getWindow().hiddenChangesetsCount = newHiddenChangesetsCount
        const changesetsCount = document.querySelectorAll("ol > li").length
        counter.textContent = t("changesetsHistory.displayedCount", {
            displayed: changesetsCount - newHiddenChangesetsCount,
            total: changesetsCount,
        })
        counter.setAttribute("changesets-count", changesetsCount)
        console.log(changesetsCount)
    }
}

function updateMap() {
    getWindow().needClearLoadMoreRequest++
    getWindow().lastLoadMoreURL = document.querySelector('.changeset_more:has([href*="before"]) a.page-link').href
    document.querySelector('.changeset_more:has([href*="before"]) a.page-link').click()
}

async function addUsernameIntoChangesetsFilter(username) {
    const filterByUsersInput = document.querySelector("#filter-by-user-input")
    if (filterByUsersInput.value === "" || filterByUsersInput.value === username) {
        filterByUsersInput.value = username
    } else {
        filterByUsersInput.value = filterByUsersInput.value + "," + username
        filterByUsersInput.setSelectionRange(filterByUsersInput.value.length, filterByUsersInput.value.length)
    }
    filterChangesets()
    updateMap()
    await GM.setValue("last-user-filter", document.getElementById("filter-by-user-input")?.value)
}

/**
 * @param {HTMLAnchorElement} usernameLink
 */
function makeUsernamesFilterable(usernameLink) {
    if (usernameLink.classList.contains("listen-for-filters")) {
        return
    }
    usernameLink.classList.add("listen-for-filters")

    const filterIcon = document.createElement("span")
    filterIcon.innerHTML = filterIconSvg
    filterIcon.classList.add("filter-username-btn")
    filterIcon.style.cursor = "pointer"
    filterIcon.style.position = "relative"
    filterIcon.style.top = "-2px"
    filterIcon.style.marginLeft = "4px"
    filterIcon.title = t("changesetsHistory.clickHideUserChangesets")
    filterIcon.onclick = async e => {
        e.preventDefault()
        await addUsernameIntoChangesetsFilter(usernameLink.textContent)
    }
    usernameLink.setAttribute("target", "_blank")
    usernameLink.onclick = async e => {
        if (isDebug()) {
            e.preventDefault()
            await addUsernameIntoChangesetsFilter(usernameLink.textContent)
        }
    }
    usernameLink.after(filterIcon)
    // i.style.border = "solid"
    // i.style.borderColor = getWindow()?.makeColor(i.textContent)
    // i.onmouseover = () => {
    //
    // }
}

function getScrollbarWidth() {
    const outer = document.createElement("div")
    outer.style.visibility = "hidden"
    outer.style.overflow = "scroll"
    outer.style.msOverflowStyle = "scrollbar"
    document.body.appendChild(outer)
    const inner = document.createElement("div")
    outer.appendChild(inner)
    const scrollbarWidth = outer.offsetWidth - inner.offsetWidth
    outer.parentNode.removeChild(outer)
    return scrollbarWidth
}

function addMassActionForGlobalChangesets() {
    if (
        (location.pathname === "/history" || location.pathname === "/history/friends") &&
        document.querySelector("#sidebar_content h2") &&
        !document.querySelector("#changesets-filter-btn")
    ) {
        const a = document.createElement("a")
        a.textContent = " 🔎"
        a.style.cursor = "pointer"
        a.id = "changesets-filter-btn"
        a.title = t("changesetsHistory.filterViaBetterOsmOrg")
        a.onclick = async () => {
            document.querySelector("#sidebar .search_forms")?.setAttribute("hidden", "true")

            async function makeTopFilterBar() {
                const filterBar = document.createElement("div")
                filterBar.classList.add("filter-bar")

                const hideBigChangesetsCheckbox = document.createElement("input")
                hideBigChangesetsCheckbox.checked = needHideBigChangesets = await GM.getValue("last-big-changesets-filter")
                hideBigChangesetsCheckbox.type = "checkbox"
                hideBigChangesetsCheckbox.style.cursor = "pointer"
                hideBigChangesetsCheckbox.id = "hide-big-changesets-checkbox"
                const hideBigChangesetLabel = document.createElement("label")
                hideBigChangesetLabel.textContent = t("changesetsHistory.hideBigChangesets")
                hideBigChangesetLabel.htmlFor = "hide-big-changesets-checkbox"
                hideBigChangesetLabel.style.marginLeft = "1px"
                hideBigChangesetLabel.style.marginBottom = "4px"
                hideBigChangesetLabel.style.cursor = "pointer"
                hideBigChangesetsCheckbox.onchange = async () => {
                    needHideBigChangesets = hideBigChangesetsCheckbox.checked
                    filterChangesets()
                    updateMap()
                    await GM.setValue("last-big-changesets-filter", hideBigChangesetsCheckbox.checked)
                }
                filterBar.appendChild(hideBigChangesetsCheckbox)
                filterBar.appendChild(hideBigChangesetLabel)
                filterBar.appendChild(document.createElement("br"))

                const label = document.createElement("span")
                label.textContent = t("changesetsHistory.hideChangesetsFrom")
                label.title = t("selection.clickForInvert")
                label.style.minWidth = "165px"
                label.style.display = "inline-block"
                label.style.cursor = "pointer"
                label.setAttribute("checked", false)
                label.id = "invert-user-filter-checkbox"
                label.onclick = e => {
                    if (e.target.textContent === t("changesetsHistory.hideChangesetsFrom")) {
                        e.target.textContent = t("changesetsHistory.showChangesetsFrom")
                    } else {
                        e.target.textContent = t("changesetsHistory.hideChangesetsFrom")
                    }
                    if (e.target.getAttribute("checked") === "false") {
                        e.target.setAttribute("checked", true)
                    } else {
                        e.target.setAttribute("checked", false)
                    }
                    if (document.querySelector("#filter-by-user-input").value !== "") {
                        filterChangesets()
                        updateMap()
                    }
                }
                filterBar.appendChild(label)
                const filterByUsersInput = document.createElement("input")
                filterByUsersInput.placeholder = t("changesetsHistory.usersFilterPlaceholder")
                filterByUsersInput.id = "filter-by-user-input"
                filterByUsersInput.style.width = 253 - getScrollbarWidth() + "px"
                filterByUsersInput.style.marginBottom = "3px"
                filterByUsersInput.addEventListener("keypress", async function (event) {
                    if (event.key === "Enter") {
                        event.preventDefault()
                        filterChangesets()
                        updateMap()
                        await GM.setValue("last-user-filter", filterByUsersInput.value)
                        await GM.setValue("last-comment-filter", filterByCommentInput.value)
                    }
                })
                filterByUsersInput.value = await GM.getValue("last-user-filter", "")
                filterBar.appendChild(filterByUsersInput)

                const label2 = document.createElement("span")
                label2.textContent = t("changesetsHistory.hideChangesetsWith")
                label2.title = t("selection.clickForInvert")
                label2.style.minWidth = "165px"
                label2.style.display = "inline-block"
                label2.style.cursor = "pointer"
                label2.id = "invert-comment-filter-checkbox"
                label2.setAttribute("checked", false)
                label2.onclick = e => {
                    if (e.target.textContent === t("changesetsHistory.hideChangesetsWith")) {
                        e.target.textContent = t("changesetsHistory.showChangesetsWith")
                    } else {
                        e.target.textContent = t("changesetsHistory.hideChangesetsWith")
                    }
                    if (e.target.getAttribute("checked") === "false") {
                        e.target.setAttribute("checked", true)
                    } else {
                        e.target.setAttribute("checked", false)
                    }
                    if (document.querySelector("#filter-by-comment-input").value !== "") {
                        filterChangesets()
                        updateMap()
                    }
                }
                filterBar.appendChild(label2)
                const filterByCommentInput = document.createElement("input")
                filterByCommentInput.id = "filter-by-comment-input"
                filterByCommentInput.placeholder = t("changesetsHistory.commentsFilterPlaceholder")
                filterByCommentInput.title = t("changesetsHistory.filterBySubstringInComments")
                filterByCommentInput.style.width = 253 - getScrollbarWidth() + "px"
                filterByCommentInput.addEventListener("keypress", async function (event) {
                    if (event.key === "Enter") {
                        event.preventDefault()
                        filterChangesets()
                        updateMap()
                        await GM.setValue("last-user-filter", filterByUsersInput.value)
                        await GM.setValue("last-comment-filter", filterByCommentInput.value)
                    }
                })
                filterByCommentInput.value = await GM.getValue("last-comment-filter", "")
                filterBar.appendChild(filterByCommentInput)

                return filterBar
            }

            getWindow().needPatchLoadMoreRequest = true
            if (massModeActive === null) {
                massModeActive = true
                document.querySelector("#sidebar div.changesets").before(await makeTopFilterBar())
                document.querySelectorAll('ol li div > a[href^="/user/"]').forEach(makeUsernamesFilterable)
            } else {
                massModeActive = !massModeActive
                document.querySelectorAll(".filter-bar").forEach(i => i.toggleAttribute("hidden"))
                document.querySelectorAll(".filter-username-btn").forEach(i => i.toggleAttribute("hidden"))
                document.querySelector("#hidden-changeset-counter")?.toggleAttribute("hidden")
                // document.querySelectorAll(".mass-action-checkbox").forEach(i => {
                // i.toggleAttribute("hidden")
                // })
            }
            filterChangesets()
            updateMap()
        }
        document.querySelector("#sidebar_content h2").appendChild(a)
        const hiddenChangesetsCounter = document.createElement("span")
        hiddenChangesetsCounter.id = "hidden-changeset-counter"
        document.querySelector("#sidebar_content h2").appendChild(hiddenChangesetsCounter)
    }
}

function addMassChangesetsActions() {
    if (!location.pathname.includes("/history")) return
    if (!document.querySelector("#sidebar_content h2")) return

    addMassActionForUserChangesets()
    addMassActionForGlobalChangesets()

    const MAX_PAGE_FOR_LOAD = 15
    sidebarObserverForMassActions?.disconnect()

    function observerHandler(mutationsList, observer) {
        // console.log(mutationsList)
        // debugger
        if (!location.pathname.includes("/history")) {
            massModeActive = null
            getWindow().needClearLoadMoreRequest = 0
            getWindow().needPatchLoadMoreRequest = false
            needHideBigChangesets = false
            currentMassDownloadedPages = null
            observer.disconnect()
            sidebarObserverForMassActions = null
            return
        }
        if (massModeForUserChangesetsActive && location.pathname !== "/history" && location.pathname !== "/history/friends") {
            document.querySelectorAll("ol li").forEach(addChangesetCheckbox)
            makeBottomActionBar()
        }
        if (massModeActive && (location.pathname === "/history" || location.pathname === "/history/friends")) {
            document.querySelectorAll('ol li div > a[href^="/user/"]').forEach(makeUsernamesFilterable)
            // sidebarObserverForMassActions?.disconnect()
            filterChangesets()
            // todo
            // sidebarObserverForMassActions.observe(document.querySelector('#sidebar'), {childList: true, subtree: true});
        }
        document.querySelectorAll("#sidebar ol li div .changeset_id").forEach(item => {
            if (item.classList.contains("custom-changeset-id-click")) return
            item.classList.add("custom-changeset-id-click")
            item.onclick = e => {
                if (!e.isTrusted) return
                e.preventDefault()
                const id = e.target.innerText
                navigator.clipboard.writeText(id).then(() => copyAnimation(e, id))
            }
            item.title = t("changesetsHistory.clickCopyChangesetId")
            if (location.pathname.match(/^\/history(\/?|\/friends)$/)) {
                const usernameA = item.parentElement.parentElement.querySelector('a[href^="/user/"]')
                getCachedUserInfo(usernameA?.textContent).then(res => {
                    if (!res) return
                    usernameA.title = makeUsernameTitle(res)
                    usernameA.before(
                        makeBadge(
                            res,
                            new Date(item.parentElement.parentElement.querySelector("time")?.getAttribute("datetime") ?? new Date()),
                        ),
                    )
                })
            }
        })
        if (currentMassDownloadedPages && currentMassDownloadedPages <= MAX_PAGE_FOR_LOAD) {
            const loader = document.querySelector('.changeset_more:has([href*="before"]) > [hidden]')
            if (loader === null) {
                makeBottomActionBar()
            } else if (loader.style.display === "") {
                document.querySelector('.changeset_more:has([href*="before"]) a.page-link').click()
                console.log(`Loading page #${currentMassDownloadedPages}`)
                currentMassDownloadedPages++
            }
        } else if (currentMassDownloadedPages > MAX_PAGE_FOR_LOAD) {
            currentMassDownloadedPages = null
            const changesetsCount = document.querySelectorAll("ol > li").length
            document.querySelector("#hidden-changeset-counter").textContent =
                ` Displayed ${changesetsCount - getWindow().hiddenChangesetsCount}/${changesetsCount}`
        } else {
            if (!document.querySelector("#infinity-list-btn")) {
                const moreButton = document.querySelector('.changeset_more:has([href*="before"]) a.page-link')
                if (!moreButton) return
                moreButton.parentElement.style.display = "inline-flex"
                const infinityList = document.createElement("button")
                infinityList.classList.add("page-link")
                infinityList.textContent = t("changesetsHistory.loadMore", { count: 20 * MAX_PAGE_FOR_LOAD })
                infinityList.id = "infinity-list-btn"
                infinityList.onclick = () => {
                    currentMassDownloadedPages = 1
                    moreButton.click()
                    infinityList.remove()
                }
                moreButton.after(infinityList)
                moreButton.after(document.createTextNode("\xA0"))
            }
        }
    }

    sidebarObserverForMassActions = new MutationObserver(observerHandler)
    sidebarObserverForMassActions.observe(document.querySelector("#sidebar"), { childList: true, subtree: true })
}

function setupMassChangesetsActions() {
    if (
        location.pathname !== "/history" &&
        location.pathname !== "/history/friends" &&
        !(location.pathname.includes("/history") && location.pathname.includes("/user/"))
    )
        return
    tryApplyModule(addMassChangesetsActions, 300, 5000)
}

//</editor-fold>

//<editor-fold desc="relation version page" defaultstate="collapsed">

function addRelationVersionView() {
    const match = location.pathname.match(/relation\/(\d+)\/history\/(\d+)\/?$/)
    if (!match) {
        return
    }
    if (document.querySelector("#load-relation-version")) return
    const btn = document.createElement("a")
    btn.textContent = "📥"
    btn.id = "load-relation-version"
    btn.title = t("relationVersionPage.loadViaOverpass")
    btn.tabIndex = 0
    btn.style.cursor = "pointer"

    async function clickForDownloadHandler(e) {
        if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
            e.preventDefault()
        } else if (e.type === "keypress") {
            return
        }
        e.stopPropagation()
        e.stopImmediatePropagation()
        btn.style.cursor = "progress"
        const match = location.pathname.match(/relation\/(\d+)\/history\/(\d+)\/?$/)
        const id = parseInt(match[1])
        const timestamp = document.querySelector("time").getAttribute("datetime")
        try {
            const { restrictionRelationErrors } = await loadRelationVersionMembersViaOverpass(id, timestamp)
            showValidationStatus(restrictionRelationErrors, document.querySelector("#sidebar_content > div details summary"))
        } catch (e) {
            btn.style.cursor = "pointer"
            throw e
        }
        btn.style.visibility = "hidden"
    }

    btn.addEventListener("click", clickForDownloadHandler)
    btn.addEventListener("keypress", clickForDownloadHandler)
    document.querySelector("#sidebar_content > div h4")?.appendChild(btn)
}

function setupRelationVersionViewer() {
    const match = location.pathname.match(/relation\/(\d+)\/history\/(\d+)\/?$/)
    if (!match) {
        return
    }
    tryApplyModule(addRelationVersionView, 500, 25000)
}

//</editor-fold>

//<editor-fold desc="user-profile" defaultstate="collapsed">

async function loadChangesetsBetween(user, fromTime, toTime) {
    let curTime = fromTime
    const processedChangesets = new Set()
    /*** @type {ChangesetMetadata[]}*/
    const changesets = []

    while (true) {
        /*** @type {{changesets: ChangesetMetadata[]}}*/
        const res = await fetchJSONWithCache(
            osm_server.apiBase +
                "changesets.json?" +
                new URLSearchParams({
                    display_name: user,
                    order: "oldest",
                    from: curTime.toISOString(),
                    to: toTime.toISOString(),
                }).toString(),
        )
        console.log(res)

        res.changesets = res.changesets.filter(i => !processedChangesets.has(i.id))
        if (res.changesets.length === 0) break

        res.changesets.forEach(i => {
            changesets.push(i)
            processedChangesets.add(i.id)
        })

        curTime = new Date(res.changesets[res.changesets.length - 1].created_at)
    }
    console.log(`${changesets.length} changesets from ${fromTime} to ${toTime} fetched`)
    changesets.forEach(i => {
        if (i.comments_count) {
            void getChangesetComments(i.id)
        }
    })
    return changesets
}

/**
 * @template T
 * @template X
 * @param {T[]} items
 * @param {(T) => X} keyFn
 * @return {T[]}
 */
function uniq(items, keyFn) {
    const uniqs = new Set()
    const result = []

    items.forEach(i => {
        const elem = keyFn(i)
        if (!uniqs.has(elem)) {
            uniqs.add(elem)
            result.push(i)
        }
    })
    return result
}

/**
 * @param user {string}
 * @return {Promise<ChangesetMetadata[]>}
 */
async function loadChangesets(user) {
    console.time(`stat-for-${user}`)
    const startTime = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 365)
    const startTime2 = new Date(new Date().getTime() - (1000 * 60 * 60 * 24 * 365 * 3) / 4)
    const startTime3 = new Date(new Date().getTime() - (1000 * 60 * 60 * 24 * 365 * 2) / 4)
    const startTime4 = new Date(new Date().getTime() - (1000 * 60 * 60 * 24 * 365) / 4)
    const endTime = new Date(new Date().getTime() + 1000 * 60 * 60 * 24)

    const parts = await Promise.all([
        loadChangesetsBetween(user, startTime, startTime2),
        loadChangesetsBetween(user, startTime2, startTime3),
        loadChangesetsBetween(user, startTime3, startTime4),
        loadChangesetsBetween(user, startTime4, endTime),
    ])

    const uniqChangesets = new Set()
    const changesets = []

    parts.forEach(part => {
        part.forEach(ch => {
            if (!uniqChangesets.has(ch.id)) {
                uniqChangesets.add(ch.id)
                changesets.push(ch)
            }
        })
    })

    console.timeEnd(`stat-for-${user}`)
    console.log("Changesets for the last year:", changesets.length)
    return changesets
}

/**
 * @param {ChangesetMetadata[]} changesets
 * @param filter
 * @return {[Object.<string, [number, number, {id: number, comment: string, comments_count: number}]>, number]}
 */
function makeChangesetsStat(changesets, filter) {
    const datesStat = {}
    let changesets_count = 0

    changesets.forEach(i => {
        if (!filter(i)) return
        changesets_count++
        const date = new Date(i.created_at)
        const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`
        if (datesStat[key] === undefined) {
            datesStat[key] = [0, 0, []]
        }
        datesStat[key][0] += i.changes_count
        datesStat[key][1] = max(datesStat[key][1], i.id)
        datesStat[key][2].push({
            id: i.id,
            comment: i?.tags?.["comment"] ?? "",
            comments_count: i.comments_count,
        })
    })

    return [
        Object.fromEntries(
            Object.entries(datesStat).sort((a, b) => {
                if (a[0] < b[0]) {
                    return -1
                }
                if (a[0] > b[0]) {
                    return 1
                }
                return 0
            }),
        ),
        changesets_count,
    ]
}

async function makeEditorNormalizer() {
    const url = "https://raw.githubusercontent.com/deevroman/openstreetmap-statistics/refs/heads/master/config/replace_rules_created_by.json"
    const rawReplaceRules = (
        await externalFetchRetry({
            url: url,
        })
    ).responseText
    console.log("replace rules loaded")

    const tag_to_name = {}
    const starts_with_list = []
    const ends_with_list = []
    const contains_list = []
    Object.entries(JSON.parse(rawReplaceRules)).forEach(([name, name_infos]) => {
        if (name_infos["aliases"]) {
            for (let alias in name_infos["aliases"]) {
                tag_to_name[alias] = name
            }
        }
        if (name_infos["starts_with"]) {
            for (const starts_with of name_infos["starts_with"]) {
                starts_with_list.push([starts_with.length, starts_with, name])
            }
        }
        if (name_infos["ends_with"]) {
            for (const ends_with of name_infos["ends_with"]) {
                ends_with_list.push([ends_with.length, ends_with, name])
            }
        }
        if (name_infos["contains"]) {
            for (const compare_str of name_infos["contains"]) {
                contains_list.push([compare_str, name])
            }
        }
    })

    return tag => {
        if (!tag) return tag

        if (tag_to_name[tag]) {
            return tag_to_name[tag]
        }

        for (let [compare_str_length, compare_str, replace_str] of starts_with_list) {
            if (tag.slice(0, compare_str_length) === compare_str) {
                return replace_str
            }
        }

        for (let [compare_str_length, compare_str, replace_str] of ends_with_list) {
            if (tag.slice(-compare_str_length) === compare_str) {
                return replace_str
            }
        }

        for (let [compare_str, replace_str] of contains_list) {
            if (tag.includes(compare_str)) {
                return replace_str
            }
        }

        return tag
    }
}

/**
 * @param {string} user
 * @return {Promise<{id: number, startTime: string}[]>}
 */
async function getUserBlocks(user) {
    if (document.querySelector('a[href$="/blocks"]')?.nextElementSibling?.textContent === undefined) {
        return []
    }
    const xml = (
        await externalFetchRetry({
            url: `/user/${user}/blocks`,
            headers: { "turbo-frame": "pagination" },
        })
    ).response
    const blocks = []
    new DOMParser()
        .parseFromString(xml, "text/html")
        .querySelectorAll("table tr")
        .forEach(i => {
            if (i.querySelector("th")) {
                return
            }
            blocks.push({
                id: parseInt(
                    i
                        .querySelector('td a[href^="/user_blocks/"]')
                        .getAttribute("href")
                        .match(/\/user_blocks\/([0-9]+)/)[1],
                ),
                startTime: i.querySelector("td time").getAttribute("datetime"),
            })
        })
    console.log(blocks)
    return blocks
}

async function betterUserStat() {
    const match = location.pathname.match(/^\/user\/([^/]+)\/?$/)
    if (!match) {
        return
    }
    const user = decodeURI(match[1])
    if (!GM_config.get("BetterProfileStat") || !location.pathname.match(/^\/user\/([^/]+)\/?$/)) {
        return
    }
    if (document.getElementById("filter-bar")) {
        return
    }
    const filterBar = document.createElement("div")
    filterBar.id = "filter-bar"
    filterBar.style.display = "flex"
    filterBar.style.gap = "3px"

    const filterInputByEditor = document.createElement("select")
    filterInputByEditor.style.flex = "1"
    filterInputByEditor.id = "filter-input-by-editor"
    filterInputByEditor.setAttribute("disabled", true)
    filterInputByEditor.title = t("userProfile.pleaseWaitUserChangesetsLoading")

    const item = document.createElement("option")
    item.value = ""
    item.setAttribute("all-editors", "yes")
    item.textContent = t("userProfile.allEditors")
    filterInputByEditor.appendChild(item)

    const calHeatmap = document.querySelector(".heatmap")
    if (!calHeatmap) {
        console.log("osm.org don't show heatmap for this user")
        return
    }
    injectCSSIntoOSMPage(`
    .tooltip-inner {
        white-space: pre-wrap;
        text-align: left;
    }
    `)
    calHeatmap.parentElement.parentElement.after(filterBar)
    filterBar.appendChild(filterInputByEditor)

    const searchByComment = document.createElement("input")
    searchByComment.type = "search"
    searchByComment.placeholder = t("userProfile.regexSearchByCommentsPlaceholder")
    searchByComment.title = t("userProfile.pleaseWaitUserChangesetsLoading")
    searchByComment.setAttribute("disabled", true)
    searchByComment.style.flex = "1"
    searchByComment.style.height = "1.5rem"
    searchByComment.style.boxSizing = "border-box"
    filterInputByEditor.before(searchByComment)

    const _replace_with_rules = makeEditorNormalizer()
    const _blocks_list = getUserBlocks(user).then(async blocks => {
        if (!blocks) {
            console.debug("user blocks not found")
            return []
        }
        for (let block of blocks) {
            block.text = (await getBlockInfo(block.id)).replaceAll("\r\n\r\n", "\n")
        }
        let curBlockIndex = 0
        for (let day of Array.from(document.querySelectorAll("[data-date]"))) {
            const dayDate = day.getAttribute("data-date")
            if (curBlockIndex < blocks.length) {
                if (blocks[curBlockIndex].startTime.startsWith(dayDate)) {
                    day.style.opacity = "1"
                    day.style.fontSize = "smaller"
                    day.textContent = "⛔"
                    curBlockIndex++
                    day.addEventListener(
                        "mouseenter",
                        () => {
                            if (searchByComment.hasAttribute("disabled")) {
                                searchByComment.setAttribute("need-activate-immediately", "true")
                            } else {
                                searchByComment.dispatchEvent(new Event("input", { bubbles: true }))
                            }
                        },
                        { once: true },
                    )
                }
            } else {
                break
            }
        }
        console.log(blocks)
        return blocks
    })
    const changesets = await loadChangesets(user)
    const replace_with_rules = await _replace_with_rules
    const blocks_list = await _blocks_list

    const editorOfChangesets = {}
    changesets.forEach(ch => (editorOfChangesets[ch.id] = replace_with_rules(ch.tags?.["created_by"])))
    filterInputByEditor.removeAttribute("disabled")
    searchByComment.removeAttribute("disabled")
    filterInputByEditor.title = t("userProfile.openSelectedChangesetsOnePage")
    searchByComment.title = t("userProfile.regexSearchNotCaseSensitive")

    async function inputHandler() {
        let filter = _ => true
        const selected = Array.from(filterInputByEditor.options).filter(i => i.selected)
        let regex
        try {
            regex = new RegExp(searchByComment.value.toLowerCase())
            searchByComment.style.color = ""
        } catch {
            searchByComment.style.color = "red"
        }
        filter = ch => {
            return selected.some(option => {
                if (option.getAttribute("all-editors") === "yes") {
                    return (ch.tags?.["comment"] ?? "").toLowerCase().match(regex)
                } else if (option.getAttribute("is-editor-name") === "yes") {
                    return editorOfChangesets[ch.id] === option.value && (ch.tags?.["comment"] ?? "").toLowerCase().match(regex)
                } else {
                    return ch.tags?.["created_by"] === option.value && (ch.tags?.["comment"] ?? "").toLowerCase().match(regex)
                }
            })
        }
        const [newHeatmapData, changesets_count] = makeChangesetsStat(changesets, filter)
        const maxPerDay = Object.values(newHeatmapData)
            .map(i => i[0])
            .reduce((a, b) => max(a, b), 0)
        searchByComment.title = t("userProfile.filteredChangesets", { count: changesets_count })

        function replaceElementTag(oldElement, newTagName) {
            const attrs = {}
            for (const attr of oldElement.attributes) {
                attrs[attr.name] = attr.value
            }
            const newElement = GM_addElement(newTagName, attrs)
            while (oldElement.firstChild) {
                newElement.appendChild(oldElement.firstChild)
            }
            oldElement.parentNode.replaceChild(newElement, oldElement)
            return newElement
        }

        function getTooltipSummary(date, value) {
            const localizedDate = getWindow().OSM.i18n.l("date.formats.long", intoPageWithFun(date))
            if (value > 0) {
                return getWindow().OSM.i18n.t(
                    "javascripts.contribution_calendar.tooltip.contributions",
                    intoPage({
                        count: value,
                        date: localizedDate,
                    }),
                )
            }
            return getWindow().OSM.i18n.t("javascripts.contribution_calendar.tooltip.no_contributions", intoPage({ date: localizedDate }))
        }

        getWindow().$("[rel=tooltip]").tooltip("dispose")
        document.querySelectorAll(".tooltip").forEach(i => i.remove())
        const hrefPrefix = location.href.endsWith("/") ? location.href.slice(0, -1) : location.href
        let curBlockIndex = 0
        for (let day of Array.from(document.querySelectorAll("[data-date]"))) {
            day = replaceElementTag(day, "a")
            const dayDate = day.getAttribute("data-date")
            let banInfo = null
            if (blocks_list && curBlockIndex < blocks_list.length) {
                if (blocks_list[curBlockIndex].startTime.startsWith(dayDate)) {
                    banInfo = blocks_list[curBlockIndex]
                    curBlockIndex++
                }
            }
            const newData = newHeatmapData[dayDate]
            if (newData) {
                day.setAttribute("data-count", newData[0])
                day.setAttribute("href", hrefPrefix + "/history?before=" + (newData[1] + 1))
                day.innerHTML = ""
                const colorDiff = document.createElement("span")
                if (banInfo) {
                    colorDiff.style.opacity = "1"
                    colorDiff.style.fontSize = "smaller"
                    colorDiff.textContent = "⛔"
                } else {
                    colorDiff.style.opacity = `${Math.sqrt(newData[0] / maxPerDay)}`
                }
                let tooltipText = getTooltipSummary(new Date(day.getAttribute("data-date")), newData[0])
                if (newData[0]) {
                    tooltipText += "\n"
                    for (let changeset of newData[2]) {
                        let changesetComment = ""
                        if (changeset.comments_count) {
                            colorDiff.style.opacity = `${Math.min(0.7, Math.max(0.35, Math.sqrt(newData[0] / maxPerDay)))}`
                            colorDiff.style.background = "red"
                            changesetComment = "💬 " + changeset.comment
                            ;(await getChangesetComments(changeset.id)).forEach(mapperCommentText => {
                                changesetComment +=
                                    "\n - " +
                                    mapperCommentText["user"] +
                                    ": " +
                                    shortOsmOrgLinksInText(mapperCommentText["text"])?.slice(0, 500)
                                if (mapperCommentText["text"].length > 500) {
                                    changesetComment += "..."
                                }
                            })
                        } else {
                            changesetComment = "• " + changeset.comment
                        }
                        tooltipText += changesetComment + "\n"
                    }
                }
                if (banInfo) {
                    tooltipText += "\n⛔" + banInfo.text
                }
                day.appendChild(colorDiff)
                getWindow()
                    .$(day)
                    .tooltip(
                        intoPage({
                            title: tooltipText,
                            customClass: "wide",
                            delay: { show: 0, hide: 0 },
                        }),
                    )
            } else {
                day.removeAttribute("data-count")
                day.setAttribute("href", "")
                day.innerHTML = ""
                if (day.nodeName === "A") {
                    day = replaceElementTag(day, "span")
                }
                if (banInfo) {
                    day.style.opacity = "1"
                    day.style.fontSize = "smaller"
                    day.textContent = "⛔"
                    getWindow()
                        .$(day)
                        .tooltip(
                            intoPage({
                                title: "⛔" + banInfo.text,
                                customClass: "wide",
                                delay: { show: 0, hide: 0 },
                            }),
                        )
                } else {
                    getWindow().$(day).tooltip("disable")
                }
            }
        }
    }

    filterInputByEditor.oninput = inputHandler
    searchByComment.oninput = inputHandler
    document.addEventListener("keydown", e => {
        if (e.altKey && e.code === "KeyO") {
            const selected = Array.from(filterInputByEditor.options).filter(i => i.selected)
            const ids = changesets
                .filter(ch => {
                    return selected.some(i => ch?.tags?.["created_by"]?.includes(i.value))
                })
                .map(i => i.id)
            const idsStr = ids.join(",")
            open(osm_server.url + `/changeset/${ids[0]}?changesets=` + idsStr, "_blank")
        }
    })

    filterInputByEditor.id = "editors"
    filterInputByEditor.addEventListener(
        "mousedown",
        function (e) {
            e.preventDefault()
            e.target.focus()
            filterInputByEditor.setAttribute("size", 7)
            filterInputByEditor.setAttribute("multiple", true)
            inputHandler()
        },
        { once: true },
    )
    searchByComment.addEventListener(
        "mousedown",
        function () {
            inputHandler()
        },
        { once: true },
    )

    const counts = {}

    changesets.forEach(i => {
        const editor = editorOfChangesets[i.id]
        counts[editor] = counts[editor] ? counts[editor] + i.changes_count : i.changes_count
    })

    Array.from(new Set(changesets.map(i => editorOfChangesets[i.id])))
        .sort((a, b) => {
            if (counts[a] < counts[b]) {
                return 1
            }
            if (counts[a] > counts[b]) {
                return -1
            }
            return 0
        })
        .forEach(i => {
            const item = document.createElement("option")
            item.value = i
            item.setAttribute("is-editor-name", "yes")
            item.textContent = t("userProfile.editorContributions", { editor: i, count: counts[i] })
            filterInputByEditor.appendChild(item)
        })
    filterInputByEditor.appendChild(document.createElement("hr"))
    Array.from(new Set(changesets.map(i => i.tags?.["created_by"])))
        .sort()
        .forEach(i => {
            const item = document.createElement("option")
            item.value = i
            item.textContent = i
            filterInputByEditor.appendChild(item)
        })

    filterInputByEditor.after(filterInputByEditor)
    console.log("setuping filters finished")
    if (searchByComment.hasAttribute("need-activate-immediately")) {
        searchByComment.dispatchEvent(new Event("input", { bubbles: true }))
    }
    return true
}

async function getBlockInfo(blockID) {
    const blockInfo = (
        await externalFetchRetry({
            url: "/api/0.6/user_blocks/" + blockID + ".json",
            responseType: "json",
            headers: { "turbo-frame": "pagination" },
        })
    ).response
    return `${blockInfo["user_block"]["created_at"]}\n${blockInfo["user_block"]["creator"]["user"]}: ${blockInfo["user_block"]["reason"]}`
}

// https://osm.org/user/Молотов-Прибой
// https://osm.org/user/user_14840936
// https://osm.org/user/Torunianin
// https://osm.org/user/user_22937564
// https://osm.org/user/korobkov
// https://osm.org/user/user_389895
// https://osm.org/user/user_20965583
async function makeProfileForDeletedUser(user) {
    const content = document.querySelector(".content-body")
    const div = document.createElement("div")
    div.classList.add("content-inner", "position-relative", "m-auto")

    const webArchiveLink = document.createElement("a")
    webArchiveLink.textContent = t("links.webArchive")
    webArchiveLink.target = "_blank"
    webArchiveLink.href = "https://web.archive.org/web/*/https://www.openstreetmap.org/user/" + decodeURI(user)
    div.appendChild(webArchiveLink)
    div.appendChild(document.createElement("br"))

    function makeOSMChaLink(username) {
        const osmchaLink = document.createElement("a")
        osmchaLink.textContent = t("userProfile.osmcha")
        osmchaLink.id = "osmcha_link"
        osmchaLink.title = t("osmcha.openProfileInOsmcha")
        osmchaLink.href = makeOsmchaLinkForUsername(username)
        osmchaLink.target = "_blank"
        osmchaLink.rel = "noreferrer"
        return osmchaLink
    }

    async function processIDs(data, elemForResult) {
        if (data.length === 1) {
            elemForResult.appendChild(document.createTextNode(t("userProfile.singleUserIdLabel")))
        } else {
            elemForResult.appendChild(document.createTextNode(t("userProfile.foundUserIds", { count: data.length })))
            elemForResult.appendChild(document.createElement("br"))
            elemForResult.appendChild(document.createTextNode(t("userProfile.userIdBadge")))
        }
        for (let i = 0; i < data.length; i++) {
            if (i !== 0) {
                elemForResult.appendChild(document.createElement("br"))
                elemForResult.appendChild(document.createElement("hr"))
                elemForResult.appendChild(document.createTextNode(t("userProfile.userIdBadge").trim()))
            }
            const id = data[i].id
            const idSpan = document.createElement("span")
            idSpan.textContent = id
            idSpan.title = t("copying.clickForCopy")
            idSpan.style.cursor = "pointer"
            idSpan.onclick = e => {
                navigator.clipboard.writeText(id).then(() => copyAnimation(e, id))
            }
            injectCSSIntoOSMPage(copyAnimationStyles)
            elemForResult.appendChild(idSpan)
            elemForResult.appendChild(document.createElement("br"))
            if (data[i].names?.length > 1) {
                const p = document.createElement("p")
                p.textContent = t("userProfile.usernames")
                injectCSSIntoOSMPage(copyAnimationStyles)
                data[i].names
                    .map(i => i.name)
                    .forEach(name => {
                        const usernameSpan = document.createElement("span")
                        usernameSpan.textContent = name
                        usernameSpan.title = t("copying.clickForCopy")
                        usernameSpan.style.cursor = "pointer"
                        usernameSpan.onclick = e => {
                            navigator.clipboard.writeText(name).then(() => copyAnimation(e, name))
                        }
                        p.appendChild(usernameSpan)
                        p.appendChild(document.createTextNode(" "))

                        const webArchiveLink = document.createElement("a")
                        webArchiveLink.textContent = "[WA] "
                        webArchiveLink.target = "_blank"
                        webArchiveLink.href = "https://web.archive.org/web/*/https://www.openstreetmap.org/user/" + name
                        p.appendChild(webArchiveLink)

                        p.appendChild(makeOSMChaLink(name))
                    })
                elemForResult.appendChild(p)
            }
            setTimeout(async () => {
                const blocksSpan = document.createElement("span")

                const loadingStatus = document.createElement("span")
                loadingStatus.textContent = t("userProfile.findingBlocks")
                loadingStatus.style.color = "gray"
                blocksSpan.appendChild(document.createTextNode(" "))
                blocksSpan.appendChild(loadingStatus)

                idSpan.after(blocksSpan)
                const startPage = await externalFetchRetry({
                    url: "/user_blocks",
                    // responseType: "xml",
                    headers: { "turbo-frame": "pagination" },
                })

                const blocksCount = new Map()

                function findBlocks(xml) {
                    const foundUserBlock = []
                    let lastUserBlock
                    new DOMParser()
                        .parseFromString(xml, "text/html")
                        .querySelectorAll("table tr")
                        .forEach(i => {
                            if (i.querySelector("th")) {
                                return
                            }
                            const username = decodeURI(
                                i
                                    .querySelector("td a")
                                    .getAttribute("href")
                                    .match(/\/user\/(.*)$/)[1],
                            )
                            if (blocksCount.has(username)) {
                                blocksCount.set(username, blocksCount.get(username) + 1)
                            } else {
                                blocksCount.set(username, 1)
                            }
                            lastUserBlock = i
                                .querySelector('td a[href^="/user_blocks/"]')
                                .getAttribute("href")
                                .match(/\/user_blocks\/([0-9]+)/)[1]
                            if (username === "user_" + id) {
                                foundUserBlock.push(lastUserBlock)
                            }
                        })
                    return [foundUserBlock, lastUserBlock]
                }

                function processFoundBlocks(foundUserBlock) {
                    foundUserBlock.forEach(blockId => {
                        const blockLink = document.createElement("a")
                        blockLink.href = "/user_blocks/" + blockId
                        blockLink.target = "_blank"
                        blockLink.textContent = "🔨/" + blockId
                        getBlockInfo(blockId).then(res => {
                            blockLink.title = res
                        })
                        blocksSpan.appendChild(blockLink)
                        blocksSpan.appendChild(document.createTextNode(" "))
                    })
                }

                let [foundUserBlock, lastUserBlock] = findBlocks(startPage.response)
                if (foundUserBlock.length) {
                    processFoundBlocks(foundUserBlock)
                } else {
                    while (lastUserBlock > 1) {
                        async function processBlocks(before) {
                            console.log("download user_block before ", before)
                            before = Math.max(1, before)
                            const startPage = await externalFetchRetry({
                                url: "/user_blocks?before=" + before,
                                // responseType: "xml",
                                headers: { "turbo-frame": "pagination" },
                            })
                            ;[foundUserBlock, before] = findBlocks(startPage.response)
                            if (!before) {
                                return
                            }
                            if (foundUserBlock.length) {
                                processFoundBlocks(foundUserBlock)
                            }
                        }

                        const onPage = 20
                        const threads = 10
                        console.log("download user_block batch before ", lastUserBlock)
                        loadingStatus.title = t("userProfile.scannedBlocksAfter", { id: lastUserBlock })
                        const batch = []
                        for (let j = 0; j < threads; j++) {
                            batch.push(processBlocks(lastUserBlock - onPage * j))
                        }
                        await Promise.all(batch)
                        lastUserBlock -= threads * onPage
                    }
                    loadingStatus.style.display = "none"
                    const arr = Array.from(blocksCount.entries()).filter(([k, v]) => v >= 10)
                    arr.sort((a, b) => {
                        if (a[1] < b[1]) {
                            return 1
                        } else if (a[1] > b[1]) {
                            return -1
                        } else {
                            return 0
                        }
                    })
                    console.log("Top banned:", arr)
                    console.log("All blocks downloaded")
                }
            })

            /*** @type {{changesets: ChangesetMetadata[]}}*/
            const lastChangesets = await fetchJSONWithCache(
                osm_server.apiBase +
                    "changesets.json?" +
                    new URLSearchParams({
                        user: id,
                        order: "newest",
                        to: new Date().toISOString(),
                    }).toString(),
            )
            const processedChangesets = new Set(lastChangesets.changesets.map(c => c.id))

            for (let i = 0; i < 20; i++) {
                const curTime = lastChangesets.changesets[lastChangesets.changesets.length - 1].created_at
                const ch = await fetchJSONWithCache(
                    osm_server.apiBase +
                        "changesets.json?" +
                        new URLSearchParams({
                            user: id,
                            order: "newest",
                            to: new Date(new Date(curTime).getTime() + 1000).toISOString(),
                            from: "2005-01-01T00:00:00Z",
                        }).toString(),
                )
                ch.changesets = ch.changesets.filter(ch => !processedChangesets.has(ch.id))
                if (ch.changesets.length === 0) break
                lastChangesets.changesets.push(...(ch.changesets ?? []))
            }

            elemForResult.appendChild(document.createTextNode(t("userProfile.lastChangesets", { count: lastChangesets.changesets?.length })))
            lastChangesets.changesets.forEach(ch => {
                const changesetLine = document.createElement("div")
                const changesetTime = ch["created_at"]
                const checkbox = document.createElement("input")
                checkbox.type = "checkbox"
                checkbox.classList.add("mass-action-checkbox")
                checkbox.textContent = "#" + ch.id + ""
                checkbox.title = t("selection.shiftClickSelectRange")
                checkbox.value = ch.id
                checkbox.setAttribute("user-id", id)
                checkbox.onclick = e => {
                    if (e.shiftKey) {
                        let currentCheckboxFound = false
                        for (const cBox of Array.from(elemForResult.querySelectorAll("input")).toReversed()) {
                            if (!currentCheckboxFound) {
                                if (cBox.value === checkbox.value) {
                                    currentCheckboxFound = true
                                }
                            } else {
                                if (cBox.checked) {
                                    break
                                }
                                cBox.checked = true
                            }
                        }
                    }
                    const selectedIDsCount = elemForResult.querySelectorAll(`input:checked[user-id="${id}"]`).length
                    elemForResult.querySelectorAll(`.copy-changesets-ids-btn[user-id="${id}"]`).forEach(i => {
                        if (selectedIDsCount) {
                            i.textContent = t("userProfile.copyIdsCount", { count: selectedIDsCount })
                        } else {
                            i.textContent = t("userProfile.copyIds")
                        }
                    })
                }
                changesetLine.appendChild(checkbox)
                changesetLine.appendChild(document.createTextNode("\xA0"))

                const a = document.createElement("a")
                a.textContent = ch.id
                a.href = "/changeset/" + ch.id
                a.target = "_blank"
                a.style.fontFamily = "monospace"
                changesetLine.appendChild(a)

                const changesetDate = document.createElement("span")
                changesetDate.textContent = " " + changesetTime + " "
                changesetDate.style.fontFamily = "monospace"
                changesetDate.style.color = "gray"
                changesetDate.style.cursor = "pointer"
                changesetDate.setAttribute("datetime", changesetTime)
                changesetDate.onclick = e => {
                    navigator.clipboard.writeText(changesetTime).then(() => copyAnimation(e, changesetTime))
                }
                changesetLine.appendChild(document.createTextNode("\xA0"))
                changesetLine.appendChild(changesetDate)

                const comment = document.createElement("span")
                comment.textContent = " " + (ch.tags?.["comment"] ?? t("userProfile.noComment"))
                changesetLine.appendChild(comment)

                if (ch.comments_count) {
                    const commentsBadge = document.createElement("a")
                    commentsBadge.textContent = " " + ch.comments_count + " 💬"
                    commentsBadge.href = "/changeset/" + ch.id
                    commentsBadge.target = "_blank"
                    setTimeout(async () => {
                        getChangesetComments(ch.id).then(res => {
                            res.forEach(comment => {
                                const shortText = shortOsmOrgLinksInText(comment["text"])
                                commentsBadge.title += `${comment["user"]}:\n${shortText}\n\n`
                            })
                            commentsBadge.title = commentsBadge.title.trimEnd()
                        })
                    })
                    changesetLine.appendChild(commentsBadge)
                }

                elemForResult.appendChild(changesetLine)
            })

            const copyIds = document.createElement("button")
            copyIds.textContent = t("userProfile.copyIds")
            copyIds.title = ""
            copyIds.classList.add("copy-changesets-ids-btn")
            copyIds.setAttribute("user-id", id)
            copyIds.onclick = e => {
                const ids = Array.from(elemForResult.querySelectorAll(`input:checked[user-id="${id}"]`))
                    .map(i => i.value)
                    .join(",")
                if (ids !== "") {
                    navigator.clipboard.writeText(ids).then(() => copyAnimation(e, ids))
                } else {
                    const ids = Array.from(elemForResult.querySelectorAll("input"))
                        .map(i => i.value)
                        .join(",")
                    navigator.clipboard.writeText(ids).then(() => copyAnimation(e, ids))
                }
            }
            elemForResult.appendChild(copyIds)
        }
    }

    const res = await externalFetchRetry({
        url: "https://whosthat.osmz.ru/whosthat.php?action=info&name=" + user,
        responseType: "json",
    })
    // FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
    // but here need resolve problem with return promise
    const data = structuredClone(res.response)
    if (data.length) {
        webArchiveLink.after(makeOSMChaLink(decodeURI(user)))

        const result = document.createElement("p")
        div.appendChild(result)
        result.title = t("userProfile.viaWhosthat")
        await processIDs(data, result)
    } else {
        setTimeout(async () => {
            const res = (
                await overpassRequest(
                    `
[out:json];
node(user:"${user.replace('"', '\\"')}")->.b;
node.b(if:lat() == b.min(lat()));
out meta;
`,
                    "json",
                )
            ).response
            if (res.elements?.length) {
                webArchiveLink.after(makeOSMChaLink(decodeURI(user)))

                const result = document.createElement("p")
                div.appendChild(result)
                result.title = t("userProfile.viaOverpassApi")
                await processIDs([{ id: res.elements[0].uid }], result)
            }
        })
    }

    if (user.match(/^user_[0-9]+$/)) {
        const userID = parseInt(user.match(/user_([0-9]+)/)[1])
        const res = await externalFetchRetry({
            url: "https://whosthat.osmz.ru/whosthat.php?action=names&id=" + userID,
            responseType: "json",
        })
        // FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
        // but here need resolve problem with return promise
        const data = structuredClone(res.response)
        let names = data[0]["names"]

        const userNamesP = document.createElement("p")
        div.appendChild(userNamesP)
        setTimeout(async () => {
            if (!names.length) {
                const res = (
                    await overpassRequest(
                        `
                            [out:json];
                            node(uid:${userID})->.b;
                            node.b(if:lat() == b.min(lat()));
                            out meta;
                        `,
                        "json",
                    )
                ).response
                if (res?.elements?.length) {
                    names = [res.elements[0].user]
                }
                div.title = t("userProfile.viaOverpassApi")
            } else {
                div.title = t("userProfile.viaWhosthat")
            }
            if (names.length) {
                userNamesP.textContent = t("userProfile.usernames")
                injectCSSIntoOSMPage(copyAnimationStyles)
                names.forEach(name => {
                    const usernameSpan = document.createElement("span")
                    usernameSpan.textContent = name
                    usernameSpan.title = t("copying.clickForCopy")
                    usernameSpan.style.cursor = "pointer"
                    usernameSpan.onclick = e => {
                        navigator.clipboard.writeText(name).then(() => copyAnimation(e, name))
                    }
                    userNamesP.appendChild(usernameSpan)
                    userNamesP.appendChild(document.createTextNode(" "))

                    const webArchiveLink = document.createElement("a")
                    webArchiveLink.textContent = "[WA] "
                    webArchiveLink.target = "_blank"
                    webArchiveLink.href = "https://web.archive.org/web/*/https://www.openstreetmap.org/user/" + name
                    userNamesP.appendChild(webArchiveLink)

                    userNamesP.appendChild(makeOSMChaLink(name))
                })
            }
        })
        await processIDs([{ id: userID }], div)
    }
    content.appendChild(div)
}

/**
 * @param {string} user
 */
function addColorForActiveBlock(user) {
    const blocksLink = document.querySelector('a[href$="/blocks"]')
    if (blocksLink?.nextElementSibling?.textContent > 0) {
        blocksLink.nextElementSibling.style.background = "rgba(255, 0, 0, 0.3)"
        if (isDarkMode()) {
            blocksLink.nextElementSibling.style.color = "white"
        }
        getCachedUserInfo(decodeURI(user)).then(userInfo => {
            if (userInfo["blocks"]["received"]["active"] === 0) {
                void updateUserInfo(decodeURI(user))
            }
        })
    } else if (blocksLink?.nextElementSibling?.textContent === "0") {
        getCachedUserInfo(decodeURI(user)).then(userInfo => {
            if (userInfo["blocks"]["received"]["active"] !== 0) {
                void updateUserInfo(decodeURI(user))
            }
        })
    }
}

async function setupHDYCInProfile() {
    const match = location.pathname.match(/^\/user\/([^/]+)(\/|\/notes)?$/)
    if (!match || location.pathname.includes("/history")) {
        return
    }
    /** @type {string} **/
    const user = match[1]
    addColorForActiveBlock(user)
    if (isOHMServer()) {
        return
    }
    if (document.getElementById("hdyc-iframe")) {
        return
    }
    if (user === "forgot-password" || user === "new") return
    document.querySelector(".content-body > .content-inner").style.paddingBottom = "0px"
    if (osm_server === prod_server) {
        if (isDarkMode()) {
            GM_addElement(document.querySelector("#content"), "iframe", {
                src: "https://www.hdyc.neis-one.org/user/" + user + "#forcedarktheme",
                width: "100%",
                id: "hdyc-iframe",
                scrolling: "no",
                background: "rgb(49, 54, 59)",
                style: "visibility:hidden;background-color: rgb(49, 54, 59);",
            })
            setTimeout(() => {
                document.getElementById("hdyc-iframe").style.visibility = "visible"
            }, 1500)
        } else {
            GM_addElement(document.querySelector("#content"), "iframe", {
                src: "https://www.hdyc.neis-one.org/user/" + user + "#forcelighttheme",
                width: "100%",
                id: "hdyc-iframe",
                scrolling: "no",
            })
        }
    }
    const isDeletedUser = !document.querySelector(".user_image")
    const usernameHeader = document.querySelector("#content h1")?.firstChild
    if (!isDeletedUser && usernameHeader && usernameHeader.nodeType === Node.TEXT_NODE) {
        const span = document.createElement("span")
        span.classList.add("copyable-username")
        span.textContent = usernameHeader.textContent
        span.title = t("copying.clickForCopy")
        span.style.cursor = "pointer"
        span.onclick = e => {
            const username = usernameHeader.textContent.trim()
            navigator.clipboard.writeText(username).then(() => copyAnimation(e, username))
        }
        usernameHeader.replaceWith(span)
        injectCSSIntoOSMPage(copyAnimationStyles)
    }
    queueMicrotask(async () => {
        if (document.querySelector(".prev-usernames")) return
        const userDetails = document.querySelector(".content-inner small dl")
        if (!userDetails) return
        // https://www.openstreetmap.org/reports/new?reportable_id=12345&reportable_type=User
        let userID = document
            .querySelector('[href*="reportable_id="]')
            ?.getAttribute("href")
            ?.match(/reportable_id=(\d+)/)?.[1]
        userID = userID ?? document.head.getAttribute("data-user")
        if (!userID) {
            const res = await fetchJSONWithCache(
                osm_server.apiBase +
                    "changesets.json?" +
                    new URLSearchParams({
                        display_name: decodeURI(user),
                        limit: 1,
                        order: "oldest",
                    }).toString(),
            )
            if (res["changesets"].length === 0) {
                const res = await fetchJSONWithCache(
                    osm_server.apiBase +
                        "notes/search.json?" +
                        new URLSearchParams({
                            display_name: decodeURI(user),
                            limit: 1,
                            closed: -1,
                            order: "oldest",
                        }).toString(),
                )
                userID = res?.["features"]?.[0]?.["properties"]?.["comments"]?.find(i => i["user"] === decodeURI(user))?.["uid"]
                if (!userID) {
                    return
                }
            } else {
                userID = res["changesets"][0]["uid"]
            }
        }

        async function addUsernames() {
            async function updateUserIDInfo(userID) {
                const res = await externalFetchRetry({
                    url: "https://whosthat.osmz.ru/whosthat.php?action=names&id=" + userID,
                    responseType: "json",
                })
                // FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
                // but here need resolve problem with return promise
                const userInfo = {
                    data: structuredClone(res.response),
                }
                if (userInfo.data[0]["names"].length > 1) {
                    userInfo["cacheTime"] = new Date()
                    await GM.setValue(storagePrefix + "useridinfo-" + userID, JSON.stringify(userInfo))

                    const usernames = userInfo.data[0]["names"].filter(i => i !== decodeURI(user)).join(", ")
                    if (document.querySelector(".prev-usernames")) {
                        document.querySelector(".prev-usernames").textContent = usernames
                    }
                }
                return userInfo
            }

            async function getCachedUserIDInfo(userID) {
                const localUserInfo = await GM.getValue(storagePrefix + "useridinfo-" + userID, "")
                if (localUserInfo) {
                    setTimeout(updateUserIDInfo, 0, userID)
                    return JSON.parse(localUserInfo)
                }
                return await updateUserIDInfo(userID)
            }

            const userIDInfo = await getCachedUserIDInfo(userID)
            if (userIDInfo.data[0]["names"].length <= 1) {
                console.log("prev user's usernames not found")
                return
            }
            const usernames = userIDInfo.data[0]["names"].filter(i => i !== decodeURI(user)).join(", ")
            const dt = document.createElement("dt")
            dt.textContent = t("userProfile.pastUsernames")
            dt.title = t("betterOsmOrg.addedByBetterOsmOrg")
            dt.classList.add("list-inline-item", "m-0", "prev-usernames-label")
            const dd = document.createElement("dd")
            dd.classList.add("list-inline-item", "prev-usernames")
            dd.textContent = usernames
            dd.title = t("betterOsmOrg.addedByBetterOsmOrg")
            userDetails.appendChild(dt)
            userDetails.appendChild(document.createTextNode("\xA0"))
            userDetails.appendChild(dd)
        }

        await addUsernames()

        function addUserID() {
            if (!document.querySelector('[href^="/api/0.6/user"]')) {
                const dt = document.createElement("dt")
                dt.textContent = t("userProfile.userIdLabel")
                dt.classList.add("list-inline-item", "m-0")
                const dd = document.createElement("dd")
                dd.classList.add("list-inline-item", "user-id")
                dd.textContent = userID
                dd.title = t("copying.clickForCopy")
                dd.style.cursor = "pointer"
                dd.onclick = e => {
                    navigator.clipboard.writeText(userID).then(() => copyAnimation(e, userID))
                }
                userDetails.appendChild(dt)
                userDetails.appendChild(document.createTextNode("\xA0"))
                userDetails.appendChild(dd)
                injectCSSIntoOSMPage(copyAnimationStyles)
            }
        }

        addUserID()
    })
    if (osm_server === prod_server) {
        const iframe = document.getElementById("hdyc-iframe")
        window.addEventListener("message", function (event) {
            if (event.origin === "https://www.hdyc.neis-one.org") {
                iframe.height = event.data.height + "px"
            }
        })
    }
    if (isDeletedUser && !location.pathname.includes("/notes")) {
        await makeProfileForDeletedUser(user)
    }
}

function setupBetterProfileStat() {
    const match = location.pathname.match(/^\/user\/([^/]+)\/?$/)
    if (!match) {
        return
    }
    tryApplyModule(betterUserStat, 300, 5000)
}

function inFrame() {
    return window.location !== window.parent.location
}

function simplifyHDCYIframe() {
    if (!inFrame()) {
        return
    }
    const forceLightTheme = location.hash.includes("forcelighttheme")
    const forceDarkTheme = location.hash.includes("forcedarktheme")
    injectCSSIntoSimplePage(`
            html, body {
                overflow-x: auto;
            }

            @media ${forceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${forceLightTheme ? "and (not all)" : ""} {
                body {
                    background-color: #181a1b;
                    color: #e8e6e3;
                }

                #header a {
                    color: lightgray !important;
                }

                #activitymap .leaflet-tile,
                #mapwrapper .leaflet-tile {
                    filter: invert(100%) hue-rotate(180deg) contrast(90%);
                }

                #activitymap path {
                    stroke: #0088ff;
                    fill: #0088ff;
                    stroke-opacity: 0.7;
                }

                #activitymapswitcher {
                    background-color: rgba(24, 26, 27, 0.8);
                }

                .leaflet-popup-content {
                    color: lightgray;
                }

                .leaflet-popup-content-wrapper, .leaflet-popup-tip {
                    background: #222;
                }

                a, .leaflet-container a {
                    color: #1c84fd;
                }

                a:visited, .leaflet-container a:visited {
                    color: #c94bff;
                }

                a[style*="black"] {
                    color: lightgray !important;
                }

                .day-cell[fill="#e8e8e8"] {
                    fill: #262a2b;
                }

                #result th {
                    background-color: rgba(24, 26, 27, 0.8);
                }

                #result td {
                    border-color: #363659;
                }

                td[style*="purple"] {
                    color: #ff72ff !important;
                }

                td[style*="green"] {
                    color: limegreen !important;
                }

                #graph_years canvas,
                #graph_editors canvas,
                #graph_days canvas,
                #graph_hours canvas {
                    filter: saturate(4);
                }

                .tickLabel {
                    color: #b3aca2;
                }

                .editors_wrapper th, .editors_wrapper td {
                    border-bottom-color: #8c8273;
                }
            }
        `)
    const loginLink = document.getElementById("loginLink")
    if (loginLink) {
        const warn = document.createElement("div")
        warn.id = "hdyc-warn"
        injectCSSIntoSimplePage(`
                #hdyc-warn, #hdycLink {
                    text-align: left !important;
                    width: 50%;
                    position: relative;
                    left: 35%;
                    right: 33%;
                }
            `)
        if (isFirefox) {
            warn.textContent = t("userProfile.disableTrackingProtection")

            document.getElementById("authenticate").before(warn)
            const hdycLink = document.createElement("a")
            const match = location.pathname.match(/^\/user\/([^/]+)$/)
            hdycLink.href = "https://www.hdyc.neis-one.org/user/" + (match ? match[1] : "")
            hdycLink.textContent = t("userProfile.goToHdyc")
            hdycLink.target = "_blank"
            hdycLink.id = "hdycLink"
            document.getElementById("authenticate").before(document.createElement("br"))
            document.getElementById("authenticate").before(hdycLink)
            document.getElementById("authenticate").remove()
            window.parent.postMessage({ height: document.body.scrollHeight }, "*")
        } else {
            warn.innerHTML = `To see more than just public profiles, do the following:<br/>
0. Turn off tracking protection if your browser has it (for example in Brave or Vivaldi)<br/>
1. <a href="https://www.hdyc.neis-one.org/" target="_blank"> Log in to HDYC</a> <br/>
2. Open the browser console (F12) <br/>
3. Open the Application tab <br/>
4. In the left panel, select <i>Storage</i>→<i>Cookies</i>→<i>https://www.hdyc.neis-one.org</i><br/>
5. Click on the cell with the name <i>SameSite</i> and type <i>None</i> in it`
            document.getElementById("authenticate").before(warn)
            const img_help = document.createElement("img")
            img_help.onload = () => {
                window.parent.postMessage({ height: document.body.scrollHeight }, "*")
            }
            img_help.src = "https://raw.githubusercontent.com/deevroman/better-osm-org/master/misc/img/hdyc-fix-in-chrome.png"
            img_help.style.width = "90%"
            warn.after(img_help)
            document.getElementById("authenticate").remove()
        }
        // var xhr = XPCNativeWrapper(new window.wrappedJSObject.XMLHttpRequest());
        // let res = await GM.xmlHttpRequest({
        //     method: "GET",
        //     url: document.querySelector("#loginLink").href,
        //     withCredentials: true
        // })
        // debugger
        return
    }
    try {
        document.getElementById("header").remove()
        document.getElementById("user").remove()
        document.getElementById("searchfield").remove()
        document.querySelector(".mapper_img").remove()
        let bCnt = 0
        for (let childNodesKey of Array.from(document.querySelector(".since").childNodes)) {
            if (childNodesKey.nodeName === "#text") {
                childNodesKey.remove()
                continue
            }
            if (childNodesKey.classList.contains("image")) {
                continue
            }
            if (childNodesKey.localName === "b") {
                if (bCnt === 2) {
                    break
                }
                bCnt++
            }
            childNodesKey.remove()
        }
    } catch (e) {
        console.error(e)
    }
    window.parent.postMessage({ height: document.body.scrollHeight }, "*")
}

//</editor-fold>

//<editor-fold desc="routers" defaultstate="collapsed">

window.addEventListener("message", async e => {
    if (e.origin !== location.origin) return
    if (e.data.type !== "add_router_data_date") return
    if (!GM_config.get("RoutersTimestamps")) return

    function addTime(text) {
        document.querySelectorAll(".routing-timestamp").forEach(i => i.remove())
        const elem = document.createElement("p")
        elem.classList.add("text-center", "routing-timestamp")
        elem.textContent = text
        elem.title = t("betterOsmOrg.addedByBetterOsmOrg")
        document.querySelector("#sidebar_content").appendChild(elem)
    }

    const url = e.data.url
    if (url.startsWith("https://valhalla1.openstreetmap.de")) {
        await abortableSleep(500, getAbortController())
        // TODO abortion controller
        externalFetchRetry({ url: "https://valhalla1.openstreetmap.de/status", responseType: "json" }).then(r => {
            addTime("Valhalla data time: " + new Date(r.response["tileset_last_modified"] * 1000).toISOString())
        })
    } else if (url.startsWith("https://routing.openstreetmap.de")) {
        document.querySelectorAll(".routing-timestamp").forEach(i => i.remove())
        const dataUrls = []

        if (url.includes("/routed-bike/")) {
            dataUrls.push(
                ["europe-asia", "https://map.project-osrm.org/timestamps/bikeeuasi.data_timestamp"],
                ["africa-oceania", "https://map.project-osrm.org/timestamps/bikeafoce.data_timestamp"],
                ["americas", "https://map.project-osrm.org/timestamps/bikeam.data_timestamp"],
            )
        } else if (url.includes("/routed-foot/")) {
            dataUrls.push(
                ["europe-asia", "https://map.project-osrm.org/timestamps/footeuasi.data_timestamp"],
                ["africa-oceania", "https://map.project-osrm.org/timestamps/footafoce.data_timestamp"],
                ["americas", "https://map.project-osrm.org/timestamps/footam.data_timestamp"],
            )
        } else if (url.includes("/routed-car/")) {
            dataUrls.push(
                ["europe-asia", "https://map.project-osrm.org/timestamps/careuasi.data_timestamp"],
                ["africa-oceania", "https://map.project-osrm.org/timestamps/carafoce.data_timestamp"],
                ["americas", "https://map.project-osrm.org/timestamps/caram.data_timestamp"],
            )
        } else {
            console.error("Unknown router mode", url)
        }
        const times = []
        for (let dataUrl of dataUrls) {
            const res = await externalFetchRetry({ url: dataUrl[1] })
            times.push([dataUrl[0], new DOMParser().parseFromString(res.response, "text/html").body.textContent.trim()])
        }
        setTimeout(() => {
            document.querySelectorAll(".routing-timestamp").forEach(i => i.remove())
            document.querySelector(".osrm-debug-link")?.remove()

            times.forEach(([name, time]) => {
                const elem = document.createElement("p")
                elem.classList.add("text-center", "routing-timestamp")
                elem.textContent = t("routers.routingDataTimeFor", { name: name, time: time })
                elem.title = t("betterOsmOrg.addedByBetterOsmOrg")
                document.querySelector("#sidebar_content").appendChild(elem)
            })

            const [x, y, z] = getCurrentXYZ()
            const p = document.createElement("p")
            p.classList.add("osrm-debug-link", "text-center", "routing-timestamp")
            const a = document.createElement("a")
            a.href = `https://map.project-osrm.org/debug/#${max(12, parseInt(z))}/${x}/${y}`
            a.target = "_blank"
            a.textContent = t("routers.openDebugMap")
            p.appendChild(a)
            document.querySelector("#sidebar_content").appendChild(p)
        })
    } else if (url.startsWith("https://graphhopper.com/api/1/route")) {
        document.querySelectorAll(".routing-timestamp").forEach(i => i.remove())
        addTime("Routing data time: " + e.data.time)
    } else {
        document.querySelectorAll(".routing-timestamp").forEach(i => i.remove())
    }
})

//</editor-fold>

//<editor-fold desc="edit-menu-links" defaultstate="collapsed">

/** @typedef {null | {
 *  name: string,
 *  template: string,
 *  description: string,
 *  warn: string|undefined,
 *  onlyMobile: boolean|undefined,
 *  disableOnOpenHistoricalMap: boolean|undefined,
 *  default: boolean|undefined,
 *  } } externalLink
 *  */

/** @type {null | externalLink[]}*/
let externalLinks = null
/** @type {null | externalLink[]}*/
let externalLinksDatabase = null
// const localExternalLinksURL = "http://localhost:7777/misc/assets/external-links.json"
const githubExternalLinksURL = `https://raw.githubusercontent.com/deevroman/better-osm-org/refs/heads/dev/misc/assets/external-links.json?bypasscache=${Math.random()}`
const externalLinksURL = githubExternalLinksURL

function makeSafeForCSS(name) {
    return name.replace(/[^a-z0-9]/g, function (s) {
        const c = s.charCodeAt(0)
        if (c === 32) return "-"
        if (c >= 65 && c <= 90) return "_" + s.toLowerCase()
        return "__" + ("000" + c.toString(16)).slice(-4)
    })
}

function addSafeNameForExternalLinks(links) {
    links.forEach(i => {
        i.safeName = makeSafeForCSS(i.name)
    })
}

async function loadAndMakeExternalLinksDatabase() {
    const raw_data = (
        await externalFetchRetry({
            url: externalLinksURL,
            responseType: "json",
        })
    ).response
    if (!raw_data) {
        throw "External link not downloaded"
    }
    console.log("external links database updated")
    externalLinksDatabase = raw_data.links.filter(l => l.disabled !== true)
    addSafeNameForExternalLinks(externalLinksDatabase)
    await GM.setValue(
        "external-links",
        JSON.stringify({
            raw_data: raw_data,
            cacheTime: new Date(),
        }),
    )
}

async function initExternalLinksList() {
    if (externalLinksDatabase) return
    const cache = await GM.getValue("external-links", "")
    if (externalLinksDatabase) return
    if (cache) {
        console.log("external links cached")
        const json = JSON.parse(cache)
        const cacheTime = new Date(json["cacheTime"])
        const sixHoursLater = new Date(cacheTime.getTime() + 6 * 60 * 60 * 1000)
        if (sixHoursLater < new Date()) {
            console.log("but cache outdated")
            setTimeout(loadAndMakeExternalLinksDatabase, 0)
        }
        externalLinksDatabase = JSON.parse(cache)["raw_data"].links.filter(l => l.disabled !== true)
        addSafeNameForExternalLinks(externalLinksDatabase)
        return
    }
    console.log("loading external links")
    try {
        await loadAndMakeExternalLinksDatabase()
    } catch (e) {
        console.log("loading external links list failed", e)
    }
}

let urlTemplateContext = {}
const randomParam = Math.random().toString()

function updateUrlTemplateContext() {
    const [x, y, z] = getCurrentXYZ()
    urlTemplateContext.latitude = urlTemplateContext.lat = urlTemplateContext.x = x
    urlTemplateContext.longitude = urlTemplateContext.lon = urlTemplateContext.y = y
    urlTemplateContext.zoom = urlTemplateContext.z = z
    const osm_m = location.pathname.match(/\/(node|way|relation|changeset|note)\/([0-9]+)/)
    urlTemplateContext.osm_type = osm_m?.[1]
    urlTemplateContext.osm_type_first_letter = urlTemplateContext.osm_type?.[0]
    urlTemplateContext.osm_id = parseInt(osm_m?.[2])
    ;["node", "way", "relation", "changeset", "note", "undefined"].forEach(i => {
        delete urlTemplateContext[`osm_${i}_id`]
    })
    urlTemplateContext[`osm_${urlTemplateContext.osm_type}_id`] = urlTemplateContext.osm_id
    // todo как мониторить изменения выделения?
    // todo как быть с iD, который в iframe?
    // urlTemplateContext.raw_selected_text = window.getSelection().toString()
    // urlTemplateContext.selected_text = encodeURI(urlTemplateContext.raw_selected_text)
    // if (urlTemplateContext.raw_selected_text === "") {
    //     urlTemplateContext.raw_selected_text = undefined
    //     urlTemplateContext.selected_text = undefined
    // }
    urlTemplateContext.random_param = randomParam
}

let coordinatesObserver = null
let dropdownStyleAdded = false

function addDropdownStyle() {
    if (dropdownStyleAdded) {
        return
    }
    dropdownStyleAdded = true
    injectCSSIntoOSMPage(`
    /* make attribution panel compact */
    @media (max-width: 500px) {

    .leaflet-control-attribution {
        padding: 0 2px
    }
    
    .leaflet-control-attribution a[href="https://wiki.osmfoundation.org/wiki/Terms_of_Use"] {
        display: none;
    }
    
    }

    #edit_tab > .dropdown-menu {
        overflow-x: hidden;
        max-height: min(85vh, 100vh - 75px);
        max-width: 100vw;
        padding-bottom: 0px !important;
        overscroll-behavior: none;
    }
    
    
    @media (max-width: 767.910px) {
    
    #edit_tab > .dropdown-menu {
        max-height: min(85vh, 100vh - 170px);
    }
    
    .open-dropdown {
        display: block !important;
        top: -100px !important;
        max-height: min(85vh, 100vh - 75px) !important;
    }
    
    }
    
    .off-hover:hover {
        background: initial !important;
    }
    
    ul:not(.editing) .invalid-external-link {
        display: none !important;
    }
    
    ul:not(.editing) .result-box {
        display: none !important;
    }
    
    ul:not(.editing) .edit-link-btn {
        display: none !important;
    }
    
    ul:not(.editing) .add-link-btn {
        display: none !important;
    }
    
    ul:not(.editing) .delete-link-btn, ul:not(.editing) .move-up-link-btn, ul:not(.editing) .move-down-link-btn {
        display: none !important;
    }
    
    .edit-link-btn {
        margin-right: 8px !important;
        border-right: solid gray 1px !important;
        padding-right: 8px !important;
        cursor: pointer;
    }
    
    .edit-link-btn:hover {
        color: var(--bs-body-color) !important;
    }
    
    .delete-link-btn, .move-up-link-btn, .move-down-link-btn {
        all: unset;
        cursor: pointer;
        margin-left: 4px;
        margin-right: 12px;
    }
    
    .in-editing .delete-link-btn {
        margin-right: 4px;
    }
    
    .in-editing {
        padding-top: 1px;
        padding-bottom: 1px;
    }
    
    #edit-link-btn {
        cursor: pointer;
    }
    
    #change-list-btn .delete-link-btn {
        display: none;
    }
      
    .add-link-btn {
        margin-right: 8px !important;
        border-right: solid gray 1px !important;
        padding-right: 8px !important;
        cursor: pointer;
    }
    
    .add-link-btn:hover {
        color: var(--bs-body-color) !important;
    }
    
    .create-link-btn:hover {
        color: var(--bs-body-color) !important;
    }
    
    ul:not(.editing) .dropdown-item#change-list-btn {
        cursor: pointer;
    }

    ul:not(.editing) .dropdown-item#change-list-btn:not(:hover) {
        color: gray !important;
    }

    #edit_tab li:has(.dropdown-item):not(.off-hover):hover {
        background-color: #7ebc6f;
    }
    
    @media (max-width: 767.910px) {
    
    li > .dropdown-item#change-list-btn:not(:has(.create-link-btn)):hover {
        color: gray !important;
    }
    
    }
    

    ul.editing > li > :where(span,a) {
        padding-left: 8px !important;
        padding-right: 8px !important;
    }
    
    .add-item-a {
        display: flex;
        alignItems: center;
        gap: 5px;
    }
    
    .add-item-a:hover {
        color: var(--bs-body-color) !important;
    }
    
    .add-item-a.in-editing:hover {
        background: initial !important;
    }
    
    .add-item-a.in-editing > span {
        width: 100%
    }
    
    .inputs-wrapper {
        display: flex;
        margin-left: 5px;
        gap: 5px;
    }
    
    .inputs-wrapper .title-input {
        flex: 1;
        width: 100%;
    }
    
    .inputs-wrapper .template-input {
        flex: 3;
        width: 100%;
    }
    
    @media (max-width: 767.910px) {
    
    .in-editing {
        align-items: center !important;
    }
    
    .inputs-wrapper {
        flex-direction: column;
        row-gap: 10px;
    }
    
    .in-editing > .create-link-btn {
        padding-top: 10px !important;
        padding-bottom: 10px !important;
    }
    
    .add-item-a.in-editing input {
        margin-left: 0px !important;
    }
    
    }
        
    `)
}

let dropdownObserver = null

async function loadCurrentLinksList() {
    const raw_data = await GM.getValue("user-external-links")
    if (!raw_data /* || isDebug()*/) {
        externalLinks = externalLinksDatabase.filter(link => {
            if (!link.default) {
                return false
            }
            if (link.onlyMobile) {
                return isMobile
            }
            if (link.disableOnOpenHistoricalMap && isOHMServer()) {
                return
            }
            return true
        })
        await GM.setValue("user-external-links", JSON.stringify(externalLinks))
    } else {
        let migrated = false
        externalLinks = JSON.parse(raw_data).map(link => {
            if (link.template === "http://test.osm2pgsql.org/?#p={zoom}/{latitude}/{longitude}") {
                link.template = "https://spyglass.jochentopf.com/?#p={zoom}/{latitude}/{longitude}"
                migrated = true
            } else if (
                link.template === "https://panoramax.openstreetmap.fr/?background=streets&focus=map&map={zoom}/{latitude}/{longitude}"
            ) {
                link.template = "https://explore.panoramax.fr/?background=streets&focus=map&map={zoom}/{latitude}/{longitude}"
                migrated = true
            }
            return link
        })
        if (migrated) {
            await GM.setValue("user-external-links", JSON.stringify(externalLinks))
        }
    }
    addSafeNameForExternalLinks(externalLinks)
    externalLinks.forEach(link => {
        if (link.template.includes("$")) {
            console.warn("$ in template. Possible error", link)
        }
    })
}

function makeUrlFromTemplate(template) {
    return template.replaceAll(/\{([a-z_]+)}/g, (match, m1) => {
        const res = urlTemplateContext[m1]
        if (res !== undefined) {
            return res
        }
        throw `failed to substitute "${m1}" on current page`
    })
}

function processExternalLink(link, firstRun, editorsListUl, isUserLink, index) {
    if (link.name === "-" && firstRun) {
        const hr = document.createElement("hr")
        hr.style.margin = "0px"
        editorsListUl.appendChild(hr)
        return
    }

    let newElem = editorsListUl.querySelector("#custom-editor-" + link.safeName)
    let warnBox = newElem?.querySelector(".warn-box")
    let resultBox = newElem?.querySelector(".result-box")
    const alreadyAdded = !!newElem
    if (!newElem) {
        newElem = editorsListUl.querySelector("li").cloneNode(true)
    }
    if (!alreadyAdded || newElem.classList.contains("need-repair")) {
        newElem.classList.remove("need-repair")
        newElem.classList.add("custom_editors")
        if (isUserLink) {
            newElem.classList.add("user-external-link")
        }
        newElem.id = "custom-editor-" + link.safeName
        newElem.setAttribute("url-template", link.template)
        const a = newElem.querySelector("a")
        a.removeAttribute("href")
        a.textContent = link.name
        a.setAttribute("target", "_blank")
        a.setAttribute("rel", "noreferrer")

        warnBox = document.createElement("span")
        warnBox.classList.add("warn-box")
        warnBox.style.color = "gray"
        if (!isUserLink && link.warn) {
            warnBox.textContent += ` (${link.warn})`
        }
        a.appendChild(warnBox)

        resultBox = document.createElement("span")
        resultBox.classList.add("result-box")
        resultBox.style.color = "gray"
        a.appendChild(resultBox)

        newElem.style.display = "flex"
        if (isUserLink) {
            const editBtn = document.createElement("button")
            editBtn.classList.add("edit-link-btn", "bi", "bi-pencil")
            editBtn.style.all = "unset"
            editBtn.title = t("editMenuLinks.editLink")
            a.prepend(editBtn)
            editBtn.onclick = async e => {
                e.preventDefault()
                e.stopPropagation()

                makeExternalLinkEditable(newElem, editorsListUl, link.name, link.template)
                newElem.remove()
            }
            const deleteBtn = makeDeleteLinkBtn(link.name, newElem)
            deleteBtn.style.marginLeft = "auto"
            a.after(deleteBtn)

            const moveDownBtn = makeMoveDownLinkBtn(link.name, newElem)
            moveDownBtn.style.marginLeft = "auto"
            a.after(moveDownBtn)
            if (index + 1 === externalLinks.length) {
                moveDownBtn.style.visibility = "hidden"
            }

            const moveUpBtn = makeMoveUpLinkBtn(link.name, newElem)
            moveUpBtn.style.marginLeft = "auto"
            a.after(moveUpBtn)
            if (index === 0) {
                moveUpBtn.style.visibility = "hidden"
            }

            a.style.display = "flex"
            a.style.alignItems = "baseline"
        } else {
            const addBtn = document.createElement("button")
            addBtn.classList.add("add-link-btn", "bi", "bi-plus-lg")
            addBtn.style.all = "unset"
            addBtn.title = t("editMenuLinks.pinThisLink")
            a.prepend(addBtn)
            addBtn.onclick = async e => {
                e.preventDefault()
                e.stopPropagation()

                newElem.remove()
                externalLinks.push(link)
                await GM.setValue("user-external-links", JSON.stringify(externalLinks))
                addUserExternalLinks(false, editorsListUl)
                editorsListUl.querySelectorAll(".edit-link-btn").forEach(i => (i.style.display = ""))
            }
        }
    }
    let actualHref
    try {
        actualHref = makeUrlFromTemplate(newElem.getAttribute("url-template"))
    } catch (e) {
        if (newElem) {
            newElem.classList.add("invalid-external-link")
        }
        if (isMobile) {
            newElem.style.overflowY = "scroll"
        }
        const errorText = ` (${e})`
        if (resultBox && resultBox.textContent !== errorText) {
            resultBox.textContent = ` (${e})`
        }
        newElem.onclick = e => {
            if (newElem.classList.contains("invalid-external-link")) {
                e.preventDefault()
            }
        }
        return
    } finally {
        if (!alreadyAdded) {
            if (isUserLink) {
                const lastLink = Array.from(editorsListUl.querySelectorAll(".user-external-link")).at(-1)
                if (lastLink) {
                    lastLink.after(newElem)
                } else {
                    editorsListUl.appendChild(newElem)
                }
            } else {
                editorsListUl.appendChild(newElem)
            }
        }
    }
    newElem.classList.remove("invalid-external-link")
    const a = newElem.querySelector("a")
    if (a.href !== actualHref && !a.classList.contains("in-editing")) {
        a.href = actualHref
        a.title = link.template
        if (resultBox) {
            resultBox.textContent = ""
        }
    }
}

function addUserExternalLinks(firstRun, editorsListUl) {
    externalLinks.forEach((link, index) => processExternalLink(link, firstRun, editorsListUl, true, index))
}

function addOtherExternalLinks(editorsListUl) {
    if (!document.querySelector("#change-list-btn") || document.querySelector("#change-list-btn").classList.contains("closed")) {
        return
    }
    externalLinksDatabase.forEach(link => {
        if (link.onlyMobile && !isMobile) {
            return
        }
        if (link.disableOnOpenHistoricalMap && isOHMServer()) {
            return
        }
        processExternalLink(link, false, editorsListUl, false)
    })
}

function makeMoveUpLinkBtn(nameValue, addItemLi) {
    const upBtn = document.createElement("button")
    upBtn.classList.add("move-up-link-btn", "bi", "bi-chevron-up")
    upBtn.title = t("editMenuLinks.moveUpLink")
    upBtn.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        const currentLinkIndex = externalLinks.findIndex(i => i.name === nameValue)
        if (currentLinkIndex === -1) {
            throw "not found" + nameValue
        }
        if (currentLinkIndex === 0) {
            return
        }
        addItemLi.parentNode.insertBefore(addItemLi, addItemLi.previousElementSibling)
        const tmp = externalLinks[currentLinkIndex]
        externalLinks[currentLinkIndex] = externalLinks[currentLinkIndex - 1]
        externalLinks[currentLinkIndex - 1] = tmp
        await GM.setValue("user-external-links", JSON.stringify(externalLinks))
    }
    return upBtn
}

function makeMoveDownLinkBtn(nameValue, addItemLi) {
    const downBtn = document.createElement("button")
    downBtn.classList.add("move-down-link-btn", "bi", "bi-chevron-down")
    downBtn.title = t("editMenuLinks.moveDownLink")
    downBtn.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        const currentLinkIndex = externalLinks.findIndex(i => i.name === nameValue)
        if (currentLinkIndex === -1) {
            throw "not found" + nameValue
        }
        if (currentLinkIndex + 1 === externalLinks.length) {
            return
        }
        addItemLi.parentNode.insertBefore(addItemLi.nextElementSibling, addItemLi)
        const tmp = externalLinks[currentLinkIndex]
        externalLinks[currentLinkIndex] = externalLinks[currentLinkIndex + 1]
        externalLinks[currentLinkIndex + 1] = tmp
        await GM.setValue("user-external-links", JSON.stringify(externalLinks))
    }
    return downBtn
}

function makeDeleteLinkBtn(nameValue, addItemLi) {
    const deleteBtn = document.createElement("button")
    deleteBtn.classList.add("delete-link-btn", "bi", "bi-trash")
    deleteBtn.title = t("editMenuLinks.removeLink")
    deleteBtn.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        if (!confirm(`Delete ${nameValue}?`)) {
            return
        }
        addItemLi.remove()
        externalLinks = externalLinks.filter(i => i.name !== nameValue)
        await GM.setValue("user-external-links", JSON.stringify(externalLinks))
    }
    return deleteBtn
}

function makeExternalLinkEditable(targetLi, editorsListUl, nameValue = "", templateValue = "") {
    const addItemLi = targetLi.cloneNode()
    const addItemA = targetLi.querySelector(":where(a, span)").cloneNode()
    addItemA.classList.add("add-item-a")
    addItemA.onclick = e => {
        if (addItemA.classList.contains("in-editing")) {
            // e.preventDefault()
            e.stopPropagation()
            e.stopImmediatePropagation()
        }
    }
    addItemA.removeAttribute("href")
    addItemLi.appendChild(addItemA)

    const deleteBtn = makeDeleteLinkBtn(nameValue, addItemLi)

    const title = document.createElement("input")
    title.classList.add("title-input")
    title.placeholder = t("editMenuLinks.linkNamePlaceholder")
    title.value = nameValue

    const createLikBtn = document.createElement("button")
    createLikBtn.classList.add("create-link-btn", "bi", nameValue === "" ? "bi-plus-lg" : "bi-floppy")
    createLikBtn.style.all = "unset"
    createLikBtn.title = t("editMenuLinks.saveLink")

    const template = document.createElement("input")
    template.classList.add("template-input")
    template.placeholder =
        (isMobile ? "" : t("editMenuLinks.urlExamplePrefix")) + "https://osm.org/{osm_type}/{osm_id}/#map={zoom}/{lat}/{lon}"
    template.name = "custom-link-template"
    template.value = templateValue

    addItemA.classList.add("in-editing")
    addItemA.prepend(createLikBtn)

    const inputWrapper = document.createElement("span")
    inputWrapper.classList.add("inputs-wrapper")
    inputWrapper.appendChild(title)
    inputWrapper.appendChild(template)
    addItemA.appendChild(inputWrapper)

    addItemA.appendChild(deleteBtn)

    targetLi.replaceWith(addItemLi)
    addItemLi.classList.add("off-hover")

    editorsListUl.querySelectorAll(".edit-link-btn").forEach(i => (i.style.display = ""))
    editorsListUl.querySelectorAll(".add-link-btn").forEach(i => (i.style.display = ""))

    createLikBtn.onclick = async e => {
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        /** @type {externalLink} */
        const newLink = {
            name: title.value,
            safeName: makeSafeForCSS(title.value),
            template: template.value,
            description: "",
        }
        if (newLink.name.trim().length === 0) {
            title.setCustomValidity("Set name")
            title.reportValidity()
            return
        }
        if (newLink.template.trim().length === 0 || !newLink.template.includes(":")) {
            template.setCustomValidity("Set valid URL template")
            template.reportValidity()
            return
        }
        if (externalLinks.some(l => l.name === newLink.name) && nameValue === "") {
            title.setCustomValidity("Name already used")
            title.reportValidity()
            return
        }
        console.log(newLink)
        if (nameValue === "") {
            externalLinks.push(newLink)
        } else {
            externalLinks = externalLinks.map(i => {
                if (i.name === nameValue) {
                    return newLink
                } else {
                    return i
                }
            })
        }
        addSafeNameForExternalLinks(externalLinks)
        await GM.setValue("user-external-links", JSON.stringify(externalLinks))
        if (nameValue !== "") {
            title.remove()
            template.remove()
            createLikBtn.remove()
            addItemLi.id = "custom-editor-" + newLink.safeName
            addItemA.classList.remove("add-item-a")
            addItemA.href = ""
            addItemA.classList.remove("in-editing")
            addItemLi.classList.remove("off-hover")
            addItemLi.classList.add("need-repair")
            processExternalLink(newLink, false, editorsListUl, true)
        }
        addUserExternalLinks(false, editorsListUl)
        editorsListUl.querySelectorAll(".edit-link-btn").forEach(i => (i.style.display = ""))
        addItemLi.classList.remove("in-editing")
    }
}

async function setupNewEditorsLinks() {
    await _setupNewEditorsLinks()
}

async function _setupNewEditorsLinks(mutationsList) {
    if (mutationsList) {
        // little optimization for scroll
        if (
            mutationsList.length === 1 &&
            mutationsList[0].type === "attributes" &&
            mutationsList[0].attributeName === "data-popper-placement"
        ) {
            return
        }
        console.debug("setupNewEditorsLinks call")
        if (mutationsList.length === 1 && mutationsList[0].type === "attributes" && mutationsList[0].attributeName === "aria-describedby") {
            document.querySelector("#" + mutationsList[0].target.getAttribute("aria-describedby"))?.remove()
        }
        if (mutationsList.every(i => i.attributeName === "class")) {
            // prevent infinite loop when two script instances on one page
            return
        }
    }
    addDropdownStyle()
    if (isMobile && document.querySelector("#map")) {
        document.querySelectorAll('button[data-bs-target="#select_language_dialog"]:not(.with-link-before)').forEach(langSwitchBtn => {
            langSwitchBtn.classList.add("with-link-before")
            const linksBtn = langSwitchBtn.cloneNode()
            linksBtn.removeAttribute("data-bs-target")
            linksBtn.removeAttribute("data-bs-toggle")
            linksBtn.title = t("editMenuLinks.openPlaceExternalWebsite")
            linksBtn.innerHTML = externalLinkSvg
            const svg = linksBtn.querySelector("svg")
            svg.setAttribute("width", 20)
            svg.setAttribute("height", 20)
            langSwitchBtn.before(linksBtn)

            function linksMenuClickHandler(e) {
                e.preventDefault()
                e.stopPropagation()
                if (document.querySelector("header").classList.contains("closed")) {
                    document.querySelector("#edit_tab > ul").classList.add("open-dropdown")
                    document.querySelector("#menu-icon").click()
                    document.querySelector("#edit_tab > button").click()
                } else if (document.querySelector("#edit_tab > .dropdown-menu").classList.contains("show")) {
                    document.querySelector("#edit_tab > ul").classList.remove("open-dropdown")
                    document.querySelector("#edit_tab > button").click()
                    document.querySelector("#menu-icon").click()
                } else {
                    document.querySelector("#edit_tab > ul").classList.add("open-dropdown")
                    document.querySelector("#edit_tab > button").click()
                }
            }

            linksBtn.addEventListener("click", linksMenuClickHandler)
        })
        document.querySelectorAll("#menu-icon:not(.listen-click)").forEach(i => {
            i.classList.add("listen-click")
            i.addEventListener("click", e => {
                if (!e.isTrusted) {
                    return
                }
                document.querySelectorAll(".open-dropdown").forEach(d => {
                    d.classList.remove("open-dropdown")
                })
            })
        })
        const dropdown = document.querySelector("#edit_tab > .dropdown-menu")
        dropdownObserver?.disconnect()
        dropdownObserver = new MutationObserver((mutations, observer) => {
            if (!dropdown.classList.contains("show") && dropdown.classList.contains("open-dropdown")) {
                observer.disconnect()
                dropdown.classList.remove("open-dropdown")
                document.querySelector("#menu-icon").click()
            }
        })
        try {
            dropdownObserver.observe(dropdown, { attributes: true })
        } catch (e) {}
    }
    const firstRun = document.getElementsByClassName("custom_editors").length === 0
    const editorsListUl = document.querySelector("#edit_tab ul")
    if (!editorsListUl) {
        return
    }
    const curURL = editorsListUl.querySelector("li a").href
    const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
    if (!match && !curURL.includes("edit?editor=id")) {
        return
    }
    try {
        coordinatesObserver?.disconnect()
        if (!curURL.includes("edit?editor=id") || !match) {
            return
        }
        if (isMobile) {
            const editJosmBtn = editorsListUl.querySelector('[href*="/edit?editor=remote"]')
            if (editJosmBtn && isMobile) {
                editJosmBtn.style.overflowY = "scroll"
                editJosmBtn.style.scrollbarWidth = "none"
            }
            const editIdBtn = editorsListUl.querySelector('[href*="/edit?editor=id"]')
            if (editIdBtn && isMobile) {
                editIdBtn.style.overflowY = "scroll"
                editIdBtn.style.scrollbarWidth = "none"
            }
        }
        if (firstRun) {
            const hr = document.createElement("hr")
            hr.style.margin = "0px"
            editorsListUl.appendChild(hr)
        }
        if (!externalLinksDatabase) {
            await initExternalLinksList()
        }
        if (!externalLinks) {
            await loadCurrentLinksList()
        }
        updateUrlTemplateContext()

        addUserExternalLinks(firstRun, editorsListUl)
        addOtherExternalLinks(editorsListUl)
        editorsListUl.querySelectorAll("a").forEach(a => {
            a?.classList?.remove("disabled")
        })

        if (!editorsListUl.querySelector("#change-list-btn") && !editorsListUl.querySelector(".create-link-btn")) {
            const hr = document.createElement("hr")
            hr.style.margin = "0px"
            editorsListUl.appendChild(hr)

            const editListLi = editorsListUl.querySelector("li").cloneNode()
            const span = document.createElement("span")
            span.textContent = t("editMenuLinks.moreLinks")
            span.id = "change-list-btn"
            span.classList.add("closed", "dropdown-item")
            if (isMobile) {
                span.classList.add("off-hover")
            }
            editListLi.appendChild(span)

            // hack for preload bootstrap font
            const dummy = document.createElement("span")
            dummy.classList.add("bi", "bi-pencil")
            dummy.style.opacity = "0"
            span.appendChild(dummy)

            editListLi.addEventListener(
                "click",
                e => {
                    e.preventDefault()
                    e.stopPropagation()
                    e.stopImmediatePropagation()
                    span.classList.remove("closed")
                    addOtherExternalLinks(editorsListUl)

                    span.textContent = t("editMenuLinks.editLinksList")
                    editorsListUl.scrollIntoView()
                    editListLi.onclick = e => {
                        e.preventDefault()
                        e.stopPropagation()
                        e.stopImmediatePropagation()

                        editorsListUl.classList.add("editing")
                        makeExternalLinkEditable(editListLi, editorsListUl)
                        editorsListUl.scrollIntoView()
                    }
                },
                { once: true },
            )
            editorsListUl.appendChild(editListLi)
        }
    } finally {
        coordinatesObserver?.disconnect()
        coordinatesObserver = new MutationObserver(_setupNewEditorsLinks)
        coordinatesObserver.observe(document.querySelector("#edit_tab"), { subtree: true, childList: true, attributes: true })
    }
}

//</editor-fold>

//<editor-fold desc="" defaultstate="collapsed">

function setupClickableAvatar() {
    const miniAvatar = document.querySelector(".user_thumbnail_tiny:not([patched-for-click])")
    if (!miniAvatar || miniAvatar.setAttribute("patched-for-click", "true")) {
        return
    }
    miniAvatar.onclick = e => {
        if (!e.isTrusted) return
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()
        if (location.pathname.match(/\/user\/.+\/history/)) {
            const targetURL = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href")
            if (e.ctrlKey || e.metaKey) {
                window.open(targetURL, "_blank")
            } else {
                window.location.pathname = targetURL
            }
            miniAvatar.click() // dirty hack for hide dropdown
        } else {
            const targetURL = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href") + "/history"
            if (targetURL !== location.pathname) {
                if (e.ctrlKey || e.metaKey) {
                    window.open(targetURL, "_blank")
                } else {
                    try {
                        getWindow().OSM.router.route(targetURL)
                    } catch {
                        window.location.pathname = targetURL
                    }
                }
                miniAvatar.click() // dirty hack for hide dropdown
            }
        }
    }
}

//</editor-fold>

//<editor-fold desc="geojson" defaultstate="collapsed">

let tracksCounter = 0
let trackMetadata = null

const trackColors = ["rgb(255,0,47)", "rgb(0,34,255)", "rgb(64,255,0)", "#000000", "#ff00e3", "#00ffce", "#ff4d00", "#a1a1a1"]

/**
 * @param {string|Document} xml
 * @param {string|null} color
 */
function displayGPXTrack(xml, color = null) {
    const doc = typeof xml === "string" ? new DOMParser().parseFromString(xml, "application/xml") : xml

    const popup = document.createElement("span")

    const name = doc.querySelector("gpx name")?.textContent
    const nameSpan = document.createElement("p")
    nameSpan.textContent = name

    const desc = doc.querySelector("gpx desc")?.textContent
    const descSpan = document.createElement("p")
    descSpan.textContent = desc

    const link = doc.querySelector("gpx link")?.getAttribute("href")
    const linkA = document.createElement("a")
    linkA.href = link
    linkA.textContent = link

    popup.appendChild(nameSpan)
    popup.appendChild(descSpan)
    popup.appendChild(linkA)

    console.time("start gpx track render")
    const min = Math.min
    const max = Math.max
    const trackColor = color ?? trackColors[tracksCounter % trackColors.length]
    tracksCounter++
    trackMetadata = {
        min_lat: 10000000,
        min_lon: 10000000,
        max_lat: -10000000,
        max_lon: -100000000,
    }
    doc.querySelectorAll("gpx trk").forEach(trk => {
        const nodesList = []
        trk.querySelectorAll("trkseg trkpt").forEach(i => {
            const lat = parseFloat(i.getAttribute("lat"))
            const lon = parseFloat(i.getAttribute("lon"))
            nodesList.push([lat, lon])

            trackMetadata.min_lat = min(trackMetadata.min_lat, lat)
            trackMetadata.min_lon = min(trackMetadata.min_lon, lon)
            trackMetadata.max_lat = max(trackMetadata.max_lat, lat)
            trackMetadata.max_lon = max(trackMetadata.max_lon, lon)
        })
        displayWay(nodesList, false, trackColor, 5, null, "customObjects", null, popup.outerHTML)
    })
    doc.querySelectorAll("gpx wpt").forEach(wpt => {
        const lat = wpt.getAttribute("lat")
        const lon = wpt.getAttribute("lon")
        const marker = showNodeMarker(lat, lon, trackColor, null, "customObjects", 3)
        const name = wpt.querySelector("name")
        const desc = wpt.querySelector("desc")
        if (name || desc) {
            const popup = document.createElement("span")
            if (name) {
                popup.textContent = name.textContent
            }
            if (desc) {
                popup.appendChild(document.createElement("br"))
                popup.appendChild(document.createTextNode(desc.textContent))
            }
            marker.bindTooltip(popup.outerHTML)
            marker.bindPopup(popup.outerHTML)
        }

        trackMetadata.min_lat = min(trackMetadata.min_lat, lat)
        trackMetadata.min_lon = min(trackMetadata.min_lon, lon)
        trackMetadata.max_lat = max(trackMetadata.max_lat, lat)
        trackMetadata.max_lon = max(trackMetadata.max_lon, lon)
    })
    console.timeEnd("start gpx track render")
}

/**
 * @param {string} xml
 */
function displayKMLTrack(xml) {
    const doc = new DOMParser().parseFromString(xml, "application/xml")
    const error = doc.querySelector("parsererror")
    if (error) {
        console.error("Parsing failed:", error.textContent)
    }

    const popup = document.createElement("span")

    const name = doc.querySelector("Document name")?.textContent
    const nameSpan = document.createElement("p")
    nameSpan.textContent = name

    const desc = doc.querySelector("Document description")?.textContent
    const descSpan = document.createElement("p")
    descSpan.textContent = desc

    const link = doc.querySelector("Document link")?.getAttribute("href")
    const linkA = document.createElement("a")
    linkA.href = link
    linkA.textContent = link

    popup.appendChild(nameSpan)
    popup.appendChild(descSpan)
    popup.appendChild(linkA)

    console.time("start kml track render")
    const min = Math.min
    const max = Math.max
    const trackColor = trackColors[tracksCounter % trackColors.length]
    tracksCounter++
    trackMetadata = {
        min_lat: 10000000,
        min_lon: 10000000,
        max_lat: -10000000,
        max_lon: -100000000,
    }
    doc.querySelectorAll("Document Placemark:has(LineString)").forEach(trk => {
        const nodesList = []
        trk.querySelectorAll("LineString coordinates").forEach(i => {
            i.firstChild.textContent
                .trim()
                .split(/\s+/)
                .map(i => i.split(","))
                .forEach(coord => {
                    const lat = parseFloat(coord[1])
                    const lon = parseFloat(coord[0])
                    nodesList.push([lat, lon])

                    trackMetadata.min_lat = min(trackMetadata.min_lat, lat)
                    trackMetadata.min_lon = min(trackMetadata.min_lon, lon)
                    trackMetadata.max_lat = max(trackMetadata.max_lat, lat)
                    trackMetadata.max_lon = max(trackMetadata.max_lon, lon)
                })
        })
        displayWay(nodesList, false, trackColor, 5, null, "customObjects", null, popup.outerHTML)
    })
    doc.querySelectorAll("Document Placemark:has(LinearRing)").forEach(trk => {
        const nodesList = []
        trk.querySelectorAll("LinearRing coordinates").forEach(i => {
            i.firstChild.textContent
                .trim()
                .split(/\s+/)
                .map(i => i.split(","))
                .forEach(coord => {
                    const lat = parseFloat(coord[1])
                    const lon = parseFloat(coord[0])
                    nodesList.push([lat, lon])

                    trackMetadata.min_lat = min(trackMetadata.min_lat, lat)
                    trackMetadata.min_lon = min(trackMetadata.min_lon, lon)
                    trackMetadata.max_lat = max(trackMetadata.max_lat, lat)
                    trackMetadata.max_lon = max(trackMetadata.max_lon, lon)
                })
        })
        displayWay(nodesList, false, trackColor, 5, null, "customObjects", null, popup.outerHTML)
    })
    doc.querySelectorAll("Document Placemark:has(Point)").forEach(pointXml => {
        const [lon, lat] = pointXml.querySelector("coordinates").firstChild.textContent.trim().split(",").map(parseFloat).slice(0, 2)
        const marker = showNodeMarker(lat, lon, trackColor, null, "customObjects", 3)
        const name = pointXml.querySelector("name")
        const desc = pointXml.querySelector("description")
        if (name || desc) {
            const popup = document.createElement("span")
            if (name) {
                popup.textContent = name.textContent
            }
            if (desc) {
                popup.appendChild(document.createElement("br"))
                popup.appendChild(document.createTextNode(desc.textContent))
            }
            marker.bindTooltip(popup.outerHTML)
            marker.bindPopup(popup.outerHTML)
        }

        trackMetadata.min_lat = min(trackMetadata.min_lat, lat)
        trackMetadata.min_lon = min(trackMetadata.min_lon, lon)
        trackMetadata.max_lat = max(trackMetadata.max_lat, lat)
        trackMetadata.max_lon = max(trackMetadata.max_lon, lon)
    })
    console.timeEnd("start kml track render")
}

/**
 * @param {import('geojson').GeoJSON} geojson
 */
function renderGeoJSONwrapper(geojson) {
    injectJSIntoPage(`
    var jsonLayer = null;
    // noinspection JSUnusedLocalSymbols
    function renderGeoJSON(data) {
        function onEachFeature(feature, layer) {
            if (feature.properties) {
                const table = document.createElement("table")
                table.style.overflow = "scroll"
                table.classList.add("geojson-props-table")
                table.classList.add("zebra_colors")
                const tbody = document.createElement("tbody")
                table.appendChild(tbody)
                Object.entries(feature.properties).forEach(([key, value]) => {
                    if (Array.isArray(value) && value.length === 0) {
                        value = "[]"
                    } else if (value === null) {
                        value = "null"
                    } else if (typeof value === 'object' && Object.entries(value).length === 0) {
                        value = "{}"
                    }
                    const th = document.createElement("th")
                    th.textContent = key
                    const td = document.createElement("td")
                    if (key === "id" && typeof value === "string" && (value.startsWith("node/") || value.startsWith("way/") || value.startsWith("relation/"))) {
                        const a = document.createElement("a")
                        a.textContent = value
                        a.href = "/" + value
                        td.appendChild(a)
                    } else {
                        td.textContent = value
                    }

                    const tr = document.createElement("tr")
                    tr.appendChild(th)
                    tr.appendChild(td)
                    tbody.appendChild(tr)
                })
                layer.on("click", e => {
                    if (e.originalEvent.altKey) {
                        layer.remove()
                        e.originalEvent.stopPropagation()
                        e.originalEvent.stopImmediatePropagation()
                    }
                })
                layer.bindPopup(table.outerHTML)
            }
        }

        jsonLayer = L.geoJSON(data, {
            onEachFeature: onEachFeature,
            pointToLayer: function (feature, latlng) {
                return L.circleMarker(latlng)
            }
        });
        jsonLayer.addTo(map);
    }
    `)
    getWindow().renderGeoJSON(intoPage(geojson))
}

var jsonLayer = null
let bannedVersions = null

function currentVersionBanned(module) {
    if (!bannedVersions) return false
    if (bannedVersions[GM_info.script.version]) {
        if (bannedVersions[GM_info.script.version][module]) {
            return bannedVersions[GM_info.script.version][module]
        }
    }
    return false
}

function insertOverlaysStyles() {
    const mapWidth = getComputedStyle(document.querySelector("#map")).width
    const mapHeight = getComputedStyle(document.querySelector("#map")).height

    injectCSSIntoOSMPage(`
            .leaflet-popup-content, .leaflet-tooltip {
                white-space: pre;
            }

            .leaflet-popup-content:has(.geojson-props-table) {
                overflow: scroll;
            }

            .leaflet-popup-content:has(.geojson-editor) {
                    /*max-width: calc(${mapWidth} / 3) !important;
                        min-width: calc(${mapWidth} / 3) !important;
                        max-height: calc(${mapHeight} / 2);
                        min-height: calc(${mapHeight} / 2);*/
                overflow-y: scroll;
                font-size: larger;
            }

            .geojson-editor {
                margin-left: 5px;
            }

            table.tags-table {
                margin-top: 5px;
                margin-left: 5px;
            }

            table.metainfo-table {
                margin-top: 5px;
                margin-left: 5px;
            }

            table.tags-table th:not(.tag-flag) {
                border: solid 2px transparent;
                min-width: 50px;
            }

            table.tags-table td:not(.tag-flag) {
                border: solid 2px transparent;
                min-width: 150px;
            }

            table.editable.tags-table th:not(.tag-flag) {
                border: solid 2px black;
                min-width: 50px;
            }

            table.editable.tags-table td:not(.tag-flag) {
                border: solid 2px black;
                min-width: 150px;
            }

            table:not(.editable).tags-table tr.add-tag-row {
                display: none;
                min-width: 150px;
            }

            table.editable.tags-table tr.add-tag-row th {
                text-align: center;
                cursor: pointer;
                min-width: 294px;
                resize: both !important;
            }

            table.tags-table textarea {
                min-width: 280px;
            }

            .mode-btn:not(.visible) {
                display: none;
            }
            
            .mode-btn > svg {
                margin-top: -3px;
            }

            .map-img-preview-popup {
                width: initial;
            }

            .zebra_colors tr:nth-child(even) td, .zebra_colors tr:nth-child(even) th {
                background-color: color-mix(in srgb, var(--bs-body-bg), black 10%);
            }

            @media ${mediaQueryForWebsiteTheme} {

                .mode-btn.visible img {
                    filter: invert(0.9);
                }

                .zebra_colors tr:nth-child(even) td, .zebra_colors tr:nth-child(even) th {
                    background-color: color-mix(in srgb, var(--bs-body-bg), white 7%);
                }

            }

            .leaflet-popup-content:has(.geotagged-img) {
                max-width: calc(${mapWidth} / 2) !important;
                min-width: calc(${mapWidth} / 2) !important;
                max-height: calc(${mapHeight} / 2);
                min-height: calc(${mapHeight} / 2);
                width: auto;
                height: auto;
                overflow-y: scroll;
            }
        `)
}

// const lastVersionsCache = {}

function loadBannedVersions() {
    externalFetchRetry({
        url: "https://raw.githubusercontent.com/deevroman/better-osm-org/refs/heads/master/misc/assets/banned_versions.json",
        responseType: "json",
    }).then(async res => {
        bannedVersions = await res.response
    })
}

function renderOSMGeoJSON(xml, options = {}) {
    initOsmAuth()
    /**
     * @param {Object.<string, string>} tags
     * @return {HTMLTableSectionElement}
     */
    function makeTBody(tags) {
        const tbody = document.createElement("tbody")
        Object.entries(tags).forEach(([key, value]) => {
            const th = document.createElement("th")
            th.textContent = key
            const td = document.createElement("td")
            td.textContent = value
            const tr = document.createElement("tr")
            th.tabIndex = 0
            td.tabIndex = 0
            tr.appendChild(th)
            tr.appendChild(td)
            tbody.appendChild(tr)
        })
        const tr = document.createElement("tr")
        tr.classList.add("add-tag-row")
        const th = document.createElement("th")
        th.textContent = "+"
        th.colSpan = 2
        th.tabIndex = 0
        th.setAttribute("contenteditable", false)
        tr.appendChild(th)
        th.onclick = () => {
            tbody.lastElementChild.before(makeRow("", "", true))
        }
        tbody.appendChild(tr)
        return tbody
    }

    function makePopupHTML(feature) {
        // debugger
        // const cachedObjectInfo = lastVersionsCache[`${feature.type}_${feature.id}`]
        // if (cachedObjectInfo && feature.properties.meta.version
        //     && parseInt(cachedObjectInfo.querySelector("node,way,relation").getAttribute("version")) > feature.properties.meta.version) {
        //     feature.properties.tags = {}
        //     lastVersionsCache[`${feature.type}_${feature.id}`].querySelectorAll("tag").forEach(i => {
        //         feature.properties.tags[i.getAttribute("k")] = i.getAttribute("v")
        //     })
        //     feature.properties.meta = {}
        //     Array.from(cachedObjectInfo.querySelector("node,way,relation").attributes).map(i => [i.name, i.value]).forEach(([key, value]) => {
        //         if (key === "visible" || key === "nodes" || key === "members" || key === "id") return
        //         feature.properties.meta[key] = value
        //     })
        // }

        const popupBody = document.createElement("span")
        popupBody.classList.add("geojson-editor")

        const objLink = document.createElement("a")
        objLink.textContent = feature.properties.type + "/" + feature.properties.id
        objLink.href = "/" + feature.properties.type + "/" + feature.properties.id
        popupBody.appendChild(objLink)

        popupBody.appendChild(document.createTextNode(", "))

        const versionLink = document.createElement("a")
        versionLink.classList.add("version-link")
        versionLink.textContent = feature.properties.meta.version ? "v" + feature.properties.meta.version : ""
        versionLink.href = "/" + feature.type + "/" + feature.id + "history/" + feature.properties.meta.version
        popupBody.appendChild(versionLink)

        const editButton = document.createElement("button")
        editButton.id = feature.properties.type + "-" + feature.properties.id + "-" + feature.properties.meta.version
        editButton.classList.add("edit-tags-btn")
        editButton.textContent = "🖊"

        popupBody.appendChild(document.createTextNode("\xA0"))
        popupBody.appendChild(editButton)

        const modeBtn = document.createElement("button")
        modeBtn.classList.add("mode-btn")
        modeBtn.title = t("geojson.switchTableAndRawEditor")

        popupBody.appendChild(document.createTextNode("\xA0"))
        popupBody.appendChild(modeBtn)

        const table = document.createElement("table")
        popupBody.appendChild(table)
        table.style.overflow = "scroll"
        table.classList.add("geojson-props-table")
        table.classList.add("zebra_colors")
        table.classList.add("tags-table")

        const details = document.createElement("details")
        details.style.color = "gray"

        const summary = document.createElement("summary")
        summary.textContent = t("geojson.metainfo")
        details.appendChild(summary)
        popupBody.appendChild(details)

        const metaTable = document.createElement("table")
        details.appendChild(metaTable)
        metaTable.style.overflow = "scroll"
        metaTable.classList.add("geojson-props-table")
        metaTable.classList.add("zebra_colors")
        metaTable.classList.add("metainfo-table")

        const metaTBody = document.createElement("tbody")
        metaTable.appendChild(metaTBody)
        Object.entries(feature.properties?.meta).forEach(([key, value]) => {
            const th = document.createElement("th")
            th.textContent = key
            const td = document.createElement("td")
            if (
                key === "id" &&
                typeof value === "string" &&
                (value.startsWith("node/") || value.startsWith("way/") || value.startsWith("relation/"))
            ) {
                const a = document.createElement("a")
                a.textContent = value
                a.href = "/" + value
                td.appendChild(a)
            } else {
                td.textContent = value
            }

            const tr = document.createElement("tr")
            tr.appendChild(th)
            tr.appendChild(td)
            metaTBody.appendChild(tr)
        })

        return popupBody
    }

    function onEachFeature(feature, layer) {
        if (!feature.properties) {
            return
        }

        getWindow().L.DomEvent.on(
            layer,
            "click",
            intoPageWithFun(e => {
                const layer = getMap()._layers[e.target._leaflet_id]
                if (e.originalEvent.altKey) {
                    layer.remove()
                    e.originalEvent.stopPropagation()
                    e.originalEvent.stopImmediatePropagation()
                } else {
                    if (!layer.getPopup()) {
                        layer.bindPopup(makePopupHTML(feature).outerHTML, intoPage({ minWidth: 300 })).openPopup()
                    }
                }
            }),
        )

        const startEdit = intoPageWithFun(async startEditEvent => {
            let lastEditMode = await GM.getValue("lastEditMode", "table")

            const table = startEditEvent.target.parentElement.querySelector("table.tags-table")
            const metaTable = startEditEvent.target.parentElement.querySelector("table.metainfo-table")
            /** @type {Map<string, string>}*/
            let oldTags = new Map()
            if (lastEditMode === "table") {
                table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
                    oldTags.set(i.querySelector("th").textContent, i.querySelector("td").textContent)
                })
            } else {
                oldTags = buildTags(table.querySelector("textarea").value)
            }

            const modeBtn = startEditEvent.target.parentElement.querySelector(".mode-btn")
            modeBtn.classList.add("visible")

            if (lastEditMode === "table") {
                modeBtn.innerHTML = rawEditSvg
            } else {
                modeBtn.innerHTML = tableEditSvg
                const textarea = table.querySelector("textarea")
                textarea.setAttribute("disabled", "true")
                textarea.value = ""
                textarea.rows = 5
                Object.entries(feature.properties?.tags).forEach(([k, v]) => {
                    textarea.value += `${k}=${v.replaceAll("\\\\", "\n")}\n`
                })
                textarea.value = textarea.value.trim()
                table.appendChild(textarea)
            }

            modeBtn.onclick = async e => {
                e.stopPropagation()
                if (lastEditMode === "table") {
                    modeBtn.innerHTML = tableEditSvg
                    lastEditMode = "raw"
                    await GM.setValue("lastEditMode", lastEditMode)

                    table.appendChild(makeTextareaFromTagsTable(table))
                    table.querySelector("tbody")?.remove()
                } else {
                    modeBtn.innerHTML = rawEditSvg
                    lastEditMode = "table"
                    await GM.setValue("lastEditMode", lastEditMode)

                    table.appendChild(makeTBody(buildTags(table.querySelector("textarea").value)))
                    table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
                        i.querySelector("th").setAttribute("contenteditable", true)
                        i.querySelector("td").setAttribute("contenteditable", true)
                    })
                    table.querySelector("textarea")?.remove()
                }
            }

            const object_type = feature.properties.type
            const object_id = parseInt(feature.properties.id)
            let object_version = parseInt(feature.properties.meta.version)

            async function syncTags() {
                const rawObjectInfo = await (
                    await osmEditAuth.fetch(osm_server.apiBase + object_type + "/" + object_id, {
                        method: "GET",
                        prefix: false,
                    })
                ).text()
                const objectInfo = new DOMParser().parseFromString(rawObjectInfo, "text/xml")
                // lastVersionsCache[`${object_type}_${object_id}`] = objectInfo
                /** @type {Map<string, string>} */
                const lastTags = new Map()
                objectInfo.querySelectorAll("tag").forEach(i => {
                    lastTags.set(i.getAttribute("k"), i.getAttribute("v"))
                })
                const new_object_version = parseInt(objectInfo.querySelector("[version]:not(osm)").getAttribute("version"))
                if (isDifferentTagsMap(lastTags, oldTags) || (object_version && object_version + 1 !== new_object_version)) {
                    console.log("applying new tags")
                    if (lastEditMode === "table") {
                        table.querySelector("tbody").remove()
                        table.appendChild(makeTBody(Object.fromEntries(lastTags)))
                    } else {
                        table.querySelector("textarea")?.remove()
                        const textarea = document.createElement("textarea")
                        textarea.value = ""
                        textarea.rows = 5
                        lastTags.entries().forEach(([k, v]) => {
                            textarea.value += `${k}=${v.replaceAll("\\\\", "\n")}\n`
                        })
                        textarea.value = textarea.value.trim()
                        table.appendChild(textarea)
                    }
                }
                object_version = new_object_version
                startEditEvent.target.parentElement.querySelector(".version-link").textContent = object_version ? "v" + object_version : ""
                startEditEvent.target.parentElement.querySelector(".version-link").href =
                    `/${object_type}/${object_id}/history/${object_version}`

                metaTable.querySelector("tbody")?.remove()

                const metaTBody = document.createElement("tbody")
                metaTable.appendChild(metaTBody)
                Array.from(objectInfo.querySelector("node,way,relation").attributes)
                    .map(i => [i.name, i.value])
                    .forEach(([key, value]) => {
                        if (key === "visible" || key === "nodes" || key === "members" || key === "id") return
                        const th = document.createElement("th")
                        th.textContent = key
                        const td = document.createElement("td")
                        td.textContent = value

                        const tr = document.createElement("tr")
                        tr.appendChild(th)
                        tr.appendChild(td)
                        metaTBody.appendChild(tr)
                    })
            }

            await syncTags()

            table.classList.add("editable")
            table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
                i.querySelector("th").setAttribute("contenteditable", true)
                i.querySelector("td").setAttribute("contenteditable", true)
            })
            table.querySelector("textarea")?.removeAttribute("disabled")
            table.addEventListener("input", () => {
                startEditEvent.target.removeAttribute("disabled")
            })
            startEditEvent.target.textContent = "📤"
            startEditEvent.target.setAttribute("disabled", true)
            startEditEvent.target.addEventListener(
                "click",
                async function upload() {
                    startEditEvent.target.style.cursor = "progress"
                    /** @type {Map<string, string>}*/
                    let newTags = new Map()
                    const lastEditMode = await GM.getValue("lastEditMode", "table")
                    if (lastEditMode === "table") {
                        table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
                            const key = i.querySelector("th").textContent.trim()
                            const value = i.querySelector("td").textContent.trim()
                            if (key === "" || value === "") {
                                // todo notify about error
                                return
                            }
                            newTags.set(key, value)
                        })
                    } else {
                        newTags = buildTags(table.querySelector("textarea").value)
                    }

                    console.log("Opening changeset")
                    const rawObjectInfo = await (
                        await osmEditAuth.fetch(osm_server.apiBase + object_type + "/" + object_id, {
                            method: "GET",
                            prefix: false,
                        })
                    ).text()
                    const objectInfo = new DOMParser().parseFromString(rawObjectInfo, "text/xml")
                    const lastVersion = parseInt(objectInfo.querySelector("[version]:not(osm)").getAttribute("version"))
                    if (lastVersion !== object_version) {
                        startEditEvent.target.textContent = "🔄"
                        alert(t("geojson.conflict"))
                        throw ""
                    }

                    const objectXML = objectInfo.querySelector("node,way,relation")
                    /** @type {Map<string, string>} */
                    const prevTags = new Map()
                    objectXML.querySelectorAll("tag").forEach(i => {
                        prevTags.set(i.getAttribute("k"), i.getAttribute("v"))
                        i.remove()
                    })
                    newTags.entries().forEach(([k, v]) => {
                        const tag = objectInfo.createElement("tag")
                        tag.setAttribute("k", k)
                        tag.setAttribute("v", v)
                        objectXML.appendChild(tag)
                    })

                    /**
                     * @param {"node"|"way"|"relation"} object_type
                     * @param {number} object_id
                     * @param {Map<string, string>} prevTags
                     * @param {Map<string, string>} newTags
                     * @return {string|string}
                     */
                    function makeComment(object_type, object_id, prevTags, newTags) {
                        const removedKeys = prevTags
                            .entries()
                            .map(([k]) => k)
                            .filter(k => newTags.get(k) === undefined)
                            .toArray()
                        const addedKeys = newTags
                            .entries()
                            .map(([k]) => k)
                            .filter(k => prevTags.get(k) === undefined)
                            .toArray()
                        const modifiedKeys = prevTags
                            .entries()
                            .filter(([k, v]) => newTags.get(k) !== undefined && newTags.get(k) !== v)
                            .map(([k]) => k)
                            .toArray()

                        let tagsHint = ""
                        if (addedKeys.length) {
                            tagsHint += "Add " + addedKeys.map(k => `${k}=${newTags.get(k)}`).join(", ") + "; "
                        }

                        if (modifiedKeys.length) {
                            tagsHint +=
                                "Changed " + modifiedKeys.map(k => `${k}=${prevTags.get(k)}\u200b→\u200b${newTags.get(k)}`).join(", ") + "; "
                        }

                        if (removedKeys.length) {
                            tagsHint += "Removed " + removedKeys.map(k => `${k}=${prevTags.get(k)}`).join(", ") + "; "
                        }

                        if (tagsHint.length > 200 || modifiedKeys.length > 1) {
                            tagsHint = ""
                            if (addedKeys.length) {
                                tagsHint += "Add " + addedKeys.join(", ") + "; "
                            }

                            if (modifiedKeys.length) {
                                tagsHint += "Changed " + modifiedKeys.join(", ") + "; "
                            }

                            if (removedKeys.length) {
                                tagsHint += "Removed " + removedKeys.join(", ") + "; "
                            }
                        }

                        tagsHint = tagsHint.match(/(.*); /)[1]

                        let mainTagsHint = ""

                        for (const i of prevTags.entries()) {
                            if (mainTags.includes(i[0]) && !removedKeys.includes(i[0]) && !modifiedKeys.includes(i[0])) {
                                mainTagsHint += ` ${i[0]}=${i[1]}`
                                break
                            }
                        }
                        for (const i of prevTags.entries()) {
                            if (i[0] === "name" && !removedKeys.includes("name") && !modifiedKeys.includes("name")) {
                                mainTagsHint += ` ${i[0]}=${i[1]}`
                                break
                            }
                        }

                        if (mainTagsHint !== "") {
                            if (removedKeys.length) {
                                tagsHint += " from" + mainTagsHint
                            } else if (modifiedKeys.length) {
                                tagsHint += " of" + mainTagsHint
                            } else if (addedKeys.length) {
                                tagsHint += " to" + mainTagsHint
                            }
                        } else {
                            tagsHint += ` for ${object_type} ${object_id}`
                        }

                        return tagsHint !== "" ? tagsHint.slice(0, 255) : `Update tags of ${object_type} ${object_id}`
                    }

                    const changesetId = await openOsmChangeset(makeComment(object_type, object_id, prevTags, newTags))
                    try {
                        objectInfo.children[0].children[0].setAttribute("changeset", changesetId)

                        const objectInfoStr = new XMLSerializer().serializeToString(objectInfo).replace(/xmlns="[^"]+"/, "")
                        console.log(objectInfoStr)
                        await osmEditAuth
                            .fetch(osm_server.apiBase + object_type + "/" + object_id, {
                                method: "PUT",
                                prefix: false,
                                body: objectInfoStr,
                            })
                            .then(async res => {
                                const text = await res.text()
                                if (res.ok) return text
                                alert(text)
                                throw new Error(text)
                            })
                    } finally {
                        startEditEvent.target.style.cursor = ""
                        await closeOsmChangeset(changesetId)
                    }

                    startEditEvent.target.textContent = "#" + changesetId
                    startEditEvent.target.style.color = "green"

                    startEditEvent.target.onclick = () => {
                        window.open("/changeset/" + changesetId, "_blank")
                    }

                    table.addEventListener(
                        "input",
                        () => {
                            startEditEvent.target.removeAttribute("disabled")
                            startEditEvent.target.textContent = "📤"
                            startEditEvent.target.onclick = null
                        },
                        { once: true },
                    )

                    oldTags = new Map()
                    objectInfo.querySelectorAll("tag").forEach(i => {
                        oldTags.set(i.getAttribute("k"), i.getAttribute("v"))
                    })
                    await syncTags()

                    console.log(objectInfo)
                },
                { once: true },
            )
            table.addEventListener(
                "keydown",
                e => {
                    if (e.code === "Enter" && (e.metaKey || e.ctrlKey)) {
                        e.preventDefault()
                        const tr = document.createElement("tr")
                        const th = document.createElement("th")
                        const td = document.createElement("td")
                        tr.appendChild(th)
                        tr.appendChild(td)
                        tr.style.height = "1rem"
                        tr.tabIndex = 0
                        e.target.after(tr)
                        tr.focus()
                    }
                },
                false,
            )
        })
        getWindow().L.DomEvent.on(
            layer,
            "popupopen",
            intoPageWithFun(
                async openEvent => {
                    const layer = getMap()._layers[openEvent.target._leaflet_id]
                    const editButton = layer.getPopup().getElement().querySelector(".edit-tags-btn")
                    if (currentVersionBanned("overpass_tags_editor")) {
                        editButton.classList.add("banned-feature")
                        editButton.textContent = t("geojson.needUpdateBetterOsmOrg")
                        editButton.title =
                            "Please click for update better-osm-org script.\nThe current version contains a bug that may corrupt OSM data."
                        editButton.addEventListener(
                            "click",
                            intoPageWithFun(() => {
                                window.open(SCRIPT_UPDATE_URL, "_blank")
                            }),
                            intoPage({ once: true }),
                        )
                    } else {
                        editButton.addEventListener("click", startEdit, intoPage({ once: true }))

                        if ((await GM.getValue("lastEditMode", "table")) === "raw") {
                            const textarea = document.createElement("textarea")
                            textarea.setAttribute("disabled", "true")
                            Object.entries(feature.properties?.tags).forEach(([k, v]) => {
                                if (k === "" && v === "") return
                                textarea.value += `${k}=${v.replaceAll("\\\\", "\n")}\n`
                            })
                            textarea.value = textarea.value.trim()
                            textarea.rows = 5
                            layer.getPopup().getElement().querySelector(".tags-table").appendChild(textarea)
                        } else {
                            layer.getPopup().getElement().querySelector(".tags-table").appendChild(makeTBody(feature.properties?.tags))
                        }
                    }
                },
                intoPage({ once: true }),
            ),
        )
    }
    xml.querySelectorAll("[action=delete]").forEach(i => i.remove())
    /** @type import('geojson').GeoJSON */
    const geojson = osmtogeojson(xml, { flatProperties: false })
    if (options["skip_tainted"]) {
        geojson.features = geojson.features.filter(f => !f.properties["tainted"])
    }
    const pointOptions = intoPage(options["point_options"] ?? {})
    const jsonLayer = getWindow().L.geoJSON(
        intoPage(geojson),
        intoPageWithFun({
            style: options["style"],
            onEachFeature: intoPageWithFun(onEachFeature),
            pointToLayer: intoPageWithFun(function (feature, latlng) {
                return getWindow().L.circleMarker(latlng, pointOptions)
            }),
        }),
    )
    jsonLayer.addTo(getMap())
    return jsonLayer
}

//</editor-fold>

//<editor-fold desc="overpass search" defaultstate="collapsed">

function yetAnotherWizard(s) {
    // const [k, v] = s.split("=")
    if (s[0] === "~" && !s.slice(1).includes("~")) {
        if (!s.includes('"') && s.includes("|")) {
            return `nwr[~"${s.slice(1)}"~".*"];`
        }
        return `nwr[${s}~".*"];`
    } else if (s[0] === "[") {
        return `nwr${s};`
    } else if (s.match(/^(node|way|rel|nwr|nw|nr|wr)/)) {
        return `${s}` + (s.slice(-1) === ";" ? "" : ";")
    } else {
        // name ~ пятёрочка, i
        // https://github.com/drolbr/Overpass-API/issues/751
        const kv_match = s.match(
            /^(?<prefix>[~!]?)(?<key>[a-zA-Z0-9_\p{L}^$:.*+]+)\s*(?<op>(=|~|!=|!~))\s*(?<value>[a-zA-Z0-9_\p{L}^$.*+]+)(?<suffix>\s*,\s*i)?$/u,
        )?.groups
        if (kv_match) {
            return `nwr[${kv_match["prefix"] ?? ""}"${kv_match["key"]}"${kv_match["op"]}"${kv_match["value"]}"${kv_match["suffix"] ?? ""}];`
        } else {
            if (s.match(/^[a-zA-Z0-9_:.]+$/)) {
                return `nwr["${s}"];`
            } else {
                if (s.match(/^[a-zA-Z0-9_:.|]+$/)) {
                    return (
                        "(\n" +
                        s
                            .split("|")
                            .map(k => `nwr["${k}"];`)
                            .join("\n") +
                        ");"
                    )
                }
                return `nwr[${s}];`
            }
        }
    }
}

let searchResultBBOX = null
const panoramaxVectorMapSourceId = "panoramax-photos-source"
const panoramaxVectorMapLayerId = "panoramax-photos-layer"
let panoramaxVectorMapImageIds = []

function extractPanoramaxUuidFromTags(tags) {
    const panoramaxTagValue = Object.entries(tags || {}).find(([k, _]) => k.startsWith("panoramax"))?.[1]
    return panoramaxTagValue?.match?.(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0]?.toLowerCase() ?? null
}

async function addBase64ImageToVectorMap(map, imageId, base64DataUrl) {
    const image = document.createElement("img")
    await new Promise((resolve, reject) => {
        image.onload = resolve
        image.onerror = reject
        image.src = base64DataUrl
    })
    if (map.hasImage(imageId)) {
        map.removeImage(imageId)
    }
    map.addImage(imageId, image)
}

async function renderPanoramaxPhotoPointOnVectorMap(withPhotos) {
    const map = findVectorMap()
    if (!map) {
        return
    }
    if (!map.isStyleLoaded?.()) {
        return
    }

    if (map.getLayer(panoramaxVectorMapLayerId)) {
        map.removeLayer(panoramaxVectorMapLayerId)
    }
    if (map.getSource(panoramaxVectorMapSourceId)) {
        map.removeSource(panoramaxVectorMapSourceId)
    }
    panoramaxVectorMapImageIds.forEach(imageId => {
        if (map.hasImage(imageId)) {
            map.removeImage(imageId)
        }
    })
    panoramaxVectorMapImageIds = []

    /** @type {import("geojson").Feature[]} */
    const features = []
    const loadedImageIds = new Set()

    for (const photoObj of withPhotos) {
        const uuid = extractPanoramaxUuidFromTags(photoObj.tags)
        if (!uuid || !Number.isFinite(photoObj.lat) || !Number.isFinite(photoObj.lon)) {
            continue
        }
        const imageId = `panoramax-thumb-${uuid}`
        if (!loadedImageIds.has(imageId)) {
            const imgSrc = `${panoramaxDiscoveryServer}/api/pictures/${uuid}/thumb.jpg`
            try {
                const base64DataUrl = await fetchImageWithCache(imgSrc)
                await addBase64ImageToVectorMap(map, imageId, base64DataUrl)
                panoramaxVectorMapImageIds.push(imageId)
                loadedImageIds.add(imageId)
            } catch (e) {
                console.error("failed to load panoramax image", uuid, e)
                continue
            }
        }
        features.push({
            type: "Feature",
            geometry: {
                type: "Point",
                coordinates: [photoObj.lon, photoObj.lat],
            },
            properties: {
                iconId: imageId,
            },
        })
    }
    if (!features.length) {
        return
    }

    map.addSource(panoramaxVectorMapSourceId, {
        type: "geojson",
        data: {
            type: "FeatureCollection",
            features: features,
        },
    })
    map.addLayer({
        id: panoramaxVectorMapLayerId,
        type: "symbol",
        source: panoramaxVectorMapSourceId,
        layout: {
            "icon-image": ["get", "iconId"],
            "icon-size": 0.25,
            "icon-allow-overlap": true,
            "icon-ignore-placement": true,
        },
    })
}

async function processOverpassQuery(query) {
    if (!query.length) return
    query = query.trim()
    await GM.setValue("lastOverpassQuery", query)
    const bound = getMap().getBounds()
    const bboxString = [
        bound.getSouthWest().wrap().lat,
        bound.getSouthWest().wrap().lng,
        bound.getNorthEast().wrap().lat,
        bound.getNorthEast().wrap().lng,
    ]
    const bboxExpr = query[query.length - 1] !== "!" ? "[bbox:" + bboxString + "]" : ""
    if (query[query.length - 1] === "!") {
        query = query.slice(0, -1)
    }
    query = query.replace("{{bbox}}", bboxString.join(","))
    const prevTitle = document.title
    const newTitle = "◴" + prevTitle
    document.title = newTitle

    try {
        const overpassQuery = `[out:xml][maxsize:64Mi]${bboxExpr};
${yetAnotherWizard(query)}
//(._;>;);
out geom;
`
        console.log(overpassQuery)

        console.time("download overpass data " + query)
        const res = await externalFetch({
            // todo switcher
            method: "POST",
            headers: {
                Referer: "https://overpass-turbo.eu/",
                Origin: "https://overpass-turbo.eu",
            },
            url: overpass_server.apiUrl + "/interpreter",
            data: overpassQuery,
            responseType: "xml",
        })
        console.timeEnd("download overpass data " + query)
        const xml = new DOMParser().parseFromString(res.response, "text/xml")
        if (res.status !== 200) {
            if (xml.querySelector("parsererror")) {
                alert(t("overpassSearch.httpError", { status: res.status }))
            } else {
                let errorMessage = t("overpassSearch.httpErrorWithQuery", { status: res.status, query: overpassQuery }) + "\n"

                xml.querySelectorAll("p").forEach(i => {
                    const lineText = i.textContent
                    if (lineText.includes("Error")) {
                        errorMessage += lineText + "\n"
                    }
                })
                alert(errorMessage)
            }
            return
        }

        const data_age = new Date(xml.querySelector("meta").getAttribute("osm_base"))
        console.log(data_age)

        getMap()?.invalidateSize()
        const bbox = (searchResultBBOX = combineBBOXes(
            Array.from(xml.querySelectorAll("bounds")).map(i => {
                return {
                    min_lat: i.getAttribute("minlat"),
                    min_lon: i.getAttribute("minlon"),
                    max_lat: i.getAttribute("maxlat"),
                    max_lon: i.getAttribute("maxlon"),
                }
            }),
        ))
        // const points = []
        Array.from(xml.querySelectorAll("node")).forEach(n => {
            const lat = parseFloat(n.getAttribute("lat"))
            const lon = parseFloat(n.getAttribute("lon"))
            // points.push([lon, lat])

            bbox.min_lat = min(bbox.min_lat, lat)
            bbox.min_lon = min(bbox.min_lon, lon)
            bbox.max_lat = max(bbox.max_lat, lat)
            bbox.max_lon = max(bbox.max_lon, lon)
        })
        console.log(bbox)
        if (bbox.min_lon === 10000000) {
            alert(t("overpassSearch.invalidQuery"))
            return
        }
        console.time("render overpass response")
        fitBounds([
            [bbox.min_lat, bbox.min_lon],
            [bbox.max_lat, bbox.max_lon],
        ])
        loadBannedVersions()
        cleanAllObjects()
        getWindow().jsonLayer?.remove()
        jsonLayer?.remove()
        jsonLayer = renderOSMGeoJSON(xml, true)
        setTimeout(async () => {
            const withPhotos = Array.from(
                xml.querySelectorAll(':has(>:is([k^=panoramax],[k^=mapillary],[k^=wikimedia_commons],[k^="ref:inaturalist.org"]))'),
            ).map(i => {
                const res = {
                    id: parseInt(i.getAttribute("id")),
                    type: i.nodeName,
                    tags: Object.fromEntries(Array.from(i.querySelectorAll("tag")).map(j => [j.getAttribute("k"), j.getAttribute("v")])),
                }
                if (res.type === "node") {
                    res.lat = parseFloat(i.getAttribute("lat"))
                    res.lon = parseFloat(i.getAttribute("lon"))
                }
                return res
            })
            renderPhotosPreview(withPhotos)
            // await renderPanoramaxPhotoPointOnVectorMap(withPhotos)
        })
        console.timeEnd("render overpass response")

        let statusPrefix = ""
        if (!xml.querySelector("node,way,relation")) {
            statusPrefix += "Empty result"
        }

        if ((new Date().getTime() - data_age.getTime()) / 1000 / 60 > 5) {
            if (statusPrefix === "") {
                statusPrefix += "Currentless of the data: " + data_age.toLocaleDateString() + " " + data_age.toLocaleTimeString()
            } else {
                statusPrefix += " | " + "Currentless of the data: " + data_age.toLocaleDateString() + " " + data_age.toLocaleTimeString()
            }
        }

        setAttributionPrefix(statusPrefix)
    } finally {
        if (document.title === newTitle) {
            document.title = prevTitle
        }
    }
}

//</editor-fold>

//<editor-fold desc="drag-and-drop" defaultstate="collapsed">

function displayCsv(text) {
    const [header, ...lines] = text.split("\n")
    console.log("lines count", lines.length)
    if (lines.length > 20000) {
        alert(t("dragAndDrop.tooManyCsvLines"))
    }
    let sep = header.includes(";") ? ";" : ","
    const columns = header
        .trim()
        .split(sep)
        .map(i => i.replace(/^"/g, "").replace(/"$/, ""))
    let latColumIndex = -1
    let lonColumIndex = -1
    columns.forEach((col, i) => {
        if (col.match(/(lat|широта)/i)) {
            latColumIndex = i
        } else if (col.match(/(lon|догота)/i)) {
            lonColumIndex = i
        }
    })
    if (latColumIndex === -1 || lonColumIndex === -1) {
        throw "Can't find lat/lon columns in CSV file"
    }

    /** @type {import("geojson").GeoJSON} */
    const geojson = {
        type: "FeatureCollection",
        features: [],
    }
    console.time("csv filtering")
    const prevTitle = document.title
    const newTitle = "◴" + prevTitle
    document.title = newTitle

    try {
        const bounds = getMap().getBounds()
        for (const line of lines) {
            const values = line
                .trim()
                .split(sep)
                .map(i => {
                    if (i[0] === '"') {
                        if (i.at(-1) === '"') {
                            return i.slice(1, -1)
                        }
                        return i.slice(1)
                    }
                    if (i.at(-1) === '"') {
                        return i.slice(0, -1)
                    }
                    return i
                })

            const lat = parseFloat(values[latColumIndex])
            const lon = parseFloat(values[lonColumIndex])
            if (isNaN(lat) || isNaN(lon)) {
                console.warn("Invalid lat/lon in CSV file:", values)
                continue
            }
            if (lines.length > 20000 && !bounds.contains(getWindow().L.latLng(lat, lon))) {
                continue
            }
            geojson.features.push({
                type: "Feature",
                geometry: {
                    type: "Point",
                    coordinates: [lon, lat],
                },
                properties: Object.fromEntries(columns.map((col, i) => [col, values[i]])),
            })
        }
        console.timeEnd("csv filtering")
        renderGeoJSONwrapper(geojson)
    } finally {
        if (document.title === newTitle) {
            document.title = prevTitle
        }
    }
}

function handleDroppedFiles(files) {
    const mapWidth = getComputedStyle(document.querySelector("#map")).width
    const mapHeight = getComputedStyle(document.querySelector("#map")).height
    insertOverlaysStyles()

    files.forEach(async file => {
        if (file.type.startsWith("image/jpeg") || file.name.endsWith(".jpg") || file.name.endsWith(".jpeg")) {
            const metadata = EXIF.readFromBinaryFile(await file.arrayBuffer())
            console.log(metadata)
            console.log(metadata.GPSLatitude, metadata.GPSLongitude)
            let lat =
                parseFloat(metadata.GPSLatitude[0]) + parseFloat(metadata.GPSLatitude[1]) / 60 + parseFloat(metadata.GPSLatitude[2]) / 3600
            let lon =
                parseFloat(metadata.GPSLongitude[0]) + parseFloat(metadata.GPSLongitude[1]) / 60 + parseFloat(metadata.GPSLongitude[2]) / 3600

            if (metadata.GPSLatitudeRef === "S") {
                lat = parseFloat(lat) * -1
            }

            if (metadata.GPSLongitudeRef === "W") {
                lon = parseFloat(lon) * -1
            }

            const marker = getWindow().L.circleMarker(
                getWindow().L.latLng(lat, lon),
                // intoPage({
                //     maxWidth: mapWidth,
                //     maxHeight: mapHeight,
                //     className: "map-img-preview-popup",
                // }),
            )
            const img = document.createElement("img")
            img.classList.add("geotagged-img")
            img.setAttribute("width", "100%")
            const fr = new FileReader()
            fr.onload = function () {
                img.src = fr.result
                marker.bindPopup(img.outerHTML)
            }
            fr.readAsDataURL(file)
            marker.addTo(getMap())
        } else if (
            file.type === "application/json" ||
            file.type === "application/geo+json" ||
            file.name.endsWith(".geojson") ||
            file.name.endsWith(".json")
        ) {
            const geojson = JSON.parse(await file.text())
            renderGeoJSONwrapper(geojson)
        } else if (file.type === "application/gpx+xml" || file.name.endsWith(".gpx")) {
            displayGPXTrack(await file.text())
        } else if (file.type === "application/vnd.openstreetmap.data+xml" || file.name.endsWith(".osm")) {
            const doc = new DOMParser().parseFromString(await file.text(), "application/xml")
            loadBannedVersions()
            jsonLayer = renderOSMGeoJSON(doc, true)
        } else if (file.type === "application/vnd.google-earth.kml+xml" || file.name.endsWith(".kml")) {
            displayKMLTrack(await file.text())
        } else if (file.type === "application/vnd.google-earth.kmz+xml" || file.name.endsWith(".kmz")) {
            const { entries } = await unzipit.unzip(await file.arrayBuffer())
            displayKMLTrack(
                await Object.entries(entries)
                    .find(i => i[0].endsWith(".kml"))[1]
                    .text(),
            )
        } else if (file.type === "text/csv" || file.name.endsWith(".csv")) {
            displayCsv(await file.text())
        } else {
            console.log(file.type)
        }
    })
}

async function setupDragAndDropViewers() {
    document.querySelector("#map")?.addEventListener("drop", e => {
        if (location.pathname.includes("/directions") || location.pathname.includes("/note/new")) {
            return
        }
        e.preventDefault()
        e.stopPropagation()
        e.stopImmediatePropagation()

        e.target.style.cursor = "progress"
        try {
            handleDroppedFiles([...e.dataTransfer.items].filter(i => i.kind === "file").map(i => i.getAsFile()))
            stopClick = true
        } finally {
            e.target.style.cursor = "grab"
        }
    })
    document.querySelector("#map")?.addEventListener("dragover", e => {
        if (!location.pathname.includes("/directions") && !location.pathname.includes("/note/new")) {
            e.preventDefault()
        }
    })

    if (location.pathname.includes("/traces")) {
        document.querySelectorAll('a[href*="edit?gpx="]').forEach(i => {
            const trackID = i.getAttribute("href").match(/edit\?gpx=(\d+)/)[1]
            const editLink = i.parentElement.parentElement.querySelector('a:not([href*="display-gpx"])')
            const url = new URL(editLink.href)
            url.search += "&display-gpx=" + trackID
            editLink.href = url.toString()
        })
    } else if (location.search.includes("&display-gpx=")) {
        const trackID = location.search.match(/&display-gpx=(\d+)/)[1]
        const res = await externalFetchRetry({
            url: `${osm_server.url}/traces/${trackID}/data`,
            responseType: "blob",
        })
        const contentType = res.response.type
        if (contentType === "application/gpx+xml") {
            displayGPXTrack(await res.response.text())
        } else if (contentType === "application/gzip") {
            displayGPXTrack(await (await decompressBlob(res.response)).text())
        } else if (contentType === "application/x-bzip2") {
            // fuck Tampermonkey, structuredClone for TM
            displayGPXTrack(
                new DOMParser().parseFromString(
                    new TextDecoder().decode(bz2.decompress(structuredClone(await res.response.bytes()))),
                    "application/xml",
                ),
            )
        } else {
            throw `Unknown track #${trackID} format: ` + contentType
        }
        if (trackMetadata) {
            fitBounds([
                [trackMetadata.min_lat, trackMetadata.min_lon],
                [trackMetadata.max_lat, trackMetadata.max_lon],
            ])
        }
    }

    // todo refactor
    const createNoteButton = document.querySelector(".control-note.leaflet-control a")
    if (createNoteButton && !createNoteButton.getAttribute("data-bs-original-title").includes(" (shift + N)")) {
        createNoteButton.setAttribute("data-bs-original-title", createNoteButton.getAttribute("data-bs-original-title") + " (shift + N)")
    }
}

//</editor-fold>

//<editor-fold desc="actions">

function updateCurrentObjectMetadata() {
    setTimeout(loadChangesetMetadata, 0)
    setTimeout(loadNoteMetadata, 0)
    setTimeout(loadNodeMetadata, 0)
    setTimeout(loadWayMetadata, 0)
    setTimeout(loadRelationMetadata, 0)
}

const mapPositionsHistory = []
const mapPositionsNextHistory = []

function runPositionTracker() {
    if (!getMap || !getMap()?.getBounds) {
        console.error("Please, reload page, if something doesn't work")
    }
    setInterval(
        () => {
            if (!getMap || !getMap()?.getBounds) return
            const bound = get4Bounds(getMap())
            if (JSON.stringify(mapPositionsHistory[mapPositionsHistory.length - 1]) === JSON.stringify(bound)) {
                return
            }
            // in case of a transition between positions
            // via timeout?
            if (JSON.stringify(mapPositionsNextHistory[mapPositionsNextHistory.length - 1]) === JSON.stringify(bound)) {
                return
            }
            mapPositionsNextHistory.length = 0
            mapPositionsHistory.push(bound)
            if (mapPositionsHistory.length > 100) {
                mapPositionsHistory.shift()
                mapPositionsHistory.shift()
            }
        },
        isMobile ? 4000 : 1000,
    )
}

let newNotePlaceholder = null

let overzoomObserver = null
const blankSuffix = "?blankTile=false"

function enableOverzoom() {
    if (!GM_config.get("OverzoomForDataLayer")) {
        return
    }
    if (customLayerInfo.url === ESRITemplate) {
        customLayerInfo.url = ESRIPrefix + "{z}/{y}/{x}" + blankSuffix
    } else if (customLayerInfo.url === ESRIBetaTemplate) {
        customLayerInfo.url = ESRIBetaPrefix + "{z}/{y}/{x}" + blankSuffix
    }
    ESRITemplate = ESRIPrefix + "{z}/{y}/{x}" + blankSuffix
    ESRIBetaTemplate = ESRIBetaPrefix + "{z}/{y}/{x}" + blankSuffix
    console.log("Enabling overzoom for map layer")
    overzoomObserver?.disconnect()

    injectJSIntoPage(`
    (function () {
        if (map && map.options) {
            map.options.maxZoom = 22
            const layers = [];
            map.eachLayer(i => layers.push(i))
            layers[0].options.maxZoom = 22
        } else {
            console.warn("overzoom not enabled")
        }
    })()
    `)

    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeName !== "IMG") {
                    return
                }
                getWindow().L.DomEvent.off(node, "error")
            })
        })
    })
    overzoomObserver = observer
    observer.observe(document.body, { childList: true, subtree: true })

    // it's unstable
    console.log("Overzoom enabled")
}

function disableOverzoom() {
    if (!GM_config.get("OverzoomForDataLayer")) {
        return
    }
    if (customLayerInfo.url === ESRITemplate) {
        customLayerInfo.url = ESRIPrefix + "{z}/{y}/{x}"
    } else if (customLayerInfo.url === ESRIBetaTemplate) {
        customLayerInfo.url = ESRIBetaPrefix + "{z}/{y}/{x}"
    }
    ESRITemplate = ESRIPrefix + "{z}/{y}/{x}"
    ESRIBetaTemplate = ESRIBetaPrefix + "{z}/{y}/{x}"
    injectJSIntoPage(`
    (function () {
        map.options.maxZoom = 19
        const layers = [];
        map.eachLayer(i => layers.push(i))
        layers[0].options.maxZoom = 19
    })()
    `)
    console.log("Overzoom disabled")
}

const ABORT_ERROR_PREV = "Abort requests for moving to prev changeset"
const ABORT_ERROR_NEXT = "Abort requests for moving to next changeset"
const ABORT_ERROR_USER_CHANGESETS = "Abort requests for moving to user changesets"
const ABORT_ERROR_WHEN_PAGE_CHANGED = "Abort requests. Reason: page changed"

let layersHidden = false

let needPreloadChangesets = false

function getPrevChangesetLink(doc = document) {
    const navigationLinks = doc.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
    if (navigationLinks && navigationLinks[0].href.includes("/changeset/")) {
        return navigationLinks[0]
    }
}

function getNextChangesetLink(doc = document) {
    const navigationLinks = doc.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
    if (navigationLinks && Array.from(navigationLinks).at(-1).href.includes("/changeset/")) {
        return Array.from(navigationLinks).at(-1)
    }
}

let repeatedEvent = false
let trustedEvent = true
const smoothScroll = "auto"

function goToPrevChangesetObject(e) {
    repeatedEvent = e.repeat
    if (!document.querySelector("ul .active-object")) {
        return
    }
    if (document.querySelector("#sidebar").matches(":hover")) {
        preventHoverEvents()
    }

    const prev = document.querySelector("ul .active-object")
    for (let i = 0; i < 10000; i++) {
        const cur = document.querySelector("ul .active-object")
        if (cur.previousElementSibling) {
            cur.previousElementSibling.classList.add("active-object")
            cur.classList.remove("active-object")
            // prettier-ignore
            if (!cur.previousElementSibling.classList.contains('tags-non-modified')
                || cur.previousElementSibling.classList.contains('location-modified')
                || cur.previousElementSibling.querySelector('.nodes-changed, .members-changed')
                || e.altKey
                || !location.search.includes("changesets=")) {
                trustedEvent = false
                cur.previousElementSibling.click()
                if (changesetObjectsSelectionModeEnabled) {
                    cur.previousElementSibling.querySelector("input")?.focus()
                }
                trustedEvent = true
                cur.previousElementSibling.scrollIntoView({ block: "center", behavior: smoothScroll })
                resetMapHover()
                cur.previousElementSibling.classList.add("map-hover")
                if (cur.previousElementSibling.querySelector(".load-relation-version")) {
                    cur.previousElementSibling.querySelector(".load-relation-version").focus()
                }
                return
            }
        } else {
            const curFrame = cur.parentElement.parentElement
            // prettier-ignore
            if (curFrame.id === "changeset_nodes" && ["changeset_ways", "changeset_relations"].includes(curFrame.previousElementSibling?.id)
                || curFrame.id === "changeset_relations" && ["changeset_ways"].includes(curFrame.previousElementSibling?.id)) {
                cur.classList.remove("active-object")
                curFrame.previousElementSibling.querySelector("#changeset_ways li:last-of-type, #changeset_relations li:last-of-type").classList.add("active-object")
                // prettier-ignore
                if (!curFrame.previousElementSibling.querySelector(".active-object").classList.contains('tags-non-modified')
                    || curFrame.previousElementSibling.querySelector(".active-object").classList.contains('location-modified')
                    || curFrame.previousElementSibling.querySelector(".active-object").querySelector('.nodes-changed, .members-changed')
                    || e.altKey
                    || !location.search.includes("changesets=")) {
                    trustedEvent = false
                    curFrame.previousElementSibling.querySelector(".active-object").click()
                    if (changesetObjectsSelectionModeEnabled) {
                        curFrame.previousElementSibling.querySelector(".active-object input")?.focus()
                    }
                    trustedEvent = true
                    curFrame.previousElementSibling.querySelector(".active-object").scrollIntoView({
                        block: "center",
                        behavior: smoothScroll,
                    })
                    resetMapHover()
                    curFrame.previousElementSibling.querySelector(".active-object").classList.add("map-hover")
                    if (curFrame.previousElementSibling?.querySelector(".load-relation-version")) {
                        curFrame.previousElementSibling.querySelector(".load-relation-version").focus()
                    }
                    if (curFrame.id === "changeset_relations") {
                        document.activeElement.blur()
                    }
                    return
                }
            } else {
                let prev = curFrame?.previousElementSibling?.previousElementSibling
                if (prev?.nodeName !== "TURBO-FRAME" && prev?.previousElementSibling?.nodeName === "TURBO-FRAME") {
                    prev = prev.previousElementSibling
                }
                if (prev?.nodeName === "TURBO-FRAME") {
                    cur.classList.remove("active-object")
                    prev.querySelector("li:last-of-type").classList.add("active-object")
                    // prettier-ignore
                    if (!prev.querySelector("li:last-of-type").classList.contains('tags-non-modified')
                        || prev.querySelector("li:last-of-type").classList.contains('location-modified')
                        || prev.querySelector("li:last-of-type")?.querySelector('.nodes-changed, .members-changed')
                        || e.altKey
                        || !location.search.includes("changesets=")) {
                        trustedEvent = false
                        prev.querySelector("li:last-of-type").click()
                        if (changesetObjectsSelectionModeEnabled) {
                            prev.querySelector("li:last-of-type input")?.focus()
                        }
                        trustedEvent = true
                        prev.querySelector("li:last-of-type").scrollIntoView({
                            block: "center",
                            behavior: smoothScroll,
                        })
                        resetMapHover()
                        prev.querySelector("li:last-of-type").classList.add("map-hover")
                        if (prev.querySelector("li:last-of-type").querySelector(".load-relation-version")) {
                            prev.querySelector("li:last-of-type").querySelector(".load-relation-version").focus()
                        }
                        return
                    }
                }
            }
        }

        if (cur === document.querySelector("ul .active-object")) {
            cur.classList.remove("active-object")
            prev.classList.add("active-object")
            return
        }
    }
}

function goToNextChangesetObject(e) {
    repeatedEvent = e.repeat
    if (document.querySelector("#sidebar").matches(":hover")) {
        preventHoverEvents()
    }
    if (!document.querySelector("ul .active-object")) {
        document
            .querySelector("#changeset_nodes li:not(.page-item), #changeset_ways li:not(.page-item), #changeset_relations li:not(.page-item)")
            .classList.add("active-object")
        trustedEvent = false
        document.querySelector("ul .active-object").click()
        if (changesetObjectsSelectionModeEnabled) {
            document.querySelector("ul .active-object input")?.focus()
        }
        trustedEvent = true
        resetMapHover()
        document.querySelector("ul .active-object").classList.add("map-hover")
        return
    }
    const prev = document.querySelector("ul .active-object")
    for (let i = 0; i < 10000; i++) {
        const cur = document.querySelector("ul .active-object")
        if (cur.nextElementSibling) {
            cur.nextElementSibling.classList.add("active-object")
            cur.classList.remove("active-object")
            // prettier-ignore
            if (!cur.nextElementSibling.classList.contains('tags-non-modified')
                || cur.nextElementSibling.classList.contains('location-modified')
                || cur.nextElementSibling.querySelector('.nodes-changed, .members-changed')
                || e.altKey
                || !location.search.includes("changesets=")) {
                trustedEvent = false
                cur.nextElementSibling.click()
                if (changesetObjectsSelectionModeEnabled) {
                    cur.nextElementSibling.querySelector("input")?.focus()
                }
                trustedEvent = true
                cur.nextElementSibling.scrollIntoView({ block: "center", behavior: smoothScroll })
                resetMapHover()
                cur.nextElementSibling.classList.add("map-hover")
                if (cur.nextElementSibling.querySelector(".load-relation-version")) {
                    cur.nextElementSibling.querySelector(".load-relation-version").focus()
                }
                return
            }
        } else {
            const curFrame = cur.parentElement.parentElement
            if (
                // prettier-ignore
                (curFrame.id === "changeset_ways" && ["changeset_nodes", "changeset_relations"].includes(curFrame.nextElementSibling?.id))
                || (curFrame.id === "changeset_relations" && ["changeset_nodes"].includes(curFrame.nextElementSibling?.id))
            ) {
                cur.classList.remove("active-object")
                curFrame.nextElementSibling.querySelector("#changeset_nodes li, #changeset_relations li").classList.add("active-object")
                // prettier-ignore
                if (!curFrame.nextElementSibling.querySelector(".active-object").classList.contains('tags-non-modified')
                    || curFrame.nextElementSibling.querySelector(".active-object").classList.contains('location-modified')
                    || curFrame.nextElementSibling.querySelector(".active-object").querySelector('.nodes-changed, .members-changed')
                    || e.altKey
                    || !location.search.includes("changesets=")) {
                    trustedEvent = false
                    curFrame.nextElementSibling.querySelector(".active-object").click()
                    if (changesetObjectsSelectionModeEnabled) {
                        curFrame.nextElementSibling.querySelector(".active-object input")?.focus()
                    }
                    trustedEvent = true
                    curFrame.nextElementSibling.querySelector(".active-object").scrollIntoView({
                        block: "center",
                        behavior: smoothScroll,
                    })

                    resetMapHover()
                    curFrame.nextElementSibling.querySelector(".active-object").classList.add("map-hover")
                    if (curFrame.nextElementSibling?.querySelector(".load-relation-version")) {
                        curFrame.nextElementSibling.querySelector(".load-relation-version").focus()
                    }
                    if (curFrame.id === "changeset_relations") {
                        document.activeElement.blur()
                    }
                    return
                }
            } else {
                let next = curFrame?.nextElementSibling?.nextElementSibling
                if (next?.nodeName !== "TURBO-FRAME" && next?.nextElementSibling?.nodeName === "TURBO-FRAME") {
                    next = next.nextElementSibling
                }
                if (next?.nodeName === "TURBO-FRAME") {
                    cur.classList.remove("active-object")
                    next.querySelector("li").classList.add("active-object")
                    // prettier-ignore
                    if (!next.querySelector("li").classList.contains('tags-non-modified')
                        || next.querySelector("li").classList.contains('location-modified')
                        || next.querySelector("li")?.querySelector('.nodes-changed, .members-changed')
                        || e.altKey
                        || !location.search.includes("changesets=")) {
                        trustedEvent = false
                        next.querySelector("li").click()
                        if (changesetObjectsSelectionModeEnabled) {
                            next.querySelector("li input")?.focus()
                        }
                        trustedEvent = true
                        next.querySelector("li").scrollIntoView({ block: "center", behavior: smoothScroll })
                        resetMapHover()
                        next.querySelector("li").classList.add("map-hover")
                        if (next.querySelector("li").querySelector(".load-relation-version")) {
                            next.querySelector("li").querySelector(".load-relation-version").focus()
                        }
                        return
                    }
                }
            }
        }

        if (cur === document.querySelector("ul .active-object")) {
            cur.classList.remove("active-object")
            prev.classList.add("active-object")
            return
        }
    }
    console.log("KeyL not found next elem")
}

function extractBboxFromElem(elem) {
    const bbox = JSON.parse(elem.getAttribute("data-changeset")).bbox
    bbox.min_lon = bbox.minlon
    bbox.min_lat = bbox.minlat
    bbox.max_lon = bbox.maxlon
    bbox.max_lat = bbox.maxlat
    return bbox
}

function preventHoverEvents() {
    if (document.querySelector("#mouse-trap")) return

    console.log("add mouse trap")
    const trap = document.createElement("div")
    trap.id = "mouse-trap"
    document.body.appendChild(trap)

    window.addEventListener(
        "mousemove",
        () => {
            trap.remove()
            console.log("remove mouse trap")
        },
        { once: true },
    )
}

function goToPrevChangeset() {
    if (!document.querySelector("ol .active-object")) {
        return
    }
    preventHoverEvents()

    const cur = document.querySelector("ol .active-object")
    let prev = cur.previousElementSibling
    while (true) {
        if (!prev) break
        if (prev.getAttribute("hidden") === "true") {
            prev = prev.previousElementSibling
        } else {
            break
        }
    }

    if (prev) {
        prev.classList.add("active-object")
        cur.classList.remove("active-object")
        let focused = prev.querySelector("a")
        focused.focus()
        if (massModeForUserChangesetsActive) {
            focused = prev.querySelector("input")
            focused?.focus()
        }
        resetSelectedChangesets()
        prev.classList.add("selected")
        prev.scrollIntoView({ block: "center", behavior: "instant" })
        cleanObjectsByKey("changesetBounds")

        setTimeout(() => {
            const bound = drawBBox(extractBboxFromElem(prev), { color: "#000000", weight: 4, fillOpacity: 0 })
            bound.bringToFront()
            focused.addEventListener(
                "focusout",
                () => {
                    bound.remove()
                },
                { once: true },
            )
            setTimeout(() => {
                bound.bringToFront()
            }, 20)
        })
    } else {
        document.querySelector('.changeset_more a[href*="after"]')?.click()
    }
}

function goToNextChangeset() {
    preventHoverEvents()
    if (!document.querySelector("ol .active-object")) {
        let next = document.querySelector("ol li")
        while (true) {
            if (next?.getAttribute("hidden") === "true") {
                next = next.nextElementSibling
            } else {
                break
            }
        }
        if (!next) {
            document.querySelector('.changeset_more a[href*="before"]')?.click()
            return
        }
        next.classList.add("active-object")
        document.querySelector("ol .active-object a").tabIndex = 0
        let focused = document.querySelector("ol .active-object a")
        focused.focus()
        if (massModeForUserChangesetsActive) {
            focused = document.querySelector("ol .active-object input")
            focused?.focus()
        }
        resetSelectedChangesets()
        document.querySelector("ol .active-object").classList.add("selected")
        cleanObjectsByKey("changesetBounds")
        const bound = drawBBox(extractBboxFromElem(document.querySelector("ol .active-object")), {
            color: "#000000",
            weight: 4,
            fillOpacity: 0,
        })
        bound.bringToFront()
        focused.addEventListener(
            "focusout",
            () => {
                bound.remove()
            },
            { once: true },
        )
        return
    }
    const cur = document.querySelector("ol .active-object")
    let next = cur.nextElementSibling
    while (true) {
        if (!next) break
        if (next.getAttribute("hidden") === "true") {
            next = next.nextElementSibling
        } else {
            break
        }
    }
    if (next) {
        next.classList.add("active-object")
        cur.classList.remove("active-object")
        let focused = next.querySelector("a")
        focused.focus()
        if (massModeForUserChangesetsActive) {
            focused = next.querySelector("input")
            focused?.focus()
        }
        resetSelectedChangesets()
        next.classList.add("selected")
        next.scrollIntoView({ block: "center", behavior: "instant" })
        cleanObjectsByKey("changesetBounds")

        setTimeout(() => {
            const bound = drawBBox(extractBboxFromElem(next), { color: "#000000", weight: 4, fillOpacity: 0 })
            bound.bringToFront()
            focused.addEventListener(
                "focusout",
                () => {
                    bound.remove()
                },
                { once: true },
            )
            setTimeout(() => {
                bound.bringToFront()
            }, 20)
        })
    } else {
        document.querySelector('.changeset_more a[href*="before"]')?.click()
    }
}

function extractLatLonFromElem(elem) {
    return [elem.getAttribute("data-lat"), elem.getAttribute("data-lon")]
}

function goToPrevSearchResult() {
    if (!document.querySelector("#sidebar_content ul .active-object")) {
        return
    }
    preventHoverEvents()

    const cur = document.querySelector("#sidebar_content ul .active-object")
    let prev = cur.previousElementSibling
    while (true) {
        if (!prev) break
        if (prev.getAttribute("hidden") === "true") {
            prev = prev.previousElementSibling
        } else {
            break
        }
    }
    if (!prev) {
        if (cur.parentElement.previousElementSibling.tagName === "UL") {
            prev = cur.parentElement.previousElementSibling?.querySelector("li:last-of-type")
        }
    }
    if (prev) {
        prev.classList.add("active-object")
        cur.classList.remove("active-object")
        const focused = prev.querySelector("a")
        focused.focus()
        prev.scrollIntoView({ block: "center", behavior: "instant" })

        const [lat, lon] = extractLatLonFromElem(document.querySelector("#sidebar_content ul .active-object a"))
        const marker = showNodeMarker(lat, lon)
        panTo(lat, lon)
        focused.addEventListener(
            "focusout",
            () => {
                marker.remove()
            },
            { once: true },
        )
    }
}

function goToNextSearchResult() {
    preventHoverEvents()
    if (!document.querySelector("#sidebar_content ul .active-object")) {
        document.querySelector("#sidebar_content ul li").classList.add("active-object")
        document.querySelector("#sidebar_content ul .active-object a").tabIndex = 0
        const focused = document.querySelector("#sidebar_content .active-object a")
        focused.focus()
        const [lat, lon] = extractLatLonFromElem(document.querySelector("#sidebar_content ul .active-object a"))
        const marker = showNodeMarker(lat, lon)
        panTo(lat, lon)
        focused.addEventListener(
            "focusout",
            () => {
                marker.remove()
            },
            { once: true },
        )
        return
    }
    const cur = document.querySelector("#sidebar_content ul .active-object")
    let next = cur.nextElementSibling
    while (true) {
        if (!next) break
        if (next.getAttribute("hidden") === "true") {
            next = next.nextElementSibling
        } else {
            break
        }
    }
    if (!next) {
        if (cur.parentElement.nextElementSibling.tagName === "UL") {
            next = cur.parentElement.nextElementSibling?.querySelector("li")
        }
    }
    if (next) {
        next.classList.add("active-object")
        cur.classList.remove("active-object")
        const focused = next.querySelector("a")
        focused.focus()
        resetSelectedChangesets()
        next.scrollIntoView({ block: "center", behavior: "instant" })

        const [lat, lon] = extractLatLonFromElem(document.querySelector("#sidebar_content ul .active-object a"))
        const marker = showNodeMarker(lat, lon)
        panTo(lat, lon)

        focused.addEventListener(
            "focusout",
            () => {
                marker.remove()
            },
            { once: true },
        )
    } else {
        document.querySelector(".search_more a")?.click()
    }
}

function goToPrevObjectVersion() {
    if (document.querySelector("#sidebar").matches(":hover")) {
        preventHoverEvents()
    }
    if (!document.querySelector("#sidebar_content .active-object")) {
        getMap()?.invalidateSize()
        document.querySelector("#element_versions_list > div:not(.hidden-version)").classList.add("active-object")
        document.querySelector("#element_versions_list > div:not(.hidden-version)").click()
        resetMapHover()
        document.querySelector("#element_versions_list > div:not(.hidden-version)").classList.add("map-hover")
    } else {
        const old = document.querySelector("#element_versions_list > div.active-object")
        let cur = old?.previousElementSibling
        while (cur && (!cur.classList.contains("browse-section") || cur.classList.contains("hidden-version"))) {
            cur = cur.previousElementSibling
        }
        if (cur) {
            cur.classList.add("active-object")
            old.classList.remove("active-object")
            cur.click()
            cur.scrollIntoView()
            resetMapHover()
            cur.classList.add("map-hover")
        }
    }
}

function goToNextObjectVersion() {
    if (document.querySelector("#sidebar").matches(":hover")) {
        preventHoverEvents()
    }
    if (!document.querySelector("#sidebar_content .active-object")) {
        getMap()?.invalidateSize()
        document.querySelector("#element_versions_list > div").classList.add("active-object")
        document.querySelector("#element_versions_list > div.active-object").click()
        resetMapHover()
        document.querySelector("#element_versions_list > div.active-object").classList.add("map-hover")
    } else {
        const old = document.querySelector("#element_versions_list > div.active-object")
        let cur = old?.nextElementSibling
        while (cur && (!cur.classList.contains("browse-section") || cur.classList.contains("hidden-version"))) {
            cur = cur.nextElementSibling
        }
        if (cur) {
            cur.classList.add("active-object")
            old.classList.remove("active-object")
            cur.click()
            cur.scrollIntoView()
            resetMapHover()
            cur.classList.add("map-hover")
        }
    }
}

function combineBBOXes(bboxes) {
    const bbox = {
        min_lat: 10000000,
        min_lon: 10000000,
        max_lat: -10000000,
        max_lon: -100000000,
    }
    for (const i of bboxes) {
        if (i?.min_lat) {
            bbox.min_lat = min(bbox.min_lat, i.min_lat)
            bbox.min_lon = min(bbox.min_lon, i.min_lon)
            bbox.max_lat = max(bbox.max_lat, i.max_lat)
            bbox.max_lon = max(bbox.max_lon, i.max_lon)
        }
    }
    return bbox
}

async function zoomToChangesets() {
    const params = new URLSearchParams(location.search)
    const changesetIDs = params.get("changesets")?.split(",")
    if (!changesetIDs) {
        return
    }

    for (const i of changesetIDs) {
        await loadChangesetMetadata(parseInt(i))
    }
    getMap()?.invalidateSize()
    const bbox = combineBBOXes(changesetIDs.map(i => changesetMetadatas[i]))
    fitBounds([
        [bbox.min_lat, bbox.min_lon],
        [bbox.max_lat, bbox.max_lon],
    ])
}

let shiftKeyZClicks = 0
let ZoomToObjectClicks = 0

function resetZoomClicks() {
    ZoomToObjectClicks = 0
}

function setupZKeysReseter() {
    window.addEventListener("mousemove", resetZoomClicks, { once: true })
}

function zoomToCurrentObject(e) {
    if (new URLSearchParams(location.search).has("changesets")) {
        void zoomToChangesets()
    } else if (location.pathname.startsWith("/changeset")) {
        const changesetMetadata = changesetMetadatas[location.pathname.match(/changeset\/(\d+)/)[1]]
        if (e.shiftKey && changesetMetadata) {
            setTimeout(async () => {
                // todo changesetID => merged BBOX
                const changesetID = parseInt(location.pathname.match(/changeset\/(\d+)/)[1])
                const nodesBag = []
                for (const node of Array.from((await getChangeset(changesetID)).data.querySelectorAll("node"))) {
                    if (node.getAttribute("visible") !== "false") {
                        nodesBag.push({
                            lat: parseFloat(node.getAttribute("lat")),
                            lon: parseFloat(node.getAttribute("lon")),
                        })
                    } else {
                        const version = searchVersionByTimestamp(
                            await getNodeHistory(node.getAttribute("id")),
                            new Date(new Date(changesetMetadata.created_at).getTime() - 1).toISOString(),
                        )
                        if (version && version.visible !== false) {
                            nodesBag.push({
                                lat: version.lat,
                                lon: version.lon,
                            })
                        }
                    }
                }
                if ((await getChangeset(changesetID)).data.querySelectorAll("relation").length && shiftKeyZClicks % 2 === 1) {
                    for (const way of (await getChangeset(changesetID)).data.querySelectorAll("way")) {
                        const targetTime =
                            way.getAttribute("visible") === "false"
                                ? new Date(new Date(changesetMetadata.created_at).getTime() - 1).toISOString()
                                : changesetMetadata.closed_at
                        try {
                            const [, currentNodesList] = await getWayNodesByTimestamp(targetTime, way.getAttribute("id"))
                            currentNodesList.forEach(coords => {
                                nodesBag.push({
                                    lat: coords[0],
                                    lon: coords[1],
                                })
                            })
                        } catch (e) {
                            console.error(e)
                        }
                    }
                }
                getMap()?.invalidateSize()
                if (nodesBag.length) {
                    const bbox = {
                        min_lat: Math.min(...nodesBag.map(i => i.lat)),
                        min_lon: Math.min(...nodesBag.map(i => i.lon)),
                        max_lat: Math.max(...nodesBag.map(i => i.lat)),
                        max_lon: Math.max(...nodesBag.map(i => i.lon)),
                    }
                    fitBounds([
                        [bbox.min_lat, bbox.min_lon],
                        [bbox.max_lat, bbox.max_lon],
                    ]) // todo max zoom
                } else {
                    fitBounds([
                        [changesetMetadata.min_lat, changesetMetadata.min_lon],
                        [changesetMetadata.max_lat, changesetMetadata.max_lon],
                    ])
                }
            })
        } else {
            getMap()?.invalidateSize()
            if (changesetMetadata) {
                fitBounds([
                    [changesetMetadata.min_lat, changesetMetadata.min_lon],
                    [changesetMetadata.max_lat, changesetMetadata.max_lon],
                ])
            } else {
                console.warn("Changeset metadata not downloaded")
            }
        }
    } else if (location.pathname.match(/(node|way|relation|note)\/\d+/)) {
        if (location.pathname.includes("node")) {
            if (nodeMetadata) {
                panTo(nodeMetadata.lat, nodeMetadata.lon, 18 + ZoomToObjectClicks++, ZoomToObjectClicks !== 1)
                setupZKeysReseter()
            } else {
                if (location.pathname.includes("history")) {
                    // panTo last visible version
                    // prettier-ignore
                    panTo(
                        document.querySelector("#element_versions_list > div span.latitude").textContent.replace(",", "."),
                        document.querySelector("#element_versions_list > div span.longitude").textContent.replace(",", "."),
                        18 + ZoomToObjectClicks++
                    )
                }
                setupZKeysReseter()
            }
        } else if (location.pathname.includes("note")) {
            if (!document.querySelector('#sidebar_content a[href*="/traces/"]') || !trackMetadata) {
                if (noteMetadata) {
                    const zoom = getZoom()
                    ZoomToObjectClicks++
                    if (zoom + ZoomToObjectClicks > 19) {
                        enableOverzoom()
                    }
                    if (ZoomToObjectClicks === 1) {
                        // prettier-ignore
                        panTo(
                            noteMetadata.geometry.coordinates[1],
                            noteMetadata.geometry.coordinates[0],
                            max(17, zoom),
                        )
                    } else {
                        // prettier-ignore
                        panTo(
                            noteMetadata.geometry.coordinates[1],
                            noteMetadata.geometry.coordinates[0],
                            max(min(overzoomObserver ? 22 : 19, max(zoom + 1, 17 + ZoomToObjectClicks - 1)), zoom),
                            true,
                        )
                    }
                    setupZKeysReseter()
                }
            } else if (trackMetadata) {
                fitBounds([
                    [trackMetadata.min_lat, trackMetadata.min_lon],
                    [trackMetadata.max_lat, trackMetadata.max_lon],
                ])
            }
        } else if (location.pathname.includes("way")) {
            if (wayMetadata) {
                fitBounds([
                    [wayMetadata.bbox.min_lat, wayMetadata.bbox.min_lon],
                    [wayMetadata.bbox.max_lat, wayMetadata.bbox.max_lon],
                ])
            }
        } else if (location.pathname.includes("relation")) {
            if (relationMetadata) {
                const viaNodes = relationMetadata.relation.members
                    .filter(m => m.role === "via")
                    .flatMap(m => {
                        if (m.type === "node") {
                            return m
                        } else {
                            return m.geometry
                        }
                    })
                if (e.code === "KeyZ" && e.shiftKey) {
                    fitBounds([
                        [Math.min(...viaNodes.map(i => i.lat)), Math.min(...viaNodes.map(i => i.lon))],
                        [Math.max(...viaNodes.map(i => i.lat)), Math.max(...viaNodes.map(i => i.lon))],
                    ])
                } else {
                    fitBounds([
                        [relationMetadata.bbox.min_lat, relationMetadata.bbox.min_lon],
                        [relationMetadata.bbox.max_lat, relationMetadata.bbox.max_lon],
                    ])
                }
            }
        }
    } else if (location.search.includes("&display-gpx=")) {
        if (trackMetadata) {
            fitBounds([
                [trackMetadata.min_lat, trackMetadata.min_lon],
                [trackMetadata.max_lat, trackMetadata.max_lon],
            ])
        }
    } else if (searchResultBBOX) {
        fitBounds([
            [searchResultBBOX.min_lat, searchResultBBOX.min_lon],
            [searchResultBBOX.max_lat, searchResultBBOX.max_lon],
        ])
    } else if (trackMetadata) {
        fitBounds([
            [trackMetadata.min_lat, trackMetadata.min_lon],
            [trackMetadata.max_lat, trackMetadata.max_lon],
        ])
    }
}

function nextVectorLayer() {
    const currentLayersIsVector = vectorLayerEnabled()
    const hashParams = new URLSearchParams(location.hash)
    if (currentLayersIsVector) {
        if (getCurrentLayers().includes("S")) {
            hashParams.set("layers", (hashParams.get("layers") ?? "").replace("S", "").replace("V", "") + "M")
        } else {
            hashParams.set("layers", (hashParams.get("layers") ?? "").replace("V", "") + "S")
        }
    } else {
        hashParams.set("layers", (hashParams.get("layers") ?? "") + "V")
    }
    location.hash = hashParams.toString()
}

async function openObjectInJosmOrLevel0(e) {
    const m = location.pathname.match(/\/(node|way|relation)\/([0-9]+)/)
    if (!m) return
    const [, type, id] = m
    const shortType = type === "node" ? "n" : type === "way" ? "w" : "r"
    if (e.altKey) {
        if (osm_server !== prod_server) {
            alert(t("actions.level0WorksOnlyOnOsmOrg"))
            return
        }
        window.open(
            level0Instance +
                "/?" +
                new URLSearchParams({
                    url: shortType + id + "!",
                }).toString(),
        )
    } else {
        if (!(await validateOsmServerInJOSM())) {
            return
        }
        window.open(
            "http://localhost:8111/load_object?" +
                new URLSearchParams({
                    objects: [shortType + id],
                    relation_members: true,
                }).toString(),
        )
    }
}

async function openSelectedObjectsOnChangesetPage(e) {
    const nodes = new Set()
    const ways = new Set()
    const relations = new Set()

    const changesetID = parseInt(location.pathname.match(/changeset\/(\d+)/)[1])
    const changesetData = (await getChangeset(changesetID)).data

    function processChangeset(data) {
        if (changesetObjectsSelectionModeEnabled) {
            document.querySelectorAll("#changeset_nodes input[type=checkbox]:checked").forEach(n => {
                nodes.add(parseInt(n.parentElement.nextElementSibling.id.match(/[0-9]+n([0-9]+)/)[1]))
            })
            document.querySelectorAll("#changeset_ways input[type=checkbox]:checked").forEach(w => {
                ways.add(parseInt(w.parentElement.nextElementSibling.id.match(/[0-9]+w([0-9]+)/)[1]))
            })
            document.querySelectorAll("#changeset_relations input[type=checkbox]:checked").forEach(r => {
                relations.add(parseInt(r.parentElement.nextElementSibling.id.match(/[0-9]+r([0-9]+)/)[1]))
            })
        } else {
            Array.from(data.querySelectorAll("node")).map(i => nodes.add(parseInt(i.getAttribute("id"))))
            Array.from(data.querySelectorAll("way")).map(i => ways.add(parseInt(i.getAttribute("id"))))
            Array.from(data.querySelectorAll("relation")).map(i => relations.add(parseInt(i.getAttribute("id"))))
        }
    }

    processChangeset(changesetData)

    if (location.search.includes("changesets=")) {
        const params = new URLSearchParams(location.search)
        const changesetIDs =
            params
                .get("changesets")
                ?.split(",")
                ?.filter(i => i !== changesetID) ?? []
        await Promise.all(
            changesetIDs.map(async i => {
                if (i === changesetID) return
                processChangeset((await getChangeset(i)).data)
            }),
        )
    }

    if (e.altKey) {
        if (osm_server !== prod_server) {
            alert(t("actions.level0WorksOnlyOnOsmOrg"))
            return
        }
        // prettier-ignore
        window.open(level0Instance + "/?" + new URLSearchParams({
            url: [
                Array.from(nodes).map(i => "n" + i).join(","),
                Array.from(ways).map(i => "w" + i + (e.shiftKey ? "!" : "")).join(","),
                Array.from(relations).map(i => "r" + i).join(",")
            ].join(",").replace(/,,/, ",").replace(/,$/, "").replace(/^,/, "")
        }).toString())
    } else {
        const prefix = isMobile ? "josm:/load_object?" : "http://localhost:8111/load_object?"
        if (!isMobile && !(await validateOsmServerInJOSM())) {
            return
        }
        // prettier-ignore
        const params = {
            objects: [
                Array.from(nodes).map(i => "n" + i).join(","),
                Array.from(ways).map(i => "w" + i).join(","),
                Array.from(relations).map(i => "r" + i).join(",")
            ].join(",")
        }
        if (!isMobile) {
            params.new_layer = "true"
        }
        window.open(prefix + new URLSearchParams(params).toString())
    }
}

let defaultZoomKeysBehaviour = false

function shouldSkipHotkeyForActiveElement(e) {
    if (document.activeElement?.name === "text") return true
    if (document.activeElement?.nodeName === "INPUT" && ["input", "text"].includes(document.activeElement.getAttribute("type"))) {
        if (e.code === "Escape") {
            document.activeElement.blur()
        }
        return true
    }
    if (document.activeElement?.nodeName === "TEXTAREA" && e.code === "Enter") {
        if (document.activeElement.parentElement?.parentElement?.querySelector(".btn-wrapper")) {
            if (e.metaKey || e.ctrlKey) {
                document.activeElement.parentElement.parentElement.querySelector(".btn-wrapper .btn-primary").click()
                return true
            }
        }
    }
    if (
        ["TEXTAREA", "INPUT", "SELECT"].includes(document.activeElement?.nodeName) &&
        document.activeElement?.getAttribute("type") !== "checkbox" &&
        document.activeElement?.getAttribute("type") !== "radio"
    ) {
        return true
    }
    if (document.activeElement?.getAttribute("contenteditable")) {
        return true
    }
    if (
        ["TH", "TD"].includes(document.activeElement?.nodeName) &&
        document.activeElement?.parentElement?.parentElement?.parentElement?.hasAttribute("contenteditable")
    ) {
        return true
    }
    if (
        ["TR"].includes(document.activeElement?.nodeName) &&
        document.activeElement?.parentElement?.parentElement?.hasAttribute("contenteditable")
    ) {
        return true
    }
    return false
}

function handleMeasuringHotkeys(e) {
    if (measuring) {
        if (((e.ctrlKey || e.metaKey) && e.code === "KeyZ") || e.code === "Backspace" || e.code === "Delete") {
            if (currentMeasuring.way.length) {
                currentMeasuring.way.pop()
                currentMeasuring.nodes.pop()?.remove()
                currentMeasuring.tempLine?.remove()
                currentMeasuring.wayLine?.remove()
                if (currentMeasuring.way.length) {
                    currentMeasuring.wayLine = displayWay(currentMeasuring.way, false, "#000000", 1)
                    currentMeasuring.tempLine = displayWay(
                        [currentMeasuring.way[currentMeasuring.way.length - 1], lastLatLng],
                        false,
                        "#000000",
                        1,
                    )
                }
            }
            return true
        } else if (e.code === "Escape") {
            endMeasuring()
            return true
        }
        return false
    } else if (prevMeasurements.length && e.code === "Escape") {
        if (confirm("Clean measurements?")) {
            cleanMeasurements(e)
        }
        return true
    }
    return false
}

function handleRelationViewerHotkeys(e) {
    if (!document.activeElement?.classList?.contains("relation-viewer-a")) {
        return false
    }
    if (e.code !== "ArrowDown" && e.code !== "ArrowUp") {
        return false
    }
    e.preventDefault()
    e.stopPropagation()
    e.stopImmediatePropagation()
    if (e.code === "ArrowDown") {
        e.target.parentElement.nextElementSibling?.querySelector("a")?.focus()
    } else if (e.code === "ArrowUp") {
        e.target.parentElement.previousElementSibling?.querySelector("a")?.focus()
    }
    return true
}

function actionOpenYandexPanoramas() {
    const [x, y, z] = getCurrentXYZ()
    window.open(`https://yandex.ru/maps/?l=stv,sta&ll=${y},${x}&z=${z}`, "_blank", "noreferrer")
}

function actionCopyCurrentShortLink() {
    navigator.clipboard.writeText(shortOsmOrgLinksInText(location.origin + location.pathname))
}

function actionCloseUi() {
    buildingViewerIframe?.remove()
    buildingViewerIframe = null
    if (document.querySelector("#osm_alert_modal")?.checkVisibility()) {
        document.querySelector("#osm_alert_modal .btn-close").click()
    } else {
        document.querySelectorAll(".sidebar-close-controls .btn-close").forEach(i => i?.click())
        document.querySelector(".welcome .btn-close")?.click()
        document.querySelector("#banner .btn-close")?.click()
        document.querySelector(".better-btn-close")?.click()
    }
}

function actionClearActiveObjectsAndContextMenus() {
    cleanObjectsByKey("activeObjects")
    document.querySelectorAll(".betterOsmContextMenu").forEach(i => i.remove())
}

function actionGoToUserLocation() {
    document.querySelector(".control-locate .control-button").click()
}

function actionToggleSwitchableTime() {
    document.querySelector("time[switchable]")?.click()
}

function actionOpenUserTraces() {
    document.querySelector('a[href="/traces/mine"], a[href$="/traces"]:not(.nav-link):not(.dropdown-item)')?.click()
}

function actionToggleCompactMode() {
    document.querySelector(".quick-look-compact-toggle-btn")?.click()
    document.querySelector(".compact-toggle-btn")?.click()
    actionToggleSwitchableTime()
}

function actionOpenOverpassSearch() {
    setTimeout(async () => {
        await interceptMapManually()
        try {
            getMap().getBounds()
        } catch (e) {
            alert("Try to reload tab")
        }
        let message = `Type overpass selector. Examples:
\tkey
\tpanoramax|mapillary|wikimedia_commons|...

\tkey=value
\tkey~val,i
\t~key1|key2|...

`
        const currentUser = decodeURI(
            document
                .querySelector('.user-menu [href^="/user/"]')
                ?.getAttribute("href")
                ?.match(/\/user\/(.*)$/)?.[1] ?? "",
        )
        if (currentUser) {
            message += currentUser.match(/^[a-zA-Z0-9_]+$/) ? `\n\tnode(user:${currentUser})` : `\n\tnode(user:"${currentUser}")`
        }
        message += `
\tway[footway=crossing](if: length() > 150)

End with ! for global search

Objects can be hidden from map by pressing Alt key.

⚠this is a simple prototype of search`
        const query = prompt(message, await GM.getValue("lastOverpassQuery", ""))
        if (query) {
            insertOverlaysStyles()
            await processOverpassQuery(query)
        }
    }, 0)
}

function isUserPageWithoutHistory() {
    return location.pathname.includes("/user/") && !location.pathname.includes("/history")
}

function isChangesetPage() {
    return location.pathname.startsWith("/changeset") && !location.pathname.includes("/changeset_comments")
}

function isObjectPage() {
    return /^\/(node|way|relation)\/\d+/.test(location.pathname)
}

function isObjectHistoryPage() {
    return /^\/(node|way|relation)\/\d+\/history/.test(location.pathname)
}

function isSearchPage() {
    return location.pathname === "/search"
}

function isHistoryPage() {
    return location.pathname.includes("/history")
}

function isFilteredHistoryPage() {
    return isHistoryPage() && (location.search.includes("after") || location.search.includes("before"))
}

function isHomeOrNotePage() {
    return location.pathname === "/" || location.pathname.includes("/note")
}

function isMainHotkeyPage() {
    return isHomeOrNotePage() || isSearchPage() || isChangesetPage() || isObjectPage() || isHistoryPage()
}

const hotkeyHelpContextsOrder = [
    "All pages",
    "User pages",
    "Changeset pages",
    "Object pages",
    "History pages",
    "Search page",
    "Main pages",
    "Debug",
]

const recentHotkeyActionsStorageKey = "recentHotkeyActions"
const recentHotkeyActionsLimit = 3

function getCurrentHotkeyContexts() {
    const contexts = new Set(["All pages"])

    if (isMainHotkeyPage()) {
        contexts.add("Main pages")
    }
    if (location.pathname.startsWith("/changeset")) {
        contexts.add("Changeset pages")
    }
    if (isObjectPage()) {
        contexts.add("Object pages")
    }
    if (isHistoryPage()) {
        contexts.add("History pages")
    }
    if (isSearchPage()) {
        contexts.add("Search page")
    }
    if (
        location.pathname.includes("/user/") ||
        /^\/user_blocks($|\/)/.test(location.pathname) ||
        /^\/blocks_by\/?$/.test(location.pathname)
    ) {
        contexts.add("User pages")
    }
    if (isDebug()) {
        contexts.add("Debug")
    }
    if (contexts.size === 1 || (contexts.size === 2 && contexts.has("Debug"))) {
        contexts.add("Main pages")
    }

    return hotkeyHelpContextsOrder.filter(context => contexts.has(context))
}

function actionShowHotkeysHelp() {
    void showHotkeyCommandsPopup()
}

function getHotkeyKeyByBaseCode(baseCode, shiftKey = false) {
    if (/^Key[A-Z]$/.test(baseCode)) {
        const letter = baseCode.slice(3)
        return shiftKey ? letter : letter.toLowerCase()
    }
    if (/^Digit\d$/.test(baseCode)) {
        return baseCode.slice(5)
    }

    return (
        {
            Escape: "Escape",
            F1: "F1",
            Slash: shiftKey ? "?" : "/",
            Backquote: shiftKey ? "~" : "`",
            Minus: shiftKey ? "_" : "-",
            Equal: shiftKey ? "+" : "=",
            Comma: shiftKey ? "<" : ",",
            Period: shiftKey ? ">" : ".",
        }[baseCode] ?? baseCode
    )
}

function formatHotkeyBinding(binding) {
    if (!binding) {
        return ""
    }

    const parts = binding.split("+")
    const baseCode = parts[parts.length - 1]
    const modifiers = parts.slice(0, -1)

    const displayBaseCode = /^Key[A-Z]$/.test(baseCode)
        ? baseCode.slice(3)
        : /^Digit\d$/.test(baseCode)
          ? baseCode.slice(5)
          : getHotkeyKeyByBaseCode(baseCode)

    return [...modifiers, displayBaseCode].join(" + ")
}

function createSyntheticHotkeyEvent(binding) {
    const parts = binding.split("+")
    const baseCode = parts[parts.length - 1]
    const modifiers = new Set(parts.slice(0, -1))
    const shiftKey = modifiers.has("Shift")

    return {
        code: baseCode,
        key: getHotkeyKeyByBaseCode(baseCode, shiftKey),
        ctrlKey: modifiers.has("Ctrl"),
        altKey: modifiers.has("Alt"),
        shiftKey,
        metaKey: modifiers.has("Meta"),
        repeat: false,
        preventDefault() {},
        stopPropagation() {},
        stopImmediatePropagation() {},
    }
}

function mergeAvailableHotkeyCommands(commands) {
    /** @type {Map<string, typeof commands[number] & { bindings: string[] }>} */
    const mergedCommands = new Map()

    commands.forEach(command => {
        const existingCommand = mergedCommands.get(command.actionId)
        if (existingCommand) {
            if (command.binding) {
                existingCommand.bindings.push(command.binding)
            }
            return
        }

        mergedCommands.set(command.actionId, {
            ...command,
            bindings: command.binding ? [command.binding] : [],
        })
    })

    return Array.from(mergedCommands.values()).sort((a, b) => {
        const aContextIndex = hotkeyHelpContextsOrder.findIndex(context => a.contexts.includes(context))
        const bContextIndex = hotkeyHelpContextsOrder.findIndex(context => b.contexts.includes(context))
        return aContextIndex - bContextIndex || a.title.localeCompare(b.title) || (a.bindings[0] || "").localeCompare(b.bindings[0] || "")
    })
}

function getAvailableHotkeyCommandsForCurrentPage() {
    const currentContexts = getCurrentHotkeyContexts()

    const commands = Object.entries(hotkeyActions).flatMap(([actionId, action]) => {
        if (isMobile && action.hideOnMobile) {
            return []
        }
        if (!action.contexts.some(context => currentContexts.includes(context))) {
            return []
        }

        const bindings = action.defaultBindings.length ? action.defaultBindings : [""]

        return bindings.flatMap(binding => {
            const event = binding ? createSyntheticHotkeyEvent(binding) : undefined
            if (action.when && !action.when(event)) {
                return []
            }
            return [
                {
                    actionId,
                    title: action.title,
                    binding,
                    event,
                    contexts: action.contexts.filter(context => currentContexts.includes(context)),
                },
            ]
        })
    })

    return mergeAvailableHotkeyCommands(commands)
}

async function getRecentHotkeyActionIds() {
    const stored = await GM.getValue(recentHotkeyActionsStorageKey, ["openOverpassSearch", "openLocalFilePicker", "openSettings"])
    return stored.filter(actionId => typeof actionId === "string")
}

async function rememberRecentHotkeyAction(actionId) {
    const current = await getRecentHotkeyActionIds()
    const next = [actionId, ...current.filter(currentActionId => currentActionId !== actionId)].slice(0, recentHotkeyActionsLimit)
    await GM.setValue(recentHotkeyActionsStorageKey, next)
}

function actionClearRecentHotkeyActions() {
    setTimeout(() => {
        void GM.setValue(recentHotkeyActionsStorageKey, [])
    })
}

let hotkeyCommandsPopupStylesInjected = false

function ensureHotkeyCommandsPopupStyles() {
    if (hotkeyCommandsPopupStylesInjected) {
        return
    }
    injectCSSIntoOSMPage(`
        #better-osm-hotkey-commands-popup {
            position: fixed;
            inset: 0;
            z-index: 9999;
            display: flex;
            justify-content: center;
            padding: 8px;
            box-sizing: border-box;

            .better-osm-hotkey-commands-panel {
                width: max-content;
                max-width: 100%;
                height: 100%;
                display: flex;
                flex-direction: column;
                overflow: hidden;
                background: var(--bs-body-bg);
                color: var(--bs-body-color);
                border: 1px solid rgba(204, 204, 204, 0.5);
                border-radius: 8px;
                padding: 16px;
                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
                font-family: sans-serif;
                box-sizing: border-box;
            }

            .better-osm-hotkey-commands-header {
                display: flex;
                align-items: flex-start;
                gap: 12px;
                margin-bottom: 8px;
            }

            .better-osm-hotkey-commands-title {
                margin: 0;
                font-size: 1rem;
            }

            .better-osm-hotkey-commands-subtitle {
                margin: 2px 0 0;
                font-size: 0.875rem;
                opacity: 0.75;
            }

            .better-osm-hotkey-commands-search {
                display: block;
                width: 100%;
                margin-bottom: 12px;
                border: 1px solid rgba(127, 127, 127, 0.35);
                border-radius: 6px;
                background: var(--bs-body-bg);
                color: inherit;
                padding: 8px 10px;
                box-sizing: border-box;
            }

            .better-osm-hotkey-commands-search::placeholder {
                opacity: 0.7;
            }

            .better-osm-hotkey-commands-search:focus {
                outline: 2px solid rgba(13, 110, 253, 0.35);
                outline-offset: 1px;
            }

            .better-osm-hotkey-commands-close {
                all: unset;
                cursor: pointer;
                margin-left: auto;
                line-height: 1;
            }

            .better-osm-hotkey-commands-group {
                margin-top: 12px;
            }

            .better-osm-hotkey-commands-content {
                flex: 1 1 auto;
                overflow-y: auto;
                min-height: 0;
            }

            .better-osm-hotkey-commands-group-title {
                margin: 0 0 6px;
                font-size: 0.875rem;
                text-transform: uppercase;
                letter-spacing: 0.04em;
                opacity: 0.7;
            }

            .better-osm-hotkey-commands-list {
                display: flex;
                flex-direction: column;
                gap: 4px;
            }

            .better-osm-hotkey-command-btn {
                display: grid;
                grid-template-columns: 1fr${isMobile ? "" : " minmax(90px, 130px)"};
                align-items: start;
                gap: 15px;
                width: 100%;
                border: 0;
                border-radius: 6px;
                background: transparent;
                color: inherit;
                padding: 6px 8px;
                text-align: left;
                cursor: pointer;

                &:hover,
                &:focus-visible {
                    background: rgba(127, 127, 127, 0.14);
                    outline: none;
                }

                &.is-active {
                    background: rgba(13, 110, 253, 0.16);
                }
            }

            .better-osm-hotkey-command-binding {
                display: flex;
                flex-direction: column;
                gap: 4px;
                font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
                font-size: 0.875rem;
                line-height: 1.3;
                opacity: 0.65;
            }

            .better-osm-hotkey-command-title {
                align-self: center;
                font-size: 0.925rem;
                line-height: 1.3;
                white-space: nowrap;
            }

            .better-osm-hotkey-commands-empty {
                margin: 8px 0 0;
                opacity: 0.75;
            }
        }
    `)
    hotkeyCommandsPopupStylesInjected = true
}

function closeHotkeyCommandsPopup() {
    document.querySelector(`#better-osm-hotkey-commands-popup`)?.remove()
}

async function showHotkeyCommandsPopup() {
    const existingPopup = document.querySelector(`#better-osm-hotkey-commands-popup`)
    if (existingPopup) {
        existingPopup.remove()
        return
    }

    ensureHotkeyCommandsPopupStyles()

    const currentContexts = getCurrentHotkeyContexts()
    const availableCommands = getAvailableHotkeyCommandsForCurrentPage()
    const recentActionIds = await getRecentHotkeyActionIds()
    const recentCommands = recentActionIds.map(actionId => availableCommands.find(command => command.actionId === actionId)).filter(Boolean)

    const overlay = document.createElement("div")
    overlay.id = "better-osm-hotkey-commands-popup"
    overlay.addEventListener("click", e => {
        if (e.target === overlay) {
            closeHotkeyCommandsPopup()
        }
    })

    const panel = document.createElement("div")
    panel.classList.add("better-osm-hotkey-commands-panel")
    panel.addEventListener("click", e => e.stopPropagation())

    const header = document.createElement("div")
    header.classList.add("better-osm-hotkey-commands-header")

    const headerText = document.createElement("div")
    const title = document.createElement("h3")
    title.classList.add("better-osm-hotkey-commands-title")
    title.textContent = t("hotkeys.availableCommands")
    const subtitle = document.createElement("p")
    subtitle.classList.add("better-osm-hotkey-commands-subtitle")
    // subtitle.textContent = `Current contexts: ${currentContexts.join(", ")}`
    headerText.append(title, subtitle)

    const closeBtn = document.createElement("button")
    closeBtn.classList.add("better-btn-close", "better-osm-hotkey-commands-close")
    closeBtn.type = "button"
    closeBtn.setAttribute("aria-label", "Close commands popup")
    closeBtn.innerHTML =
        '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">' +
        '  <path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>' +
        "</svg>"
    closeBtn.querySelector("svg").style.height = "1.25rem"
    closeBtn.onclick = closeHotkeyCommandsPopup

    header.append(headerText, closeBtn)
    panel.append(header)

    const searchInput = document.createElement("input")
    searchInput.classList.add("better-osm-hotkey-commands-search")
    searchInput.type = "search"
    searchInput.placeholder = t("hotkeys.searchByNamePlaceholder")
    searchInput.setAttribute("aria-label", t("hotkeys.searchByNamePlaceholder"))
    panel.append(searchInput)

    const content = document.createElement("div")
    content.classList.add("better-osm-hotkey-commands-content")
    panel.append(content)
    let activeCommandIndex = -1

    function getCommandButtons() {
        return Array.from(content.querySelectorAll(".better-osm-hotkey-command-btn"))
    }

    function setActiveCommandButton(nextIndex) {
        const buttons = getCommandButtons()
        buttons.forEach(button => button.classList.remove("is-active"))

        if (!buttons.length) {
            activeCommandIndex = -1
            return
        }

        activeCommandIndex = ((nextIndex % buttons.length) + buttons.length) % buttons.length
        const activeButton = buttons[activeCommandIndex]
        activeButton.classList.add("is-active")
        activeButton.scrollIntoView({ block: "nearest" })
    }

    function renderCommandsList(query = "") {
        content.replaceChildren()
        activeCommandIndex = -1

        function appendCommandButton(list, command) {
            const button = document.createElement("button")
            button.classList.add("better-osm-hotkey-command-btn")
            button.type = "button"
            button.dataset.actionId = command.actionId
            button.dataset.binding = command.bindings.join("\n")

            const binding = document.createElement("span")
            binding.classList.add("better-osm-hotkey-command-binding")
            command.bindings
                .map(formatHotkeyBinding)
                .filter(Boolean)
                .forEach(formattedBinding => {
                    const bindingLine = document.createElement("span")
                    bindingLine.textContent = formattedBinding
                    binding.append(bindingLine)
                })

            const label = document.createElement("span")
            label.classList.add("better-osm-hotkey-command-title")
            label.textContent = command.title

            if (isMobile) {
                button.append(label)
            } else {
                button.append(label, binding)
            }
            button.addEventListener("click", () => {
                closeHotkeyCommandsPopup()
                setTimeout(() => {
                    void rememberRecentHotkeyAction(command.actionId)
                    runHotkeyAction(command.actionId, command.event)
                }, 0)
            })
            list.append(button)
        }

        const normalizedQuery = query.trim().toLowerCase()
        const filteredCommands = normalizedQuery
            ? availableCommands.filter(command => command.title.toLowerCase().includes(normalizedQuery))
            : availableCommands
        const filteredRecentCommands = recentCommands.filter(command => filteredCommands.includes(command))
        const recentCommandKeys = new Set(filteredRecentCommands.map(command => command.actionId))
        const remainingCommands = filteredCommands.filter(command => !recentCommandKeys.has(command.actionId))

        if (!filteredCommands.length) {
            const emptyState = document.createElement("p")
            emptyState.classList.add("better-osm-hotkey-commands-empty")
            emptyState.textContent = normalizedQuery ? t("hotkeys.noHotkeysMatchSearch") : t("hotkeys.noCatalogedCommandsMatchPage")
            content.append(emptyState)
            return
        }

        if (filteredRecentCommands.length) {
            const group = document.createElement("section")
            group.classList.add("better-osm-hotkey-commands-group")

            const heading = document.createElement("h4")
            heading.classList.add("better-osm-hotkey-commands-group-title")
            heading.textContent = t("hotkeys.recent")
            group.append(heading)

            const list = document.createElement("div")
            list.classList.add("better-osm-hotkey-commands-list")

            filteredRecentCommands.forEach(command => {
                appendCommandButton(list, command)
            })

            group.append(list)
            content.append(group)
        }

        const filteredGroupedCommands = remainingCommands.reduce((groups, command) => {
            const primaryContext =
                hotkeyHelpContextsOrder.find(context => command.contexts.includes(context) && currentContexts.includes(context)) ??
                "All pages"
            if (!groups[primaryContext]) {
                groups[primaryContext] = []
            }
            groups[primaryContext].push(command)
            return groups
        }, {})

        hotkeyHelpContextsOrder
            .filter(context => filteredGroupedCommands[context]?.length)
            .forEach(context => {
                const group = document.createElement("section")
                group.classList.add("better-osm-hotkey-commands-group")

                const heading = document.createElement("h4")
                heading.classList.add("better-osm-hotkey-commands-group-title")
                heading.textContent = context
                group.append(heading)

                const list = document.createElement("div")
                list.classList.add("better-osm-hotkey-commands-list")

                filteredGroupedCommands[context].forEach(command => {
                    appendCommandButton(list, command)
                })

                group.append(list)
                content.append(group)
            })

        setActiveCommandButton(0)
    }

    searchInput.addEventListener("input", () => renderCommandsList(searchInput.value))
    searchInput.addEventListener("keydown", e => {
        if (e.key === "Escape") {
            e.preventDefault()
            e.stopPropagation()
            closeHotkeyCommandsPopup()
            return
        }
        if (e.key === "ArrowDown") {
            e.preventDefault()
            setActiveCommandButton(activeCommandIndex + 1)
            return
        }
        if (e.key === "ArrowUp") {
            e.preventDefault()
            setActiveCommandButton(activeCommandIndex - 1)
            return
        }
        if (e.key === "Enter") {
            const activeButton = getCommandButtons()[activeCommandIndex]
            if (activeButton) {
                e.preventDefault()
                activeButton.click()
            }
        }
    })
    panel.addEventListener("keydown", e => {
        if (e.key === "Escape") {
            e.preventDefault()
            e.stopPropagation()
            closeHotkeyCommandsPopup()
        }
    })

    overlay.append(panel)
    document.body.append(overlay)
    renderCommandsList()
    panel.style.width = `${Math.min(panel.scrollWidth, window.innerWidth - 16)}px`
    searchInput.focus()
}

function hotkeyCommandsPopupClickHandler(e) {
    e.preventDefault()
    e.stopPropagation()
    void showHotkeyCommandsPopup()
}

function actionToggleMapLayersVisibility(e) {
    if (!getWindow().mapIntercepted) return
    e.preventDefault()
    for (let member in layers) {
        layers[member].forEach(i => {
            if (layersHidden) {
                i.getElement().style.visibility = ""
            } else {
                i.getElement().style.visibility = "hidden"
            }
        })
    }
    if (getWindow()?.jsonLayer) {
        if (layersHidden) {
            injectJSIntoPage(`jsonLayer.eachLayer(i => i.getElement().style.visibility = "")`)
        } else {
            injectJSIntoPage(`jsonLayer.eachLayer(i => i.getElement().style.visibility = "hidden")`)
        }
    } else if (jsonLayer) {
        if (layersHidden) {
            jsonLayer.eachLayer(intoPageWithFun(i => (getMap()._layers[i._leaflet_id].getElement().style.visibility = "")))
        } else {
            jsonLayer.eachLayer(intoPageWithFun(i => (getMap()._layers[i._leaflet_id].getElement().style.visibility = "hidden")))
        }
    }
    layersHidden = !layersHidden
}

function actionToggleDarkMapStyle() {
    darkModeForMap = !darkModeForMap
    if (darkModeForMap) {
        injectDarkMapStyle()
    } else {
        darkMapStyleElement?.remove()
    }
}

function actionOpenUserBlocks() {
    if (isUserPageWithoutHistory()) {
        document.querySelector('a[href^="/user/"][href$="/blocks"]')?.click()
    }
}

function actionToggleEditMenu() {
    document.querySelector("#edit_tab ul").tabIndex = -1
    if (document.querySelector("header").classList.contains("closed")) {
        document.querySelector("#menu-icon").click()
        document.querySelector("#edit_tab > button").click()
    } else if (document.querySelector("#edit_tab > .dropdown-menu").classList.contains("show")) {
        document.querySelector("#change-list-btn.closed")?.click()
    } else {
        document.querySelector("#edit_tab button").click()
    }
}

function actionNextVectorLayer() {
    nextVectorLayer()
}

function actionSetCustomVectorStyle() {
    if (!document.querySelector("#map canvas")) {
        Array.from(document.querySelectorAll(".layers-ui .base-layers label")).at(-2)?.click()
    }
    void askCustomStyleUrl()
}

function actionOpenMessageComposer() {
    if (isUserPageWithoutHistory()) {
        document.querySelector('a[href^="/messages/new/"]')?.click()
    }
}

function actionOpenMessageComposerForCurrentUser() {
    if (location.pathname.includes("/user/")) {
        const username = location.pathname.match(/^\/user\/([^/]+)/)[1]
        window.open("/messages/new/" + decodeURI(username))
    } else {
        const username = document
            .querySelector("#sidebar_content a[href^='/user/']")
            .getAttribute("href")
            .match(/^\/user\/([^/]+)/)[1]
        window.open("/messages/new/" + decodeURI(username))
    }
}

function actionOpenCurrentPageUserProfile() {
    const user_link = document.querySelector('#sidebar_content a[href^="/user/"]')
    if (user_link) {
        if (user_link.checkVisibility()) {
            user_link?.click()
        } else {
            document.querySelector('#sidebar_content li:not([hidden-data-changeset]) a[href^="/user/"]')?.click()
        }
        return
    }
    document.querySelector('#content a[href^="/user/"]:not([href$=rss]):not([href*="/diary"]):not([href*="/traces"])')?.click()
}

function actionOpenOwnUserProfile() {
    window.location.pathname = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href")
}

function actionOpenFiltersOrLayers(e) {
    if (location.pathname.match(/^\/note\//) || location.pathname === "/") {
        document.querySelector(".control-layers a").click()
        if (document.querySelector(".layers-ui").style.display !== "none") {
            Array.from(document.querySelectorAll(".overlay-layers label"))[0].scrollIntoView({ block: "center" })
            e.preventDefault()
            document.querySelector("#filter-notes-by-string").focus()
        }
    } else {
        if (!document.querySelector("#changesets-filter-btn") && !document.querySelector("#mass-action-btn")) {
            document.querySelector(".control-layers a").click()
            Array.from(document.querySelectorAll(".overlay-layers label"))[0].scrollIntoView({ block: "center" })
        } else {
            document.querySelector("#changesets-filter-btn")?.click()
            document.querySelector("#mass-action-btn")?.click()
        }
    }
}

function actionOpenExternalService(e) {
    if (e.shiftKey) {
        window.open("https://overpass-api.de/achavi/?changeset=" + location.pathname.match(/\/changeset\/(\d+)/)[1])
    } else if (!e.altKey) {
        const usernameMatch = location.pathname.match(/^\/user\/([^/]+)\/?$/)
        if (usernameMatch) {
            window.open(makeOsmchaLinkForUsername(decodeURI(usernameMatch[1])))
        } else {
            const osmchaLink = document.querySelector("#osmcha_link")
            if (osmchaLink) {
                osmchaLink?.click()
            } else {
                document.querySelector(".relation-viewer-link")?.click()
            }
        }
    }
}

function actionOpenUserComments() {
    if (location.pathname.includes("/diary_comments")) {
        document.querySelector('a[href^="/user/"][href$="changeset_comments"]')?.click()
    } else {
        document.querySelector('a[href^="/user/"][href$="_comments"]')?.click()
    }
}

function actionCopyMapCenterCoordinates() {
    setTimeout(async () => {
        const center = getMapCenter()
        const format = (await GM.getValue("CoordinatesFormat")) ?? "Lat Lon"
        if (format === "Lon Lat") {
            navigator.clipboard.writeText(`${center.lng} ${center.lat}`)
        } else {
            navigator.clipboard.writeText(`${center.lat} ${center.lng}`)
        }
    })
}

function getPrimaryChangesetLink() {
    const activeObject = document.querySelector("#element_versions_list > div.active-object")
    if (activeObject) {
        return activeObject.querySelector('a[href^="/changeset/"]')
    }
    return document.querySelectorAll('a[href^="/changeset/"]:not([href*="?locale="])')?.[0] ?? null
}

function actionOpenPrimaryChangeset() {
    getPrimaryChangesetLink()?.click()
}

function actionOpenPrimaryChangesetInNewTab() {
    const changesetLink = getPrimaryChangesetLink()
    if (changesetLink?.href) {
        window.open(changesetLink.href, "_blank")
    }
}

function actionToggleNotesLayer() {
    Array.from(document.querySelectorAll(".overlay-layers label input"))[0].removeAttribute("disabled")
    Array.from(document.querySelectorAll(".overlay-layers label"))[0].click()
}

function actionDownloadVisibleNotesAsKml() {
    downloadVisibleNotesAsKml()
}

function actionToggleMapDataLayer() {
    Array.from(document.querySelectorAll(".overlay-layers label input"))[1].removeAttribute("disabled")
    Array.from(document.querySelectorAll(".overlay-layers label"))[1].click()
    if (!location.hash.includes("D")) {
        disableOverzoom()
    } else {
        enableOverzoom()
    }
}

function actionToggleGpsTracksLayer() {
    Array.from(document.querySelectorAll(".overlay-layers label"))[2].click()
}

function actionSwitchToSatelliteImagery() {
    enableOverzoom()
    switchTilesAndButtons()
}

function actionOpenUserNotesPage() {
    document.querySelector('a[href^="/user/"][href$="/notes"]')?.click()
    addAltClickHandlerForNotes()
}

function actionOpenNoteAuthorNotesInNewTab() {
    window.open(document.querySelector('#sidebar_content a[href^="/user/"]').getAttribute("href") + "/notes", "_blank")
}

function actionCreateNote() {
    if (location.pathname.includes("/node") || location.pathname.includes("/way") || location.pathname.includes("/relation")) {
        newNotePlaceholder = "\n \n" + location.href
    }
    document.querySelector(".control-note .control-button").click()
}

function actionOpenUserDiary() {
    document.querySelector('a[href^="/user/"][href$="/diary"]')?.click()
}

function actionAppendDebugQueryFlag() {
    location.search += "&kek"
}

function actionTriggerDebugger() {
    // eslint-disable-next-line no-debugger
    debugger
    throw "debug"
}

function actionOpenSpyGlass() {
    try {
        document.getElementById("spy-glass").click()
    } catch (e) {
        debug_alert("script not injected :(")
    }
}

function actionShowGpsTracksOverlay() {
    enableOverzoom()
    setZoom(Math.min(14, getZoom()))
    if (!document.querySelectorAll(".overlay-layers label")[2].querySelector("input").checked) {
        Array.from(document.querySelectorAll(".overlay-layers label"))[2].click()
    }
    switchOverlayTiles()
}

function actionSetCustomTileUrl() {
    enableOverzoom()
    askCustomTileUrl()
}

function actionBypassTileCaches() {
    enableOverzoom()
    switchBypassCaches()
}

function actionOpenSelectedObjectEditTarget() {
    if (location.pathname.startsWith("/changeset/")) {
        if (document.querySelector(".active-object")) {
            const activeObjectUrl = document.querySelector(".active-object").querySelector("a").getAttribute("href")
            window.open(activeObjectUrl, "_blank")
        } else {
            const firstObjectUrl = document
                .querySelector("turbo-frame:is(#changeset_nodes, #changeset_ways, #changeset_relations)")
                .querySelector("ul a")
                .getAttribute("href")
            window.open(firstObjectUrl, "_blank")
        }
    } else {
        document.querySelector(".edit_tags_class").click()
    }
}

function actionOpenAlternateEditor() {
    if (document.querySelector("#editanchor").getAttribute("data-editor") === "id") {
        document.querySelectorAll("#edit_tab .dropdown-menu .editlink")[1]?.click()
    } else {
        document.querySelectorAll("#edit_tab .dropdown-menu .editlink")[0]?.click()
    }
}

function actionOpenEditMenuPrimary() {
    document.querySelector("#editanchor")?.click()
}

function actionOpenUserHistoryFromProfile() {
    document.querySelector('a[href^="/user/"][href$="/history"]')?.click()
}

function actionOpenUserReportForm() {
    document.querySelector('a[href*="/reports/new"]')?.click()
}

function actionRevertCurrentChangesetSelection() {
    document.querySelector("#revert_button_class").click()
}

function actionToggleChangesetObjectSelection() {
    if (document.querySelector(".select-objects-btn")) {
        document.querySelector(".select-objects-btn").click()
    } else {
        addCheckboxesForChangesetObjects()
    }
}

function actionOpenInJosmOrLevel0(e) {
    setTimeout(async () => {
        if (location.pathname.includes("changeset")) {
            await openSelectedObjectsOnChangesetPage(e)
        } else {
            await openObjectInJosmOrLevel0(e)
        }
    })
}

function actionOpenOwnHistoryPage() {
    const targetURL = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href") + "/history"
    if (targetURL !== location.pathname) {
        try {
            getWindow().OSM.router.route(targetURL)
        } catch {
            window.location.pathname = targetURL
        }
    }
}

function actionOpenRelevantHistoryPage() {
    if (isObjectPage()) {
        if (/^\/(node|way|relation)\/\d+\/?$/.test(location.pathname)) {
            getWindow().OSM.router.route(window.location.pathname + "/history")
        } else if (/^\/(node|way|relation)\/\d+\/history\/\d+\/?$/.test(location.pathname)) {
            const historyPath = window.location.pathname.match(/(\/(node|way|relation)\/\d+\/history)\/\d+/)[1]
            getWindow().OSM.router.route(historyPath)
        }
    } else if (isHomeOrNotePage()) {
        addCompactSidebarStyle()
        document.querySelector('.nav-link[href*="/history"]')?.click()
    } else if (location.pathname.includes("/user/")) {
        document.querySelector('a[href^="/user/"][href$="/history"]')?.click()
    }
}

function actionOpenChangesetAuthorHistory() {
    const userChangesetsLink = document.querySelectorAll("div.secondary-actions")[1]?.querySelector('a[href^="/user/"]')
    if (userChangesetsLink) {
        getAbortController().abort(ABORT_ERROR_USER_CHANGESETS)
        userChangesetsLink.focus()
        userChangesetsLink.click()
    }
}

function actionResetFilteredHistoryPage() {
    try {
        getWindow().OSM.router.route(location.pathname)
        setupCompactChangesetsHistory()
    } catch {
        if (isSafari) {
            window.location.search = ""
        } else {
            window.location = location.pathname
        }
    }
}

function actionOpenFirstObjectVersion() {
    getWindow().OSM.router.route(location.pathname.match(/\/(node|way|relation)\/\d+/)[0] + "/history/1")
}

function actionOpenFirstChangesetForCurrentPageUser() {
    const user_link = document.querySelector('#sidebar_content a[href^="/user/"]')
    if (user_link) {
        const username = decodeURI(user_link.getAttribute("href").match(/\/user\/([^/]+)/)[1])
        getCachedUserInfo(username).then(res => {
            if (res["firstChangesetID"]) {
                getWindow().OSM.router.route(`/changeset/${res["firstChangesetID"]}`)
            } else {
                console.warn("not found first changeset for " + username)
            }
        })
    }
}

function actionOpenFirstChangesetPageForCurrentUserHistory() {
    const user_link = document.querySelector('#sidebar_content a[href^="/user/"]')
    if (user_link) {
        const username = decodeURI(user_link.getAttribute("href").match(/\/user\/([^/]+)/)[1])
        getCachedUserInfo(username).then(res => {
            if (res["firstChangesetID"]) {
                getWindow().OSM.router.route(`${location.pathname}?after=${res["firstChangesetID"] - 1}`)
            } else {
                console.warn("not found first changeset for " + username)
            }
        })
    }
}

function actionZoomOutToWorld() {
    const center = getMapCenter()
    setZoom(2)
    fetch(`https://nominatim.openstreetmap.org/reverse.php?lon=${center.lng}&lat=${center.lat}&format=jsonv2`).then(res => {
        res.json().then(r => {
            if (r?.address?.state) {
                setAttributionPrefix(`${r.address.state}`)
            }
        })
    })
}

function actionZoomToCurrentObjectHotkey(e) {
    if (e.shiftKey) {
        shiftKeyZClicks += 1
        document.addEventListener(
            "mousemove",
            () => {
                shiftKeyZClicks = 0
            },
            { once: true },
        )
    } else {
        shiftKeyZClicks = 0
    }
    zoomToCurrentObject(e)
}

function actionMapPositionBack() {
    if (mapPositionsHistory.length > 1) {
        mapPositionsNextHistory.push(mapPositionsHistory[mapPositionsHistory.length - 1])
        mapPositionsHistory.pop()
        fitBounds(mapPositionsHistory[mapPositionsHistory.length - 1])
    }
}

function actionMapPositionForward() {
    if (mapPositionsNextHistory.length) {
        mapPositionsHistory.push(mapPositionsNextHistory.pop())
        fitBounds(mapPositionsHistory[mapPositionsHistory.length - 1])
    }
}

function actionZoomOutHotkey(e) {
    if (document.activeElement?.id === "map") {
        e.preventDefault()
        e.stopImmediatePropagation()
        e.stopPropagation()
    }
    if (!e.altKey) {
        setZoom(getZoom() - 2)
    } else {
        setZoom(getZoom() - 1)
    }
    document.querySelector("#map").focus()
}

function actionZoomInHotkey(e) {
    if (document.activeElement?.id === "map") {
        e.preventDefault()
        e.stopImmediatePropagation()
        e.stopPropagation()
    }
    if (!e.altKey) {
        setZoom(getZoom() + 2)
    } else {
        setZoom(getZoom() + 1)
    }
    document.querySelector("#map").focus()
}

function actionGoToPrevChangeset(e) {
    goToPrevChangeset(e)
}

function actionGoToNextChangeset(e) {
    goToNextChangeset(e)
}

function actionGoToPrevSearchResultPage(e) {
    goToPrevSearchResult(e)
}

function actionGoToNextSearchResultPage(e) {
    goToNextSearchResult(e)
}

function actionChangesetObjectsTimeTrack() {
    if (location.pathname.startsWith("/changeset")) {
        const params = new URLSearchParams(location.search)
        const changesetIDs = params.get("changesets")?.split(",") ?? [parseInt(location.pathname.match(/changeset\/(\d+)/)[1])]
        const objects = []
        if (changesetIDs) {
            setTimeout(async () => {
                for (const i of changesetIDs) {
                    ;(await getChangeset(i)).data.querySelectorAll("node,way,relation").forEach(obj => {
                        objects.push(obj)
                    })
                }
                objects.sort((a, b) => {
                    const A = new Date(a.getAttribute("timestamp"))
                    const B = new Date(b.getAttribute("timestamp"))
                    if (A < B) return -1
                    if (A > B) return 1
                    return 0
                })
                const nodesList = []
                for (let object of objects) {
                    if (object.nodeName === "node" && object.getAttribute("visible") === "true") {
                        nodesList.push([object.getAttribute("lat"), object.getAttribute("lon")])
                    } else if (object.nodeName === "way") {
                        // TODO
                    }
                }
                showNodeMarker(nodesList[0][0], nodesList[0][1], "#ff0000", null, "customObjects", 8)
                showNodeMarker(nodesList.at(-1)[0], nodesList.at(-1)[1], "#00ff04", null, "customObjects", 8)
                showActiveWay(nodesList, c("#0022ff"), false, null, true, 2)
            })
        }
    }
}

function actionGoToPrevChangesetObjectHotkey(e) {
    goToPrevChangesetObject(e)
}

function actionGoToNextChangesetObjectHotkey(e) {
    goToNextChangesetObject(e)
}

function actionGoToPrevObjectVersionHotkey() {
    goToPrevObjectVersion()
}

function actionGoToNextObjectVersionHotkey() {
    goToNextObjectVersion()
}

function actionGoToPrevSidebarTab() {
    const links = Array.from(document.querySelectorAll("#sidebar_content nav div ul a"))
    for (let i = 0; i < links.length; i++) {
        if (links[i].parentElement.classList.contains("active")) {
            links[i - 1]?.click()
            break
        }
    }
}

function actionGoToNextSidebarTab() {
    const links = Array.from(document.querySelectorAll("#sidebar_content nav div ul a"))
    for (let i = 0; i < links.length; i++) {
        if (links[i].parentElement.classList.contains("active")) {
            links[i + 1]?.click()
            break
        }
    }
}

function actionGoToPrevPaginationPage(selector) {
    document.querySelector(selector)?.click()
}

function actionGoToPrevUserNotesPage() {
    document.querySelectorAll(".pagination li a")[0]?.click()
}

function actionGoToNextUserNotesPage() {
    document.querySelectorAll(".pagination li a")[1]?.click()
}

function actionGoToPrevChangesetListPage() {
    const link = getPrevChangesetLink()
    if (link) {
        getAbortController().abort(ABORT_ERROR_PREV)
        needPreloadChangesets = true
        link.focus()
        link.click()
    }
}

function actionGoToNextChangesetListPage() {
    const link = getNextChangesetLink()
    if (link) {
        getAbortController().abort(ABORT_ERROR_NEXT)
        needPreloadChangesets = true
        link.focus()
        link.click()
    }
}

function actionOpenScriptUpdateUrl() {
    window.open(`${SCRIPT_UPDATE_URL}?bypasscache=${Math.random()}`, "_blank")
}

function actionOpenDevScriptUpdateUrl() {
    window.open(`${DEV_SCRIPT_UPDATE_URL}?bypasscache=${Math.random()}`, "_blank")
}

function actionOpenLocalFilePicker() {
    const input = document.createElement("input")
    input.type = "file"
    input.accept = ".jpg,.jpeg,.csv,.kml,.kmz,.gpx,.json,.geojson,.osm"
    input.multiple = true
    input.addEventListener("change", () => {
        void handleDroppedFiles(Array.from(input.files ?? []))
    })
    input.click()
}

function actionOpenSettings() {
    GM_config.open()
}

//</editor-fold>

//<editor-fold desc="hotkeys">
let hotkeysConfigured = false

/**
 * @typedef {"All pages" | "Main pages" | "User pages" | "Changeset pages" | "Object pages" | "History pages" | "Search page" | "Debug"} HotkeyContext
 */

/**
 * @typedef {Object} HotkeyActionDefinition
 * @property {string} title
 * @property {string[]} defaultBindings
 * @property {HotkeyContext[]} contexts
 * @property {boolean=} preventDefault
 * @property {boolean=} hideOnMobile
 * @property {(e: KeyboardEvent) => boolean=} when
 * @property {(e: KeyboardEvent) => void} run
 */

/** @type {Record<string, HotkeyActionDefinition>} */
const hotkeyActions = {
    showHotkeysHelp: {
        title: "Show hotkeys help",
        defaultBindings: ["F1"],
        contexts: ["All pages"],
        preventDefault: true,
        hideOnMobile: true,
        run: actionShowHotkeysHelp,
    },
    openOverpassSearch: {
        title: "Make Overpass request",
        defaultBindings: ["Shift+Slash"],
        contexts: ["Main pages"],
        run: actionOpenOverpassSearch,
    },
    openLocalFilePicker: {
        title: "Open local file",
        defaultBindings: [],
        contexts: ["Main pages"],
        run: actionOpenLocalFilePicker,
    },
    toggleMapLayersVisibility: {
        title: "Toggle objects layers visibility",
        defaultBindings: ["Backquote"],
        contexts: ["Changeset pages", "Object pages"],
        run: actionToggleMapLayersVisibility,
    },
    toggleDarkMapStyle: {
        title: "Toggle dark map style",
        defaultBindings: ["Alt+Backquote"],
        contexts: ["Main pages"],
        run: actionToggleDarkMapStyle,
    },
    openYandexPanoramas: {
        title: "Open Yandex panoramas",
        defaultBindings: ["KeyY"],
        contexts: ["Main pages"],
        run: actionOpenYandexPanoramas,
    },
    copyCurrentShortLink: {
        title: "Copy current short URL",
        defaultBindings: ["KeyP"],
        contexts: ["Main pages"],
        run: actionCopyCurrentShortLink,
    },
    closeUi: {
        title: "Close open UI panels",
        defaultBindings: ["KeyQ"],
        contexts: ["Main pages"],
        run: actionCloseUi,
        hideOnMobile: true,
    },
    clearActiveObjectsAndContextMenus: {
        title: "Hide active objects and context menus",
        defaultBindings: ["Escape"],
        contexts: ["Main pages"],
        run: actionClearActiveObjectsAndContextMenus,
        hideOnMobile: true,
    },
    goToUserLocation: {
        title: "Go to your location",
        defaultBindings: ["Shift+KeyL"],
        contexts: ["Main pages"],
        run: actionGoToUserLocation,
        hideOnMobile: true,
    },
    openUserTraces: {
        title: "Open your traces",
        defaultBindings: ["KeyT"],
        contexts: ["User pages"],
        when: () => isUserPageWithoutHistory(),
        run: actionOpenUserTraces,
    },
    toggleCompactMode: {
        title: "Toggle compact mode and datetime format",
        defaultBindings: ["KeyT"],
        contexts: ["Changeset pages", "Object pages", "History pages"],
        when: () => !isUserPageWithoutHistory(),
        run: actionToggleCompactMode,
    },
    toggleSwitchableTime: {
        title: "Toggle switchable time",
        defaultBindings: ["Shift+KeyT", "Alt+KeyT"],
        contexts: ["Changeset pages", "Object pages", "History pages"],
        run: actionToggleSwitchableTime,
    },
    openUserBlocks: {
        title: "Open user blocks",
        defaultBindings: ["KeyB"],
        contexts: ["User pages"],
        run: actionOpenUserBlocks,
    },
    toggleEditMenu: {
        title: "Toggle custom links menu",
        defaultBindings: ["KeyX"],
        contexts: ["Main pages"],
        run: actionToggleEditMenu,
        hideOnMobile: true,
    },
    nextVectorLayer: {
        title: "Switch to next vector layer",
        defaultBindings: ["KeyV"],
        contexts: ["Main pages"],
        run: actionNextVectorLayer,
    },
    setCustomVectorStyle: {
        title: "Set custom vector style",
        defaultBindings: ["Shift+KeyV"],
        contexts: ["Main pages"],
        run: actionSetCustomVectorStyle,
    },
    openMessageComposer: {
        title: "Open direct message composer",
        defaultBindings: ["KeyM"],
        contexts: ["User pages"],
        when: () => isUserPageWithoutHistory(),
        run: actionOpenMessageComposer,
    },
    openMessageComposerForCurrentUser: {
        title: "Open direct message composer",
        defaultBindings: ["Shift+KeyM"],
        contexts: ["Changeset pages"],
        run: actionOpenMessageComposerForCurrentUser,
    },
    openCurrentPageUserProfile: {
        title: "Open current page user profile",
        defaultBindings: ["KeyU"],
        contexts: ["Main pages", "User pages"],
        run: actionOpenCurrentPageUserProfile,
    },
    openOwnUserProfile: {
        title: "Open your profile",
        defaultBindings: ["Shift+KeyU"],
        contexts: ["Main pages", "User pages"],
        run: actionOpenOwnUserProfile,
    },
    openFiltersOrLayers: {
        title: "Open notes filters",
        defaultBindings: ["KeyF"],
        contexts: ["Main pages"],
        run: actionOpenFiltersOrLayers,
        hideOnMobile: true,
    },
    openExternalService: {
        title: "Open OSMCha",
        defaultBindings: ["KeyO"],
        contexts: ["Changeset Page"],
        run: actionOpenExternalService,
    },
    openSecondExternalService: {
        title: "Open Achavi",
        defaultBindings: ["Shift+KeyO"],
        contexts: ["Changeset pages"],
        run: actionOpenExternalService,
    },
    openUserComments: {
        title: "Open user comments",
        defaultBindings: ["KeyC"],
        contexts: ["User pages"],
        when: () => isUserPageWithoutHistory(),
        run: actionOpenUserComments,
    },
    copyMapCenterCoordinates: {
        title: "Copy map center coordinates",
        defaultBindings: ["Alt+KeyC"],
        contexts: ["Main pages"],
        when: () => !isUserPageWithoutHistory(),
        run: actionCopyMapCenterCoordinates,
    },
    openPrimaryChangeset: {
        title: "Open current changeset",
        defaultBindings: ["KeyC"],
        contexts: ["Object pages"],
        when: () => !isUserPageWithoutHistory() && !isChangesetPage(),
        run: actionOpenPrimaryChangeset,
    },
    openPrimaryChangesetInNewTab: {
        title: "Open current changeset in new tab",
        defaultBindings: ["Shift+KeyC"],
        contexts: ["Object pages"],
        when: () => !isUserPageWithoutHistory() && !isChangesetPage(),
        run: actionOpenPrimaryChangesetInNewTab,
    },
    toggleNotesLayer: {
        title: "Toggle notes layer",
        defaultBindings: ["KeyN"],
        contexts: ["Main pages"],
        when: () => !isUserPageWithoutHistory(),
        run: actionToggleNotesLayer,
    },
    downloadVisibleNotesAsKml: {
        title: "Download visible notes as KML",
        defaultBindings: [],
        contexts: ["Main pages"],
        when: () => !isUserPageWithoutHistory(),
        run: actionDownloadVisibleNotesAsKml,
    },
    toggleMapDataLayer: {
        title: "Toggle Map Data layer",
        defaultBindings: ["KeyD"],
        contexts: ["Main pages"],
        when: () => !isUserPageWithoutHistory(),
        run: actionToggleMapDataLayer,
    },
    toggleGpsTracksLayer: {
        title: "Toggle GPS tracks layer",
        defaultBindings: ["KeyG"],
        contexts: ["Main pages"],
        run: actionToggleGpsTracksLayer,
    },
    switchToSatelliteImagery: {
        title: "Switch to satellite imagery",
        defaultBindings: ["KeyS"],
        contexts: ["Main pages"],
        run: actionSwitchToSatelliteImagery,
    },
    openUserNotesPage: {
        title: "Open current user's notes",
        defaultBindings: ["KeyN", "Shift+KeyN", "Alt+KeyN"],
        contexts: ["User pages"],
        when: () => isUserPageWithoutHistory(),
        run: actionOpenUserNotesPage,
    },
    openNoteAuthorNotesInNewTab: {
        title: "Open note author's notes in new tab",
        defaultBindings: ["Alt+KeyN"],
        contexts: ["Main pages"],
        when: () => /^\/note\/\d+/.test(location.pathname),
        run: actionOpenNoteAuthorNotesInNewTab,
    },
    createNote: {
        title: "Create note",
        defaultBindings: ["Shift+KeyN"],
        contexts: ["Main pages"],
        run: actionCreateNote,
        hideOnMobile: true,
    },
    openUserDiary: {
        title: "Open current user's diary",
        defaultBindings: ["KeyD"],
        contexts: ["User pages"],
        when: () => isUserPageWithoutHistory(),
        run: actionOpenUserDiary,
    },
    appendDebugQueryFlag: {
        title: "Disable script on tab",
        defaultBindings: ["Alt+Shift+KeyD"],
        contexts: ["Debug"],
        run: actionAppendDebugQueryFlag,
    },
    triggerDebugger: {
        title: "Trigger debugger",
        defaultBindings: ["Alt+KeyD"],
        contexts: ["Debug"],
        run: actionTriggerDebugger,
    },
    openSpyGlass: {
        title: "Spy Glass mode",
        defaultBindings: ["Shift+KeyD"],
        contexts: ["Debug"],
        run: actionOpenSpyGlass,
    },
    openScriptUpdateUrl: {
        title: "Check script updates",
        defaultBindings: [],
        contexts: ["Debug"],
        run: actionOpenScriptUpdateUrl,
    },
    openDevScriptUpdateUrl: {
        title: "Check dev script updates",
        defaultBindings: [],
        contexts: ["Debug"],
        run: actionOpenDevScriptUpdateUrl,
    },
    clearRecentHotkeyActions: {
        title: "Clear recent actions",
        defaultBindings: [],
        contexts: ["Debug"],
        run: actionClearRecentHotkeyActions,
    },
    showGpsTracksOverlay: {
        title: "Show Strava GPS tracks overlay",
        defaultBindings: ["Shift+KeyG", "Alt+KeyG"],
        contexts: ["Debug"],
        run: actionShowGpsTracksOverlay,
    },
    openSettings: {
        title: "Settings",
        defaultBindings: [],
        contexts: ["Debug"],
        run: actionOpenSettings,
    },
    setCustomTileUrl: {
        title: "Set custom tile URL",
        defaultBindings: ["Shift+KeyS"],
        contexts: ["Main pages"],
        run: actionSetCustomTileUrl,
    },
    bypassTileCaches: {
        title: "Toggle bypass tile caches mode",
        defaultBindings: ["Alt+KeyS"],
        contexts: ["Main pages"],
        run: actionBypassTileCaches,
    },
    openSelectedObjectEditTarget: {
        title: "Open selected object or tags editor",
        defaultBindings: ["Alt+KeyE"],
        contexts: ["Main pages"],
        run: actionOpenSelectedObjectEditTarget,
        hideOnMobile: true,
    },
    openAlternateEditor: {
        title: "Open second map editor",
        defaultBindings: ["Shift+KeyE"],
        contexts: ["Main pages"],
        when: () => !/^\/user\/([^/]+)\/?$/.test(location.pathname),
        run: actionOpenAlternateEditor,
        hideOnMobile: true,
    },
    openEditMenuPrimary: {
        title: "Open map editor",
        defaultBindings: ["KeyE"],
        contexts: ["Main pages"],
        when: () => !/^\/user\/([^/]+)\/?$/.test(location.pathname),
        run: actionOpenEditMenuPrimary,
        hideOnMobile: true,
    },
    openUserHistoryFromProfile: {
        title: "Open current user's history",
        defaultBindings: ["KeyE", "Shift+KeyE"],
        contexts: ["User pages"],
        when: () => /^\/user\/([^/]+)\/?$/.test(location.pathname),
        run: actionOpenUserHistoryFromProfile,
    },
    openUserReportForm: {
        title: "Open user report form",
        defaultBindings: ["KeyR"],
        contexts: ["User pages"],
        when: () => isUserPageWithoutHistory(),
        run: actionOpenUserReportForm,
    },
    revertCurrentChangesetSelection: {
        title: "Revert current changeset selection",
        defaultBindings: ["KeyR", "Shift+KeyR", "Alt+KeyR", "Alt+Shift+KeyR"],
        contexts: ["Changeset pages"],
        when: e => !isUserPageWithoutHistory() && (changesetObjectsSelectionModeEnabled || e.altKey),
        run: actionRevertCurrentChangesetSelection,
    },
    toggleChangesetObjectSelection: {
        title: "Toggle changeset object selection",
        defaultBindings: ["KeyR", "Shift+KeyR"],
        contexts: ["Changeset pages"],
        when: e => !isUserPageWithoutHistory() && !changesetObjectsSelectionModeEnabled && !e.altKey,
        run: actionToggleChangesetObjectSelection,
    },
    openInJosm: {
        title: "Open object in JOSM",
        defaultBindings: ["KeyJ"],
        contexts: ["Changeset pages", "Object pages"],
        run: actionOpenInJosmOrLevel0,
    },
    openInLevel0: {
        title: "Open object in Level0",
        defaultBindings: ["Shift+KeyJ", "Alt+KeyJ"],
        contexts: ["Changeset pages", "Object pages"],
        run: actionOpenInJosmOrLevel0,
    },
    openInLevel0WithFullGeometry: {
        title: "Open object in Level0 with full geometry",
        defaultBindings: ["Shift+Alt+KeyJ"],
        contexts: ["Changeset pages", "Object pages"],
        run: actionOpenInJosmOrLevel0,
    },
    openOwnHistoryPage: {
        title: "Open your changesets history",
        defaultBindings: ["Shift+KeyH"],
        contexts: ["Main pages", "User pages", "Changeset pages", "Object pages", "History pages"],
        run: actionOpenOwnHistoryPage,
    },
    openRelevantHistoryPage: {
        title: "Open relevant history page",
        defaultBindings: ["KeyH"],
        contexts: ["Main pages", "User pages", "Object pages"],
        when: () => isObjectPage() || isHomeOrNotePage() || isUserPageWithoutHistory(),
        run: actionOpenRelevantHistoryPage,
    },
    openChangesetAuthorHistory: {
        title: "Open changeset author's history",
        defaultBindings: ["KeyH"],
        contexts: ["Changeset pages"],
        when: () => isChangesetPage(),
        run: actionOpenChangesetAuthorHistory,
    },
    resetFilteredHistoryPage: {
        title: "Reset filtered history page",
        defaultBindings: ["KeyH"],
        contexts: ["History pages"],
        when: () => isFilteredHistoryPage(),
        run: actionResetFilteredHistoryPage,
    },
    openFirstObjectVersion: {
        title: "Open first object version",
        defaultBindings: ["Digit1"],
        contexts: ["Object pages"],
        when: () => isObjectPage(),
        run: actionOpenFirstObjectVersion,
    },
    openFirstChangesetForCurrentPageUser: {
        title: "Open first changeset for current page user",
        defaultBindings: ["Digit1"],
        contexts: ["Changeset pages"],
        when: () => location.pathname.startsWith("/changeset"),
        run: actionOpenFirstChangesetForCurrentPageUser,
    },
    openFirstChangesetPageForCurrentUserHistory: {
        title: "Open first changeset page for current user history",
        defaultBindings: ["Digit1"],
        contexts: ["History pages"],
        when: () => /\/user\/[^\\]+\/history\/?/.test(location.pathname),
        run: actionOpenFirstChangesetPageForCurrentUserHistory,
    },
    zoomOutToWorld: {
        title: "Zoom out to world",
        defaultBindings: ["Digit0"],
        contexts: ["Main pages"],
        run: actionZoomOutToWorld,
    },
    zoomToCurrentObjectHotkey: {
        title: "Zoom to current object",
        defaultBindings: ["KeyZ"],
        contexts: ["Changeset pages", "Object pages"],
        run: actionZoomToCurrentObjectHotkey,
    },
    smartZoomToCurrentObjectHotkey: {
        title: "Zoom to current object nodes",
        defaultBindings: ["Shift+KeyZ"],
        contexts: ["Changeset pages", "Object pages"],
        run: actionZoomToCurrentObjectHotkey,
    },
    mapPositionBack: {
        title: "Go to previous map position",
        defaultBindings: ["Digit8"],
        contexts: ["Main pages"],
        run: actionMapPositionBack,
    },
    mapPositionForward: {
        title: "Go to next map position",
        defaultBindings: ["Digit9"],
        contexts: ["Main pages"],
        run: actionMapPositionForward,
    },
    zoomOutHotkey: {
        title: "Zoom out with doubled zoom step",
        defaultBindings: ["Minus"],
        contexts: ["Main pages"],
        when: () => !defaultZoomKeysBehaviour,
        run: actionZoomOutHotkey,
        hideOnMobile: true,
    },
    zoomOutWithDefaultStepHotkey: {
        title: "Zoom out with default zoom step",
        defaultBindings: ["Alt+Minus"],
        contexts: ["Main pages"],
        when: () => !defaultZoomKeysBehaviour,
        run: actionZoomOutHotkey,
        hideOnMobile: true,
    },
    zoomInHotkey: {
        title: "Zoom in with doubled zoom step",
        defaultBindings: ["Equal"],
        contexts: ["Main pages"],
        when: () => !defaultZoomKeysBehaviour,
        run: actionZoomInHotkey,
        hideOnMobile: true,
    },
    zoomInWithDefaultStepHotkey: {
        title: "Zoom in with default zoom step",
        defaultBindings: ["Alt+Equal"],
        contexts: ["Main pages"],
        when: () => !defaultZoomKeysBehaviour,
        run: actionZoomInHotkey,
        hideOnMobile: true,
    },
    goToPrevChangeset: {
        title: "Go to previous changeset in list",
        defaultBindings: ["KeyK"],
        contexts: ["History pages"],
        when: () => /^(\/user\/.+)?\/history\/?$/.test(location.pathname),
        run: actionGoToPrevChangeset,
    },
    goToNextChangeset: {
        title: "Go to next changeset in list",
        defaultBindings: ["KeyL"],
        contexts: ["History pages"],
        when: () => /^(\/user\/.+)?\/history\/?$/.test(location.pathname),
        run: actionGoToNextChangeset,
    },
    goToPrevSearchResultPage: {
        title: "Go to previous search result",
        defaultBindings: ["KeyK"],
        contexts: ["Search page"],
        when: () => isSearchPage(),
        run: actionGoToPrevSearchResultPage,
    },
    goToNextSearchResultPage: {
        title: "Go to next search result",
        defaultBindings: ["KeyL"],
        contexts: ["Search page"],
        when: () => isSearchPage(),
        run: actionGoToNextSearchResultPage,
    },
    changesetObjectsTimeTrack: {
        title: "Preview changeset objects time track",
        defaultBindings: ["Alt+KeyP"],
        contexts: ["Debug", "Changeset pages"],
        when: () => isDebug() && location.pathname.startsWith("/changeset"),
        run: actionChangesetObjectsTimeTrack,
    },
    goToPrevChangesetListPage: {
        title: "Previous changeset",
        defaultBindings: ["Comma"],
        contexts: ["Changeset pages"],
        when: () => isChangesetPage(),
        run: actionGoToPrevChangesetListPage,
    },
    goToNextChangesetListPage: {
        title: "Next changeset",
        defaultBindings: ["Period"],
        contexts: ["Changeset pages"],
        when: () => isChangesetPage(),
        run: actionGoToNextChangesetListPage,
    },
    goToPrevChangesetObjectHotkey: {
        title: "Previous changeset object",
        defaultBindings: ["KeyK"],
        contexts: ["Changeset pages"],
        when: () => isChangesetPage(),
        run: actionGoToPrevChangesetObjectHotkey,
    },
    goToNextChangesetObjectHotkey: {
        title: "Next changeset object",
        defaultBindings: ["KeyL"],
        contexts: ["Changeset pages"],
        when: e => isChangesetPage() && !e.shiftKey,
        run: actionGoToNextChangesetObjectHotkey,
    },
    goToPrevSidebarTab: {
        title: "Previous sidebar tab",
        defaultBindings: ["Comma"],
        contexts: ["Object pages"],
        when: () => isObjectPage(),
        run: actionGoToPrevSidebarTab,
    },
    goToNextSidebarTab: {
        title: "Next sidebar tab",
        defaultBindings: ["Period"],
        contexts: ["Object pages"],
        when: () => isObjectPage(),
        run: actionGoToNextSidebarTab,
    },
    goToPrevObjectVersionHotkey: {
        title: "Previous object version",
        defaultBindings: ["KeyK"],
        contexts: ["Object pages", "History pages"],
        when: () => isObjectHistoryPage(),
        run: actionGoToPrevObjectVersionHotkey,
    },
    goToNextObjectVersionHotkey: {
        title: "Next object version",
        defaultBindings: ["KeyL"],
        contexts: ["Object pages", "History pages"],
        when: e => isObjectHistoryPage() && !e.shiftKey,
        run: actionGoToNextObjectVersionHotkey,
    },
    goToPrevUserListPage: {
        title: "Previous list page",
        defaultBindings: ["Comma"],
        contexts: ["User pages"],
        when: () =>
            /user\/.+\/(traces|diary_comments|changeset_comments)/.test(location.pathname) ||
            /\/user_blocks($|\/)/.test(location.pathname) ||
            /\/blocks_by$/.test(location.pathname),
        run: () => actionGoToPrevPaginationPage('.pagination a[href*="after"]'),
    },
    goToNextUserListPage: {
        title: "Next list page",
        defaultBindings: ["Period"],
        contexts: ["User pages"],
        when: () =>
            /user\/.+\/(traces|diary_comments|changeset_comments)/.test(location.pathname) ||
            /\/user_blocks($|\/)/.test(location.pathname) ||
            /\/blocks_by$/.test(location.pathname),
        run: () => actionGoToPrevPaginationPage('.pagination a[href*="before"]'),
    },
    goToPrevUserNotesPage: {
        title: "Previous user notes page",
        defaultBindings: ["Comma"],
        contexts: ["User pages"],
        when: () => /user\/.+\/notes/.test(location.pathname),
        run: actionGoToPrevUserNotesPage,
    },
    goToNextUserNotesPage: {
        title: "Next user notes page",
        defaultBindings: ["Period"],
        contexts: ["User pages"],
        when: () => /user\/.+\/notes/.test(location.pathname),
        run: actionGoToNextUserNotesPage,
    },
}

function getHotkeyBaseCode(e) {
    if (/^[0-9]$/.test(e.key)) {
        return `Digit${e.key}`
    }
    if (["Slash", "Backslash", "NumpadDivide"].includes(e.code) || e.key === "/") {
        return "Slash"
    }
    if (["Backquote", "Quote"].includes(e.code) || e.key === "`" || e.key === "~") {
        return "Backquote"
    }
    return e.code || e.key
}

function getHotkeyCombo(e) {
    const parts = []
    if (e.ctrlKey) parts.push("Ctrl")
    if (e.altKey) parts.push("Alt")
    if (e.shiftKey) parts.push("Shift")
    if (e.metaKey) parts.push("Meta")
    parts.push(getHotkeyBaseCode(e))
    return parts.join("+")
}

function runHotkeyAction(actionId, e) {
    const action = hotkeyActions[actionId]
    if (!action) {
        console.warn(`Unknown hotkey action: ${actionId}`)
        return false
    }
    if (action.preventDefault) {
        e.preventDefault?.()
        e.stopPropagation?.()
        e.stopImmediatePropagation?.()
    }
    action.run(e)
    return true
}

function runHotkeyActionForEvent(e) {
    const combo = getHotkeyCombo(e)
    const actionId = Object.entries(hotkeyActions).find(
        ([, action]) => action.defaultBindings.includes(combo) && (!action.when || action.when(e)),
    )?.[0]
    if (!actionId) {
        return false
    }
    return runHotkeyAction(actionId, e)
}

function hotkeyKeydownHandler(e) {
    if (e.repeat && !["KeyK", "KeyL"].includes(e.code)) return
    if (shouldSkipHotkeyForActiveElement(e)) return
    if (handleRelationViewerHotkeys(e)) return
    if (handleMeasuringHotkeys(e)) return
    console.log("Key: ", e.key)
    console.log("Key code: ", e.code)
    if (e.code !== "KeyZ" && e.code !== "KeyD" && e.code !== "KeyS" && e.code !== "KeyS") {
        resetZoomClicks()
    }
    if (e.metaKey || e.ctrlKey) {
        return
    }
    runHotkeyActionForEvent(e)
}

function addButtonIntoRightButtonsList(linksMenuClickHandler) {
    setTimeout(async () => {
        for (let i = 0; i < 40; i++) {
            await sleep(30)
            if (document.querySelector("#open-external-panel-btn")) {
                break
            }
            const actionsBtn = document.querySelector(".control-query").cloneNode(true)
            actionsBtn.classList.remove("control-query")
            actionsBtn.id = "open-external-panel-btn"
            actionsBtn.querySelector("a").innerHTML = toolsIconSvg
            actionsBtn.querySelector("svg").setAttribute("stroke-width", "1.75")
            actionsBtn.querySelector("svg").setAttribute("width", 20)
            actionsBtn.querySelector("svg").setAttribute("height", 20)
            actionsBtn.addEventListener("click", linksMenuClickHandler)
            document.querySelector(".control-query").after(actionsBtn)
        }
    })
}

function setupNavigationViaHotkeys() {
    if ("/id" === location.pathname || document.querySelector("#id-embed")) return
    updateCurrentObjectMetadata()
    if (hotkeysConfigured) return
    hotkeysConfigured = true

    runPositionTracker()

    defaultZoomKeysBehaviour = GM_config.get("DefaultZoomKeysBehaviour")

    document.addEventListener("keydown", hotkeyKeydownHandler, false)
    if (isMobile) {
        addButtonIntoRightButtonsList(hotkeyCommandsPopupClickHandler)
    }
}

function setupOverzoomForDataLayer() {
    if (location.hash.includes("D") && location.hash.includes("layers")) {
        enableOverzoom()
    }
}

//</editor-fold>

//<editor-fold desc="better-error-page" defaultstate="collapsed">

function setupPrometheusLink() {
    if (document.querySelector("h2")?.textContent !== "This website is under heavy load (queue full)") {
        return
    }
    const hint = document.createElement("div")
    hint.textContent = ""
    hint.innerHTML = `
<a href="https://prometheus.openstreetmap.org/d/5rTT87FMk/web-site">Website prometheus</a>
<br>
<br>
<a href="https://github.com/openstreetmap/openstreetmap-website">GitHub openstreetmap-website</a>
<br>
<br>
<a href="https://github.com/openstreetmap/operations/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen">OpenStreetMap operations issues</a>
`
    document.body.appendChild(hint)
    setTimeout(() => {
        const iframe = GM_addElement("iframe", {
            src: "https://deevroman.github.io/Dino-Game-Clone",
            width: "70%",
            height: "250px",
            title: "better-osm-org easter egg :-)",
        })
        iframe.style.border = "0"
        document.body.appendChild(iframe)
    }, 4000)
}

//</editor-fold>

//<editor-fold desc="better-tags-paste" defaultstate="collapsed">

function fixTagsPaste() {
    if (!GM_config.get("BetterTagsPaste")) {
        return
    }
    function repairTags(text, context) {
        return text
            .split("\n")
            .map(line => {
                if (line.includes("=") || (!line.includes(" ") && !line.includes("\t"))) {
                    return line
                }
                const [key, value] = line.split(/[ \t](.+)/)
                if (key === undefined || value === undefined) {
                    return line
                }
                if (context === "iD") {
                    return `${key}=${value}`
                } else {
                    return `${key} = ${value}`
                }
            })
            .join("\n")
    }

    document.addEventListener("paste", e => {
        console.log("checking paste event")
        const t = e.target
        if (!(t instanceof HTMLTextAreaElement)) {
            return
        }
        let context
        if (location.pathname === "/id") {
            if (!t.classList.contains("tag-text")) {
                return
            }
            context = "iD"
        } else {
            if (!location.pathname.includes("node") && !location.pathname.includes("way") && !location.pathname.includes("relation")) {
                return
            }
            if (t.getAttribute("rows") !== "10" || t.getAttribute("cols") !== "40") {
                return
            }
            context = "osm.org tags editor"
        }
        const pos = t.selectionStart
        const textBefore = t.value.slice(0, pos)
        const line = textBefore.split(/\r?\n/).pop() ?? ""
        if (line.includes("=") || line.trim() !== "") {
            return
        }
        const raw = e.clipboardData.getData("text")
        if (!raw.trim().match(/^[0-9_:a-zA-Z]+\s/)) {
            return
        }
        const fixedText = repairTags(raw)
        if (raw === fixedText) {
            return
        }
        e.preventDefault()

        function pasteText(text, start, end) {
            let ok = false
            try {
                ok = document.execCommand("insertText", false, text)
            } catch (_) {}
            if (!ok) {
                t.setRangeText(text, start, end, "end")
                const ev = new InputEvent("input", { bubbles: true, cancelable: false, data: text, inputType: "insertFromPaste" })
                t.dispatchEvent(ev)
            }
        }

        // сначала вставляем без изменений,
        t.focus()
        const start = t.selectionStart
        const firstEnd = t.selectionEnd
        t.setSelectionRange(start, firstEnd)
        pasteText(raw, start, firstEnd)
        // потом с, чтобы ctrl + Z мог откатить исправление
        t.setSelectionRange(start, start + raw.length)
        pasteText(fixedText, start, start + raw.length)
    })
}

function setupBetterTagsPaste() {
    fixTagsPaste()
}
//</editor-fold>

//<editor-fold desc="id-editor" defaultstate="collapsed">

function addImageryOffsetsDB() {
    console.log("addImageryOffsetsDB")
    const offsetSelectionSection = document.querySelector(".disclosure-wrap-background_offset")
    if (!offsetSelectionSection) {
        return false
    }
    if (document.querySelector(".find-offsets-btn")) {
        return true
    }
    const loadBtn = document.createElement("button")
    loadBtn.textContent = t("idEditor.findOffsets")
    loadBtn.classList.add("find-offsets-btn")
    loadBtn.title = t("betterOsmOrg.experimentalFeature")
    loadBtn.onclick = async () => {
        loadBtn.style.cursor = "progress"
        try {
            const [x, y, z] = getCurrentXYZ()
            const offsets = (
                await externalFetchRetry({
                    url: `https://offsets.textual.ru/get?lat=${x}&lon=${y}&format=json&radius=2`,
                    responseType: "json",
                })
            ).response.filter(i => i.type === "offset")
            console.log(offsets)
            document.querySelectorAll(".offsets-item").forEach(i => i.remove())
            offsets.forEach(i => {
                const lat = i.lat
                const lon = i.lon
                const imlat = i.imlat
                const imlon = i.imlon
                const latDiff = -getDistanceFromLatLonInKm(lat, lon, imlat, lon) * 1000
                const lonDiff = getDistanceFromLatLonInKm(lat, lon, lat, imlon) * 1000

                debugger
                const item = document.createElement("div")
                item.classList.add("offsets-item")
                const btn = document.createElement("button")
                btn.style.cursor = "pointer"
                btn.style.textAlign = "left"
                btn.style.width = "100%"
                btn.style.marginTop = "2px"
                btn.style.marginBottom = "2px"
                btn.style.whiteSpace = "pre"
                btn.textContent = `${latDiff.toFixed(2)} ${lonDiff.toFixed(2)} ${i.date} ${i.author}\n${i.imagery} ${i.description}`
                btn.onclick = () => {
                    offsetSelectionSection.querySelector("input").value = `${lonDiff.toFixed(2)}, ${latDiff.toFixed(2)}`
                    offsetSelectionSection.querySelector("input").dispatchEvent(new Event("change"))
                }
                item.appendChild(btn)
                loadBtn.after(item)
            })
        } finally {
            loadBtn.style.cursor = "pointer"
        }
    }
    offsetSelectionSection.appendChild(loadBtn)
    return true
}

function setupImageryOffsetsDB() {
    tryApplyModule(addImageryOffsetsDB, 2000, 10000)
}

function setupIDframe() {
    if (GM_config.get("DarkModeForID")) {
        injectCSSIntoOSMPage(`
                @media ${mediaQueryForWebsiteTheme} {
                    ${GM_getResourceText("DARK_THEME_FOR_ID_CSS")}
                }`)
    }
    GM_registerMenuCommand("Show iD OAuth token", function () {
        let token = document.querySelector("#id-container")?.getAttribute("data-token")
        if (!token) {
            token = localStorage.getItem(`${osm_server.url}oauth2_access_token`)
            if (!token) {
                alert(t("idEditor.focusIframeAlert"))
                return
            }
        }
        alert(token)
    })
    setupBetterTagsPaste()
    if (isDebug()) {
        setupImageryOffsetsDB()
    }
}

//</editor-fold>

//<editor-fold desc="osm-website" defaultstate="collapsed">

// Модули должны стать классами
// - поддерживается всеми браузерами, в которых есть TM.
// - изоляция функций и глобальных переменных
// - для модулей, которые внедряются через setInterval можно сохранить таймер, чтобы предотвратить дублирование вызовов
// - возможность сохранить результат внедрения

/***@type {((function(): Promise<void>|void))[]}*/
const modules = [
    setupDarkModeForMap,
    setupHDYCInProfile,
    setupBetterProfileStat,
    setupCompactChangesetsHistory,
    setupMassChangesetsActions,
    setupRevertButton,
    setupResolveNotesButton,
    setupDeletor,
    setupHideNoteHighlight,
    setupSatelliteLayers,
    setupVersionsDiff,
    setupChangesetQuickLook,
    setupNewEditorsLinks,
    setupNavigationViaHotkeys,
    setupClickableAvatar,
    setupOverzoomForDataLayer,
    setupDragAndDropViewers,
    setupBetterTagsPaste,
    setupClickableMap,
]
/***@type {((function(): Promise<void>|void))[]}*/
const alwaysEnabledModules = [
    setupRelationVersionViewer,
    setupMakeVersionPageBetter,
    setupNotesFiltersButtons,
    setupGPXFiltersButtons,
    setupSpyGlassButtons,
    setupNewContextMenuItems,
    setupPrometheusLink,
]

function selectOverpassServer() {
    if (isOHMServer()) {
        overpass_server = OHM_OVERPASS_INSTANCE
    } else if (isOGFServer()) {
        overpass_server = OGF_OVERPASS_INSTANCE
    } else if (GM_config.get("OverpassInstance") === MAILRU_OVERPASS_INSTANCE.name) {
        overpass_server = MAILRU_OVERPASS_INSTANCE
    } else if (GM_config.get("OverpassInstance") === PRIVATECOFFEE_OVERPASS_INSTANCE.name) {
        overpass_server = PRIVATECOFFEE_OVERPASS_INSTANCE
    } else {
        overpass_server = MAIN_OVERPASS_INSTANCE
    }
}

function selectLevel0Instance() {
    if (isDebug()) {
        level0Instance = REBORN_LEVEL0_INSTANCE
    } else {
        level0Instance = MAIN_LEVEL0_INSTANCE
    }
}

function applyModules(path) {
    for (const module of modules.filter(module => GM_config.get(module.name.slice("setup".length)))) {
        queueMicrotask(() => {
            // console.log(module.name)
            module(path)
        })
    }
    for (const module of alwaysEnabledModules) {
        queueMicrotask(() => {
            // console.log(module.name)
            void module(path)
        })
    }
}

function cleanupBeforeNewLocation(path) {
    try {
        shiftKeyZClicks = 0
        abortPrevControllers(ABORT_ERROR_WHEN_PAGE_CHANGED)
        tracksCounter = 0
        cleanAllObjects()
        setAttributionPrefix("")
        addSwipes()
        document.querySelector("#fixed-rss-feed")?.remove()
        buildingViewerIframe?.remove()
        buildingViewerIframe = null
        historyPagePaginationDeletingObserver?.disconnect()
        historyPagePaginationDeletingObserver = null
        paginationInHistoryStepObserver?.disconnect()
        paginationInHistoryStepObserver = null
        removePOIMoverMenu()
        // prettier-ignore
        if (!path.startsWith("/changeset") && !path.startsWith("/history") &&
            !path.startsWith("/node") && !path.startsWith("/way") && path !== "/relation" &&
            !path.startsWith("/note")) {
            showSearchForm()
        }
    } catch {
        /* empty */
    }
}

function registerFastOneTimeModulesApplier(path) {
    const sidebarContent = document.getElementById("sidebar_content")
    if (!sidebarContent || sidebarContent.childNodes.length) return
    try {
        new MutationObserver(function (_, observer) {
            observer.disconnect()
            console.log("%c⚡️Fast modules apply!", "background: #000; color: #0f0")
            console.log(path)
            applyModules(path)
        }).observe(sidebarContent, { subtree: true, childList: true })
    } catch (e) {
        console.error(e)
    }
}

function setupOSMWebsite() {
    if (location.pathname === "/id") {
        setupIDframe()
        return
    }

    setTimeout(async () => {
        if (location.pathname.includes("/user/") && !location.pathname.includes("/history")) return
        if (getWindow && !getWindow().mapIntercepted) {
            console.log("map not intercepted after 900ms")
            document.querySelector(".control-share a").click()
            document.querySelector(".control-share a").click()
            await interceptMapManually()
        }
    }, 900)

    resetSearchFormFocus()
    selectOverpassServer()
    selectLevel0Instance()
    let lastPath = ""
    new MutationObserver(
        (function mainObserverHandler() {
            const path = location.pathname
            if (path === lastPath) return
            cleanupBeforeNewLocation(path)
            lastPath = path
            applyModules(path)
            registerFastOneTimeModulesApplier(path)
            return mainObserverHandler
        })(),
    ).observe(document, { subtree: true, childList: true })
    if (location.pathname.startsWith("/dashboard") || location.pathname.startsWith("/user/")) {
        setTimeout(loadFriends, 4000)
    }
    setTimeout(() => {
        if (GM_config.get("CompactChangesetsHistory")) {
            document.querySelector('.nav-link[href^="/history"]')?.addEventListener(
                "click",
                () => {
                    addCompactSidebarStyle()
                },
                { once: true },
            )
        }
        document.querySelectorAll('nav a[href="/export"]').forEach(i =>
            i.addEventListener("contextmenu", async e => {
                e.preventDefault()
                const bounds = await getMapBounds()
                showBboxMenu(e, {
                    max_lat: bounds.getNorthWest().lat,
                    min_lon: bounds.getNorthWest().lng,
                    min_lat: bounds.getSouthEast().lat,
                    max_lon: bounds.getSouthEast().lng,
                })
            }),
        )
    })
}

//</editor-fold>

//<editor-fold desc="taginfo" defaultstate="collapsed">

async function saveCurrentRegionalInstance(value, name) {
    const history = JSON.parse(await GM.getValue("last-regional-taginfo-instances", "[]")).filter(i => i.value !== value)
    history.push({
        value,
        name,
    })
    if (history.length > 10) {
        history.shift()
    }
    await GM.setValue("last-regional-taginfo-instances", JSON.stringify(history))
}

function addRegionalTaginfoSelector() {
    if (location.origin !== "https://taginfo.openstreetmap.org") {
        const instance_select = document.querySelector("#instance_select")
        if (!instance_select) {
            return
        }
        if (location.pathname.includes(instance_select.value)) {
            void saveCurrentRegionalInstance(instance_select.value, instance_select.selectedOptions[0].textContent)
        }
        if (!document.querySelector(".global-taginfo-link")) {
            const globalHref = "https://taginfo.openstreetmap.org/" + location.pathname.replace(/^.+?(\/|$)/, "") + location.hash
            const tools = document.querySelector("#tools ul")
            if (tools) {
                const li = document.createElement("li")
                li.classList.add("button")
                tools.prepend(li)

                const a = document.createElement("a")
                a.classList.add("global-taginfo-link")
                a.textContent = "global"
                a.target = "_blank"
                a.href = globalHref
                li.appendChild(a)
            }
            const dataDate = document.querySelector(".header-date")
            dataDate.style.display = "flex"
            const newTitle = "Go to global Taginfo"
            dataDate.setAttribute("title", newTitle)
            dataDate.setAttribute("data-tooltip-text", newTitle)
            const globalLink = document.createElement("a")
            globalLink.classList.add("global-taginfo-link")
            globalLink.style.marginRight = "auto"
            globalLink.textContent = "🌐"
            globalLink.href = globalHref
            dataDate.prepend(globalLink)
        }
        return
    }
    if (document.querySelector("#instance_select")) {
        return
    }
    const locale_select = document.querySelector("#locale")
    const instance_select = locale_select.cloneNode()
    instance_select.id = "instance_select"
    locale_select.before(instance_select)

    instance_select.appendChild(document.createElement("option"))

    const page = new DOMParser().parseFromString(GM_getResourceText("REGIONAL_TAGINFOS"), "text/html")
    page.querySelectorAll("option").forEach(i => {
        const opt = document.createElement("option")
        opt.value = i.value
        opt.textContent = i.textContent.replace(/^   /, "")
        instance_select.appendChild(opt)
    })
    queueMicrotask(async () => {
        const history = JSON.parse(await GM.getValue("last-regional-taginfo-instances", "[]"))
        const hr = document.createElement("hr")
        instance_select.firstChild.after(hr)
        history.forEach(({ value, name }) => {
            const opt = document.createElement("option")
            opt.value = value
            opt.textContent = name
            instance_select.firstChild.after(opt)
        })
    })
    instance_select.onchange = async e => {
        const instance = instance_select.value
        void saveCurrentRegionalInstance(instance, instance_select.selectedOptions[0].textContent)
        location.href = "https://taginfo.geofabrik.de/" + instance + location.pathname + location.search + location.hash
    }
}

function setupTaginfo() {
    if (!GM_config.get("BetterTaginfo")) return

    const instance_text = document.querySelector("#instance")?.textContent
    const instance = instance_text?.replace(/ \(.*\)/, "")

    addRegionalTaginfoSelector()

    // fix overpass links on regional taginfo
    if (instance_text?.includes(" ")) {
        const turboLink = document.querySelector("#turbo_button:not(.fixed-link)")
        if (turboLink && (turboLink.href.includes("%22+in") || turboLink.href.includes("*+in") || turboLink.href.includes("relation+in"))) {
            turboLink.href = turboLink.href.replace(/(%22|\*|relation)\+in\+(.*)&/, `$1+in+"${instance}"&`)
            turboLink.classList?.add("fixed-link")
        }
    }

    function escapeTaginfoString(s) {
        return s.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("␣", " ")
    }

    function makeOverpassLink() {
        const a = document.createElement("a")
        a.classList.add("overpass-link")
        a.textContent = "🔍"
        a.target = "_blank"
        return a
    }

    if (location.pathname.match(/reports\/key_lengths$/)) {
        document.querySelectorAll(".dt-body[data-col='1']").forEach(i => {
            if (i.querySelector(".overpass-link")) return
            const overpassLink = makeOverpassLink()
            const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ""))
            const key = i.querySelector(".empty") ? "" : escapeTaginfoString(i.querySelector("a").textContent)
            overpassLink.href =
                `${overpass_server.url}?` +
                (count > 100000
                    ? new URLSearchParams({
                          w: instance ? `"${key}"=* in "${instance}"` : `"${key}"=*`,
                      }).toString()
                    : new URLSearchParams({
                          w: instance ? `"${key}"=* in "${instance}"` : `"${key}"=* global`,
                          R: "",
                      }).toString())
            i.prepend(document.createTextNode("\xA0"))
            i.prepend(overpassLink)
        })
    } else if (location.pathname.match(/relations\//)) {
        if (location.hash !== "#roles") {
            return
        }
        if (!document.querySelector(".value")) {
            console.log("Table not loaded")
            return
        }
        document.querySelectorAll("#roles .dt-body[data-col='0']").forEach(i => {
            if (i.querySelector(".overpass-link")) return
            const overpassLink = makeOverpassLink()
            overpassLink.style.cursor = "progress"
            const role = i.querySelector(".empty") ? "" : escapeTaginfoString(i.textContent)
            const type = location.pathname.match(/relations\/(.*$)/)[1]
            const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ""))
            if (instance) {
                fetchJSONWithCache(
                    "https://nominatim.openstreetmap.org/search?" +
                        new URLSearchParams({
                            format: "json",
                            q: instance,
                        }).toString(),
                ).then(r => {
                    if (r[0]["osm_id"]) {
                        const query = `// ${instance}
area(id:${3600000000 + parseInt(r[0]["osm_id"])})->.a;
rel[type=${type}](if:count_by_role("${role}") > 0)(area.a);
out geom;
`
                        // prettier-ignore
                        overpassLink.href = `${overpass_server.url}?` + (count > 1000
                            ? new URLSearchParams({Q: query})
                            : new URLSearchParams({Q: query, R: ""})).toString()
                        overpassLink.style.cursor = "pointer"
                    } else {
                        overpassLink.remove()
                    }
                })
            } else {
                const query = `rel[type=${type}](if:count_by_role("${role}") > 0)${count > 1000 ? "({{bbox}})" : ""};\nout geom;`
                // prettier-ignore
                overpassLink.href = `${overpass_server.url}?` + (count > 1000
                    ? new URLSearchParams({Q: query})
                    : new URLSearchParams({Q: query, R: ""})).toString()
                overpassLink.style.cursor = "pointer"
            }
            i.prepend(document.createTextNode("\xA0"))
            i.prepend(overpassLink)
        })
    } else if (location.hash === "#values") {
        const key = escapeTaginfoString(document.querySelector("h1").textContent)
        document.querySelectorAll("#values .dt-body[data-col='0']").forEach(i => {
            if (i.querySelector(".overpass-link")) return
            const overpassLink = makeOverpassLink()
            const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ""))
            const value = i.querySelector(".empty") ? "" : escapeTaginfoString(i.querySelector("a").textContent)
            overpassLink.href =
                `${overpass_server.url}?` +
                (count > 10000
                    ? new URLSearchParams({
                          w: instance ? `"${key}"="${value}" in "${instance}"` : `"${key}"="${value}"`,
                      }).toString()
                    : new URLSearchParams({
                          w: instance ? `"${key}"="${value}" in "${instance}"` : `"${key}"="${value}" global`,
                          R: "",
                      }).toString())
            i.prepend(document.createTextNode("\xA0"))
            i.prepend(overpassLink)
        })
    } else if (location.hash === "#combinations") {
        if (location.pathname.includes("/keys/")) {
            const key = escapeTaginfoString(document.querySelector("h1").textContent)
            document.querySelectorAll("#combinations .dt-body[data-col='1']").forEach(i => {
                if (i.querySelector(".overpass-link")) return
                const overpassLink = makeOverpassLink()
                const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ""))
                const key2 = i.querySelector(".empty") ? "" : escapeTaginfoString(i.querySelector("a").textContent)
                overpassLink.href =
                    `${overpass_server.url}?` +
                    (count > 10000
                        ? new URLSearchParams({
                              w: `"${key}"=* and "${key2}"=*`,
                          }).toString()
                        : new URLSearchParams({
                              w: instance ? `"${key}"=* and "${key2}"=* in "${instance}"` : `"${key}"=* and "${key2}"=* global`,
                              R: "",
                          }).toString())
                i.prepend(document.createTextNode("\xA0"))
                i.prepend(overpassLink)
            })
        } else if (location.pathname.includes("/tags/")) {
            const rawKeyValue = escapeTaginfoString(document.querySelector("h1").textContent)
            const [key, ...tail] = rawKeyValue.split("=")
            const tagValue = `${key}="${tail.join("=")}"`
            document.querySelectorAll("#combinations .dt-body[data-col='1']").forEach(i => {
                if (i.querySelector(".overpass-link")) return
                const overpassLink = makeOverpassLink()
                const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ""))
                const [keyLink, valueLink] = Array.from(i.querySelectorAll("a"))
                const rawKey2 = `"${escapeTaginfoString(keyLink.textContent)}"`
                const rawValue2 = valueLink === undefined ? "*" : `"${escapeTaginfoString(valueLink.textContent)}"`
                const tag2Value = `${rawKey2}=${rawValue2}`
                overpassLink.href =
                    `${overpass_server.url}?` +
                    (count > 10000
                        ? new URLSearchParams({
                              w: `${tagValue} and ${tag2Value}`,
                          }).toString()
                        : new URLSearchParams({
                              w: instance ? `${tagValue} and ${tag2Value} in "${instance}"` : `${tagValue} and ${tag2Value} global`,
                              R: "",
                          }).toString())
                i.prepend(document.createTextNode("\xA0"))
                i.prepend(overpassLink)
            })
        }
    } else if (location.pathname.includes("/tags/") && document.querySelector(".overview-container")) {
        // overpass links for tag by OSM type
        const rawKeyValue = escapeTaginfoString(document.querySelector("h1").textContent)
        const [key, ...tail] = rawKeyValue.split("=")
        const keyValue = `${key}="${tail.join("=")}"`
        document.querySelectorAll("#grid-overview .dt-body[data-col='0']").forEach(i => {
            if (i.querySelector(".overpass-link")) return
            const icon = i.querySelector("img")
            const type = icon.src.match(/\/types\/(.+)\.svg$/)[1]
            const overpassTypeSelector = type === "all" ? "" : `type:${type} and`
            const overpassLink = document.createElement("a")
            overpassLink.classList.add("overpass-link")
            overpassLink.textContent = i.lastChild.textContent.trim()
            overpassLink.title = t("taginfo.searchWithOverpass")
            overpassLink.target = "_blank"
            const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ""))
            overpassLink.href =
                `${overpass_server.url}?` +
                (count > 10000
                    ? new URLSearchParams({
                          w: instance ? `${overpassTypeSelector} ${keyValue} in "${instance}"` : `${overpassTypeSelector} ${keyValue}`,
                      }).toString()
                    : new URLSearchParams({
                          w: instance ? `${overpassTypeSelector} ${keyValue} in "${instance}"` : `${overpassTypeSelector} ${keyValue} global`,
                          R: "",
                      }).toString())
            i.lastChild.replaceWith(overpassLink)
            overpassLink.before(document.createTextNode("\xA0"))
        })
    }
}

//</editor-fold>

//<editor-fold desc="wiki" defaultstate="collapsed">

function addToList(list, who, type) {
    if (list.has(who)) {
        console.error(`duplicate ${type} vote from ${who}`)
        return false
    }
    list.add(who)
    return true
}

function setupWiki() {
    if (!location.pathname.startsWith("/wiki/Proposal:")) {
        return
    }
    const supportList = new Set()
    const opposeList = new Set()
    const abstainList = new Set()

    const supportSymbolQuery = '[href="/wiki/File:Symbol_support_vote.svg"]'
    const opposeSymbolQuery = '[href="/wiki/File:Symbol_oppose_vote.svg"]'
    const abstainSymbolQuery = '[href="/wiki/File:Symbol_abstain_vote.svg"]'
    let hasDupeVotes = false
    for (const ul of document.querySelectorAll(":is(h1,h2):has(#Voting) ~ ul")) {
        for (const li of ul.querySelectorAll('li:has([typeof="mw:File"])')) {
            const anchors = Array.from(li.querySelectorAll(":scope > a"))
            const who = (anchors.at(-2) ?? anchors.at(-1)).textContent
            if (li.querySelectorAll(supportSymbolQuery).length === 1) {
                hasDupeVotes |= !addToList(supportList, who, "support")
            } else if (li.querySelectorAll(opposeSymbolQuery).length === 1) {
                hasDupeVotes |= !addToList(opposeList, who, "oppose")
            } else if (li.querySelectorAll(abstainSymbolQuery).length === 1) {
                hasDupeVotes |= !addToList(abstainList, who, "abstain")
            } else {
                console.error("invalid data for", li)
                return
            }
        }
    }

    if (hasDupeVotes) {
        console.error("duplicate votes found in vote lists")
        return
    }

    const sum = supportList.size + opposeList.size
    if (sum === 0) {
        console.log("votes not found")
        return
    }
    console.log(supportList, opposeList, abstainList)
    const results = document.createElement("table")
    results.style.textAlign = "right"

    const support = results.insertRow()
    const oppose = results.insertRow()
    const abstain = results.insertRow()

    const supportImg = document.querySelector(supportSymbolQuery).cloneNode(true)
    const opposeImg = document.querySelector(opposeSymbolQuery).cloneNode(true)
    const abstainImg = document.querySelector(abstainSymbolQuery).cloneNode(true)

    support.insertCell().appendChild(supportImg)
    support.insertCell().appendChild(document.createTextNode(`${supportList.size}`))
    support.insertCell().appendChild(document.createTextNode(`${((supportList.size / sum) * 100).toFixed(1)}%`))
    if (supportList.size > opposeList.size * 3) {
        support.insertCell().appendChild(document.createTextNode(`> 75%`))
    } else {
        support.insertCell().appendChild(document.createTextNode(t("wiki.needMoreVotes", { count: 3 * opposeList.size - supportList.size })))
    }

    oppose.insertCell().appendChild(opposeImg)
    oppose.insertCell().appendChild(document.createTextNode(`${opposeList.size}`))
    oppose.insertCell().appendChild(document.createTextNode(`${((opposeList.size / sum) * 100).toFixed(1)}%`))

    abstain.insertCell().appendChild(abstainImg)
    abstain.insertCell().appendChild(document.createTextNode(`${abstainList.size}`))
    abstain.insertCell().appendChild(document.createTextNode(``))

    Array.from(document.querySelectorAll(":is(h2,h1):has(#Voting) ~ :is(ul,dl)")).at(-1).after(results)
    results.before(document.createElement("br"))
    results.before(document.createTextNode(t("wiki.interimResults")))
}

//</editor-fold>

//<editor-fold desc="overpass" defaultstate="collapsed">

function addLevel0Reborn() {
    if (!document.querySelector("#export-dialog.is-active")) {
        return
    }
    if (document.querySelector("#export-editors-level0-reborn")) {
        document.querySelector("#export-editors-level0-reborn").remove()
        document.querySelector("#export-editors-level0-reborn-bbox")?.remove()
    }
    const l0export = document.querySelector("#export-editors-level0")

    const l0reborn = l0export.cloneNode(true)
    l0reborn.id = "export-editors-level0-reborn"
    l0reborn.textContent += "Reborn β"
    l0reborn.setAttribute("href", "")
    l0reborn.onclick = function () {
        const originalHref = l0export.getAttribute("href")
        const newHref = originalHref.replace(MAIN_LEVEL0_INSTANCE, REBORN_LEVEL0_INSTANCE)
        Array.from(document.querySelectorAll(".modal.is-active .modal-card-head .delete")).at(-1).click()
        l0reborn.setAttribute("href", newHref)
    }

    l0export.after(l0reborn)

    const l0rebornBboox = l0export.cloneNode(true)
    l0rebornBboox.id = "export-editors-level0-reborn-bbox"
    l0rebornBboox.textContent = "only bbox"
    l0rebornBboox.setAttribute("href", "")
    l0rebornBboox.onclick = function () {
        document.querySelector("#export-map-state").click()
        const bbox = Array.from(document.querySelectorAll(".modal.is-active .modal-card-body p:has(small)")).at(-1).firstChild.data

        const originalHref = l0export.getAttribute("href")
        const originalURL = new URL(originalHref)
        const params = new URLSearchParams(originalURL.search)
        const overpassURL = new URL(params.get("url"))

        const bboxedParams = new URLSearchParams(overpassURL.search)
        if (!bboxedParams.get("data").includes("[bbox:")) {
            bboxedParams.set("data", `[bbox:${bbox.replaceAll(" ", "")}]` + bboxedParams.get("data"))
        }
        bboxedParams.toString()

        overpassURL.search = bboxedParams.toString()
        params.set("url", overpassURL.toString())
        originalURL.search = params.toString()

        const newHref = originalURL.toString().replace(MAIN_LEVEL0_INSTANCE, REBORN_LEVEL0_INSTANCE)
        Array.from(document.querySelectorAll(".modal.is-active .modal-card-head .delete")).at(-1).click()
        l0rebornBboox.setAttribute("href", newHref)
    }
    l0reborn.after(l0rebornBboox)
    l0reborn.after(document.createTextNode("\xA0"))
}

function setupOverpass() {
    if (!isDebug()) {
        return
    }
    const obs = new MutationObserver(addLevel0Reborn)
    obs.observe(document.querySelector("#export-dialog"), { attributes: true })
}

//</editor-fold>

//<editor-fold desc="osmcha" defaultstate="collapsed">

async function extractOsmchaToken() {
    function _extractOsmchaToken() {
        let token = localStorage.getItem("token")
        if (!token) {
            token = JSON.parse(localStorage.getItem("auth"))["state"]["token"]
        }
        return token
    }

    try {
        return _extractOsmchaToken()
    } catch (e) {
        console.error(e)
        await sleep(3000)
        return _extractOsmchaToken()
    }
}

async function storeOsmchaTokenWithOwner(origin, token, STORAGE_KEY) {
    const userinfo = await fetch(`${origin}/api/v1/users`, { headers: { Authorization: `Token ${token}` } }).then(res => res.json())
    if (!userinfo.uid) {
        return
    }
    const prev = await GM.getValue(STORAGE_KEY, {})
    await GM.setValue(STORAGE_KEY, {
        ...prev,
        [userinfo.uid]: token,
    })
    console.log("stored osmcha token", origin, userinfo.uid, token.slice(0, 5) + "...")
}

function setupOsmcha() {
    setTimeout(async () => {
        const token = await extractOsmchaToken()
        await GM.setValue("OSMCHA_TOKEN", token)
        await storeOsmchaTokenWithOwner("https://osmcha.org", token, "OSMCHA_TOKENS")
    }, 1000)
}

function setupOhmOsmcha() {
    setTimeout(async () => {
        const token = await extractOsmchaToken()
        await GM.setValue("OHM_OSMCHA_TOKEN", token)
        await storeOsmchaTokenWithOwner("https://osmcha.openhistoricalmap.org", token, "OHM_OSMCHA_TOKENS")
    }, 1000)
}

//</editor-fold>

//<editor-fold desc="script-menu" defaultstate="collapsed">

function makeCommandsMenu() {
    try {
        GM_registerMenuCommand(t("scriptMenu.settings"), function () {
            if (!inFrame()) {
                actionOpenSettings()
            }
        })
        if (isMobile || isDebug()) {
            GM_registerMenuCommand(t("scriptMenu.checkScriptUpdates"), actionOpenScriptUpdateUrl)
        }
        if (isDebug()) {
            GM_registerMenuCommand(t("scriptMenu.checkDevScriptUpdates"), actionOpenDevScriptUpdateUrl)
        }

        // New Year Easter egg
        const curDate = new Date()
        if ((curDate.getMonth() === 11 && curDate.getDate() >= 18) || (curDate.getMonth() === 0 && curDate.getDate() < 10)) {
            GM_registerMenuCommand("☃️", runSnowAnimation)
        }

        GM_registerMenuCommand(t("scriptMenu.listOfHotkeys"), actionShowHotkeysHelp)
        // GM_registerMenuCommand("Ask question on forum", function () {
        //     window.open("https://community.openstreetmap.org/t/better-osm-org-a-script-that-adds-useful-little-things-to-osm-org/121670")
        // });
    } catch (e) {
        console.error(e)
    }
}

//</editor-fold>

//<editor-fold desc="main" defaultstate="collapsed">
// TODO сначала проверить сайт, а после инициализировать GM_config и вызывать main
function _main() {
    "use strict"
    if (GM_config.get("DebugMode")) {
        _isDebug = true
    }
    if (location.origin === ogf_prod_server.origin && !isDebug()) {
        return
    }
    if (GM_config.get("ColorblindFriendlyPalette")) {
        setColorblindFriendlyPalette()
    }
    if (location.origin === "https://www.hdyc.neis-one.org" || location.origin === "https://hdyc.neis-one.org") {
        simplifyHDCYIframe()
        return
    }

    if (location.origin === "https://osmcha.org") {
        setupOsmcha()
        return
    }
    if (location.origin === "https://osmcha.openhistoricalmap.org") {
        setupOhmOsmcha()
        return
    }
    makeCommandsMenu()
    if (location.origin === "https://taginfo.openstreetmap.org" || location.origin === "https://taginfo.geofabrik.de") {
        new MutationObserver(
            (function fn() {
                queueMicrotask(setupTaginfo)
                return fn
            })(),
        ).observe(document, { subtree: true, childList: true })
        return
    }
    if (isOsmServer()) {
        setupOSMWebsite()
    }
    if (location.origin === "https://wiki.openstreetmap.org") {
        setupWiki()
    }
    if (location.origin === "https://overpass-turbo.eu") {
        setupOverpass()
    }
}

function main() {
    performance.mark("BETTER_OSM_MAIN_CALL")
    runOnDOMContentLoaded(_main)
}
//</editor-fold>

// garbage collection for cached infos (user info, changeset history)
async function runGC() {
    const USERINFO_PREFIXES = ["userinfo-", "ohm-userinfo-", "ogf-userinfo-", "dev-userinfo-"]
    const USER_ID_INFO_PREFIXES = ["useridinfo-", "ohm-useridinfo-", "ogf-useridinfo-", "dev-useridinfo-"]
    const USER_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 14

    async function deleteIfExpiredCache(key) {
        try {
            const cacheData = JSON.parse(await GM.getValue(key))
            if (!cacheData.cacheTime) return

            const cacheTime = new Date(cacheData.cacheTime).getTime()
            if (isNaN(cacheTime) || cacheTime + USER_CACHE_TTL_MS < Date.now()) {
                await GM.deleteValue(key)
            }
        } catch {
            /* empty */
        }
    }

    if (Math.random() > 0.5) return
    if (!location.pathname.includes("/history") && !location.pathname.includes("/note") && !location.pathname.includes("/user")) return
    const lastGC = await GM.getValue("last-garbage-collection-time")
    console.debug("last-garbage-collection-time", new Date(lastGC))
    if (lastGC && new Date(lastGC).getTime() + 1000 * 60 * 60 * 24 * 2 > Date.now()) return
    await GM.setValue("last-garbage-collection-time", Date.now())

    const keys = await GM.listValues()
    for (const i of keys) {
        if ([...USERINFO_PREFIXES, ...USER_ID_INFO_PREFIXES].some(prefix => i.startsWith(prefix))) {
            await deleteIfExpiredCache(i)
        }
    }
    console.log("Old cache cleaned")
}
setTimeout(runGC, 1000 * 12)

/* const unhandledExceptions = []

const globalWarn = "<a class='global-report' title='Open browser console'>⚠️ </a>"

window.addEventListener("error", function (message, file, line, col, error) {
    if (isDebug()) {
        unhandledExceptions.push(error)
        setAttributionPrefix(globalWarn)
    }
    return false
})

getWindow().addEventListener(
    "error",
    intoPageWithFun(function (message, file, line, col, error) {
        if (isDebug()) {
            unhandledExceptions.push(error)
            setAttributionPrefix(globalWarn)
        }
        return false
    }),
)

getWindow().addEventListener(
    "unhandledrejection",
    intoPageWithFun(e => {
        if (isDebug()) {
            unhandledExceptions.push(e)
            setAttributionPrefix(globalWarn)
        }
        return false
    }),
)

injectJSIntoPage(`
// window.$(".global-report")
//     .on(
//         "click",
//         intoPageWithFun(() => {
//             alert(1)
//         }),
//     )
// `)
 */
performance.mark("BETTER_OSM_END")