From e4203e8966f1f95fbe94e21714212ccfa522bc12 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 17 Nov 2025 12:51:48 -0800 Subject: [PATCH] Add initial workspace review queue --- assets/scss/main.scss | 32 + assets/scss/theme.scss | 4 + components/AppIcon.vue | 6 +- components/AppNavbar.vue | 2 + components/AppPage.vue | 17 +- components/ChatBox.vue | 129 +++ components/ChatBoxMessage.vue | 64 ++ components/dashboard/Toolbar.vue | 22 +- components/review/AttributeDiff.vue | 284 +++++++ components/review/ChangesBadge.vue | 92 +++ components/review/ChangesetDetails.vue | 71 ++ components/review/Details.vue | 98 +++ components/review/Discussion.vue | 115 +++ components/review/FilterDropdown.vue | 82 ++ components/review/Item.vue | 147 ++++ components/review/Map.vue | 210 +++++ components/review/Overlay.vue | 61 ++ components/review/Sidebar.vue | 150 ++++ components/review/Toolbar.vue | 96 +++ package-lock.json | 1018 +++++++++++++++++++++++- package.json | 4 + pages/dashboard.vue | 2 +- pages/workspace/[id]/review.vue | 138 ++++ services/cache.ts | 168 ++++ services/changesets.ts | 449 +++++++++++ services/index.ts | 27 + services/osm.ts | 240 +++++- services/review.ts | 325 ++++++++ services/tdei.ts | 34 + types/adiff.ts | 30 + types/maplibre-adiff-viewer.d.ts | 1 + types/osm.ts | 91 +++ types/osmchange-parser.d.ts | 1 + types/tdei.ts | 26 + types/workspaces.ts | 20 + util/time.ts | 17 + 36 files changed, 4257 insertions(+), 16 deletions(-) create mode 100644 components/ChatBox.vue create mode 100644 components/ChatBoxMessage.vue create mode 100644 components/review/AttributeDiff.vue create mode 100644 components/review/ChangesBadge.vue create mode 100644 components/review/ChangesetDetails.vue create mode 100644 components/review/Details.vue create mode 100644 components/review/Discussion.vue create mode 100644 components/review/FilterDropdown.vue create mode 100644 components/review/Item.vue create mode 100644 components/review/Map.vue create mode 100644 components/review/Overlay.vue create mode 100644 components/review/Sidebar.vue create mode 100644 components/review/Toolbar.vue create mode 100644 pages/workspace/[id]/review.vue create mode 100644 services/cache.ts create mode 100644 services/changesets.ts create mode 100644 services/review.ts create mode 100644 types/adiff.ts create mode 100644 types/maplibre-adiff-viewer.d.ts create mode 100644 types/osm.ts create mode 100644 types/osmchange-parser.d.ts create mode 100644 types/tdei.ts create mode 100644 types/workspaces.ts create mode 100644 util/time.ts diff --git a/assets/scss/main.scss b/assets/scss/main.scss index 6aeedde..2e00af6 100644 --- a/assets/scss/main.scss +++ b/assets/scss/main.scss @@ -1,5 +1,12 @@ @import "theme.scss"; @import "bootstrap/scss/bootstrap.scss"; +@import "maplibre-gl/dist/maplibre-gl.css"; + +:root { + --ws-create-color: $review-create-color; + --ws-modify-color: $review-modify-color; + --ws-delete-color: $review-delete-color; +} html, body, #__nuxt { width: 100%; @@ -34,3 +41,28 @@ label > .form-select:first-child { width: 100%; height: 100%; } + +.dropdown-menu { + box-shadow: $box-shadow; +} + +/* Review */ + +.bg-create { + background-color: $review-create-color; +} +.bg-modify { + background-color: $review-modify-color; +} +.bg-delete { + background-color: $review-delete-color; +} +.text-create { + color: #{darken($review-create-color, 27%)}; +} +.text-modify { + color: $review-modify-color; +} +.text-delete { + color: #{darken($review-delete-color, 3%)}; +} diff --git a/assets/scss/theme.scss b/assets/scss/theme.scss index 0ff4519..5df4fdf 100644 --- a/assets/scss/theme.scss +++ b/assets/scss/theme.scss @@ -10,3 +10,7 @@ $primary: #9b0092; @import "bootstrap/scss/mixins"; $navbar-height: 58px; + +$review-create-color: #39dbc0; +$review-modify-color: #db950a; +$review-delete-color: #cc2c47; diff --git a/components/AppIcon.vue b/components/AppIcon.vue index 19c3b2f..0273716 100644 --- a/components/AppIcon.vue +++ b/components/AppIcon.vue @@ -30,6 +30,10 @@ const classes = computed(() => ([ .material-icons { vertical-align: middle; margin-top: -3px; + + &.me-2 { + margin-right: 0.33em !important; + } } .badge .material-icons { font-size: 1em; @@ -37,7 +41,7 @@ const classes = computed(() => ([ margin-top: 0; &.me-2 { - margin-right: 0.25rem !important; + margin-right: 0.25em !important; } } diff --git a/components/AppNavbar.vue b/components/AppNavbar.vue index dfe5802..8266eeb 100644 --- a/components/AppNavbar.vue +++ b/components/AppNavbar.vue @@ -54,6 +54,8 @@ const auth = tdeiClient.auth diff --git a/components/ChatBoxMessage.vue b/components/ChatBoxMessage.vue new file mode 100644 index 0000000..1c3d370 --- /dev/null +++ b/components/ChatBoxMessage.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/components/dashboard/Toolbar.vue b/components/dashboard/Toolbar.vue index 7f95fc9..76f788b 100644 --- a/components/dashboard/Toolbar.vue +++ b/components/dashboard/Toolbar.vue @@ -4,7 +4,18 @@ Edit -
+
+ + + Review + - - Export + + Export
- - Settings + + Settings
@@ -49,6 +60,7 @@ const editRoute = computed(() => ({ hash: editHash.value })); +const reviewRoute = computed(() => workspacePath('review')); const exportRoute = computed(() => workspacePath('export')); const settingsRoute = computed(() => workspacePath('settings')); diff --git a/components/review/AttributeDiff.vue b/components/review/AttributeDiff.vue new file mode 100644 index 0000000..41d7d1c --- /dev/null +++ b/components/review/AttributeDiff.vue @@ -0,0 +1,284 @@ + + + + + + + diff --git a/components/review/ChangesBadge.vue b/components/review/ChangesBadge.vue new file mode 100644 index 0000000..88b5d5c --- /dev/null +++ b/components/review/ChangesBadge.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/components/review/ChangesetDetails.vue b/components/review/ChangesetDetails.vue new file mode 100644 index 0000000..72caf03 --- /dev/null +++ b/components/review/ChangesetDetails.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/components/review/Details.vue b/components/review/Details.vue new file mode 100644 index 0000000..adde49a --- /dev/null +++ b/components/review/Details.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue new file mode 100644 index 0000000..ca8789d --- /dev/null +++ b/components/review/Discussion.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/components/review/FilterDropdown.vue b/components/review/FilterDropdown.vue new file mode 100644 index 0000000..1c89542 --- /dev/null +++ b/components/review/FilterDropdown.vue @@ -0,0 +1,82 @@ + + + diff --git a/components/review/Item.vue b/components/review/Item.vue new file mode 100644 index 0000000..c470c73 --- /dev/null +++ b/components/review/Item.vue @@ -0,0 +1,147 @@ + + + + + + + diff --git a/components/review/Map.vue b/components/review/Map.vue new file mode 100644 index 0000000..770e0fa --- /dev/null +++ b/components/review/Map.vue @@ -0,0 +1,210 @@ + + + + + + + diff --git a/components/review/Overlay.vue b/components/review/Overlay.vue new file mode 100644 index 0000000..cb2c57a --- /dev/null +++ b/components/review/Overlay.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/components/review/Sidebar.vue b/components/review/Sidebar.vue new file mode 100644 index 0000000..958d619 --- /dev/null +++ b/components/review/Sidebar.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/components/review/Toolbar.vue b/components/review/Toolbar.vue new file mode 100644 index 0000000..98edf75 --- /dev/null +++ b/components/review/Toolbar.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index c1aa9dd..a67869a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,16 @@ "name": "workspaces", "hasInstallScript": true, "dependencies": { + "@osmcha/maplibre-adiff-viewer": "^1.3.1", + "@osmcha/osmchange-parser": "^1.0.0", "@sentry/integrations": "^7.114.0", "@sentry/nuxt": "^10.31.0", "@zip.js/zip.js": "^2.7.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "bootstrap": "^5.3.3", + "dayjs": "^1.11.19", + "maplibre-gl": "^5.10.0", "nuxt": "^4.0.0", "papaparse": "^5.5.1", "vue": "^3.4.19", @@ -1956,6 +1960,39 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.0", "license": "BSD-3-Clause", @@ -1982,6 +2019,94 @@ "node": ">=8" } }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz", + "integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.2.tgz", + "integrity": "sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz", + "integrity": "sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.3", "license": "MIT", @@ -3722,6 +3847,31 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@osmcha/maplibre-adiff-viewer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@osmcha/maplibre-adiff-viewer/-/maplibre-adiff-viewer-1.3.1.tgz", + "integrity": "sha512-VE67VWJh1DIH6pYlA+8aYxxKkrW1HkaRpV1DyE0Uiot9BSduwBKspnjm0ugKXV9UfbiKRa8/dI//cv/YyEKefw==", + "license": "ISC", + "dependencies": { + "@turf/area": "^7.2.0", + "@turf/bbox": "^7.2.0", + "@turf/bbox-polygon": "^7.2.0", + "deep-equal": "^2.2.3", + "id-area-keys": "^6.5.0" + }, + "peerDependencies": { + "maplibre-gl": ">4.0.0" + } + }, + "node_modules/@osmcha/osmchange-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@osmcha/osmchange-parser/-/osmchange-parser-1.0.0.tgz", + "integrity": "sha512-cGGKwlm3BX0iVN3olmp0rGlNxW1XdFC82VmyUS0xXstEi2RlJXn/b/ENrromwBjNNKDFMDWb3V1dwpjAFnwR5g==", + "license": "ISC", + "dependencies": { + "sax": "^1.4.1" + } + }, "node_modules/@oxc-minify/binding-android-arm64": { "version": "0.80.0", "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.80.0.tgz", @@ -6277,6 +6427,77 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@turf/area": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.3.tgz", + "integrity": "sha512-FT66TCxUec+3RsCCyO1kWP57/tiEWEqYfpIs5n44dup401Cne/E+xunahEWxMfP/HSUxfcRQqrjH5vEulLrNoA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.3", + "@turf/meta": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.3.tgz", + "integrity": "sha512-1zNO/JUgDp0N+3EG5fG7+8EolE95OW1LD8ur0hRP0JK+lRyN0gAvJT7n1I9pu/NIqTa8x/zXxGRc1dcOdohYkg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.3", + "@turf/meta": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox-polygon": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-7.3.3.tgz", + "integrity": "sha512-m2WfHVoJLZJf+nJizRfnm0GHyJN3eYY/oWL6xsp1bDodgBgrNqNPRD3OfA00x4HIt5VYXOyQ0GMDfvILLjlXWw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.3.tgz", + "integrity": "sha512-9Ias0L1GuZPIzO6sk8jraTEuLJye6n9LYNEdw69ZGOQ6C1IigjxkPW49zmn21aTv1z27vxdVLSS3r+78DB2QnQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.3.tgz", + "integrity": "sha512-Tz1j4h70iFB5SebWWoVv/uL59x4aOngXU+d1xQDXzOCn/O6txnreGVGMcYU362c5F06yqZx38H9UFTQ553lK0w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.3", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.0", "license": "MIT", @@ -6326,7 +6547,6 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -6387,6 +6607,15 @@ "version": "1.20.2", "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -7755,6 +7984,22 @@ "devOptional": true, "license": "Python-2.0" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ast-kit": { "version": "2.1.2", "license": "MIT", @@ -7833,6 +8078,21 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/b4a": { "version": "1.6.7", "license": "Apache-2.0" @@ -8122,6 +8382,24 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "license": "MIT", @@ -8865,6 +9143,12 @@ "node": ">= 12" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/db0": { "version": "0.3.2", "license": "MIT", @@ -8921,6 +9205,44 @@ "callsite": "^1.0.0" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8959,6 +9281,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "license": "MIT", @@ -8966,6 +9305,23 @@ "node": ">=8" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.4", "license": "MIT" @@ -9209,6 +9565,12 @@ "version": "0.1.2", "license": "MIT" }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -9290,6 +9652,32 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "license": "MIT" @@ -10381,6 +10769,21 @@ "version": "1.1.0", "license": "MIT" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "license": "ISC", @@ -10450,6 +10853,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fuse.js": { "version": "7.1.0", "license": "Apache-2.0", @@ -10572,6 +10984,12 @@ "git-up": "^8.1.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -10723,6 +11141,18 @@ "version": "1.2.2", "license": "MIT" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -10733,6 +11163,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "license": "MIT", @@ -10743,6 +11185,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "license": "MIT", @@ -10839,6 +11296,15 @@ "node": ">=16.17.0" } }, + "node_modules/id-area-keys": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/id-area-keys/-/id-area-keys-6.5.0.tgz", + "integrity": "sha512-VY6yV+KtWwQFoOx+/GBKP9cWr+BcCdB0iEnp44Wt19+D32FMS9Sfmbl+JgpNABPbInJto+3Aj4XbzxQ9yo+Cfg==", + "license": "ISC", + "engines": { + "node": ">=18" + } + }, "node_modules/ieee754": { "version": "1.2.1", "funding": [ @@ -10970,6 +11436,20 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ioredis": { "version": "5.7.0", "license": "MIT", @@ -10999,10 +11479,58 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.3.2", "license": "MIT" }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "license": "MIT", @@ -11013,6 +11541,22 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-builtin-module": { "version": "3.2.1", "license": "MIT", @@ -11026,6 +11570,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "license": "MIT", @@ -11039,6 +11595,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "license": "MIT", @@ -11106,6 +11678,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-module": { "version": "1.0.0", "license": "MIT" @@ -11117,6 +11701,22 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "4.0.0", "license": "MIT", @@ -11141,6 +11741,51 @@ "@types/estree": "*" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-ssh": { "version": "1.4.1", "license": "MIT", @@ -11158,6 +11803,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-url": { "version": "1.2.4", "license": "MIT" @@ -11172,6 +11850,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-what": { "version": "4.1.16", "license": "MIT", @@ -11348,6 +12054,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "license": "MIT", @@ -11375,6 +12087,12 @@ "node": ">=18" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11701,6 +12419,44 @@ "source-map-js": "^1.2.0" } }, + "node_modules/maplibre-gl": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.17.0.tgz", + "integrity": "sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^5.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.2", + "@maplibre/vt-pbf": "^4.2.1", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -11904,6 +12660,12 @@ "version": "0.4.1", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "5.1.5", "funding": [ @@ -12412,6 +13174,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ofetch": { "version": "1.4.1", "license": "MIT", @@ -12840,6 +13647,18 @@ "version": "2.0.3", "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pend": { "version": "1.2.0", "license": "MIT" @@ -12910,6 +13729,15 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "funding": [ @@ -13379,6 +14207,12 @@ "node": ">=0.10.0" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/precinct": { "version": "12.2.0", "license": "MIT", @@ -13464,6 +14298,12 @@ "node": ">= 6" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/protocols": { "version": "2.0.2", "license": "MIT" @@ -13537,6 +14377,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/quote-unquote": { "version": "1.0.0", "license": "MIT" @@ -13691,6 +14537,26 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regjsparser": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", @@ -13784,6 +14650,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "license": "MIT", @@ -13903,6 +14778,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -13921,6 +14802,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "license": "MIT", @@ -14066,6 +14964,38 @@ "node": ">= 18" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "license": "ISC" @@ -14313,6 +15243,19 @@ "version": "3.9.0", "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamx": { "version": "2.22.1", "license": "MIT", @@ -14481,6 +15424,15 @@ "postcss": "^8.4.32" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/superjson": { "version": "2.2.2", "license": "MIT", @@ -14737,6 +15689,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tmp": { "version": "0.2.5", "license": "MIT", @@ -16111,6 +17069,64 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/winston": { "version": "3.17.0", "license": "MIT", diff --git a/package.json b/package.json index 5e693a0..5578ed6 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,16 @@ "lint:fix": "eslint . --fix" }, "dependencies": { + "@osmcha/maplibre-adiff-viewer": "^1.3.1", + "@osmcha/osmchange-parser": "^1.0.0", "@sentry/integrations": "^7.114.0", "@sentry/nuxt": "^10.31.0", "@zip.js/zip.js": "^2.7.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "bootstrap": "^5.3.3", + "dayjs": "^1.11.19", + "maplibre-gl": "^5.10.0", "nuxt": "^4.0.0", "papaparse": "^5.5.1", "vue": "^3.4.19", diff --git a/pages/dashboard.vue b/pages/dashboard.vue index 738db87..f4cc716 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -49,7 +49,7 @@ let lastWorkspaceId: number; + + diff --git a/services/cache.ts b/services/cache.ts new file mode 100644 index 0000000..218e002 --- /dev/null +++ b/services/cache.ts @@ -0,0 +1,168 @@ +export type ValidCacheKey = IDBValidKey | { [key: string]: IDBValidKey }; + +export interface LocalCache { + get(key: TKey): Promise; + set(key: TKey, value: TValue): Promise; + evict(key: TKey): Promise; + prune(): Promise; +} + +const CACHE_DB_VERSION = 1; +const CACHE_STORE_NAME = 'cache'; + +export class LruIndexedDbCache +implements LocalCache { + readonly dbName: string; + readonly ttlMilliseconds: number; + readonly keyPath: IDBObjectStoreParameters['keyPath']; + + #db?: IDBDatabase; + #openPromise?: Promise; + + constructor( + dbName: string, + ttlMilliseconds: number, + keyPath: IDBObjectStoreParameters['keyPath'], + ) { + this.dbName = dbName; + this.ttlMilliseconds = ttlMilliseconds; + this.keyPath = keyPath; + } + + get(key: TKey): Promise { + return new Promise((resolve, reject) => { + this.#tx((store) => { + const request = typeof key === 'object' + ? store.get(Object.values(key)) // composite key + : store.get(key); // simple key + + request.onsuccess = () => { + const entry = request.result; + + if (!entry) { + return resolve(undefined); + } + + // This implementation doesn't enforce strict eviction on access. + // As in, we don't check for expiration here, and there is no max + // age setting (yet). This cache will return entries that expired + // between prune operations and even refresh the stale entry: + // + entry.lastAccessed = Date.now(); + store.put(entry); + resolve(entry.value); + }; + + request.onerror = () => reject(request.error); + }); + }); + } + + set(key: TKey, value: TValue): Promise { + return this.#tx((store) => { + if (typeof key === 'object') { + store.put({ ...key, value, lastAccessed: Date.now() }); // composite key + } + else { + store.put({ key, value, lastAccessed: Date.now() }); // simple key + } + }); + } + + evict(key: TKey): Promise { + return this.#tx((store) => { + if (typeof key === 'object') { + store.delete(Object.values(key)); // composite key + } + else { + store.delete(key); // simple key + } + }); + } + + prune(): Promise { + const expirationTime = Date.now() - this.ttlMilliseconds; + console.info(`Evicting entries in "${this.dbName}" < ${expirationTime}`); + + return this.#tx((store) => { + const index = store.index('lastAccessed'); + const request = index.openCursor(IDBKeyRange.upperBound(expirationTime)); + + request.onsuccess = () => { + const cursor = request.result; + + if (cursor) { + cursor.delete(); + cursor.continue(); + } + }; + }); + } + + async #open(): Promise { + if (this.#db) { + return this.#db; + } + if (this.#openPromise) { + return this.#openPromise; + } + + this.#openPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, CACHE_DB_VERSION); + + request.onupgradeneeded = () => { + this.#upgradeDb(request.result); + }; + request.onerror = () => { + reject(request.error); + }; + + request.onsuccess = () => { + this.#db = request.result; + this.#db.onversionchange = () => { + // Close the DB if it opens in another tab with a different version: + this.#db?.close(); + }; + + resolve(this.#db); + }; + }); + + try { + return await this.#openPromise; + } + finally { + this.#openPromise = undefined; + } + } + + async #tx(callback: (store: IDBObjectStore) => void): Promise { + const db = await this.#open(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(CACHE_STORE_NAME, 'readwrite'); + tx.oncomplete = () => { + resolve(); + }; + tx.onabort = () => { + reject(tx.error); + }; + tx.onerror = () => { + reject(tx.error); + }; + + callback(tx.objectStore(CACHE_STORE_NAME)); + }); + } + + #upgradeDb(db: IDBDatabase) { + console.info(`Upgrading ${this.dbName} to version ${CACHE_DB_VERSION}`); + + if (!db.objectStoreNames.contains(CACHE_STORE_NAME)) { + const store = db.createObjectStore(CACHE_STORE_NAME, { + keyPath: this.keyPath, + }); + store.createIndex('lastAccessed', 'lastAccessed'); + } + } +} diff --git a/services/changesets.ts b/services/changesets.ts new file mode 100644 index 0000000..49d6c1e --- /dev/null +++ b/services/changesets.ts @@ -0,0 +1,449 @@ +import type { OsmApiClient } from '~/services/osm'; +import { OSMCHANGE_ACTION_TYPES } from '~/types/osm'; +import { LruIndexedDbCache } from '~/services/cache'; + +import type { LocalCache } from '~/services/cache'; +import type { + OsmChange, + OsmChangeset, + OsmElement, + OsmNode, + OsmElementType, +} from '~/types/osm'; +import type { + AdiffAction, + AdiffElement, + AdiffNode, + AdiffWay, + AdiffWayNodeRef, + AugmentedDiff, +} from '~/types/adiff'; +import type { WorkspaceId } from '~/types/workspaces'; + +const ADIFF_ACTION_TYPES = OSMCHANGE_ACTION_TYPES; + +export type OsmChangeCacheKey = { workspaceId: WorkspaceId; changesetId: number }; +export const OsmChangeDb = LruIndexedDbCache; +export type AugmentedDiffCacheKey = { workspaceId: WorkspaceId; changesetId: number }; +export const AugmentedDiffDb = LruIndexedDbCache; + +export class OsmChangeCache { + readonly #cache: LocalCache; + + constructor(cache: LocalCache) { + this.#cache = cache; + } + + async get(workspaceId: WorkspaceId, changesetId: number): Promise { + const osmChange = await this.#cache.get({ workspaceId, changesetId }); + + if (osmChange) { + for (const type of OSMCHANGE_ACTION_TYPES) { + for (const element of osmChange[type] ?? []) { + element.timestamp = new Date(element.timestamp); + } + } + + return osmChange; + } + } + + set(workspaceId: WorkspaceId, changesetId: number, osmChange: OsmChange): Promise { + return this.#cache.set({ workspaceId, changesetId }, osmChange); + } + + prune(): Promise { + return this.#cache.prune(); + } +} + +export class AugmentedDiffCache { + readonly #cache: LocalCache; + + constructor(cache: LocalCache) { + this.#cache = cache; + } + + async get(workspaceId: WorkspaceId, changesetId: number): Promise { + const adiff = await this.#cache.get({ workspaceId, changesetId }); + + if (adiff) { + return adiff; + } + } + + set(workspaceId: WorkspaceId, changesetId: number, adiff: AugmentedDiff) { + return this.#cache.set({ workspaceId, changesetId }, adiff); + } + + prune(): Promise { + return this.#cache.prune(); + } +} + +export class ChangesetManager { + readonly #osmClient: OsmApiClient; + readonly #oscCache: OsmChangeCache; + readonly #adiffCache: AugmentedDiffCache; ; + + constructor( + osmClient: OsmApiClient, + oscCache: OsmChangeCache, + adiffCache: AugmentedDiffCache, + ) { + this.#osmClient = osmClient; + this.#oscCache = oscCache; + this.#adiffCache = adiffCache; + + this.pruneCache(); + } + + async getFullChangesets(workspaceId: WorkspaceId): Promise { + const changesets = await this.#osmClient.listChangesets(workspaceId); + const promises = []; + + for (const changeset of changesets) { + promises.push( + this.getOsc(workspaceId, changeset).then(c => changeset.osmChange = c), + ); + } + + await Promise.all(promises); + + return changesets; + } + + async pruneCache() { + const executeCallback: (f: () => void) => void = window.requestIdleCallback + ?? ((f: () => void) => f()); + executeCallback(() => { + this.#oscCache.prune(); + }); + executeCallback(() => { + this.#adiffCache.prune(); + }); + } + + async getOsc(workspaceId: WorkspaceId, changeset: OsmChangeset): Promise { + if (changeset.open) { + // Skip the cache--someone may add more to this changeset: + return await this.#osmClient.getOsmChange(workspaceId, changeset.id); + } + + let osc = await this.#oscCache.get(workspaceId, changeset.id); + + if (!osc) { + osc = await this.#osmClient.getOsmChange(workspaceId, changeset.id); + await this.#oscCache.set(workspaceId, changeset.id, osc); + } + + return osc; + } + + async getAdiff(workspaceId: WorkspaceId, changeset: OsmChangeset): Promise { + if (changeset.open) { + // Skip the cache--someone may add more to this changeset: + return await this.#buildAdiff(workspaceId, changeset.osmChange); + } + + let adiff = await this.#adiffCache.get(workspaceId, changeset.id); + + if (!adiff) { + adiff = await this.#buildAdiff(workspaceId, changeset.osmChange); + await this.#adiffCache.set(workspaceId, changeset.id, adiff); + } + + return adiff; + } + + async #buildAdiff(workspaceId: WorkspaceId, osmChange: OsmChange): Promise { + const builder = new ChangesetAdiffBuilder(this.#osmClient, workspaceId); + + return await builder.build(osmChange); + } +} + +function isNode(element: AdiffElement): element is AdiffNode { + return element.type === 'node'; +} + +function isWay(element: AdiffElement): element is AdiffWay { + return element.type === 'way'; +} + +function osmToAdiff(element: OsmElement): AdiffElement { + const adiff: AdiffElement = { + ...(element as AdiffElement), + visible: element.visible ?? true, + tags: { ...element.tags }, + }; + + if ('nodes' in adiff && 'nodes' in element) { + adiff.nodes = element.nodes?.map(ref => ({ ref, lat: 0, lon: 0 })); + } + + return adiff; +} + +function nodeToRef(node: OsmNode): AdiffWayNodeRef { + return { ref: node.id, lat: node.lat, lon: node.lon }; +} + +function splitIdVersion(idVersion: string): [number, number] { + const idVersionParts = idVersion.split('v'); + const id = Number(idVersionParts[0]); + const version = Number(idVersionParts[1]); + + return [id, version]; +} + +type OsmElementVersionMap = Map; +type OsmElementMap = Map; +type OsmTypeMap = { + [K in OsmElementType]: { + fromChangesetIds: Set; + versions: OsmElementMap; + } +}; + +class AdiffElementCache { + readonly #typeMap: OsmTypeMap; + + constructor() { + this.#typeMap = { + node: { fromChangesetIds: new Set(), versions: new Map() }, + way: { fromChangesetIds: new Set(), versions: new Map() }, + relation: { fromChangesetIds: new Set(), versions: new Map() }, + }; + } + + remember(element: OsmElement, fromChangeset: boolean = false) { + this.getVersions(element.type, element.id).set(element.version, element); + + if (fromChangeset) { + this.#typeMap[element.type].fromChangesetIds.add(element.id); + } + } + + getVersions(type: OsmElementType, id: number): OsmElementVersionMap { + let versionMap = this.#typeMap[type].versions.get(id); + + if (!versionMap) { + versionMap = new Map(); + this.#typeMap[type].versions.set(id, versionMap); + } + + return versionMap; + } + + getVersion( + type: T['type'], + id: number, + version: number, + ): T | undefined { + return this.#typeMap[type].versions.get(id)?.get(version) as T; + } + + getLatestVersion(type: T['type'], id: number): T | undefined { + const versionMap = this.#typeMap[type].versions.get(id); + + if (!versionMap) { + return undefined; + } + + return versionMap.get(Math.max(...versionMap.keys())) as T; + } + + isInChangeset(type: OsmElementType, id: number): boolean { + return this.#typeMap[type].fromChangesetIds.has(id); + } +} + +class ChangesetAdiffBuilder { + readonly #osmClient: OsmApiClient; + readonly #workspaceId: WorkspaceId; + readonly #cache: AdiffElementCache; + readonly #actions: AdiffAction[]; + + constructor(osmClient: OsmApiClient, workspaceId: WorkspaceId) { + this.#osmClient = osmClient; + this.#workspaceId = workspaceId; + this.#cache = new AdiffElementCache(); + this.#actions = []; + } + + async build(osmChange: OsmChange): Promise { + const actions: AdiffAction[] = []; + const promises = []; + + for (const type of ADIFF_ACTION_TYPES) { + for (const element of osmChange[type] ?? []) { + actions.push({ type, old: undefined, new: osmToAdiff(element) }); + this.#cache.remember(element, true); + } + } + + for (const action of actions) { + this.#actions.push(action); + + if (action.type !== 'create') { + promises.push( + this.#getPreviousElement(action.new).then(e => action.old = e), + ); + } + + promises.push(this.#expandReferences(action)); + } + + await Promise.all(promises); + + return { actions: this.#actions }; + } + + #expandReferences(action: AdiffAction): Promise { + if (isNode(action.new)) { + return this.#expandNodeReferences(action.new); + } + + return this.#expandWayReferences(action.new); + } + + async #expandNodeReferences(node: AdiffNode): Promise { + const ways = await this.#fetchWays(node); + + for (const way of ways) { + if (this.#cache.isInChangeset('way', way.id)) { + continue; + } + + const old = { ...way, nodes: [...way.nodes ?? []], tags: { ...way.tags } }; + const action: AdiffAction = { type: 'modify', old, new: way }; + + while (action.old && action.old.changeset > node.changeset) { + action.old = await this.#getPreviousElement(action.old); + } + + if (action.old === old) { + await this.#resolveWayNodes(old, old.changeset); + } + + this.#actions.push(action); + } + } + + async #expandWayReferences(way: AdiffWay): Promise { + await this.#resolveWayNodes(way, way.changeset); + } + + async #resolveWayNodes(element: AdiffWay, changesetId: number) { + const resolvedNodes: AdiffWayNodeRef[] = []; + let nodeIds: (string | number)[] = element.nodes?.map(r => r.ref) ?? []; + + while (nodeIds.length > 0) { + const refs = await this.#fetchNodes(nodeIds); + nodeIds = []; + + for (const node of refs) { + if (node.changeset <= changesetId) { + resolvedNodes.push(nodeToRef(node)); + } + else { + nodeIds.push(`${node.id}v${node.version - 1}`); + } + } + } + + element.nodes = resolvedNodes; + } + + async #fetchNodes(nodeIds: (string | number)[]): Promise { + const refs: OsmNode[] = []; + const pendingIds = []; + + for (const nodeId of nodeIds) { + if (Number.isFinite(nodeId)) { + const node = this.#cache.getLatestVersion('node', nodeId as number); + + if (node) { + refs.push(node); + } + else { + pendingIds.push(nodeId); + } + } + else { + const [id, version] = splitIdVersion(nodeId as string); + const node = this.#cache.getVersion('node', id, version); + + if (node) { + refs.push(node); + } + else { + pendingIds.push(nodeId); + } + } + } + + if (pendingIds.length > 0) { + const nodes = await this.#osmClient.getNodes(this.#workspaceId, pendingIds); + + for (const node of nodes) { + refs.push(node); + this.#cache.remember(node); + } + } + + return refs; + } + + async #fetchWays(node: AdiffElement): Promise { + let ways = await this.#osmClient.getWaysForNode(this.#workspaceId, node.id); + const resolvedWays = []; + + while (ways.length > 0) { + const pendingWayIds = []; + + for (const way of ways) { + if (way.changeset <= node.changeset) { + const adiffWay = osmToAdiff(way) as AdiffWay; + await this.#resolveWayNodes(adiffWay, node.changeset); + resolvedWays.push(adiffWay); + } + else { + pendingWayIds.push(`${way.id}v${way.version - 1}`); + } + } + + if (pendingWayIds.length > 0) { + ways = await this.#osmClient.getWays(this.#workspaceId, pendingWayIds); + } + else { + ways.length = 0; + } + } + + return resolvedWays; + } + + async #getPreviousElement(element: AdiffElement): Promise { + const version = element.version - 1; + let prevElement = this.#cache.getVersion(element.type, element.id, version); + + if (!prevElement) { + prevElement = await this.#osmClient.getElement( + this.#workspaceId, + element.type, + element.id, + version); + this.#cache.remember(prevElement); + } + + const adiffElement = osmToAdiff(prevElement); + + if (isWay(adiffElement)) { + await this.#resolveWayNodes(adiffElement, element.changeset - 1); + } + + return adiffElement; + } +} diff --git a/services/index.ts b/services/index.ts index 29fd560..c298ef7 100644 --- a/services/index.ts +++ b/services/index.ts @@ -1,7 +1,15 @@ import { reactive } from 'vue'; import { TdeiAuthStore, TdeiClient, TdeiUserClient } from '~/services/tdei'; +import { + AugmentedDiffCache, + AugmentedDiffDb, + OsmChangeCache, + OsmChangeDb, + ChangesetManager, +} from '~/services/changesets'; import { OsmApiClient } from '~/services/osm'; import { PathwaysEditorManager } from '~/services/pathways'; +import { ReviewManager } from '~/services/review'; import { RapidManager } from '~/services/rapid'; import { WorkspacesClient } from '~/services/workspaces'; @@ -21,5 +29,24 @@ tdeiClient.restartAutoAuthRefresh(); export const osmClient = new OsmApiClient(osmWebUrl, osmApiUrl, tdeiClient); export const workspacesClient = new WorkspacesClient(apiUrl, tdeiClient, osmClient); +const oscCacheTtl = 1000 * 60 * 60 * 24 * 45; // 45 days +const adiffCacheTtl = oscCacheTtl; +const oscKeypath = ['workspaceId', 'changesetId']; +const adiffKeypath = oscKeypath; + +const oscCacheDb = new OsmChangeDb('osc-cache', oscCacheTtl, oscKeypath); +const adiffCacheDb = new AugmentedDiffDb('adiff-cache', adiffCacheTtl, adiffKeypath); + +export const changesetManager = new ChangesetManager( + osmClient, + new OsmChangeCache(oscCacheDb), + new AugmentedDiffCache(adiffCacheDb)); +export const reviewManager = new ReviewManager( + changesetManager, + osmClient, + tdeiClient, + workspacesClient, +); + export const rapidManager = new RapidManager(rapidUrl, osmWebUrl, tdeiAuth); export const pathwaysManager = new PathwaysEditorManager(pathwaysUrl, osmWebUrl, tdeiAuth); diff --git a/services/osm.ts b/services/osm.ts index 7b3bf02..91ef12d 100644 --- a/services/osm.ts +++ b/services/osm.ts @@ -1,7 +1,23 @@ +import parseOsmChangeXml from '@osmcha/osmchange-parser'; +import type { FeatureCollection, Point } from 'geojson'; + import { BaseHttpClient, BaseHttpClientError } from "~/services/http"; +import * as xml from '~/util/xml'; + import type { ICancelableClient } from '~/services/loading'; import type { TdeiClient } from '~/services/tdei'; -import * as xml from '~/util/xml'; +import { OSMCHANGE_ACTION_TYPES } from '~/types/osm'; +import type { + OsmChange, + OsmChangeset, + OsmChangesetComment, + OsmElement, + OsmNode, + OsmNote, + OsmTags, + OsmWay, +} from '~/types/osm'; +import type { WorkspaceId } from '~/types/workspaces'; function formatFeatureIdPlaceholder(attributeName: string, feature: Element) { const id = feature.getAttribute(attributeName); @@ -36,6 +52,30 @@ function formatFeatureIdPlaceholders(feature: Element) { } } +function notesGeoJsonToEntities(geoJson: FeatureCollection): OsmNote[] { + const notes = []; + + for (const feature of geoJson.features) { + const geometry = feature.geometry as Point; + const properties = feature.properties ?? { }; + + for (const comment of properties.comments) { + comment.date = new Date(comment.date); + } + + notes.push({ + id: properties.id, + status: properties.status, + lat: geometry.coordinates[1] ?? 0, + lon: geometry.coordinates[0] ?? 0, + created_at: new Date(properties.date_created), + comments: properties.comments, + }); + } + + return notes; +} + function cleanOscForDemo(features: Element[]) { const nodeIds = new Set(); const ways = []; @@ -207,8 +247,8 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { await this._delete(`workspaces/${workspaceId}`); } - async getWorkspaceBbox(id: number) { - const response = await this._get(`workspaces/${id}/bbox.json`); + async getWorkspaceBbox(workspaceId: number) { + const response = await this._get(`workspaces/${workspaceId}/bbox.json`); if (response.status === 204) { return undefined @@ -238,7 +278,147 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { return `${bbox.min_lon},${bbox.min_lat},${bbox.max_lon + pad},${bbox.max_lat + pad}`; } - async createChangeset(workspaceId: number): number { + async getElement( + workspaceId: WorkspaceId, + type: string, + id: number, + version: number, + ): Promise { + const response = await this._get(`${type}/${id}/${version}`, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/json', + 'X-Workspace': workspaceId, + }, + }); + + const element = (await response.json()).elements[0]; + element.tags = element.tags ?? { }; + + return element; + } + + async getNodes(workspaceId: WorkspaceId, nodeIds: (number | string)[]): Promise { + const response = await this._get(`nodes?nodes=${nodeIds.join(',')}`, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/json', + 'X-Workspace': workspaceId, + }, + }); + + const nodes = (await response.json()).elements; + + for (const node of nodes) { + node.timestamp = new Date(node.timestamp); + } + + return nodes; + } + + async getWays(workspaceId: WorkspaceId, wayIds: (number | string)[]): Promise { + const response = await this._get(`ways?ways=${wayIds.join(',')}`, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/json', + 'X-Workspace': workspaceId, + }, + }); + + const ways = (await response.json()).elements; + + for (const way of ways) { + way.timestamp = new Date(way.timestamp); + } + + return ways; + } + + async getWaysForNode(workspaceId: WorkspaceId, nodeId: number): Promise { + const response = await this._get(`node/${nodeId}/ways`, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/json', + 'X-Workspace': workspaceId, + }, + }); + + return (await response.json()).elements; + } + + async listChangesets(workspaceId: WorkspaceId): Promise { + const response = await this._get(`changesets.json`, { + headers: { ...this._requestHeaders, 'X-Workspace': workspaceId }, + }); + + const changesets = (await response.json())?.changesets ?? []; + + for (const changeset of changesets) { + changeset.created_at = new Date(changeset.created_at); + changeset.closed_at = new Date(changeset.closed_at); + } + + return changesets; + } + + async getChangeset( + workspaceId: WorkspaceId, + changesetId: number, + includeDiscussion: boolean = false, + ): Promise { + let url = `changeset/${changesetId}.json`; + + if (includeDiscussion) { + url += '?include_discussion=true'; + } + + const response = await this._get(url, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/json', + 'X-Workspace': workspaceId, + }, + }); + + const changeset = (await response.json())?.changeset; + + if (!changeset) { + return; + } + + changeset.created_at = new Date(changeset.created_at); + changeset.closed_at = new Date(changeset.closed_at); + + for (const comment of changeset.comments ?? []) { + comment.date = new Date(comment.date); + } + + return changeset; + } + + async getOsmChange(workspaceId: WorkspaceId, changesetId: number) + : Promise + { + const response = await this._get(`changeset/${changesetId}/download`, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/xml', + 'X-Workspace': workspaceId, + }, + }); + + const osmChange = parseOsmChangeXml(await response.text()); + + for (const type of OSMCHANGE_ACTION_TYPES) { + for (const element of osmChange[type] ?? []) { + element.timestamp = new Date(element.timestamp); + } + } + + return osmChange; + } + + async createChangeset(workspaceId: WorkspaceId): Promise { const doc = xml.parse(''); const changesetNode = doc.firstChild.firstChild; changesetNode.appendChild(xml.makeNode(doc, "tag", { k: 'workspace', v: workspaceId })); @@ -247,7 +427,7 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { const body = xml.serialize(doc); const response = await this._put('changeset/create', body, { - headers: { ...this._requestHeaders, 'X-Workspace': workspaceId } + headers: { ...this._requestHeaders, 'X-Workspace': workspaceId }, }); return Number(await response.text()); @@ -258,11 +438,57 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { headers: { 'Content-Type': 'application/xml', 'Authorization': this._requestHeaders['Authorization'], - 'X-Workspace': workspaceId - } + 'X-Workspace': workspaceId, + }, }); } + async getChangesetComments( + workspaceId: WorkspaceId, + changesetId: number, + ): Promise { + // There is no OSM API that returns comments directly. We must request the + // comments with the changeset: + // + const changeset = await this.getChangeset(workspaceId, changesetId, true); + + return changeset?.comments ?? []; + } + + async postChangesetComment( + workspaceId: WorkspaceId, + changesetId: number, + message: string, + ): Promise { + const body = new FormData(); + body.append('text', message); + + await this._post(`changeset/${changesetId}/comment`, body, { + headers: { + 'Authorization': this._requestHeaders['Authorization'], + 'X-Workspace': workspaceId, + }, + }); + } + + async getNotes(workspaceId: WorkspaceId, includeClosed: boolean): Promise { + const params = new URLSearchParams(); + // Fetch the maximum number of notes: + params.append('limit', '10000'); + // -1: all, 0: open only, > 0: days closed: + params.append('closed', includeClosed ? '-1' : '0'); + + const response = await this._get(`notes/search.json?${params}`, { + headers: { + ...this._requestHeaders, + 'Accept': 'application/json', + 'X-Workspace': workspaceId, + }, + }); + + return notesGeoJsonToEntities(await response.json()); + } + async getWorkspaceData(workspaceId: number): Promise { const bboxParam = await this.getExportBbox(workspaceId); const response = await this._get(`map.json?bbox=${bboxParam}`, { diff --git a/services/review.ts b/services/review.ts new file mode 100644 index 0000000..d5634bb --- /dev/null +++ b/services/review.ts @@ -0,0 +1,325 @@ +import type { ChangesetManager } from '~/services/changesets'; +import type { OsmApiClient } from '~/services/osm'; +import type { TdeiClient } from '~/services/tdei'; +import type { WorkspacesClient } from '~/services/workspaces'; +import type { OsmChange, OsmChangeset, OsmNote } from '~/types/osm'; +import type { TdeiFeedback } from '~/types/tdei'; +import type { Workspace } from '~/types/workspaces'; + +export const $CHANGESET = Symbol('changeset'); +export const $FEEDBACK = Symbol('feedback'); +export const $NOTE = Symbol('note'); + +export type ReviewItemData = OsmChangeset | OsmNote | TdeiFeedback; + +export class ReviewListItem { + readonly type: symbol; + readonly key: symbol; + readonly data: ReviewItemData; + + loadingChangeset: boolean = false; + oscPromise?: Promise; + + constructor(type: symbol, data: ReviewItemData) { + this.type = type; + this.key = Symbol(); + this.data = data; + } + + get id(): number { + return this.data.id; + } + + get isChangeset(): boolean { + return this.type === $CHANGESET; + } + + get isFeedback(): boolean { + return this.type === $FEEDBACK; + } + + get isNote(): boolean { + return this.type === $NOTE; + } + + get isResolved(): boolean { + switch (this.type) { + case $FEEDBACK: + return (this.data as TdeiFeedback).status !== 'open'; + case $NOTE: + return (this.data as OsmNote).status !== 'open'; + } + + return false; + } + + get displayType(): string { + switch (this.type) { + case $CHANGESET: + return 'Changeset'; + case $FEEDBACK: + return 'Feedback'; + case $NOTE: + return 'Note'; + } + + return 'Unknown'; + } + + get title(): string | undefined { + switch (this.type) { + case $CHANGESET: + return (this.data as OsmChangeset).tags?.comment || '(no comment)'; + case $FEEDBACK: + return (this.data as TdeiFeedback).feedback_text; + case $NOTE: + return (this.data as OsmNote).comments[0]?.text; + } + + return undefined; + } + + get displayName(): string { + switch (this.type) { + case $CHANGESET: + return (this.data as OsmChangeset).user; + case $FEEDBACK: + return (this.data as TdeiFeedback).customer_email; + case $NOTE: + return (this.data as OsmNote).comments[0]?.user ?? 'Anonymous'; + } + + return 'Unknown'; + } + + get feedbackOverdue(): boolean { + return this.isFeedback + && (this.data as TdeiFeedback).status === 'open' + && (this.data as TdeiFeedback).due_date < new Date(); + } + + get hasComments(): boolean { + return this.commentCount > 0; + } + + get commentCount(): number { + switch (this.type) { + case $CHANGESET: + return (this.data as OsmChangeset).comments_count; + case $NOTE: + return (this.data as OsmNote).comments.length - 1; + } + + return 0; + } + + get badgeClass(): string { + switch (this.type) { + case $CHANGESET: + return 'bg-dark'; + case $FEEDBACK: + return 'bg-danger'; + case $NOTE: + return 'bg-info'; + } + + return 'bg-secondary'; + } + + get badgeIcon(): string { + return this.type === $CHANGESET ? 'commit' : 'chat_bubble'; + } + + get changesetCounts() { + return { + create: (this.data as OsmChangeset).osmChange?.create?.length ?? 0, + modify: (this.data as OsmChangeset).osmChange?.modify?.length ?? 0, + delete: (this.data as OsmChangeset).osmChange?.delete?.length ?? 0, + }; + } + + async awaitOsmChange(promise: Promise): Promise { + this.loadingChangeset = true; + this.oscPromise = promise; + (this.data as OsmChangeset).osmChange = await promise; + this.loadingChangeset = false; + } +} + +export class ReviewListFilter { + includeChangesets: boolean = true; + includeFeedback: boolean = true; + includeNotes: boolean = true; + includeResolved: boolean = false; + startDate: Date | undefined; + endDate: Date | undefined; + text: string | undefined; + + testDates(item: ReviewItemData): boolean { + if (this.startDate && item.created_at < this.startDate) { + return false; + } + if (this.endDate && item.created_at > this.endDate) { + return false; + } + + return true; + } + + testChangeset(changeset: OsmChangeset) { + if (!this.testDates(changeset)) { + return false; + } + if (this.text && !changeset.tags?.comment?.includes(this.text)) { + return false; + } + + return true; + } + + testFeedback(feedback: TdeiFeedback) { + if (!this.testDates(feedback)) { + return false; + } + if (this.text && !feedback.feedback_text.includes(this.text)) { + return false; + } + if (!this.includeResolved && feedback.status !== 'open') { + return false; + } + + return true; + } + + testNote(note: OsmNote) { + if (!this.testDates(note)) { + return false; + } + if (this.text && !note.comments[0]?.text.includes(this.text)) { + return false; + } + + return true; + } +} + +function compareCreatedAtDesc(a: ReviewListItem, b: ReviewListItem) { + return b.data.created_at.getTime() - a.data.created_at.getTime(); +} + +export class ReviewList { + readonly #changesets: ChangesetManager; + readonly #osmClient: OsmApiClient; + readonly #tdeiClient: TdeiClient; + readonly #workspacesClient: WorkspacesClient; + + readonly workspaceId: number; + readonly items: ReviewListItem[] = []; + + workspace?: Workspace; + + constructor( + changesets: ChangesetManager, + osmClient: OsmApiClient, + tdeiClient: TdeiClient, + workspacesClient: WorkspacesClient, + workspaceId: number, + ) { + this.#changesets = changesets; + this.#osmClient = osmClient; + this.#tdeiClient = tdeiClient; + this.#workspacesClient = workspacesClient; + this.workspaceId = workspaceId; + } + + async refresh(filter: ReviewListFilter) { + this.items.length = 0; + const [changesets, feedback, notes] = await this.#fetchLists(filter); + + for (const changeset of changesets) { + if (filter.testChangeset(changeset)) { + this.items.push(new ReviewListItem($CHANGESET, changeset)); + } + } + + for (const submission of feedback) { + if (filter.testFeedback(submission)) { + this.items.push(new ReviewListItem($FEEDBACK, submission)); + } + } + + for (const note of notes) { + if (filter.testNote(note)) { + this.items.push(new ReviewListItem($NOTE, note)); + } + } + + this.items.sort(compareCreatedAtDesc); + } + + async loadOsmChange(item: ReviewListItem) { + if (!item.isChangeset) { + return; + } + + const changeset = item.data as OsmChangeset; + item.awaitOsmChange(this.#changesets.getOsc(this.workspaceId, changeset)); + } + + async #fetchLists(filter: ReviewListFilter) { + const changesetPromise = filter.includeChangesets + ? this.#osmClient.listChangesets(this.workspaceId) + : Promise.resolve([]); + const notePromise = filter.includeNotes + ? this.#osmClient.getNotes(this.workspaceId, filter.includeResolved) + : Promise.resolve([]); + + if (!this.workspace) { + this.workspace = await this.#workspacesClient.getWorkspace(this.workspaceId); + } + + const feedbackPromise = filter.includeFeedback && this.workspace?.tdeiRecordId + ? this.#tdeiClient.getDatasetFeedback(this.workspace.tdeiRecordId, filter.includeResolved) + : Promise.resolve([]); + + return await Promise.all([ + changesetPromise, + feedbackPromise, + notePromise, + ]); + } +} + +export class ReviewManager { + readonly #changesets: ChangesetManager; + readonly #osmClient: OsmApiClient; + readonly #tdeiClient: TdeiClient; + readonly #workspacesClient: WorkspacesClient; + + constructor( + changesets: ChangesetManager, + osmClient: OsmApiClient, + tdeiClient: TdeiClient, + workspacesClient: WorkspacesClient, + ) { + this.#changesets = changesets; + this.#osmClient = osmClient; + this.#tdeiClient = tdeiClient; + this.#workspacesClient = workspacesClient; + } + + getList(workspaceId: number): ReviewList { + return new ReviewList( + this.#changesets, + this.#osmClient, + this.#tdeiClient, + this.#workspacesClient, + workspaceId, + ); + } + + getFilter(): ReviewListFilter { + // TODO: enable saving/loading filters + return new ReviewListFilter(); + } +} diff --git a/services/tdei.ts b/services/tdei.ts index 09895d3..6f0535d 100644 --- a/services/tdei.ts +++ b/services/tdei.ts @@ -2,6 +2,7 @@ import { BlobReader, BlobWriter, ZipReader } from '@zip.js/zip.js'; import { BaseHttpClient, BaseHttpClientError } from '~/services/http'; import type { ICancelableClient } from '~/services/loading'; +import type { TdeiFeedback } from '~/types/tdei.ts'; const MIN_TOKEN_REFRESH_MS = 10 * 1000; @@ -343,6 +344,39 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { return await fileResponse.blob(); } + async getDatasetFeedback( + tdeiDatasetId: string, + showResolved: boolean = false, + ): Promise { + const feedback = []; + const pageSize = 50; + const pageNum = 1; + let items; + + const params = new URLSearchParams(); + params.append('tdei_dataset_id', tdeiDatasetId); + params.append('page_size', pageSize.toString()); + + if (!showResolved) { + params.append('status', 'open'); + } + + do { + params.set('page_no', pageNum.toString()); + const response = await this._get(`osw/dataset-viewer/feedbacks?${params}`); + items = (await response.json()) ?? []; + + for (const submission of items) { + submission.created_at = new Date(submission.created_at); + submission.updated_at = new Date(submission.updated_at); + submission.due_date = new Date(submission.due_date); + feedback.push(submission); + } + } while (items.length === pageSize); + + return feedback; + } + #setAuth(username: string, body: any) { const jwt = getJwtBody(body.access_token); diff --git a/types/adiff.ts b/types/adiff.ts new file mode 100644 index 0000000..2639599 --- /dev/null +++ b/types/adiff.ts @@ -0,0 +1,30 @@ +import type { + OsmChangeActionType, + OsmNode, + OsmWay, +} from '~/types/osm'; + +export type AdiffNode = OsmNode; + +export interface AdiffWayNodeRef { + ref: number; + lat: number; + lon: number; +}; + +export interface AdiffWay extends Omit { + nodes: AdiffWayNodeRef[]; +} + +export type AdiffElement = AdiffNode | AdiffWay; +export type AdiffActionType = OsmChangeActionType; + +export interface AdiffAction { + type: AdiffActionType; + new: AdiffElement; + old?: AdiffElement; +} + +export interface AugmentedDiff { + actions: AdiffAction[]; +} diff --git a/types/maplibre-adiff-viewer.d.ts b/types/maplibre-adiff-viewer.d.ts new file mode 100644 index 0000000..507417d --- /dev/null +++ b/types/maplibre-adiff-viewer.d.ts @@ -0,0 +1 @@ +declare module '@osmcha/maplibre-adiff-viewer'; diff --git a/types/osm.ts b/types/osm.ts new file mode 100644 index 0000000..7282618 --- /dev/null +++ b/types/osm.ts @@ -0,0 +1,91 @@ +export const OSM_ELEMENT_TYPES = ['node', 'way', 'relation'] as const; +export type OsmElementType = typeof OSM_ELEMENT_TYPES[number]; + +export type OsmTags = Record; +export type OsmRelationRef = { type: OsmElementType; ref: number; role: string }; + +interface BaseElement { + id: number; + visible?: boolean; + version: number; + changeset: number; + timestamp: Date; + user: string; + uid: number; + type: OsmElementType; + tags: OsmTags; +}; + +export interface OsmNode extends BaseElement { + type: 'node'; + lat: number; + lon: number; +}; + +export interface OsmWay extends BaseElement { + type: 'way'; + nodes: number[]; +}; + +export interface OsmRelation extends BaseElement { + type: 'relation'; + members: OsmRelationRef[]; +}; + +export type OsmElement = OsmNode | OsmWay | OsmRelation; + +export const OSMCHANGE_ACTION_TYPES = ['create', 'modify', 'delete'] as const; +type OsmChangeActionTypeTuple = typeof OSMCHANGE_ACTION_TYPES; +export type OsmChangeActionType = OsmChangeActionTypeTuple[number]; + +export type OsmChange = { + [K in OsmChangeActionType]: OsmElement[] | undefined; +}; + +export interface OsmChangesetComment { + id: number; + text: string; + visible: boolean; + user: string; + uid: string; + date: Date; +}; + +export interface OsmChangeset { + id: number; + created_at: Date; + closed_at: Date; + open: boolean; + user: string; + uid: number; + min_lat: number; + min_lon: number; + max_lat: number; + max_lon: number; + comments_count: number; + changes_count: number; + tags: OsmTags; + comments?: OsmChangesetComment[]; + osmChange: OsmChange; // Workspaces only +}; + +export type OsmNoteStatus = 'open' | 'closed'; +export type OsmNoteAction = 'opened' | 'closed' | 'commented' | 'reopened'; + +export interface OsmNoteComment { + action: OsmNoteAction; + date: Date; + user?: string; + uid?: number; + text: string; + html: string; +} + +export interface OsmNote { + id: number; + status: OsmNoteStatus; + lat: number; + lon: number; + created_at: Date; + comments: OsmNoteComment[]; +} diff --git a/types/osmchange-parser.d.ts b/types/osmchange-parser.d.ts new file mode 100644 index 0000000..c216004 --- /dev/null +++ b/types/osmchange-parser.d.ts @@ -0,0 +1 @@ +declare module '@osmcha/osmchange-parser'; diff --git a/types/tdei.ts b/types/tdei.ts new file mode 100644 index 0000000..3a01edd --- /dev/null +++ b/types/tdei.ts @@ -0,0 +1,26 @@ +export interface TdeiProjectGroupItem { + tdei_project_group_id: string; + name: string; +} + +export interface TdeiDatasetItem { + tdei_dataset_id: string; + name: string; +} + +export interface TdeiFeedback { + id: number; + status: string; + location_latitude: number; + location_longitude: number; + customer_email: string; + feedback_text: string; + created_at: Date; + updated_at: Date; + due_date: Date; + resolution_status: string | null; + resolution_description: string | null; + resolved_by: string | null; + project_group: TdeiProjectGroupItem; + dataset: TdeiDatasetItem; +} diff --git a/types/workspaces.ts b/types/workspaces.ts new file mode 100644 index 0000000..ff9a61f --- /dev/null +++ b/types/workspaces.ts @@ -0,0 +1,20 @@ + +export type WorkspaceId = number; +export type WorkspaceType = 'osw' | 'pathways'; +export type WorkspaceAppAccess = 0 | 1 | 2; + +export interface Workspace { + id: WorkspaceId; + type: WorkspaceType; + title: string; + description?: string; + tdeiRecordId?: string; + tdeiProjectGroupId: string; + tdeiServiceId?: string; + tdeiMetadata?: string; + createdAt: Date; + createdBy: string; + createdByName: string; + externalAppAccess: WorkspaceAppAccess; + kartaViewToken?: string; +} diff --git a/util/time.ts b/util/time.ts new file mode 100644 index 0000000..a347d2b --- /dev/null +++ b/util/time.ts @@ -0,0 +1,17 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +const shortFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'short', + timeStyle: 'short', +}); + +export function formatShort(date: Date): string { + return shortFormatter.format(date); +} + +export function formatElapsed(date: Date): string { + return dayjs(date).fromNow(); +}