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..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. @@ -473,8 +474,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 +740,38 @@ 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", + + // 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 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. @@ -774,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/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..c521e18 100644 --- a/lib/build.js +++ b/lib/build.js @@ -19,9 +19,27 @@ 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" */ + +/** @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 +54,7 @@ function validateData(options) { process.stdout.write('\n'); } +/** @param {Options} options */ function buildDev(options) { if (_currBuild) return _currBuild; @@ -53,6 +72,7 @@ function buildDev(options) { process.stdout.write('\n'); } +/** @param {Options} options */ function buildDist(options) { if (_currBuild) return _currBuild; @@ -78,7 +98,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 +112,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 @@ -114,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); @@ -176,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 = {}; @@ -193,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), ]; @@ -246,8 +282,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..3259f0b 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,40 @@ "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": "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/unit-types.json" } + ], + "additionalProperties": false, + "required": ["dimension", "usage"] } }, "additionalProperties": false, diff --git a/scripts/build-schema.js b/scripts/build-schema.js new file mode 100644 index 0000000..7ae07fe --- /dev/null +++ b/scripts/build-schema.js @@ -0,0 +1,130 @@ +// @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 unitTypes = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://cdn.jsdelivr.net/npm/@ideditor/schema-builder/schemas/generated/units.json', + + $defs: Object.fromEntries( + dimension.enum.map((dimension) => { + const defaults = + unitPreference.supplemental.unitPreferenceData[dimension] || {}; + + /** @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), + ); + + // 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); + } + } + + return [dimension, { enum: [...units] }]; + }), + ), + + allOf: dimension.enum.map((dimension) => { + return { + if: { properties: { dimension: { const: dimension } } }, + then: { + properties: { + units: { items: { $ref: `#/$defs/${dimension}` } }, + impliedUnit: { $ref: `#/$defs/${dimension}` }, + }, + }, + }; + }), +}; + +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 }); + +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), + ); +}