From 13715994a5613f481ed0269443a96b9db49514a1 Mon Sep 17 00:00:00 2001 From: Kyle Hensel Date: Thu, 19 Mar 2026 23:36:18 +1100 Subject: [PATCH 1/2] Implement a schema for the measurement field --- .github/workflows/build.yml | 1 + .gitignore | 1 + CHANGELOG.md | 6 +++ README.md | 38 ++++++++++++- eslint.config.mjs | 2 +- lib/build.js | 34 ++++++++++-- lib/translations.js | 14 +++++ lib/units.js | 59 ++++++++++++++++++++ package-lock.json | 28 ++++++++++ package.json | 3 ++ schemas/field.json | 42 +++++++++++++-- scripts/build-schema.js | 105 ++++++++++++++++++++++++++++++++++++ 12 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 lib/units.js create mode 100644 scripts/build-schema.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d295fd6..a8946b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,5 +24,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + - run: npm run build - run: npm run lint - run: npm run test diff --git a/.gitignore b/.gitignore index 0696fba..a51cf06 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ npm-debug.log /tests/workspace transifex.auth +schemas/generated diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bfaff..d1f7638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ [#x]: https://github.com/ideditor/schema-builder/issues/x --> +# Unreleased + +* :warning: Add _measurement_ field type ([#198], thanks [@k-yle]) + * Data consumers who don't support `type=measurement` should treat it as a `type=combo` if `autoSuggestions=true`, otherwise treat it as `type=text`. + +[#198]: https://github.com/ideditor/schema-builder/pull/198 # 6.5.1 ##### 2024-Mar-14 diff --git a/README.md b/README.md index b4d99a6..68ada54 100644 --- a/README.md +++ b/README.md @@ -473,8 +473,7 @@ A string specifying the UI and behavior of the field. Must be one of the followi * `access` - Block of dropdowns for defining the `access=*` tags on a highway * `address` - Block of text and dropdown fields for entering address information (localized for editing location) -* `roadspeed` - Numeric text field for speed and dropdown for "mph" / "km/h", defaulting to the speed unit used for roads in the feature's region -* `roadheight` - Numeric text field for height and dropdowns for "m" / "ft" and "in", defaulting to the height unit used for roads in the feature's region +* `measurement` - Numeric text field with associated unit of measurement, such as inches or kilometers-per-hour. The field may have multiple units. See [#measurement](#measurement) for details. * `restrictions` - Graphical field for editing turn restrictions * `wikidata` - Search field for selecting a Wikidata entity * `wikipedia` - Block of fields for selecting a wiki language and Wikipedia page @@ -740,6 +739,41 @@ Combo field types can accept key-label pairs in the `options` value of the `stri 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. +##### `measurement` + +Used when `type = measurement`. Defines the unit of measurements that are supported by this field. For example: + +```json +{ + "key": "diameter", + "type": "measurement", + "measurement": { + // The dimension being measured. This constrains the permitted units. + // The ID id defined by CLDR. + "dimension": "length", + + "units": { + // The key defines the ID of the of the unit, as defined by CLDR. + // The values define the suffix used in the OSM tag value. + // If there are multiple values in the array (such as "kW", "KW"), + // then the first one is the preferred value, but iD will still + // recognise the alternative/s. + "meter": ["m"], + "centimeter": ["cm"], + "yard": ["yd"], + + // mm is the default unit in OSM, so the tag value should have no suffix. + // Therefore, the first array item is blank. + // The second value exists so that iD will recognise tag values with an + // explicit 'mm' suffix. + "millimeter": ["", "mm"] + } + } +} +``` + +Translations for the [`narrow` and `long` form](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) of each unit are bundled into iD-tagging-schema's locale files. + ### Deprecations Use `deprecated.json` ([Example](https://github.com/openstreetmap/id-tagging-schema/blob/main/data/deprecated.json), [Schema](https://github.com/ideditor/schema-builder/blob/main/schemas/deprecated.json)) to specify tag deprecations. diff --git a/eslint.config.mjs b/eslint.config.mjs index a8ede84..a6aecaa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -33,7 +33,7 @@ export default [ 'no-await-in-loop': 'error', 'no-caller': 'error', 'no-catch-shadow': 'error', - 'no-console': 'warn', + 'no-console': 'off', 'no-constructor-return': 'error', 'no-div-regex': 'error', 'no-duplicate-imports': 'warn', diff --git a/lib/build.js b/lib/build.js index 52256c1..8917c33 100644 --- a/lib/build.js +++ b/lib/build.js @@ -20,8 +20,25 @@ const defaultsSchema = require('../schemas/preset_defaults.json'); const deprecatedSchema = require('../schemas/deprecated.json'); const discardedSchema = require('../schemas/discarded.json'); +/** @import { TranslationOptions } from "./translations.js" */ + +/** @typedef {{ + inDirectory: string; + interimDirectory: string; + outDirectory: string; + sourceLocale: string; + taginfoProjectInfo: unknown, + processCategories: null | unknown; + processFields: null | unknown; + processPresets: null | unknown; + listReusedIcons: boolean; +}} BuildOptions */ + +/** @typedef {Partial} Options */ + let _currBuild = null; +/** @param {Options} options */ function validateData(options) { const START = '🔬 ' + styleText('yellow', 'Validating schema...'); const END = '👍 ' + styleText('green', 'schema okay'); @@ -36,6 +53,7 @@ function validateData(options) { process.stdout.write('\n'); } +/** @param {Options} options */ function buildDev(options) { if (_currBuild) return _currBuild; @@ -53,6 +71,7 @@ function buildDev(options) { process.stdout.write('\n'); } +/** @param {Options} options */ function buildDist(options) { if (_currBuild) return _currBuild; @@ -78,7 +97,8 @@ function buildDist(options) { }); } -function processData(options, type) { +/** @internal @param {Options} options @returns {Options} */ +export function getDefaultOptions(options) { if (!options) options = {}; options = Object.assign({ inDirectory: 'data', @@ -91,7 +111,15 @@ function processData(options, type) { processPresets: null, listReusedIcons: false }, options); + return options; +} +/** + * @param {Options} options + * @param {'build-interim' | 'build-dist' | 'validate'} type + */ +function processData(options, type) { + options = getDefaultOptions(options); const dataDir = './' + options.inDirectory; // Translation strings @@ -246,8 +274,8 @@ function generateCategories(dataDir, tstrings) { return categories; } - -function generateFields(dataDir, tstrings, searchableFieldIDs) { +/** @internal */ +export function generateFields(dataDir, tstrings, searchableFieldIDs) { let fields = {}; fs.globSync(dataDir + '/fields/**/*.json', { diff --git a/lib/translations.js b/lib/translations.js index 7913cf8..b5190c9 100644 --- a/lib/translations.js +++ b/lib/translations.js @@ -3,6 +3,7 @@ import fs from 'fs'; import fetch from 'node-fetch'; import YAML from 'js-yaml'; import { transifexApi } from '@transifex/api'; +import { getExternalTranslations } from './units.js'; export function expandTStrings(tstrings) { const presets = tstrings.presets || {}; @@ -82,6 +83,17 @@ export function sortObject(original) { return sorted; } +/** @typedef {{ + translOrgId: string; + translProjectId: string; + translResourceIds: string[]; + translReviewedOnly: false | string[]; + inDirectory: string; + outDirectory: string; + sourceLocale: string; +}} TranslationOptions */ + +/** @param {Partial} options */ function fetchTranslations(options) { // Transifex doesn't allow anonymous downloading @@ -217,6 +229,8 @@ function fetchTranslations(options) { for (let code in allStrings) { let obj = {}; obj[code] = allStrings[code] || {}; + Object.assign(obj[code], getExternalTranslations(code, options)); + fs.writeFileSync(`${outDir}/${code}.json`, JSON.stringify(obj, null, 4)); fs.writeFileSync(`${outDir}/${code}.min.json`, JSON.stringify(obj)); } diff --git a/lib/units.js b/lib/units.js new file mode 100644 index 0000000..a2bf69d --- /dev/null +++ b/lib/units.js @@ -0,0 +1,59 @@ +// @ts-check +import { createRequire } from 'node:module'; +import { generateFields, getDefaultOptions } from './build.js'; + +const require = createRequire(import.meta.url); + +let cachedFields; + +/** + * @param {string} locale + * @param {Partial} options + */ +export function getExternalTranslations(locale, options) { + options = getDefaultOptions(options); + const language = locale.split('-')[0]; + + cachedFields ||= generateFields(options.inDirectory, { fields: {} }, {}); + + let localeData; + let languageData; + try { + localeData = require(`cldr-units-full/main/${locale}/units.json`); + } catch { + // ignore + } + try { + languageData = require(`cldr-units-full/main/${language}/units.json`); + } catch { + // ignore + } + + if (!localeData && !languageData) { + console.warn(`No CLDR data for ${language}`); + } + + const output = {}; + + for (const field of Object.values(cachedFields)) { + if (!field.measurement) continue; + + const { dimension, units } = field.measurement; + + for (const unit in units) { + for (const type of ['long', 'narrow']) { + const translation = + localeData?.main[locale].units[type][`${dimension}-${unit}`] + .displayName || + languageData?.main[language].units[type][`${dimension}-${unit}`] + .displayName; + + output[dimension] ||= {}; + output[dimension][unit] ||= {}; + output[dimension][unit][type] = translation; + } + } + } + + return { units: output }; +} diff --git a/package-lock.json b/package-lock.json index 8a86634..5e4f30f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "ISC", "dependencies": { "@transifex/api": "^7.1.0", + "cldr-core": "^47.0.0", + "cldr-units-full": "^47.0.0", "js-yaml": "^4.0.0", "json-schema-to-typescript-lite": "^15.0.0", "jsonschema": "^1.1.0", @@ -2028,6 +2030,21 @@ "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true }, + "node_modules/cldr-core": { + "version": "47.0.0", + "resolved": "https://registry.npmjs.org/cldr-core/-/cldr-core-47.0.0.tgz", + "integrity": "sha512-tdYRy66DMgpjEwVOWCTN0zhNr+zh1+d4A6MCNgJKU7voFDGsrwcWHor6jcqudHDmElCgyVNqWBKAB1JeNdSOKg==", + "license": "Unicode-3.0" + }, + "node_modules/cldr-units-full": { + "version": "47.0.0", + "resolved": "https://registry.npmjs.org/cldr-units-full/-/cldr-units-full-47.0.0.tgz", + "integrity": "sha512-JZTaawtsqV74XMWImiDNwWY7sTLBY/Djtez3owEGTNen17dfpaQ6VXN3rdVfAFxMiUn2gRcY7Mj/JkoBAywgUQ==", + "license": "Unicode-3.0", + "peerDependencies": { + "cldr-core": "47.0.0" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6592,6 +6609,17 @@ "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true }, + "cldr-core": { + "version": "47.0.0", + "resolved": "https://registry.npmjs.org/cldr-core/-/cldr-core-47.0.0.tgz", + "integrity": "sha512-tdYRy66DMgpjEwVOWCTN0zhNr+zh1+d4A6MCNgJKU7voFDGsrwcWHor6jcqudHDmElCgyVNqWBKAB1JeNdSOKg==" + }, + "cldr-units-full": { + "version": "47.0.0", + "resolved": "https://registry.npmjs.org/cldr-units-full/-/cldr-units-full-47.0.0.tgz", + "integrity": "sha512-JZTaawtsqV74XMWImiDNwWY7sTLBY/Djtez3owEGTNen17dfpaQ6VXN3rdVfAFxMiUn2gRcY7Mj/JkoBAywgUQ==", + "requires": {} + }, "cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", diff --git a/package.json b/package.json index 49c0bb7..fd077ed 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "exports": "./lib/index.js", "dependencies": { "@transifex/api": "^7.1.0", + "cldr-core": "^47.0.0", + "cldr-units-full": "^47.0.0", "js-yaml": "^4.0.0", "json-schema-to-typescript-lite": "^15.0.0", "jsonschema": "^1.1.0", @@ -31,6 +33,7 @@ "node": ">=22" }, "scripts": { + "build": "node scripts/build-schema.js", "lint": "eslint lib", "lint:fix": "eslint lib --fix", "test": "NODE_OPTIONS=--experimental-vm-modules jest schema-builder.test.js" diff --git a/schemas/field.json b/schemas/field.json index cf1fc68..7754c08 100644 --- a/schemas/field.json +++ b/schemas/field.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/ideditor/schema-builder/raw/main/schemas/field.json", + "$id": "https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/field.json", "title": "Field", "description": "A reusable form element for presets", "type": "object", @@ -67,14 +67,13 @@ "lanes", "localized", "manyCombo", + "measurement", "multiCombo", "networkCombo", "number", "onewayCheck", "radio", "restrictions", - "roadheight", - "roadspeed", "schedule", "semiCombo", "structureRadio", @@ -340,11 +339,11 @@ }, "additionalProperties": false }, - "urlFormat": { + "urlFormat": { "description": "Permalink URL for `identifier` fields. Must contain a {value} placeholder", "type": "string" }, - "pattern": { + "pattern": { "description": "Regular expression that a valid `identifier` value is expected to match", "type": "string" }, @@ -364,6 +363,39 @@ "iconsCrossReference": { "description": "A field can reference icons of another by using that field's identifier contained in brackets, like {field}.", "type": "string" + }, + "measurement": { + "type": "object", + "description": "defines the units of measurement that this field uses. Only supported by the 'measurement' field type.", + "properties": { + "dimension": { + "type": "string", + "description": "The corresponding 'dimension' from CLDR", + "$ref": "./generated/dimension.json" + }, + "usage": { + "type": "string", + "description": "The corresponding 'usage' from CLDR" + }, + "units": { + "type": "object", + "description": "Defines the permitted units. The key is the ID used by CLDR (see https://cdn.jsdelivr.net/npm/cldr-core/supplemental/unitPreferenceData.json). The value is the value used in the OSM tag. If there are multiple values, the first one will be preferred. Use an empty string if the unit is not included in the OSM tag.", + "additionalProperties": { + "type": "array", + "items": { + "type": ["string", "null"] + }, + "minItems": 1 + }, + "minProperties": 1 + } + }, + "allOf": [ + { "$ref": "./generated/usage.json" }, + { "$ref": "./generated/units.json" } + ], + "additionalItems": false, + "required": ["dimension", "usage", "units"] } }, "additionalProperties": false, diff --git a/scripts/build-schema.js b/scripts/build-schema.js new file mode 100644 index 0000000..d97e0dd --- /dev/null +++ b/scripts/build-schema.js @@ -0,0 +1,105 @@ +// @ts-check +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import unitPreference from 'cldr-core/supplemental/unitPreferenceData.json' with { type: 'json' }; +import unitTranslations from 'cldr-units-full/main/en/units.json' with { type: 'json' }; + +// this file auto-generates the files in schemas/generated/* based on npm dependencies + +const dimension = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/generated/dimension.json', + + enum: [ + ...new Set([ + ...Object.keys(unitPreference.supplemental.unitPreferenceData), + // not all dimensions are defined in unitPreferenceData (such as frequency + // in Hertz). Therefore, we need to check the full list too. + ...Object.keys(unitTranslations.main.en.units.long) + .filter((item) => item.includes('-') && !item.startsWith('10p')) // exclude metric prefixes + .map((item) => item.split('-')[0]), + ]), + ], +}; + +const usage = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/generated/usage.json', + + allOf: dimension.enum.map((key) => { + const value = unitPreference.supplemental.unitPreferenceData[key] || { + default: {}, + }; + return { + if: { properties: { dimension: { const: key } } }, + then: { properties: { usage: { enum: Object.keys(value) } } }, + }; + }), +}; + +const units = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/generated/units.json', + + allOf: dimension.enum.map((dimension) => { + const defaults = + unitPreference.supplemental.unitPreferenceData[dimension] || {}; + + /** @type {import('json-schema').JSONSchema4['properties']} */ + const properties = {}; + + for (const key in unitTranslations.main.en.units.long) { + if (key.startsWith(`${dimension}-`)) { + const unit = key.split('-').slice(1).join('-'); + properties[unit] = { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }; + } + } + + // units.json does not include 'Mixed Units', so we need to add some of + // the 'Mixed Units' (only the ones which are used). + const mixedUnits = new Set( + Object.values(defaults) + .flatMap(Object.values) + .flat() + .map((item) => item.unit), + ); + + for (const unit of mixedUnits) { + properties[unit] ||= { + type: 'array', + items: { type: 'null' }, + minItems: 1, + maxItems: 1, + }; + } + + return { + if: { properties: { dimension: { const: dimension } } }, + then: { + properties: { + units: { + additionalProperties: false, + properties, + }, + }, + }, + }; + }), +}; + +const files = { dimension, usage, units }; + +const generatedFolder = join(import.meta.dirname, '../schemas/generated'); +await fs.mkdir(generatedFolder, { recursive: true }); + +for (const [fileName, fileContent] of Object.entries(files)) { + // eslint-disable-next-line no-await-in-loop + await fs.writeFile( + join(generatedFolder, `${fileName}.json`), + JSON.stringify(fileContent, null, 4), + ); +} From 161112209ae9b18a6a548695209462cc51027bc4 Mon Sep 17 00:00:00 2001 From: Kyle Hensel Date: Fri, 27 Mar 2026 19:30:31 +1100 Subject: [PATCH 2/2] new approach: define units in a separate file --- README.md | 54 ++++++++++++++-------- lib/build.js | 8 ++++ schemas/field.json | 27 +++++------ scripts/build-schema.js | 99 ++++++++++++++++++++++++++--------------- 4 files changed, 120 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 68ada54..02bb370 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ data/ defaults.json deprecated.json discarded.json + units.json ``` The format for each file is defined in the [`schemas`](schemas) directory. @@ -748,30 +749,27 @@ Used when `type = measurement`. Defines the unit of measurements that are suppor "key": "diameter", "type": "measurement", "measurement": { - // The dimension being measured. This constrains the permitted units. - // The ID id defined by CLDR. + // The dimension being measured. This constrains the permitted units. + // The ID id defined by CLDR. "dimension": "length", - "units": { - // The key defines the ID of the of the unit, as defined by CLDR. - // The values define the suffix used in the OSM tag value. - // If there are multiple values in the array (such as "kW", "KW"), - // then the first one is the preferred value, but iD will still - // recognise the alternative/s. - "meter": ["m"], - "centimeter": ["cm"], - "yard": ["yd"], - - // mm is the default unit in OSM, so the tag value should have no suffix. - // Therefore, the first array item is blank. - // The second value exists so that iD will recognise tag values with an - // explicit 'mm' suffix. - "millimeter": ["", "mm"] - } + // The corresponding 'usage' from CLDR. + "usage": "default", + + // If the field only allows some units, you can list them here + // using CLDR's unit names. If not specified, then, all units from + // this dimension are allowed. + "units": ["meter", "centimeter", "foot-and-inch"], + + // Some OSM tags have a default unit, which does not need to be explicitly included in the tag value. + // This field defines how to interpret a unit-less value: + "impliedUnit": "meter" } } ``` +To convert the unit IDs into the values used by OSM, see [§Units](#Units). + Translations for the [`narrow` and `long` form](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) of each unit are bundled into iD-tagging-schema's locale files. ### Deprecations @@ -808,6 +806,26 @@ To update a specific tag to a specific new tag }, ``` +## Units + +The [`units.json` file](https://github.com/openstreetmap/id-tagging-schema/blob/main/data/units.json) defines the suffix which is used in the OSM tag value for every unit of measurement. + +```jsonc +{ + "power": { + // The key defines the ID of the of the unit, as defined by CLDR. + // The values define the suffix used in the OSM tag value. + "megawatt": ["MW"], + + // If there are multiple values in the array, then the first one + // is the preferred value, but iD will still recognise the alternative/s. + "kilowatt": ["kW", "KW"], + + // `horsepower` is not included, therefore it can't be used by any fields. + }, +} +``` + ## Contributing iD's [code of conduct](https://github.com/openstreetmap/iD/blob/release/CODE_OF_CONDUCT.md) and diff --git a/lib/build.js b/lib/build.js index 8917c33..c521e18 100644 --- a/lib/build.js +++ b/lib/build.js @@ -19,6 +19,7 @@ const categorySchema = require('../schemas/preset_category.json'); const defaultsSchema = require('../schemas/preset_defaults.json'); const deprecatedSchema = require('../schemas/deprecated.json'); const discardedSchema = require('../schemas/discarded.json'); +const unitsSchema = require('../schemas/generated/units.json'); /** @import { TranslationOptions } from "./translations.js" */ @@ -142,6 +143,11 @@ function processData(options, type) { validateSchema(dataDir + '/discarded.json', discarded, discardedSchema); } + const units = read(dataDir + '/units.json'); + if (units) { + validateSchema(dataDir + '/units.json', units, unitsSchema); + } + let categories = generateCategories(dataDir, tstrings); if (options.processCategories) options.processCategories(categories); @@ -204,6 +210,7 @@ function processData(options, type) { if (defaults) fs.writeFileSync(distDir + '/preset_defaults.json', JSON.stringify(defaults, null, 4)); if (deprecated) fs.writeFileSync(distDir + '/deprecated.json', JSON.stringify(deprecated, null, 4)); if (discarded) fs.writeFileSync(distDir + '/discarded.json', JSON.stringify(discarded, null, 4)); + if (units) fs.writeFileSync(distDir + '/units.json', JSON.stringify(units, null, 4)); expandTStrings(tstrings); let translationsForJson = {}; @@ -221,6 +228,7 @@ function processData(options, type) { minifyJSON(distDir + '/preset_defaults.json', distDir + '/preset_defaults.min.json'), minifyJSON(distDir + '/deprecated.json', distDir + '/deprecated.min.json'), minifyJSON(distDir + '/discarded.json', distDir + '/discarded.min.json'), + minifyJSON(distDir + '/units.json', distDir + '/units.min.json'), minifyJSON(distDir + '/translations/' + sourceLocale + '.json', distDir + '/translations/' + sourceLocale + '.min.json'), generateTypeDefs(distDir), ]; diff --git a/schemas/field.json b/schemas/field.json index 7754c08..3259f0b 100644 --- a/schemas/field.json +++ b/schemas/field.json @@ -378,24 +378,25 @@ "description": "The corresponding 'usage' from CLDR" }, "units": { - "type": "object", - "description": "Defines the permitted units. The key is the ID used by CLDR (see https://cdn.jsdelivr.net/npm/cldr-core/supplemental/unitPreferenceData.json). The value is the value used in the OSM tag. If there are multiple values, the first one will be preferred. Use an empty string if the unit is not included in the OSM tag.", - "additionalProperties": { - "type": "array", - "items": { - "type": ["string", "null"] - }, - "minItems": 1 - }, - "minProperties": 1 + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Optional, if only some units are allowed for this tag, then list the permitted units here, using the unit IDs from CLDR (see https://cdn.jsdelivr.net/npm/cldr-core/supplemental/unitPreferenceData.json).", + "items": { + "type": "string" + } + }, + "impliedUnit": { + "type": "string", + "description": "Some OSM tags have a default unit, which does not need to be explicitly included in the tag value. This field defines how to interpret a unit-less value." } }, "allOf": [ { "$ref": "./generated/usage.json" }, - { "$ref": "./generated/units.json" } + { "$ref": "./generated/unit-types.json" } ], - "additionalItems": false, - "required": ["dimension", "usage", "units"] + "additionalProperties": false, + "required": ["dimension", "usage"] } }, "additionalProperties": false, diff --git a/scripts/build-schema.js b/scripts/build-schema.js index d97e0dd..7ae07fe 100644 --- a/scripts/build-schema.js +++ b/scripts/build-schema.js @@ -37,61 +37,86 @@ const usage = { }), }; -const units = { +const unitTypes = { $schema: 'http://json-schema.org/draft-07/schema#', $id: 'https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/generated/units.json', - allOf: dimension.enum.map((dimension) => { - const defaults = - unitPreference.supplemental.unitPreferenceData[dimension] || {}; + $defs: Object.fromEntries( + dimension.enum.map((dimension) => { + const defaults = + unitPreference.supplemental.unitPreferenceData[dimension] || {}; - /** @type {import('json-schema').JSONSchema4['properties']} */ - const properties = {}; + /** @type {Set} */ + const units = new Set( + // units.json does not include 'Mixed Units', so we need to add some of + // the 'Mixed Units' (only the ones which are used): + Object.values(defaults) + .flatMap(Object.values) + .flat() + .map((item) => item.unit), + ); - for (const key in unitTranslations.main.en.units.long) { - if (key.startsWith(`${dimension}-`)) { - const unit = key.split('-').slice(1).join('-'); - properties[unit] = { - type: 'array', - items: { type: 'string' }, - minItems: 1, - }; + // also add all standard units: + for (const key in unitTranslations.main.en.units.long) { + if (key.startsWith(`${dimension}-`)) { + const unit = key.split('-').slice(1).join('-'); + units.add(unit); + } } - } - - // units.json does not include 'Mixed Units', so we need to add some of - // the 'Mixed Units' (only the ones which are used). - const mixedUnits = new Set( - Object.values(defaults) - .flatMap(Object.values) - .flat() - .map((item) => item.unit), - ); - for (const unit of mixedUnits) { - properties[unit] ||= { - type: 'array', - items: { type: 'null' }, - minItems: 1, - maxItems: 1, - }; - } + return [dimension, { enum: [...units] }]; + }), + ), + allOf: dimension.enum.map((dimension) => { return { if: { properties: { dimension: { const: dimension } } }, then: { properties: { - units: { - additionalProperties: false, - properties, - }, + units: { items: { $ref: `#/$defs/${dimension}` } }, + impliedUnit: { $ref: `#/$defs/${dimension}` }, }, }, }; }), }; -const files = { dimension, usage, units }; +const units = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/generated/units.json', + title: 'Units of measurement', + description: + 'Defines the suffix used in the OSM tag value for every unit of measurement. If there are multiple values, the first one will be preferred.', + type: 'object', + properties: Object.fromEntries( + Object.entries(unitTypes.$defs).map(([dimension, value]) => { + return [ + dimension, + { + type: 'object', + properties: Object.fromEntries( + value.enum.map((unit) => { + return [ + unit, + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { type: 'string' }, + }, + ]; + }), + ), + additionalProperties: false, + minProperties: 1, + }, + ]; + }), + ), + additionalProperties: false, +}; + +const files = { dimension, usage, 'unit-types': unitTypes, units }; const generatedFolder = join(import.meta.dirname, '../schemas/generated'); await fs.mkdir(generatedFolder, { recursive: true });