Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: mapeoProject.importConfig #405

Merged
merged 36 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2feae41
initial implementation of mapeoProject.importConfig
Dec 5, 2023
86c9b91
parse icons (still wip)
Dec 5, 2023
7dd8abd
Can already import fields, icons and presets
Dec 13, 2023
0fd026e
delete presets fields and icons when importing config
Dec 13, 2023
b79198b
fix type errors
Dec 13, 2023
17e34e0
readConfig abstraction & bug fixes
gmaclennan Dec 14, 2023
ee183cd
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/i…
Dec 14, 2023
5a7ff4f
Merge commit '17e34e00046a66b4d4c286f0bf13268dc391ea41' of github.com…
Dec 14, 2023
8246a18
Write defaultPresets to projectSettings
Dec 18, 2023
e384251
fix wrong merge
Dec 18, 2023
9395c85
sort preset ids to add to default config
Dec 18, 2023
131697b
sort presets on generator, add `deleteAll` helper
Dec 19, 2023
9c186c3
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/i…
Jan 31, 2024
3d52628
initial tests of src/config-import.js
Jan 31, 2024
5836fbd
added tests for invalid presets.json file
Jan 31, 2024
1f5d870
fix wrong matching of icons directory, add icons tests
Jan 31, 2024
cdf2f6f
add missing validConfig.zip
Jan 31, 2024
e3aa86a
add tests for fields
Feb 1, 2024
529e303
add tests for presets, move preset error check on a filter before
Feb 1, 2024
30cc84d
Apply suggestions from code review from evan
tomasciccola Feb 1, 2024
3ab89e7
various changes
Feb 1, 2024
d8f7d75
Clean up parseIcon fn
Feb 1, 2024
b1a7a58
add `isRecord` and 'hasOwn' to avoid duplicating checks
Feb 1, 2024
8e31ab0
address review changes:
Feb 5, 2024
c5dab4f
Use Infinity as default for preset.sort, logic bug in :106
Feb 5, 2024
8e8da77
delete `sort` field in preset after sorting
Feb 5, 2024
5f59e82
fix tests
Feb 5, 2024
4067976
call config.close() as soon as we know we won't need the file handle
Feb 5, 2024
526a9a1
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/i…
Feb 5, 2024
f3313b4
add valid cases for icons, fields and presets tests
Feb 6, 2024
f72c244
replace bigZip with smaller file, add warning with MAX_ENTIES for mac,
Feb 6, 2024
a0c04be
Apply suggestions from code review
tomasciccola Feb 7, 2024
7acf998
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/i…
Feb 7, 2024
118bc55
add limit for icon size, raise a warning if going over it
Feb 7, 2024
49ca3df
add default config zip
Feb 7, 2024
00d9bba
fix silly bug, update deps to solve type errors
Feb 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added config/defaultConfig.zip
Binary file not shown.
301 changes: 291 additions & 10 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"@mapeo/schema": "3.0.0-next.13",
"@mapeo/sqlite-indexer": "1.0.0-alpha.8",
"@sinclair/typebox": "^0.29.6",
"@types/yauzl-promise": "^2.1.5",
"b4a": "^1.6.3",
"better-sqlite3": "^8.7.0",
"big-sparse-array": "^1.0.3",
Expand Down Expand Up @@ -145,6 +146,7 @@
"throttle-debounce": "^5.0.0",
"tiny-typed-emitter": "^2.1.0",
"type-fest": "^4.5.0",
"varint": "^6.0.0"
"varint": "^6.0.0",
"yauzl-promise": "^4.0.0"
}
}
271 changes: 271 additions & 0 deletions src/config-import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import yauzl from 'yauzl-promise'
import { validate, valueSchemas } from '@mapeo/schema'
import { json, buffer } from 'node:stream/consumers'
import path from 'node:path'

// Throw error if a zipfile contains more than 10,000 entries
const MAX_ENTRIES = 10_000
const MAX_ICON_SIZE = 10_000_000

/**
* @typedef {yauzl.Entry} Entry
*/
/**
* @typedef {{
* presets: { [id: string]: unknown }
* fields: { [id: string]: unknown }
* }} PresetsFile
*/
/**
* @typedef {Parameters<import('./icon-api.js').IconApi['create']>[0]} IconData
*/

/**
* @param {string} configPath
*/
export async function readConfig(configPath) {
/** @type {Error[]} */
const warnings = []
/** @type {yauzl.ZipFile} */
let iconsZip

const presetsZip = await yauzl.open(configPath)
if (presetsZip.entryCount > MAX_ENTRIES) {
// MAX_ENTRIES in MAC can be inacurrate
throw new Error(`Zip file contains too many entries. Max is ${MAX_ENTRIES}`)
}
/** @type {undefined | Entry} */
let presetsEntry
for await (const entry of presetsZip) {
if (entry.filename === 'presets.json') {
presetsEntry = entry
break
}
}
if (!presetsEntry) {
throw new Error('Zip file does not contain presets.json')
}
const presetsFile = await json(await presetsEntry.openReadStream())
validatePresetsFile(presetsFile)

return {
get warnings() {
return warnings
},

async close() {
presetsZip.close()
iconsZip.close()
},

/**
* @returns {AsyncIterable<IconData>}
*/
async *icons() {
/** @type {IconData | undefined} */
let icon

iconsZip = await yauzl.open(configPath)
const entries = await iconsZip.readEntries(MAX_ENTRIES)
for (const entry of entries) {
if (!entry.filename.match(/^icons\/([^/]+)$/)) continue
if (entry.uncompressedSize > MAX_ICON_SIZE) {
warnings.push(
new Error(
`icon ${entry.filename} is bigger than maximum allowed size (10MB) `
)
)
continue
}
const buf = await buffer(await entry.openReadStream())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a maximum icon file size?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to guard against icon file size, what do you think a good limit should be? (I'm thinking less than 10MBs)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think 10 megabytes sounds reasonable. Something like this, probably:

// ...

const MAX_ICON_SIZE = 10_000_000

// ...

if (entry.uncompressedSize > MAX_ICON_SIZE) continue

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, probably before continuing we should push to the warnings a message detailing the icon culprit

const iconFilename = entry.filename.replace(/^icons\//, '')
try {
const { name, variant } = parseIcon(iconFilename, buf)
if (!icon) {
icon = {
name,
variants: [variant],
}
} else if (icon.name === name) {
icon.variants.push(variant)
} else {
yield icon
icon = {
name,
variants: [variant],
}
}
} catch (err) {
warnings.push(err)
}
}
if (icon) {
yield icon
}
},

/**
* @returns {Iterable<{ name: string, value: import('@mapeo/schema').FieldValue }>}
*/
*fields() {
const { fields } = presetsFile
for (const [name, field] of Object.entries(fields)) {
if (!isRecord(field) || !hasOwn(field, 'key')) {
warnings.push(new Error(`Invalid field ${name}`))
continue
}
/** @type {Record<string, unknown>} */
const fieldValue = {
schemaName: 'field',
tagKey: field.key,
}
for (const key of Object.keys(valueSchemas.field.properties)) {
if (hasOwn(field, key)) {
fieldValue[key] = field[key]
}
}
if (!validate('field', fieldValue)) {
warnings.push(new Error(`Invalid field ${name}`))
continue
}
yield {
name,
value: fieldValue,
}
}
},

/**
* @returns {Iterable<{ fieldNames: string[], iconName: string | undefined, value: import('@mapeo/schema').PresetValue }>}
*/
*presets() {
const { presets } = presetsFile
tomasciccola marked this conversation as resolved.
Show resolved Hide resolved
// sort presets using the sort field, turn them into an array
/** @type {Array<Record<string, unknown>>} */
const sortedPresets = []
for (const [presetName, preset] of Object.entries(presets)) {
if (isRecord(preset)) {
sortedPresets.push(preset)
} else {
warnings.push(new Error(`invalid preset ${presetName}`))
}
}
sortedPresets.sort((preset, nextPreset) => {
const sort = typeof preset.sort === 'number' ? preset.sort : Infinity
const nextSort =
typeof nextPreset.sort === 'number' ? nextPreset.sort : Infinity
return sort - nextSort
})

// 5. for each preset get the corresponding fieldId and iconId, add them to the db
for (let preset of sortedPresets) {
/** @type {Record<string, unknown>} */
const presetValue = {
schemaName: 'preset',
fieldIds: [],
addTags: {},
removeTags: {},
terms: [],
}
for (const key of Object.keys(valueSchemas.preset.properties)) {
if (hasOwn(preset, key)) {
presetValue[key] = preset[key]
}
}
if (!validate('preset', presetValue)) {
warnings.push(new Error(`Invalid preset ${preset.name}`))
continue
}
yield {
fieldNames:
'fields' in preset && Array.isArray(preset.fields)
? preset.fields
: [],
iconName:
'icon' in preset && typeof preset.icon === 'string'
? preset.icon
: undefined,
value: presetValue,
}
tomasciccola marked this conversation as resolved.
Show resolved Hide resolved
}
},
}
}

/**
* @param {string} filename
* @param {Buffer} buf
* @returns {{ name: string, variant: IconData['variants'][Number] }}}
*/
function parseIcon(filename, buf) {
const parsedFilename = path.parse(filename)
const matches = parsedFilename.base.match(
/([a-zA-Z0-9-]+)-([a-zA-Z]+)@(\d+)x\.[a-zA-Z]+$/
)
if (!matches) {
throw new Error(`Unexpected icon filename ${filename}`)
}
/* eslint-disable no-unused-vars */
const [_, name, size, pixelDensityStr] = matches
const pixelDensity = Number(pixelDensityStr)
if (!(pixelDensity === 1 || pixelDensity === 2 || pixelDensity === 3)) {
throw new Error(`Error loading icon. invalid pixel density ${pixelDensity}`)
}
if (!(size === 'small' || size === 'medium' || size === 'large')) {
throw new Error(`Error loading icon. invalid size ${size}`)
}
if (!name) {
throw new Error('Error loading icon. missing name')
tomasciccola marked this conversation as resolved.
Show resolved Hide resolved
}
/** @type {'image/png' | 'image/svg+xml'} */
let mimeType
switch (parsedFilename.ext.toLowerCase()) {
case '.png':
mimeType = 'image/png'
break
case '.svg':
mimeType = 'image/svg+xml'
break
default:
throw new Error(`Unexpected icon extension ${parsedFilename.ext}`)
}
return {
name,
variant: {
size,
mimeType,
pixelDensity,
blob: buf,
},
}
}

/**
* @param {unknown} presetsFile
* @returns {asserts presetsFile is PresetsFile}
*/
function validatePresetsFile(presetsFile) {
if (
!isRecord(presetsFile) ||
!isRecord(presetsFile.presets) ||
!isRecord(presetsFile.fields)
) {
throw new Error('Invalid presets.json file')
}
}

/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
*/
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}

/**
* @param {Record<string | symbol, unknown>} obj
* @param {string | symbol} prop
*/
function hasOwn(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}
83 changes: 83 additions & 0 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { MemberApi } from './member-api.js'
import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
import { Logger } from './logger.js'
import { IconApi } from './icon-api.js'
import { readConfig } from './config-import.js'

/** @typedef {Omit<import('@mapeo/schema').ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */

Expand Down Expand Up @@ -632,6 +633,78 @@ export class MapeoProject extends TypedEmitter {
// 4. Assign LEFT role for device
await this.#roles.assignRole(this.#deviceId, LEFT_ROLE_ID)
}

/** @param {Object} opts
* @param {string} opts.configPath
* @returns {Promise<Error[]>}
*/
async importConfig({ configPath }) {
// check for already present fields and presets and delete them if exist
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense to do this here instead of moving it outside to a function in src/config-imprort.js? I left it here since we are using a couple of methods of the class and it felt weird passing them to the function as params...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with it here. Could also add a deleteAll() method to DataType or we could do a quick helper:

async function deleteAll(dataType) {
  const deletions = []
  for (const doc of await dataType.getMany()) {
    deletions.push(dataType.delete(doc.versionId))
  }
  return Promise.all(deletions)
}

await deleteAll(this.preset)
await deleteAll(this.field)

const config = await readConfig(configPath)
/** @type {Map<string, string>} */
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
const iconNameToId = new Map()
/** @type {Map<string, string>} */
const fieldNameToId = new Map()

// Do this in serial not parallel to avoid memory issues (avoid keeping all icon buffers in memory)
for await (const icon of config.icons()) {
const iconId = await this.#iconApi.create(icon)
iconNameToId.set(icon.name, iconId)
}

// Ok to create fields and presets in parallel
const fieldPromises = []
for (const { name, value } of config.fields()) {
fieldPromises.push(
this.#dataTypes.field.create(value).then(({ docId }) => {
fieldNameToId.set(name, docId)
})
)
}
await Promise.all(fieldPromises)

const presetsWithRefs = []
for (const { fieldNames, iconName, value } of config.presets()) {
const fieldIds = fieldNames.map((fieldName) => {
const id = fieldNameToId.get(fieldName)
if (!id) {
throw new Error(
`field ${fieldName} not found (referenced by preset ${value.name})})`
)
}
return id
})
presetsWithRefs.push({
...value,
iconId: iconName && iconNameToId.get(iconName),
fieldIds,
})
}

// close the zip handles after we know we won't be needing them anymore
await config.close()

const presetPromises = presetsWithRefs.map((preset) =>
this.preset.create(preset)
)
const createdPresets = await Promise.all(presetPromises)
const presetIds = createdPresets.map(({ docId }) => docId)

await this.$setProjectSettings({
defaultPresets: {
point: presetIds,
line: [],
area: [],
vertex: [],
relation: [],
},
})

return config.warnings
}
}

/**
Expand All @@ -644,6 +717,16 @@ function extractEditableProjectSettings(projectDoc) {
return result
}

// TODO: maybe a better signature than a bunch of any?
/** @param {DataType<any,any,any,any,any>} dataType */
async function deleteAll(dataType) {
const deletions = []
for (const { versionId } of await dataType.getMany()) {
deletions.push(dataType.delete(versionId))
}
return Promise.all(deletions)
}
Comment on lines +722 to +728
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to do all of this in a single database transaction? It's possible that we'll get a list from getMany which will be different by the time we delete them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a way now of doing a bunch of deletions in a single transaction. I think this would imply changes to src/datatype.js and also probably the sqlite-indexer? Probably @gmaclennan can cheap in


/**
* Return a map of namespace -> core keypair
*
Expand Down
Loading
Loading