diff --git a/README.md b/README.md index 53ba27f..640a29f 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ data/ The format for each file is defined in the [`schemas`](schemas) directory. +#### Preset categories + +Files in `data/preset_categories/` (the `categories/` folder in the tree above) define preset groups in the editor. Each JSON file requires `name`, `icon`, and `members`. See the [icons subpage](ICONS.md) for icon ids. + ### Presets A [preset](https://wiki.openstreetmap.org/wiki/Preset) represents a specific type of @@ -329,6 +333,10 @@ This can be overwritten by adding the field explicitly like `"fields": [ "shop", An icon representing a preset, e.g. `"icon": "temaki-power_tower"` ([Example](https://github.com/openstreetmap/id-tagging-schema/blob/main/data/presets/power/tower.json)). More information about available icon sets and usage of icons can be found on the [icons subpage](ICONS.md). +You may set `"icon": "{presets/}"`, e.g. `{presets/shop/books}` for `data/presets/shop/books.json` (or `_books.json`, see [`searchable`](#searchable)). Only `presets/*` is allowed (not `fields/*`). Chains are OK and will also get resolved during build. + +For icons on each combo or radio option, see [`icons`](#icons). + ##### `imageURL` The URL of a remote image file. This does not fully replace `icon`—both may be shown in the UI. @@ -729,7 +737,11 @@ For `identifier` fields, the regular expression that valid values are expected t ##### `icons` -For combo and radio fields, the `icons` object might contain the name of icons which represent the different values of the field. More information about available icon sets and usage of icons can be found on the [icons subpage](ICONS.md). +On [combo / dropdown](#combodropdown-fields) and [radio](#radio-buttons) fields, the `icons` object maps each option key to an icon id shown beside that value in the editor, e.g. `"zebra": "iD-crossing_markings-zebra"` in the snippet below. More information about available icon sets and usage of icons can be found on the [icons subpage](ICONS.md). + +You may set each value to `"{presets/}"` instead of a literal id, with the same rules as [preset `icon`](#icon). + +To copy an entire `icons` map from another field, use [`iconsCrossReference`](#iconscrossreference). Combo field types can accept key-label pairs in the `options` value of the `strings` property. @@ -739,7 +751,7 @@ Combo field types can accept key-label pairs in the `options` value of the `stri "type": "combo", "label": "Crossing Markings", "icons": { - "zebra": "iD-crossing_markings-zebra", + "zebra": "{presets/highway/footway/crossing/zebra}", "lines": "iD-crossing_markings-lines", … } @@ -748,7 +760,9 @@ Combo field types can accept key-label pairs in the `options` value of the `stri ##### `iconsCrossReference` -An optional property to reference to the icons of another field, indicated by using that field's name contained in brackets, like `{field}`. This is for example useful when there are multiple variants of fields for the same tag, which should all use the same icons. +An optional property to copy the entire `icons` object from another field by giving that field's id in brackets (no `presets/` prefix)—for example `{kerb}` copies the field whose id is `kerb`. Useful when several field variants share the same option icons. + +If a field uses `iconsCrossReference`, the builder copies the other field’s `icons` map first, then expands every `{presets/…}` value in **all** field `icons` maps (including the copy). ### Deprecations diff --git a/lib/build.js b/lib/build.js index 4bdd914..ea26a8b 100644 --- a/lib/build.js +++ b/lib/build.js @@ -122,7 +122,7 @@ function processData(options, type) { let fields = generateFields(dataDir, tstrings, searchableFieldIDs, references); if (options.processFields) options.processFields(fields); - let presets = generatePresets(dataDir, tstrings, searchableFieldIDs, options.listReusedIcons, references); + let presets = generatePresets(dataDir, tstrings, searchableFieldIDs, references); if (options.processPresets) options.processPresets(presets); // Additional consistency checks @@ -131,6 +131,8 @@ function processData(options, type) { dereferenceUntranslatedContent(presets, fields); + reportReusedIcons(presets, options.listReusedIcons); + const defaults = read(dataDir + '/preset_defaults.json'); if (defaults) { validateSchema(dataDir + '/preset_defaults.json', defaults, defaultsSchema); @@ -338,10 +340,57 @@ function stripLeadingUnderscores(str) { } -function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons, references) { - let presets = {}; +/** + * @param {Record} presets + * @param {boolean | number} listReusedIcons + */ +function reportReusedIcons(presets, listReusedIcons) { + if (!listReusedIcons) return; + + const icons = {}; + for (const id in presets) { + const preset = presets[id]; + if (preset.searchable !== false) { + const icon = preset.icon || '(none)'; + if (!icons[icon]) icons[icon] = []; + icons[icon].push(id); + } + } + + const reuseLimit = typeof listReusedIcons === 'number' && listReusedIcons > 0 ? listReusedIcons : 1; - let icons = {}; + let reusedIconPresetCount = 0; + const reusedIcons = Object.keys(icons).filter(function(iconID) { + const presetIDs = icons[iconID]; + if (presetIDs.length > reuseLimit) { + reusedIconPresetCount += presetIDs.length; + return true; + } + return false; + }); + + if (reusedIcons.length > 0) { + process.stdout.write(reusedIcons.length + ' icon(s), including (none), are each used more than ' + reuseLimit + ' time(s), affecting ' + reusedIconPresetCount + ' presets\n'); + + reusedIcons.sort(function(iconID1, iconID2) { + return icons[iconID2].length - icons[iconID1].length; + + }).forEach(function(iconID) { + const presetIDs = icons[iconID]; + process.stdout.write(iconID + ', ' + presetIDs.length + '\n'); + for (let i in presetIDs) { + process.stdout.write('-' + presetIDs[i] + '\n'); + } + process.stdout.write('\n'); + }); + } else { + process.stdout.write(styleText('green', 'No icon is used more than ' + reuseLimit + ' time(s) across all searchable presets\n')); + } +} + + +function generatePresets(dataDir, tstrings, searchableFieldIDs, references) { + let presets = {}; fs.globSync(dataDir + '/presets/**/*.json', { posix: true, @@ -398,46 +447,8 @@ function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons, } presets[id] = preset; - - if (preset.searchable !== false) { - let icon = preset.icon || '(none)'; - if (!icons[icon]) icons[icon] = []; - icons[icon].push(id); - } }); - if (listReusedIcons) { - const reuseLimit = typeof listReusedIcons === 'number' && listReusedIcons > 0 ? listReusedIcons : 1; - - let reusedIconPresetCount = 0; - const reusedIcons = Object.keys(icons).filter(function(iconID) { - const presetIDs = icons[iconID]; - if (presetIDs.length > reuseLimit) { - reusedIconPresetCount += presetIDs.length; - return true; - } - return false; - }); - - if (reusedIcons.length > 0) { - process.stdout.write(reusedIcons.length + ' icon(s), including (none), are each used more than ' + reuseLimit + ' time(s), affecting ' + reusedIconPresetCount + ' presets\n'); - - reusedIcons.sort(function(iconID1, iconID2) { - return icons[iconID2].length - icons[iconID1].length; - - }).forEach(function(iconID) { - const presetIDs = icons[iconID]; - process.stdout.write(iconID + ', ' + presetIDs.length + '\n'); - for (let i in presetIDs) { - process.stdout.write('-' + presetIDs[i] + '\n'); - } - process.stdout.write('\n'); - }); - } else { - process.stdout.write(styleText('green', 'No icon is used more than ' + reuseLimit + ' time(s) across all searchable presets\n')); - } - } - return presets; } diff --git a/lib/references.js b/lib/references.js index 679cbe6..8239914 100644 --- a/lib/references.js +++ b/lib/references.js @@ -3,10 +3,112 @@ export function isReference(string) { return string.startsWith('{') && string.endsWith('}'); } +/** + * Resolve "{presets/}" to a concrete icon id (following chains). Only + * `presets/` is valid for icons; `{fields/…}` and bare `{id}` throw. + * + * @param {Record} presets + * @param {string} ref + * @param {string} contextMessage + * @param {Set} [visitedPresetIds] + * @returns {string} + */ +function resolveIconRefToId(presets, ref, contextMessage, visitedPresetIds = new Set()) { + if (!isReference(ref)) return ref; + + const inner = ref.slice(1, -1); + const slashIdx = inner.indexOf('/'); + if (slashIdx === -1) { + throw new Error( + `Invalid icon reference “${ref}” in ${contextMessage}: use “{presets/}” (with a “presets/” prefix).`, + ); + } + + const type = inner.slice(0, slashIdx); + const id = inner.slice(slashIdx + 1); + + if (type === 'fields') { + throw new Error( + `Invalid icon reference “${ref}” in ${contextMessage}: icon references must use “{presets/}”, not “{fields/…}”.`, + ); + } + if (type !== 'presets') { + throw new Error( + `Invalid icon reference “${ref}” in ${contextMessage}: only “{presets/}” is allowed (unexpected prefix “${type}/”).`, + ); + } + if (!id) { + throw new Error( + `Invalid icon reference “${ref}” in ${contextMessage}: missing preset id after “presets/”.`, + ); + } + + if (visitedPresetIds.has(id)) { + throw new Error( + `Cycle detected while resolving icon reference “${ref}” in ${contextMessage}: preset “${id}” appears more than once in the chain.`, + ); + } + visitedPresetIds.add(id); + + const referenced = presets[id]; + if (!referenced) { + throw new Error( + `Cannot resolve icon reference “${ref}” in ${contextMessage}: there is no preset “${id}”.`, + ); + } + + const next = referenced.icon; + if (next === undefined || next === null || next === '') { + throw new Error( + `Cannot resolve icon reference “${ref}” in ${contextMessage}: preset “${id}” has no “icon” property.`, + ); + } + + if (isReference(next)) { + return resolveIconRefToId(presets, next, contextMessage, visitedPresetIds); + } + return next; +} + +/** + * @param {Record} presets + * @param {Record }>} fields + */ +function dereferencePresetIconStrings(presets, fields) { + for (const presetID in presets) { + const preset = presets[presetID]; + if (typeof preset.icon === 'string' && isReference(preset.icon)) { + preset.icon = resolveIconRefToId( + presets, + preset.icon, + `preset “${presetID}” icon`, + ); + } + } + + for (const fieldID in fields) { + const field = fields[fieldID]; + if (!field.icons || typeof field.icons !== 'object') continue; + for (const key of Object.keys(field.icons)) { + const v = field.icons[key]; + if (typeof v === 'string' && isReference(v)) { + field.icons[key] = resolveIconRefToId( + presets, + v, + `field “${fieldID}” icons.${key}`, + ); + } + } + } +} + /** * This is only used to expand references to _untranslated content_. * For example, `fields` can reference the list of field IDs from another * preset. + * + * @param {Record} presets + * @param {Record} fields */ export function dereferenceUntranslatedContent(presets, fields) { for (const presetID in presets) { @@ -98,6 +200,9 @@ export function dereferenceUntranslatedContent(presets, fields) { delete field.locationSetCrossReference; } } + + // 11. preset `icon` and field `icons` values may use "{presets/}" for another preset's icon id + dereferencePresetIconStrings(presets, fields); } /** diff --git a/schemas/field.json b/schemas/field.json index 4e0d218..e6e1982 100644 --- a/schemas/field.json +++ b/schemas/field.json @@ -359,7 +359,7 @@ "enum": ["preset", "changeset", "manual", "group"] }, "icons": { - "description": "For combo and radio fields: Name of icons which represents different values of this field", + "description": "For combo and radio fields: Name of icons which represents different values of this field. Values may be icon ids or \"{presets/}\".", "type": "object", "minProperties": 1, "additionalProperties": { @@ -367,7 +367,7 @@ } }, "iconsCrossReference": { - "description": "A field can reference icons of another by using that field's identifier contained in brackets, like {field}.", + "description": "Copy the entire icons map from another field using that field's id in brackets, e.g. {other_field}.", "type": "string" } }, diff --git a/schemas/preset.json b/schemas/preset.json index a544eb4..0b8bb3a 100644 --- a/schemas/preset.json +++ b/schemas/preset.json @@ -59,7 +59,7 @@ } }, "icon": { - "description": "Name of preset icon which represents this preset", + "description": "Name of preset icon which represents this preset, or cross-reference with \"{presets/}\".", "type": "string" }, "imageURL": { diff --git a/schemas/preset_category.json b/schemas/preset_category.json index 12ca9fb..9a91355 100644 --- a/schemas/preset_category.json +++ b/schemas/preset_category.json @@ -10,7 +10,7 @@ "type": "string" }, "icon": { - "description": "Name of preset icon which represents this preset", + "description": "Name of preset icon which represents this category", "type": "string" }, "members": { diff --git a/tests/schema-builder.test.js b/tests/schema-builder.test.js index fe4bc92..dd67f80 100644 --- a/tests/schema-builder.test.js +++ b/tests/schema-builder.test.js @@ -247,4 +247,232 @@ describe('schema-builder', () => { done(); }); }); + + describe('preset icon references {presets/…}', () => { + const taginfo = { + name: 'Test', + description: 'Test', + project_url: 'https://example.com', + contact_name: 'T', + contact_email: 't@example.com', + }; + + function minimalPreset(overrides) { + return Object.assign( + { + name: 'Test preset', + geometry: ['point'], + tags: { test: 'x' }, + terms: ['a'], + }, + overrides, + ); + } + + it('T1 resolves preset icon from {presets/id}', (done) => { + writeSourceData({ + 'data/presets/t_icon_base.json': minimalPreset({ + tags: { ir: 'base' }, + icon: 'fas-foo', + }), + 'data/presets/t_icon_child.json': minimalPreset({ + tags: { ir: 'child' }, + icon: '{presets/t_icon_base}', + }), + }); + schemaBuilder + .buildDist({ + inDirectory: _workspace + '/data', + interimDirectory: _workspace + '/interim', + outDirectory: _workspace + '/dist', + taginfoProjectInfo: taginfo, + }) + .then(() => { + const presets = JSON.parse( + fs.readFileSync(_workspace + '/dist/presets.json', 'utf8'), + ); + expect(presets['t_icon_child'].icon).toBe('fas-foo'); + done(); + }); + }); + + it('T2 resolves chained preset icon references', (done) => { + writeSourceData({ + 'data/presets/t_chain_a.json': minimalPreset({ + tags: { c: 'a' }, + icon: 'maki-x', + }), + 'data/presets/t_chain_b.json': minimalPreset({ + tags: { c: 'b' }, + icon: '{presets/t_chain_a}', + }), + 'data/presets/t_chain_c.json': minimalPreset({ + tags: { c: 'c' }, + icon: '{presets/t_chain_b}', + }), + }); + schemaBuilder + .buildDist({ + inDirectory: _workspace + '/data', + interimDirectory: _workspace + '/interim', + outDirectory: _workspace + '/dist', + taginfoProjectInfo: taginfo, + }) + .then(() => { + const presets = JSON.parse( + fs.readFileSync(_workspace + '/dist/presets.json', 'utf8'), + ); + expect(presets['t_chain_c'].icon).toBe('maki-x'); + done(); + }); + }); + + it('T3 resolves field icons value from {presets/id}', (done) => { + writeSourceData({ + 'data/presets/t_fld_base.json': minimalPreset({ + tags: { f: 'b' }, + icon: 'iD-bus', + }), + 'data/fields/t_fld_combo.json': { + key: 't_fld', + type: 'combo', + label: 'T fld', + universal: true, + strings: { options: { yes: 'Yes' } }, + icons: { yes: '{presets/t_fld_base}' }, + terms: ['z'], + }, + }); + schemaBuilder + .buildDist({ + inDirectory: _workspace + '/data', + interimDirectory: _workspace + '/interim', + outDirectory: _workspace + '/dist', + taginfoProjectInfo: taginfo, + }) + .then(() => { + const fields = JSON.parse( + fs.readFileSync(_workspace + '/dist/fields.json', 'utf8'), + ); + expect(fields['t_fld_combo'].icons.yes).toBe('iD-bus'); + done(); + }); + }); + + it('T4 resolves {presets/…} inside icons after iconsCrossReference', (done) => { + writeSourceData({ + 'data/presets/t_xr_base.json': minimalPreset({ + tags: { xr: 'b' }, + icon: 'fa-rss', + }), + 'data/fields/t_xr_b.json': { + key: 'xr_b', + type: 'combo', + label: 'XR b', + universal: true, + strings: { options: { yes: 'Yes' } }, + icons: { yes: '{presets/t_xr_base}' }, + terms: ['y'], + }, + 'data/fields/t_xr_a.json': { + key: 'xr_a', + type: 'combo', + label: 'XR a', + universal: true, + strings: { options: { yes: 'Yes' } }, + iconsCrossReference: '{t_xr_b}', + terms: ['y'], + }, + }); + schemaBuilder + .buildDist({ + inDirectory: _workspace + '/data', + interimDirectory: _workspace + '/interim', + outDirectory: _workspace + '/dist', + taginfoProjectInfo: taginfo, + }) + .then(() => { + const fields = JSON.parse( + fs.readFileSync(_workspace + '/dist/fields.json', 'utf8'), + ); + expect(fields['t_xr_a'].icons.yes).toBe('fa-rss'); + expect(fields['t_xr_b'].icons.yes).toBe('fa-rss'); + done(); + }); + }); + + it('T5 rejects {fields/…} in preset icon', () => { + writeSourceData({ + 'data/presets/t_bad_fields.json': minimalPreset({ + tags: { bad: 'f' }, + icon: '{fields/nope}', + }), + }); + expect(() => + schemaBuilder.validate({ inDirectory: _workspace + '/data' }), + ).toThrow(/fields/); + }); + + it('T6 rejects unknown prefix in icon reference', () => { + writeSourceData({ + 'data/presets/t_bad_type.json': minimalPreset({ + tags: { bad: 't' }, + icon: '{unknown/foo}', + }), + }); + expect(() => + schemaBuilder.validate({ inDirectory: _workspace + '/data' }), + ).toThrow(/presets/); + }); + + it('T7 rejects cyclic preset icon references', () => { + writeSourceData({ + 'data/presets/t_cyc_a.json': minimalPreset({ + tags: { cy: 'a' }, + icon: '{presets/t_cyc_b}', + }), + 'data/presets/t_cyc_b.json': minimalPreset({ + tags: { cy: 'b' }, + icon: '{presets/t_cyc_a}', + }), + }); + expect(() => + schemaBuilder.validate({ inDirectory: _workspace + '/data' }), + ).toThrow(/Cycle/); + }); + + it('T8 rejects missing preset in icon reference', () => { + writeSourceData({ + 'data/presets/t_miss.json': minimalPreset({ + tags: { m: '1' }, + icon: '{presets/does/not/exist}', + }), + }); + expect(() => + schemaBuilder.validate({ inDirectory: _workspace + '/data' }), + ).toThrow(/no preset/); + }); + + it('T9 buildDev interim icons.json lists only resolved icon ids', () => { + writeSourceData({ + 'data/presets/t_icn_base.json': minimalPreset({ + tags: { ic: 'b' }, + icon: 'maki-parking', + }), + 'data/presets/t_icn_child.json': minimalPreset({ + tags: { ic: 'c' }, + icon: '{presets/t_icn_base}', + }), + }); + schemaBuilder.buildDev({ + inDirectory: _workspace + '/data', + interimDirectory: _workspace + '/interim', + }); + const icons = JSON.parse( + fs.readFileSync(_workspace + '/interim/icons.json', 'utf8'), + ); + expect(icons).toContain('maki-parking'); + expect(JSON.stringify(icons)).not.toMatch(/\{presets\//); + }); + }); });