Skip to content

Commit

Permalink
export and import layers (closes #18)
Browse files Browse the repository at this point in the history
  • Loading branch information
ThomasHalwax committed Aug 28, 2023
1 parent 22fea06 commit 7f6a2a5
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 61 deletions.
6 changes: 2 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"react-easy-sort": "^1.5.1",
"react-fast-compare": "^3.2.0",
"reproject": "^1.2.7",
"sanitize-filename": "^1.6.3",
"subleveldown": "^6.0.1",
"throttle-debounce": "^5.0.0",
"typeface-roboto": "^1.1.13",
Expand Down
24 changes: 24 additions & 0 deletions src/main/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { dialog, Notification } from 'electron'
import sanitizeFilename from 'sanitize-filename'
import fs from 'fs'

export const exportLayer = async (event, layerName, content) => {

const dialogOptions = {
title: 'Export layer',
defaultPath: sanitizeFilename(`${layerName}.json`),
filters: [{ name: 'Layer', extensions: ['json'] }]
}

const interaction = await dialog.showSaveDialog(event.sender.getOwnerBrowserWindow(), dialogOptions)
if (interaction.canceled) return
try {
await fs.promises.writeFile(interaction.filePath, JSON.stringify(content))
} catch (error) {
const n = new Notification({
title: 'Export failed',
body: error.message
})
n.show()
}
}
3 changes: 3 additions & 0 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Session } from './Session'
import { ApplicationMenu } from './menu'
import { WindowManager } from './WindowManager'
import { ProjectStore, SessionStore, LegacyStore, PreferencesProvider } from './stores'
import { exportLayer } from './export.js'
import { ipc } from './ipc'
import * as dotenv from 'dotenv'
import SelfUpdate from './SelfUpdate'
Expand Down Expand Up @@ -101,6 +102,8 @@ const ready = async () => {
open(link.url)
})

ipcMain.on('EXPORT_LAYER', exportLayer)

await session.restore()
await menu.show()

Expand Down
62 changes: 6 additions & 56 deletions src/renderer/Clipboard.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as R from 'ramda'
import * as ID from './ids'
import { clone } from './model/Import'

const CONTENT_TYPE = 'application/json;vnd=odin'
export const CONTENT_TYPE = 'application/json;vnd=odin'

const canCopy = id =>
ID.isLayerId(id) ||
Expand Down Expand Up @@ -74,60 +74,10 @@ Clipboard.prototype.cut = async function () {
}

Clipboard.prototype.paste = async function () {
const entries = (await readEntries()).sort(([a], [b]) => ID.ord(a) - ID.ord(b))
const entrymap = Object.fromEntries(entries)

// Collect all features which are not included in a layer
// which itself is also part of selection.
// These (uncovered) features will be assigned to default layer.

const uncoveredIds = entries.reduce((acc, [key]) => {
if (!ID.isFeatureId(key)) return acc
if (entrymap[ID.layerId(key)]) return acc
acc.push(key)
return acc
}, [])

// Create default layer if necessary.

const tuples = []
let defaultLayerId = await this.store.defaultLayerId()
if (uncoveredIds.length && !defaultLayerId) {
defaultLayerId = ID.layerId()
tuples.push([defaultLayerId, { name: 'Default Layer' }])
tuples.push([ID.defaultId(defaultLayerId), ['default']])
}

// All (uncovered) features where layer is not explicitly included
// in selection are assigned to default layer.

const keymap = uncoveredIds.reduce((acc, featureId) => {
// Map old (uncovered) layer to default layer:
acc[ID.layerId(featureId)] = defaultLayerId
return acc
}, {})


// Add new replacement to keymap (old key -> new key):
const replace = key => R.tap(replacement => (keymap[key] = replacement))

const rewrite = R.cond([
[ID.isLayerId, key => replace(key)(ID.layerId())],
[ID.isFeatureId, key => replace(key)(ID.featureId(keymap[ID.layerId(key)]))],
[ID.isLinkId, key => replace(key)(ID.linkId(keymap[ID.containerId(key)]))],
[ID.isTagsId, key => replace(key)(ID.tagsId(keymap[ID.associatedId(key)]))],
[ID.isMarkerId, key => replace(key)(ID.markerId())],
[ID.isTileServiceId, key => replace(key)(ID.tileServiceId())],
[ID.isStyleId, key => replace(key)(ID.styleId(keymap[ID.associatedId(key)]))],
[R.T, key => key]
])

// Nasty side-effect: adds tuples (aka acc):
entries.reduce((acc, [key, value]) => {
acc.push([rewrite(key), value])
return acc
}, tuples)

const entries = await readEntries()
if (!entries) return
const defaultLayerId = await this.store.defaultLayerId()
const tuples = await clone(defaultLayerId, entries)
this.store.insert(tuples)
}

Expand Down
13 changes: 12 additions & 1 deletion src/renderer/DragAndDrop.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import path from 'path'
import { reproject } from 'reproject'
import Emitter from '../shared/emitter'
import * as ID from './ids'
import { CONTENT_TYPE } from './Clipboard'
import { clone } from './model/Import'

const readJSON = async path => {
const content = await fs.readFile(path, 'utf8')
Expand Down Expand Up @@ -43,9 +45,18 @@ DragAndDrop.prototype.drop = async function (event) {
}

DragAndDrop.prototype.json = async function (files) {
// We expect JSON to be valid GeoJSON (FeatureCollection) only.

const geoJSON = await Promise.all(files.map(file => readJSON(file.path)))

/* treat dropped files the same way as if they were copied/pasted */
const natives = geoJSON.filter(json => json.contentType === CONTENT_TYPE)
if (natives.length > 0) {
const defaultLayerId = await this.store.defaultLayerId()
const imports = await Promise.all(natives.map(native => clone(defaultLayerId, native.entries)))
this.store.insert(imports.flat())
}

/* plain old geoJSON */
const featureCollections = geoJSON.filter(json => json.type === 'FeatureCollection')

const tuples = featureCollections.flatMap((collection, index) => {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/Toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const Toolbar = () => {
commandRegistry.separator(),
commandRegistry.command('LAYER_SET_DEFAULT'),
commandRegistry.command('PIN'),
commandRegistry.command('LAYER_EXPORT'),
commandRegistry.command('SELECT_TILE_LAYERS'),
commandRegistry.separator(),
commandRegistry.command('PRINT_SWITCH_SCOPE')
Expand Down
59 changes: 59 additions & 0 deletions src/renderer/model/Import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as R from 'ramda'
import * as ID from '../ids'

export const clone = async (defaultLayerId, unsorted) => {
const entries = unsorted.sort(([a], [b]) => ID.ord(a) - ID.ord(b))
const entrymap = Object.fromEntries(entries)

// Collect all features which are not included in a layer
// which itself is also part of selection.
// These (uncovered) features will be assigned to default layer.

const uncoveredIds = entries.reduce((acc, [key]) => {
if (!ID.isFeatureId(key)) return acc
if (entrymap[ID.layerId(key)]) return acc
acc.push(key)
return acc
}, [])

// Create default layer if necessary.

const tuples = []
if (uncoveredIds.length && !defaultLayerId) {
defaultLayerId = ID.layerId()
tuples.push([defaultLayerId, { name: 'Default Layer' }])
tuples.push([ID.defaultId(defaultLayerId), ['default']])
}

// All (uncovered) features where layer is not explicitly included
// in selection are assigned to default layer.

const keymap = uncoveredIds.reduce((acc, featureId) => {
// Map old (uncovered) layer to default layer:
acc[ID.layerId(featureId)] = defaultLayerId
return acc
}, {})


// Add new replacement to keymap (old key -> new key):
const replace = key => R.tap(replacement => (keymap[key] = replacement))

const rewrite = R.cond([
[ID.isLayerId, key => replace(key)(ID.layerId())],
[ID.isFeatureId, key => replace(key)(ID.featureId(keymap[ID.layerId(key)]))],
[ID.isLinkId, key => replace(key)(ID.linkId(keymap[ID.containerId(key)]))],
[ID.isTagsId, key => replace(key)(ID.tagsId(keymap[ID.associatedId(key)]))],
[ID.isMarkerId, key => replace(key)(ID.markerId())],
[ID.isTileServiceId, key => replace(key)(ID.tileServiceId())],
[ID.isStyleId, key => replace(key)(ID.styleId(keymap[ID.associatedId(key)]))],
[R.T, key => key]
])

// Nasty side-effect: adds tuples (aka acc):
entries.reduce((acc, [key, value]) => {
acc.push([rewrite(key), value])
return acc
}, tuples)

return tuples
}
33 changes: 33 additions & 0 deletions src/renderer/model/commands/LayerCommands.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ipcRenderer } from 'electron'
import { readEntries } from '../../Clipboard'
import EventEmitter from '../../../shared/emitter'
import * as ID from '../../ids'

Expand Down Expand Up @@ -40,11 +42,42 @@ SelectTilePreset.prototype.execute = async function () {
this.selection.set([ID.defaultTilePresetId])
}

const ExportLayer = function (services) {
this.selection = services.selection
this.store = services.store
this.clipboard = services.clipboard
this.path = 'mdiExport'
this.selection.on('selection', () => this.emit('changed'))
}

Object.assign(ExportLayer.prototype, EventEmitter.prototype)

ExportLayer.prototype.execute = async function () {
const layerId = this.selected()[0]
const layer = await this.store.value(layerId)

await this.clipboard.copy()
const entries = await readEntries()
const content = {
contentType: 'application/json;vnd=odin',
entries
}

ipcRenderer.send('EXPORT_LAYER', layer.name, content)
}
ExportLayer.prototype.enabled = function () {
return this.selected().length === 1
}

ExportLayer.prototype.selected = function () {
return this.selection.selected().filter(ID.isLayerId)
}

/**
*
*/
export default services => ({
LAYER_SET_DEFAULT: new SetDefaultLayer(services),
LAYER_EXPORT: new ExportLayer(services),
SELECT_TILE_LAYERS: new SelectTilePreset(services)
})

0 comments on commit 7f6a2a5

Please sign in to comment.