From 09b8a16bffcbd9647e5a5e91291a2d82e1201e3d Mon Sep 17 00:00:00 2001 From: Kyle Hensel Date: Mon, 12 Aug 2024 20:06:17 +1000 Subject: [PATCH] Support importing an osmChange file --- config/eslint.config.mjs | 1 - css/80_app.css | 10 + data/core.yaml | 11 + jsconfig.json | 6 + modules/actions/import_osmChange.js | 159 ++++++++++++ modules/actions/import_osmPatch.js | 268 ++++++++++++++++++++ modules/actions/index.js | 4 + modules/actions/replace_relation_members.js | 14 + modules/actions/replace_way_nodes.js | 14 + modules/core/context.js | 12 +- modules/core/history.js | 6 + modules/global.d.ts | 16 ++ modules/operations/import_file.js | 82 ++++++ modules/services/osm.js | 6 + modules/ui/commit.js | 1 + modules/ui/error_modal.js | 36 +++ modules/ui/sections/data_layers.js | 5 +- modules/ui/sections/import_file.js | 63 +++++ modules/util/array.js | 6 + modules/util/dom.js | 18 ++ package-lock.json | 61 +++++ package.json | 1 + 22 files changed, 795 insertions(+), 5 deletions(-) create mode 100644 jsconfig.json create mode 100644 modules/actions/import_osmChange.js create mode 100644 modules/actions/import_osmPatch.js create mode 100644 modules/actions/replace_relation_members.js create mode 100644 modules/actions/replace_way_nodes.js create mode 100644 modules/global.d.ts create mode 100644 modules/operations/import_file.js create mode 100644 modules/ui/error_modal.js create mode 100644 modules/ui/sections/import_file.js create mode 100644 modules/util/dom.js diff --git a/config/eslint.config.mjs b/config/eslint.config.mjs index ca46364447..9710bea298 100644 --- a/config/eslint.config.mjs +++ b/config/eslint.config.mjs @@ -31,7 +31,6 @@ export default [ 'indent': ['off', 4], 'keyword-spacing': 'error', 'linebreak-style': ['error', 'unix'], - 'no-await-in-loop': 'error', 'no-caller': 'error', 'no-catch-shadow': 'error', 'no-console': 'warn', diff --git a/css/80_app.css b/css/80_app.css index 81297915b6..f5924e4c9d 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -135,19 +135,29 @@ em { strong { font-weight: bold; } + +.button-link, a, a:visited, a:active { color: #7092ff; } +.button-link:focus, a:focus { color: #597be7; } @media (hover: hover) { + .button-link:hover, a:hover { color: #597be7; } } + +.button-link { + display: flex; + align-items: center; +} + kbd { display: inline-block; text-align: center; diff --git a/data/core.yaml b/data/core.yaml index 5cdb0658e5..1d2ffa0250 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -365,6 +365,17 @@ en: area: This feature can't follow the area because it is only connected at a single point. Add another point manually to continue. generic: This feature can't follow the other feature because they are only connected at a single point. Add another point manually to continue. unknown: This feature can't follow another feature. + import_from_file: + title: Load edits from file + tooltip: Import an osmChange file or osmPatch file into the editor. + loading: Importing file… + error: + title: Failed to import file + unknown: An unknown error occurred. + conflicts: Some features in the osmChange file have been edited by other users, causing a conflict. + annotation: + osmChange: Imported an osmChange file + osmPatch: Imported an osmPatch file reflect: title: long: Flip Long diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000..3dd70974cd --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "downlevelIteration": true, + "types": ["modules/global.d.ts"] + } +} diff --git a/modules/actions/import_osmChange.js b/modules/actions/import_osmChange.js new file mode 100644 index 0000000000..1b65576b15 --- /dev/null +++ b/modules/actions/import_osmChange.js @@ -0,0 +1,159 @@ +// @ts-check +/** @import * as Osm from 'osm-api' */ +import { osmNode, osmRelation, osmWay } from '../osm'; +import { actionAddEntity } from './add_entity'; +import { actionChangeTags } from './change_tags'; +import { actionDeleteNode } from './delete_node'; +import { actionDeleteRelation } from './delete_relation'; +import { actionDeleteWay } from './delete_way'; +import { actionMoveNode } from './move_node'; +import { actionReplaceRelationMembers } from './replace_relation_members'; +import { actionReplaceWayNodes } from './replace_way_nodes'; + +/** + * A map of the IDs from the osmPatch file to the IDs in our graph. + * @typedef {Record>} IDMap + */ + +/** + * @param {Osm.OsmFeatureType} type + * @param {number} id + * @param {IDMap} idMap + */ +const getId = (type, id, idMap) => { + const mappedId = id > 0 ? type[0] + id : idMap[type][id]; + if (mappedId === undefined) { + throw new Error(`No entry in idMap for ${type} ${id}`); + } + return mappedId; +}; + +/** + * @param {Osm.OsmChange} osmChange + * @param {boolean} allowConflicts + */ +export function actionImportOsmChange(osmChange, allowConflicts) { + /** @param {iD.Graph} graph */ + return (graph) => { + /** @type {IDMap} */ + const idMap = { node: {}, way: {}, relation: {} }; + + // check that the versions from the osmChange file match the + // versions in our graph. If not, there are conflicts. + if (!allowConflicts) { + for (const feature of [...osmChange.modify, ...osmChange.delete]) { + const entityId = getId(feature.type, feature.id, idMap); + const entity = graph.entity(entityId); + if (+entity.version !== feature.version) { + throw new Error( + `Conflicts on ${entityId}, expected v${feature.version}, got v${entity.version}` + ); + } + } + } + + // create placeholders in the graph for all new features, so + // that all new features are allocated an ID. + for (const feature of osmChange.create) { + switch (feature.type) { + case 'node': { + const entity = osmNode({ + tags: feature.tags, + loc: [feature.lon, feature.lat], + }); + idMap[feature.type][feature.id] = entity.id; + graph = actionAddEntity(entity)(graph); + break; + } + + case 'way': { + const entity = osmWay({ + tags: feature.tags, + // `nodes` are added later, once an ID has + // been allocated to all new features + nodes: [], + }); + idMap[feature.type][feature.id] = entity.id; + graph = actionAddEntity(entity)(graph); + break; + } + + case 'relation': { + const entity = osmRelation({ + tags: feature.tags, + // `members` are added later, once an ID has + // been allocated to all new features + members: [], + }); + idMap[feature.type][feature.id] = entity.id; + graph = actionAddEntity(entity)(graph); + break; + } + + default: + // eslint-disable-next-line no-unused-expressions -- exhaustivity check + /** @satisfies {never} */ (feature); + } + } + + // loop through the `create` features again, to set the loc/nodes/members + // we can also handle the `modify` features at the same time. + for (const feature of [...osmChange.create, ...osmChange.modify]) { + const entityId = getId(feature.type, feature.id, idMap); + + // firstly, change tags + graph = actionChangeTags(entityId, feature.tags)(graph); + + // secondly, change loc/nodes/members + switch (feature.type) { + case 'node': + graph = actionMoveNode(entityId, [feature.lon, feature.lat])(graph); + break; + + case 'way': { + const newNodes = feature.nodes.map((id) => getId('node', id, idMap)); + graph = actionReplaceWayNodes(entityId, newNodes)(graph); + break; + } + + case 'relation': { + const newMembers = feature.members.map((member) => ({ + id: getId(member.type, member.ref, idMap), + role: member.role, + type: member.type, + })); + graph = actionReplaceRelationMembers(entityId, newMembers)(graph); + break; + } + + default: + // eslint-disable-next-line no-unused-expressions -- exhaustivity check + /** @satisfies {never} */ (feature); + } + } + + // delete + for (const feature of osmChange.delete) { + const entityId = getId(feature.type, feature.id, idMap); + switch (feature.type) { + case 'node': + graph = actionDeleteNode(entityId)(graph); + break; + + case 'way': + graph = actionDeleteWay(entityId)(graph); + break; + + case 'relation': + graph = actionDeleteRelation(entityId)(graph); + break; + + default: + // eslint-disable-next-line no-unused-expressions -- exhaustivity check + /** @satisfies {never} */ (feature); + } + } + + return graph; + }; +} diff --git a/modules/actions/import_osmPatch.js b/modules/actions/import_osmPatch.js new file mode 100644 index 0000000000..48a4a1c20f --- /dev/null +++ b/modules/actions/import_osmPatch.js @@ -0,0 +1,268 @@ +// @ts-check +/** @import * as Osm from 'osm-api' */ + +import { osmNode, osmRelation, osmWay } from '../osm'; +import { actionAddEntity } from './add_entity'; +import { actionChangeTags } from './change_tags'; +import { actionDeleteNode } from './delete_node'; +import { actionDeleteRelation } from './delete_relation'; +import { actionDeleteWay } from './delete_way'; +import { actionMoveNode } from './move_node'; +import { actionReplaceRelationMembers } from './replace_relation_members'; + +/** + * @param {Osm.Tags} originalTags + * @param {Osm.Tags} diff + * @returns {Osm.Tags} + */ +function applyTagDiff(originalTags, diff) { + const newTags = { ...originalTags }; + for (const [key, value] of Object.entries(diff)) { + if (value === '🗑️') { + delete newTags[key]; + } else { + newTags[key] = `${value}`; + } + } + return newTags; +} + +/** + * @typedef {{ id: string; type: Osm.OsmFeatureType; role: string }} RelationMember + * + * @param {RelationMember[]} originalMembers + * @param {Osm.OsmRelation['members']} diff + * @returns {RelationMember[]} + */ +function applyMemberDiff(originalMembers, diff) { + let newMembersList = structuredClone(originalMembers); + for (const item of diff) { + const firstOldIndex = newMembersList.findIndex( + (m) => m.type === item.type && +m.id.slice(1) === item.ref + ); + // start by removing all existing ones + newMembersList = newMembersList.filter( + (m) => !(m.type === item.type && +m.id.slice(1) === item.ref) + ); + + if (item.role === '🗑️') { + // we've delete every occurrence of this feature from the relation, so nothing to do. + } else { + // if this feature already existed, all instances of it have already been removed. + // so add back to the of the array + const member = { + id: item.type[0] + item.ref, + type: item.type, + role: item.role, + }; + if (firstOldIndex === -1) { + // this item is new, so add it to the end of the array + newMembersList.push(member); + } else { + // add it back at its original position + newMembersList.splice(firstOldIndex, 0, member); + } + } + } + return newMembersList; +} + +/** + * @param {import('geojson').Geometry} geom + * @param {Osm.Tags} tags + * @param {Osm.OsmRelation['members']} relationMembers + */ +function geojsonToOsmGeometry(geom, tags, relationMembers) { + switch (geom.type) { + case 'Point': { + return [osmNode({ tags, loc: geom.coordinates })]; + } + + case 'MultiPoint': { + const children = geom.coordinates.map((loc) => osmNode({ loc })); + const site = osmRelation({ + tags: { type: 'site', ...tags }, + members: children.map((child) => ({ + type: child.type, + id: child.id, + role: '', + })), + }); + return [site, ...children]; + } + + case 'LineString': { + const children = geom.coordinates.map((loc) => osmNode({ loc })); + const way = osmWay({ + tags, + nodes: children.map((child) => child.id), + }); + return [way, ...children]; + } + + case 'MultiLineString': { + const nodes = []; + const ways = []; + + for (const segment of geom.coordinates) { + const segmentNodes = segment.map((loc) => osmNode({ loc })); + const way = osmWay({ nodes: segmentNodes.map((n) => n.id) }); + nodes.push(...segmentNodes); + ways.push(way); + } + + const relation = osmRelation({ + tags: { type: 'multilinestring', ...tags }, + members: ways.map((w) => ({ role: '', type: 'way', id: w.id })), + }); + + return [relation, ...ways, ...nodes]; + } + + case 'GeometryCollection': { + return [osmRelation({ tags, members: relationMembers })]; + } + + // this logic is based on what RapiD uses: + // https://github.com/facebookincubator/Rapid/blob/8a58b2/modules/services/esri_data.js#L103-L134 + case 'Polygon': + case 'MultiPolygon': { + const nodes = []; + const ways = []; + + const groups = + geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; + + for (const rings of groups) { + for (const [index, ring] of rings.entries()) { + const ringNodes = ring.map((loc) => osmNode({ loc })); + if (ringNodes.length < 3) return []; + + const first = ringNodes[0]; + const last = ringNodes[ringNodes.length - 1]; + + if (first.loc.join(',') === last.loc.join(',')) { + // the first and last node have the same location, so + // reuse the same node. + ringNodes.pop(); + ringNodes.push(first); + } else { + // the first and last node are in a different location, so + // add the first node, to ensure that rings are closed. + ringNodes.push(first); + } + + const way = osmWay({ nodes: ringNodes.map((n) => n.id) }); + nodes.push(...ringNodes); + ways.push({ way, role: index === 0 ? 'outer' : 'inner' }); + } + + if (groups.length === 1 && rings.length === 1) { + // special case: only 1 ring, so we don't need a multipolygon, + // we can just create a simple way and abort early. + let way = ways[0].way; + way = way.update({ tags }); + return [way, ...nodes]; + } + } + + const relation = osmRelation({ + tags: { type: 'multipolygon', ...tags }, + members: ways.map(({ way, role }) => ({ + type: 'way', + id: way.id, + role, + })), + }); + return [relation, ...ways.map((w) => w.way), ...nodes]; + } + + default: { + // eslint-disable-next-line no-unused-expressions -- exhaustivity check + /** @satisfies {never} */ (geom); + return []; + } + } +} + +/** @param {Osm.OsmPatch} osmPatch */ +export function actionImportOsmPatch(osmPatch) { + /** @param {iD.Graph} graph */ + return (graph) => { + for (const feature of osmPatch.features) { + const { + __action, + __members: memberDiff, + ...tagDiff + } = feature.properties; + + switch (__action) { + case undefined: { + // create + const entities = geojsonToOsmGeometry( + feature.geometry, + tagDiff, + memberDiff + ); + for (const entity of entities) { + graph = actionAddEntity(entity)(graph); + } + break; + } + + case 'edit': { + const entity = graph.entity(feature.id); + + // update tags + graph = actionChangeTags( + feature.id, + applyTagDiff(entity.tags, tagDiff) + )(graph); + + // then update members for relations + if (entity.type === 'relation' && memberDiff) { + const newMembers = applyMemberDiff(entity.members, memberDiff); + graph = actionReplaceRelationMembers(entity.id, newMembers)(graph); + } + + break; + } + + case 'move': { + if (feature.id[0] !== 'n' || feature.geometry.type !== 'LineString') { + throw new Error('trying to move a non-node'); + } + graph = actionMoveNode( + feature.id, + feature.geometry.coordinates[1] + )(graph); + break; + } + + case 'delete': { + switch (feature.id[0]) { + case 'n': + graph = actionDeleteNode(feature.id)(graph); + break; + + case 'w': + graph = actionDeleteWay(feature.id)(graph); + break; + + case 'r': + graph = actionDeleteRelation(feature.id)(graph); + break; + } + break; + } + + default: { + // eslint-disable-next-line no-unused-expressions -- exhaustivity check + /** @satisfies {never} */ (__action); + } + } + } + + return graph; + }; +} diff --git a/modules/actions/index.js b/modules/actions/index.js index ca8fe5f3d6..c6687baf6d 100644 --- a/modules/actions/index.js +++ b/modules/actions/index.js @@ -16,6 +16,8 @@ export { actionDeleteWay } from './delete_way'; export { actionDiscardTags } from './discard_tags'; export { actionDisconnect } from './disconnect'; export { actionExtract } from './extract'; +export { actionImportOsmChange } from './import_osmChange'; +export { actionImportOsmPatch } from './import_osmPatch'; export { actionJoin } from './join'; export { actionMerge } from './merge'; export { actionMergeNodes } from './merge_nodes'; @@ -26,6 +28,8 @@ export { actionMoveMember } from './move_member'; export { actionMoveNode } from './move_node'; export { actionNoop } from './noop'; export { actionOrthogonalize } from './orthogonalize'; +export { actionReplaceRelationMembers } from './replace_relation_members'; +export { actionReplaceWayNodes } from './replace_way_nodes'; export { actionRestrictTurn } from './restrict_turn'; export { actionReverse } from './reverse'; export { actionRevert } from './revert'; diff --git a/modules/actions/replace_relation_members.js b/modules/actions/replace_relation_members.js new file mode 100644 index 0000000000..bf1e43c575 --- /dev/null +++ b/modules/actions/replace_relation_members.js @@ -0,0 +1,14 @@ +// @ts-check + +/** + * @param {string} entityId + * @param {{ id: string; type: string; role: string }[]} newMembers + */ +export function actionReplaceRelationMembers(entityId, newMembers) { + /** @param {iD.Graph} graph */ + return (graph) => { + let entity = graph.entity(entityId); + entity = entity.update({ members: newMembers }); + return graph.replace(entity); + }; +} diff --git a/modules/actions/replace_way_nodes.js b/modules/actions/replace_way_nodes.js new file mode 100644 index 0000000000..b507cb34d6 --- /dev/null +++ b/modules/actions/replace_way_nodes.js @@ -0,0 +1,14 @@ +// @ts-check + +/** + * @param {string} entityId + * @param {string[]} newNodeIds + */ +export function actionReplaceWayNodes(entityId, newNodeIds) { + /** @param {iD.Graph} graph */ + return (graph) => { + let entity = graph.entity(entityId); + entity = entity.update({ nodes: newNodeIds }); + return graph.replace(entity); + }; +} diff --git a/modules/core/context.js b/modules/core/context.js index 8e6fdd655d..afce265dfd 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -41,6 +41,7 @@ export function coreContext() { let _defaultChangesetComment = context.initialHashParams.comment; let _defaultChangesetSource = context.initialHashParams.source; let _defaultChangesetHashtags = context.initialHashParams.hashtags; + let _defaultChangesetTags = {}; context.defaultChangesetComment = function(val) { if (!arguments.length) return _defaultChangesetComment; _defaultChangesetComment = val; @@ -56,6 +57,11 @@ export function coreContext() { _defaultChangesetHashtags = val; return context; }; + context.defaultChangesetTags = function(val) { + if (!arguments.length) return _defaultChangesetTags; + _defaultChangesetTags = val; + return context; + }; /* Document title */ /* (typically shown as the label for the browser window/tab) */ @@ -169,12 +175,12 @@ export function coreContext() { }; // Download the full entity and its parent relations. The callback may be called multiple times. - context.loadEntity = (entityID, callback) => { + context.loadEntity = async (entityID, callback) => { if (_connection) { const cid = _connection.getConnectionId(); - _connection.loadEntity(entityID, afterLoad(cid, callback)); + await _connection.loadEntity(entityID, afterLoad(cid, callback)); // We need to fetch the parent relations separately. - _connection.loadEntityRelations(entityID, afterLoad(cid, callback)); + await _connection.loadEntityRelations(entityID, afterLoad(cid, callback)); } }; diff --git a/modules/core/history.js b/modules/core/history.js index 1d019922ba..cac38bdea6 100644 --- a/modules/core/history.js +++ b/modules/core/history.js @@ -322,6 +322,12 @@ export function coreHistory(context) { } }, + /** @param {string} name */ + appendImageryUsed(name) { + const imagery = new Set(_imageryUsed); + imagery.add(name); + _imageryUsed = [...imagery]; + }, photoOverlaysUsed: function(sources) { if (sources) { diff --git a/modules/global.d.ts b/modules/global.d.ts new file mode 100644 index 0000000000..447607ef02 --- /dev/null +++ b/modules/global.d.ts @@ -0,0 +1,16 @@ +declare global { + declare const expect: Chai.ExpectStatic; + + declare namespace iD { + export type Context = ReturnType; + export type Graph = NonNullable>; + + export * from '.'; + } + + declare namespace d3 { + export type * from 'd3'; + } +} + +export {}; diff --git a/modules/operations/import_file.js b/modules/operations/import_file.js new file mode 100644 index 0000000000..49ca81ec16 --- /dev/null +++ b/modules/operations/import_file.js @@ -0,0 +1,82 @@ +// @ts-check +/** @import * as Osm from 'osm-api' */ +import { parseOsmChangeXml } from 'osm-api'; +import { uploadFile } from '../util/dom'; +import { actionImportOsmChange, actionImportOsmPatch } from '../actions'; +import { t } from '../core'; +import { utilArrayChunk } from '../util'; + +/** + * we need to do a full load for every feature that we're + * about to modify or delete, to ensure that all operations + * are safe, in particular, deleting features and modifying + * relations. + * @param {iD.Context} context + * @param {string[]} entityIds + */ +async function downloadFeatures(context, entityIds) { + // split into chunks of parallel requests, to avoid + // getting blocked by the OSM API. + for (const chunk of utilArrayChunk(entityIds, 10)) { + await Promise.all(chunk.map(context.loadEntity)); + } +} + +/** + * @param {iD.Context} context + * @param {boolean} allowConflicts + * @param {() => void} [onLoadingStart] + */ +export async function operationImportFile( + context, + allowConflicts, + onLoadingStart +) { + const files = await uploadFile({ + accept: '.osc,.osmPatch.geo.json', + multiple: true, + }); + if (!files.length) return; // cancelled + + onLoadingStart?.(); + + for (const file of files) { + if (file.name.endsWith('.osc')) { + // osmChange + const osmChange = parseOsmChangeXml(await file.text()); + + context.defaultChangesetTags(osmChange.changeset?.tags); + + await downloadFeatures( + context, + [...osmChange.modify, ...osmChange.delete].map( + (feature) => feature.type[0] + feature.id + ) + ); + + const annotation = t('operations.import_from_file.annotation.osmChange'); + context.history().appendImageryUsed('.osmChange data file'); + context.perform( + actionImportOsmChange(osmChange, allowConflicts), + annotation + ); + } else if (file.name.endsWith('.osmPatch.geo.json')) { + // osmPatch + /** @type {Osm.OsmPatch} */ + const osmPatch = JSON.parse(await file.text()); + + context.defaultChangesetTags(osmPatch.changesetTags); + + await downloadFeatures( + context, + osmPatch.features + .filter((f) => f.properties.__action) + .map((f) => /** @type {string} */ (f.id)) + ); + + const annotation = t('operations.import_from_file.annotation.osmPatch'); + context.history().appendImageryUsed('.osmPatch data file'); + context.perform(actionImportOsmPatch(osmPatch), annotation); + } + } +} diff --git a/modules/services/osm.js b/modules/services/osm.js index 07baebe20b..632b1b2e00 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -721,6 +721,7 @@ export default { // GET /api/0.6/node/#id // GET /api/0.6/[way|relation]/#id/full loadEntity: function(id, callback) { + return new Promise((resolve, reject) => { var type = osmEntity.id.type(id); var osmID = osmEntity.id.toOSM(id); var options = { skipSeen: false }; @@ -729,9 +730,11 @@ export default { '/api/0.6/' + type + '/' + osmID + (type !== 'node' ? '/full' : '') + '.json', function(err, entities) { if (callback) callback(err, { data: entities }); + return err ? reject(err) : resolve(entities); }, options ); + }); }, // Load a single note by id , XML format @@ -768,6 +771,7 @@ export default { // Load the relations of a single entity with the given. // GET /api/0.6/[node|way|relation]/#id/relations loadEntityRelations: function(id, callback) { + return new Promise((resolve, reject) => { var type = osmEntity.id.type(id); var osmID = osmEntity.id.toOSM(id); var options = { skipSeen: false }; @@ -776,9 +780,11 @@ export default { '/api/0.6/' + type + '/' + osmID + '/relations.json', function(err, entities) { if (callback) callback(err, { data: entities }); + return err ? reject(err) : resolve(entities); }, options ); + }); }, diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 59cbdaf813..c71609035d 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -88,6 +88,7 @@ export function uiCommit(context) { var detected = utilDetect(); var tags = { + ...context.defaultChangesetTags(), comment: prefs('comment') || '', created_by: context.cleanTagValue('iD ' + context.version), host: context.cleanTagValue(detected.host), diff --git a/modules/ui/error_modal.js b/modules/ui/error_modal.js new file mode 100644 index 0000000000..b3427b6b58 --- /dev/null +++ b/modules/ui/error_modal.js @@ -0,0 +1,36 @@ +// @ts-check +import { select as d3_select } from 'd3'; +import { uiConfirm } from './confirm'; + +export function uiErrorModal() { + let _modal = d3_select(null); + let _title = ''; + let _subtitle = ''; + + /** @param {d3.Selection} selection */ + let errorModal = (selection) => { + _modal = uiConfirm(selection).okButton(); + + _modal.select('.modal-section.header').append('h3').html(_title); + _modal.select('.modal-section.message-text').html(_subtitle); + _modal.select('button.close').attr('class', 'hide'); + + return errorModal; + }; + + /** @param {string} val */ + errorModal.setTitle = (val) => { + _title = val; + return errorModal; + }; + + /** @param {string} val */ + errorModal.setSubtitle = (val) => { + _subtitle = val; + return errorModal; + }; + + errorModal.close = () => _modal.remove(); + + return errorModal; +} diff --git a/modules/ui/sections/data_layers.js b/modules/ui/sections/data_layers.js index 4053c3bf17..252179589a 100644 --- a/modules/ui/sections/data_layers.js +++ b/modules/ui/sections/data_layers.js @@ -12,9 +12,11 @@ import { modeBrowse } from '../../modes/browse'; import { uiCmd } from '../cmd'; import { uiSection } from '../section'; import { uiSettingsCustomData } from '../settings/custom_data'; +import { uiSectionImportFile } from './import_file'; export function uiSectionDataLayers(context) { + const drawImportItems = uiSectionImportFile(context); var settingsCustomData = uiSettingsCustomData(context) .on('change', customChanged); @@ -37,7 +39,8 @@ export function uiSectionDataLayers(context) { .call(drawQAItems) .call(drawCustomDataItems) .call(drawVectorItems) // Beta - Detroit mapping challenge - .call(drawPanelItems); + .call(drawPanelItems) + .call(drawImportItems); } function showsLayer(which) { diff --git a/modules/ui/sections/import_file.js b/modules/ui/sections/import_file.js new file mode 100644 index 0000000000..3a717064a2 --- /dev/null +++ b/modules/ui/sections/import_file.js @@ -0,0 +1,63 @@ +// @ts-check +import { t } from '../../core'; +import { operationImportFile } from '../../operations/import_file'; +import { svgIcon } from '../../svg'; +import { uiLoading } from '../loading'; +import { uiTooltip } from '../tooltip'; +import { uiErrorModal } from '../error_modal'; + +/** @param {iD.Context} context */ +export function uiSectionImportFile(context) { + const _loading = uiLoading(context) + .message(t.html('operations.import_from_file.loading')) + .blocking(true); + + const _errorModal = uiErrorModal(); + + /** @param {PointerEvent} event */ + async function onClickImport(event) { + try { + await operationImportFile(context, event.ctrlKey, () => { + context.container().call(_loading); + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + const subtitle = `${error}`.includes('Conflicts') + ? t.html('operations.import_from_file.error.conflicts') + : t.html('operations.import_from_file.error.unknown'); + + context + .container() + .call( + _errorModal + .setTitle(t.html('operations.import_from_file.error.title')) + .setSubtitle(subtitle) + ); + } + _loading.close(); + } + + /** @param {d3.Selection} selection */ + return (selection) => { + const importDivEnter = selection + .selectAll('.layer-list-import') + .data([0]) + .enter() + .append('div') + .attr('class', 'layer-list-import'); + + importDivEnter + .append('button') + .attr('class', 'button-link') + .call( + uiTooltip() + .title(() => t.append('operations.import_from_file.tooltip')) + .placement('right') + ) + .call(svgIcon('#iD-icon-save', 'inline')) + .call(t.append('operations.import_from_file.title')) + .on('click', onClickImport); + }; +} diff --git a/modules/util/array.js b/modules/util/array.js index b653271da5..6adaee7494 100644 --- a/modules/util/array.js +++ b/modules/util/array.js @@ -59,6 +59,12 @@ export function utilArrayUniq(a) { } +/** + * @template T + * @param {T[]} a + * @param {number} chunkSize + * @returns {T[][]} + */ // Splits array into chunks of given chunk size // var a = [1,2,3,4,5,6,7]; // utilArrayChunk(a, 3); diff --git a/modules/util/dom.js b/modules/util/dom.js new file mode 100644 index 0000000000..c19f124879 --- /dev/null +++ b/modules/util/dom.js @@ -0,0 +1,18 @@ +// @ts-check + +/** + * @param {Partial} [options] + * @returns {Promise} + */ +export function uploadFile(options) { + return new Promise((resolve) => { + const input = document.createElement('input'); + Object.assign(input, { type: 'file', ...options }); + + input.onchange = () => + resolve(Array.from(input.files || []).filter(Boolean)); + input.oncancel = () => resolve([]); + input.click(); + input.remove(); + }); +} diff --git a/package-lock.json b/package-lock.json index 99f7b897c0..349da50385 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "lodash-es": "~4.17.15", "marked": "~14.0.0", "node-diff3": "~3.1.0", + "osm-api": "^2.1.3", "osm-auth": "~2.5.0", "pannellum": "2.5.6", "pbf": "^4.0.1", @@ -4034,6 +4035,27 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -6923,6 +6945,18 @@ "node": ">= 0.8.0" } }, + "node_modules/osm-api": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/osm-api/-/osm-api-2.1.3.tgz", + "integrity": "sha512-hYMngCUny/SbcbnRd8W2OBSSUa5gPvGVOaGBBSPpnu2uGadPWSeOC1PcbtguS8KSYIzlbL0iGRpua5kjfQdZig==", + "dependencies": { + "@types/geojson": "^7946.0.13", + "fast-xml-parser": "^4.4.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/osm-auth": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/osm-auth/-/osm-auth-2.5.0.tgz", @@ -8512,6 +8546,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -12047,6 +12086,14 @@ "version": "2.0.6", "dev": true }, + "fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "requires": { + "strnum": "^1.0.5" + } + }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -13994,6 +14041,15 @@ "type-check": "^0.4.0" } }, + "osm-api": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/osm-api/-/osm-api-2.1.3.tgz", + "integrity": "sha512-hYMngCUny/SbcbnRd8W2OBSSUa5gPvGVOaGBBSPpnu2uGadPWSeOC1PcbtguS8KSYIzlbL0iGRpua5kjfQdZig==", + "requires": { + "@types/geojson": "^7946.0.13", + "fast-xml-parser": "^4.4.1" + } + }, "osm-auth": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/osm-auth/-/osm-auth-2.5.0.tgz", @@ -15066,6 +15122,11 @@ "version": "3.1.1", "dev": true }, + "strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "supports-color": { "version": "7.2.0", "dev": true, diff --git a/package.json b/package.json index df6a33c4bd..f973e74cc2 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "lodash-es": "~4.17.15", "marked": "~14.0.0", "node-diff3": "~3.1.0", + "osm-api": "^2.1.3", "osm-auth": "~2.5.0", "pannellum": "2.5.6", "pbf": "^4.0.1",