diff --git a/README.md b/README.md index 4cb14d3..4082e62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SIDC Kit -SIDC Kit is a small Node-focused TypeScript toolkit for working with military Symbol Identification Codes. +SIDC Kit is a small TypeScript toolkit for working with military Symbol Identification Codes in Node and browser-bundled apps. It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) renderer and adds a curated semantic layer for common workflows: @@ -8,12 +8,13 @@ It wraps the MIT-licensed [`milsymbol`](https://www.npmjs.com/package/milsymbol) - explain known SIDCs into structured parts - build known SIDCs from structured parts - render SIDCs to SVG with `milsymbol` +- identify clean `milsymbol` SVG renderings against the curated set -V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, explain, and build intentionally support only a tiny curated set and do not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. +V0 can render syntactically valid 30-digit SIDCs that `milsymbol` supports. Search, build, and reverse lookup intentionally support only a curated set. Explain returns curated semantics when a SIDC is in that set and partial field decomposition for other renderable number SIDCs where `milsymbol` or the curated function-ID table provides a label. It does not claim exhaustive MIL-STD-2525 or STANAG APP-6 semantic coverage. ## Install -Install from npm after the first release is published: +Install from npm: ```sh npm install sidc-kit @@ -32,10 +33,35 @@ npm run build npm test ``` +## Fixture Generation + +For repository development, generated land-unit catalog entries come from `fixtures/curated-land-units.json` and are written into `src/data/catalog/land-units.ts`. + +```sh +npm run generate:fixtures +npm run check:fixtures +``` + +The generator derives number-based MIL-STD-2525D/APP-6D SIDCs from structured land-unit parts, keeps echelon/mobility in digits 9-10 and function ID in digits 11-20, and verifies each generated SIDC renders with the installed `milsymbol` package. + ## Usage +### CLI + +```sh +sidc-kit search "friendly infantry" --limit 3 +sidc-kit search "friendly infantry" --json +sidc-kit explain 130310001412110000000000000000 --json +sidc-kit build --affiliation friend --domain land --entity infantry --echelon platoon +sidc-kit render 130310001412110000000000000000 --size 40 > symbol.svg +``` + +The CLI wraps the public API without a separate data model. Human defaults use plain text, while `--json` returns JSON for commands and typed JSON errors on stderr. Render size must be between 1 and 4096 pixels. + +### TypeScript + ```ts -import { buildSidc, explainSidc, renderSymbol, searchSymbols } from "sidc-kit"; +import { buildSidc, explainSidc, identifySymbol, renderSymbol, searchSymbols } from "sidc-kit"; const results = searchSymbols("friendly infantry platoon"); @@ -49,17 +75,42 @@ const sidc = buildSidc({ }); const rendered = renderSymbol(sidc, { size: 40 }); + +const matches = identifySymbol(rendered.svg, { size: 40 }); +``` + +## Browser Bundles + +SIDC Kit publishes browser-safe ESM at the package root. Browser and map UI builds should import from `sidc-kit` through a modern bundler: + +```ts +import { renderSymbol, searchSymbols } from "sidc-kit"; + +const match = searchSymbols("friendly infantry platoon")[0]; +const marker = renderSymbol(match.sidc, { size: 32 }); ``` +The package runtime does not import Node built-ins. The test suite verifies browser consumption by bundling the package root with esbuild using `platform: "browser"` defaults and exercising `renderSymbol` and `searchSymbols` from the generated bundle. Since rendering delegates to `milsymbol`, browser bundles include the `milsymbol` renderer unless your app lazy-loads this package. + ## API ### `searchSymbols(query, options?)` -Performs deterministic lexical matching over curated names, aliases, and part labels. Returns ranked results with `score`, `sidc`, `name`, `aliases`, `parts`, and `coverage`. +Performs deterministic lexical matching over curated names, aliases, and part labels. Exact names, exact aliases, exact parts, and field-specific token matches are weighted predictably, with catalog order used as the tie-breaker. Returns ranked results with `score`, `sidc`, `name`, `aliases`, `parts`, and `coverage`. + +Curated search terms include practical abbreviations and regional spellings for supported records, such as `inf`/`infantry`, `arty`/`artillery`, `recon`/`reconnaissance`, and `tank`/`armor`/`armour`. ### `explainSidc(sidc)` -Explains a curated 30-digit SIDC into a stable JSON-serializable object. Unknown but syntactically valid SIDCs fail with `UNSUPPORTED_SIDC`. +Explains a 30-digit SIDC into a stable JSON-serializable object. + +Curated SIDCs return `coverage: "curated"` with `name`, `aliases`, and the curated `parts` object. Non-curated SIDCs that `milsymbol` can validate return `coverage: "partial"` with: + +- `parts`: only the interpreted fields +- `fields`: per-field `code`, optional `value`, and `coverage` +- `unknownFields`: field names that were present in the SIDC but not interpreted + +Unsupported or malformed SIDCs still fail with typed `SidcKitError` codes such as `INVALID_SIDC` or `UNSUPPORTED_SIDC`. ### `buildSidc(parts)` @@ -70,9 +121,21 @@ Partial combinations that match more than one curated SIDC fail with `AMBIGUOUS_ Renders a syntactically valid 30-digit SIDC with `milsymbol` and returns SVG plus anchor and size metadata when available. SIDCs that `milsymbol` cannot validate or render are reported as `RENDER_FAILED`. +### `identifySymbol(input, options?)` + +Compares a clean inline SVG string, or a percent-encoded `data:image/svg+xml` URL, against normalized `milsymbol` renderings for the curated fixture set. Returns ranked candidates with `confidence` and `evidence`; exact normalized SVG matches report `confidence: 1`. + +The default `minConfidence` is `0.99`, so unrelated or weakly similar SVGs return an empty list rather than a guessed SIDC. Pass a lower `minConfidence` when you want to inspect near matches or ambiguous alternatives. + +Reverse lookup v0 is deterministic clean-rendered-input comparison. Oversized SVG input is rejected before fuzzy scoring. It does not recognize screenshots, photos, scanned images, raster PNG/JPEG files, cropped symbols, hand-edited icons, map marker composites, or arbitrary MIL-STD-2525/APP-6 symbols outside the curated set. + ## Coverage -Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. +Rendering coverage follows the installed `milsymbol` package. The curated semantic set includes common, verified examples across land units, air, sea surface, sea subsurface, land equipment, land installations, and control measures. Examples include infantry platoons and companies, fighter and rotary-wing aircraft, UAVs, destroyers, frigates, merchant ships, submarines, unmanned underwater vehicles, tanks, armored personnel carriers, bases, airports, checkpoints, and waypoints. + +Reverse lookup coverage is the same curated set and currently accepts clean SVG renderings only. + +Partial decomposition is intentionally limited to affiliation, symbol set, status, domain, echelon, and entity. Entity labels come from function IDs already present in the curated table; unknown function IDs are reported through `unknownFields` instead of guessed. Status is labeled only when it is present or when `milsymbol` exposes a semantic condition label; otherwise status is reported through `unknownFields`. Image-based reverse lookup is intentionally deferred. diff --git a/fixtures/curated-land-units.json b/fixtures/curated-land-units.json new file mode 100644 index 0000000..2ceaa6d --- /dev/null +++ b/fixtures/curated-land-units.json @@ -0,0 +1,98 @@ +[ + { + "name": "Friendly Land Unit Infantry Platoon", + "aliases": [ + "friendly infantry platoon", + "friendly inf platoon", + "friend infantry platoon", + "friend inf platoon", + "blue infantry platoon", + "land infantry platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "infantry", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Infantry Company", + "aliases": [ + "friendly infantry company", + "friendly inf company", + "friend infantry company", + "friend inf company", + "blue infantry company", + "land infantry company" + ], + "parts": { + "affiliation": "friend", + "entity": "infantry", + "echelon": "company" + } + }, + { + "name": "Hostile Land Unit Infantry Platoon", + "aliases": [ + "hostile infantry platoon", + "hostile inf platoon", + "enemy infantry platoon", + "enemy inf platoon", + "red infantry platoon", + "land infantry platoon hostile" + ], + "parts": { + "affiliation": "hostile", + "entity": "infantry", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Armor Platoon", + "aliases": [ + "friendly armor platoon", + "friendly armour platoon", + "friendly tank platoon", + "friendly armoured platoon", + "friend tank platoon", + "blue armor platoon", + "land armor platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "armor", + "entityType": "tank", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Artillery Platoon", + "aliases": [ + "friendly artillery platoon", + "friendly arty platoon", + "friend field artillery platoon", + "friend arty platoon", + "blue artillery platoon", + "land artillery platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "artillery", + "echelon": "platoon" + } + }, + { + "name": "Friendly Land Unit Reconnaissance Platoon", + "aliases": [ + "friendly reconnaissance platoon", + "friend recon platoon", + "blue reconnaissance platoon", + "land recon platoon" + ], + "parts": { + "affiliation": "friend", + "entity": "reconnaissance", + "echelon": "platoon" + } + } +] diff --git a/package-lock.json b/package-lock.json index 55855cf..98e860e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,512 @@ "dependencies": { "milsymbol": "^3.0.4" }, + "bin": { + "sidc-kit": "dist/cli.js" + }, "devDependencies": { + "@types/node": "^20.19.43", + "esbuild": "^0.28.1", "typescript": "^5.9.3" }, "engines": { "node": ">=20" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/milsymbol": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/milsymbol/-/milsymbol-3.0.4.tgz", @@ -37,6 +536,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index d5c52aa..47b4454 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,26 @@ "version": "0.3.2", "description": "A small TypeScript toolkit for rendering SIDCs and working with curated SIDC semantics.", "type": "module", + "sideEffects": false, "main": "./dist/index.js", + "browser": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "sidc-kit": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "browser": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" } }, "files": [ "dist", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "LICENSE" ], "repository": { "type": "git", @@ -26,8 +34,11 @@ "homepage": "https://github.com/the-Drunken-coder/sidc-kit#readme", "scripts": { "build": "tsc -p tsconfig.json", - "prepack": "npm run build", - "test": "npm run build && node --test" + "check:fixtures": "node scripts/generate-curated-fixtures.mjs --check", + "generate:fixtures": "node scripts/generate-curated-fixtures.mjs", + "prepack": "npm run check:fixtures && npm run build", + "test": "npm run check:fixtures && npm run build && node --test && node scripts/browser-smoke.mjs", + "test:browser": "npm run build && node scripts/browser-smoke.mjs" }, "keywords": [ "sidc", @@ -44,6 +55,8 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "@types/node": "^20.19.43", + "esbuild": "^0.28.1", "typescript": "^5.9.3" }, "engines": { diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs new file mode 100644 index 0000000..03ee294 --- /dev/null +++ b/scripts/browser-smoke.mjs @@ -0,0 +1,62 @@ +import { mkdir, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import * as esbuild from "esbuild"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const smokeDir = path.join(repoRoot, ".tmp", "browser-smoke"); +const entryPath = path.join(smokeDir, "entry.mjs"); +const bundlePath = path.join(smokeDir, "bundle.mjs"); +const expectedSidc = "130310001412110000000000000000"; + +const entrySource = ` +import { renderSymbol, searchSymbols } from "sidc-kit"; + +const expectedSidc = ${JSON.stringify(expectedSidc)}; +const results = searchSymbols("friendly infantry platoon"); +if (results[0]?.sidc !== expectedSidc) { + throw new Error("searchSymbols did not return the expected infantry platoon SIDC."); +} + +const rendered = renderSymbol(expectedSidc, { size: 40 }); +if (rendered.sidc !== expectedSidc || !rendered.svg.startsWith("")) { + throw new Error("renderSymbol did not return a valid SVG payload."); +} + +export const smokeResult = { + sidc: rendered.sidc, + searchCount: results.length, + svgLength: rendered.svg.length +}; +`; + +await rm(smokeDir, { recursive: true, force: true }); +await mkdir(smokeDir, { recursive: true }); + +try { + await writeFile(entryPath, entrySource); + + await esbuild.build({ + absWorkingDir: repoRoot, + bundle: true, + entryPoints: [entryPath], + format: "esm", + logLevel: "silent", + outfile: bundlePath, + platform: "browser", + target: "es2022" + }); + + const { smokeResult } = await import(pathToFileURL(bundlePath).href); + if (smokeResult?.sidc !== expectedSidc || smokeResult.searchCount < 1 || smokeResult.svgLength < 100) { + throw new Error("Bundled browser smoke result was incomplete."); + } + + const bundleStats = await stat(bundlePath); + console.log( + `Browser bundle smoke passed: esbuild platform=browser defaults bundled ${bundleStats.size} bytes and exercised renderSymbol/searchSymbols.` + ); +} finally { + await rm(smokeDir, { recursive: true, force: true }); +} diff --git a/scripts/generate-curated-fixtures.mjs b/scripts/generate-curated-fixtures.mjs new file mode 100644 index 0000000..072685b --- /dev/null +++ b/scripts/generate-curated-fixtures.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ms from "milsymbol"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const sourcePath = path.join(repoRoot, "fixtures", "curated-land-units.json"); +const targetPath = path.join(repoRoot, "src", "data", "catalog", "land-units.ts"); + +const STANDARD = "MIL-STD-2525D/APP-6D"; +const SYMBOL_SET = "land unit"; +const STATUS = "present"; +const DOMAIN = "land"; + +const VERSION_CODE = "13"; +const CONTEXT_CODE = "0"; +const SYMBOL_SET_CODE = "10"; +const STATUS_CODE = "0"; +const HEADQUARTERS_TASK_FORCE_DUMMY_CODE = "0"; +const UNUSED_MODIFIER_FIELDS = "0000000000"; + +const ECHELON_START = 8; +const ECHELON_END = 10; +const FUNCTION_ID_START = 10; +const FUNCTION_ID_END = 20; + +const affiliationCodes = { + friend: "3", + hostile: "6" +}; + +const echelonCodes = { + platoon: "14", + company: "15" +}; + +const landUnitFunctionIds = { + infantry: "1211000000", + "armor:tank": "1205000000", + artillery: "1303000000", + reconnaissance: "1213000000" +}; + +const mode = parseMode(process.argv.slice(2)); + +const sourceRecords = await readSourceRecords(); +const generatedSymbols = sourceRecords.map(toGeneratedSymbol); +validateGeneratedSymbols(generatedSymbols); + +const generatedFile = renderSymbolsFile(generatedSymbols); + +if (mode === "check") { + const currentFile = await fs.readFile(targetPath, "utf8"); + if (normalizeLineEndings(currentFile) !== generatedFile) { + throw new Error(`${path.relative(repoRoot, targetPath)} is out of date. Run npm run generate:fixtures.`); + } +} else { + await fs.writeFile(targetPath, generatedFile); +} + +function parseMode(args) { + if (args.length === 0) { + return "write"; + } + + if (args.length === 1 && args[0] === "--check") { + return "check"; + } + + throw new Error("Usage: node scripts/generate-curated-fixtures.mjs [--check]"); +} + +async function readSourceRecords() { + const rawSource = await fs.readFile(sourcePath, "utf8"); + const parsed = JSON.parse(rawSource); + + if (!Array.isArray(parsed)) { + throw new Error(`${path.relative(repoRoot, sourcePath)} must contain a JSON array.`); + } + + return parsed.map((record, index) => normalizeSourceRecord(record, index)); +} + +function normalizeSourceRecord(record, index) { + const location = `fixture ${index + 1}`; + if (!isRecord(record)) { + throw new Error(`${location} must be an object.`); + } + + const aliases = readAliases(record.aliases, `${location}.aliases`); + const sourceParts = record.parts; + if (!isRecord(sourceParts)) { + throw new Error(`${location}.parts must be an object.`); + } + + const parts = { + standard: STANDARD, + symbolSet: SYMBOL_SET, + affiliation: readString(sourceParts.affiliation, `${location}.parts.affiliation`), + status: STATUS, + domain: DOMAIN, + entity: readString(sourceParts.entity, `${location}.parts.entity`), + ...readOptionalPart(sourceParts, "entityType", `${location}.parts.entityType`), + ...readOptionalPart(sourceParts, "entitySubtype", `${location}.parts.entitySubtype`), + echelon: readString(sourceParts.echelon, `${location}.parts.echelon`) + }; + + return { + name: readString(record.name, `${location}.name`), + aliases, + parts + }; +} + +function readAliases(value, location) { + if (!Array.isArray(value)) { + throw new Error(`${location} must be an array.`); + } + + const aliases = value.map((alias, index) => readString(alias, `${location}[${index}]`)); + if (new Set(aliases).size !== aliases.length) { + throw new Error(`${location} must not contain duplicate aliases.`); + } + + return aliases; +} + +function readOptionalPart(sourceParts, key, location) { + if (!(key in sourceParts)) { + return {}; + } + + return { + [key]: readString(sourceParts[key], location) + }; +} + +function readString(value, location) { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new Error(`${location} must be a non-empty trimmed string.`); + } + + return value; +} + +function toGeneratedSymbol(sourceRecord) { + const { sidc, echelonCode, functionId } = buildNumberSidc(sourceRecord.parts); + const symbol = { + sidc, + name: sourceRecord.name, + aliases: sourceRecord.aliases, + parts: sourceRecord.parts + }; + + validateRenderedSymbol(symbol, { echelonCode, functionId }); + return symbol; +} + +function buildNumberSidc(parts) { + const affiliationCode = requireCode(affiliationCodes, parts.affiliation, "affiliation", parts.affiliation); + const echelonCode = requireCode(echelonCodes, parts.echelon, "echelon", parts.echelon); + const functionKey = [parts.entity, parts.entityType, parts.entitySubtype].filter(Boolean).join(":"); + const functionId = requireCode(landUnitFunctionIds, functionKey, "land-unit function ID", functionKey); + + const sidc = + VERSION_CODE + + CONTEXT_CODE + + affiliationCode + + SYMBOL_SET_CODE + + STATUS_CODE + + HEADQUARTERS_TASK_FORCE_DUMMY_CODE + + echelonCode + + functionId + + UNUSED_MODIFIER_FIELDS; + + if (!/^\d{30}$/.test(sidc)) { + throw new Error(`Generated SIDC ${sidc} must be exactly 30 digits.`); + } + + if (sidc.slice(ECHELON_START, ECHELON_END) !== echelonCode) { + throw new Error(`Generated SIDC ${sidc} must keep echelon/mobility in digits 9-10.`); + } + + if (sidc.slice(FUNCTION_ID_START, FUNCTION_ID_END) !== functionId) { + throw new Error(`Generated SIDC ${sidc} must keep function ID in digits 11-20.`); + } + + return { sidc, echelonCode, functionId }; +} + +function requireCode(codes, key, label, value) { + const code = codes[key]; + if (code) { + return code; + } + + throw new Error(`No ${label} code is configured for ${value}.`); +} + +function validateRenderedSymbol(symbol, expected) { + const rendered = new ms.Symbol(symbol.sidc, { size: 40 }); + const metadata = rendered.getMetadata(); + + if (rendered.isValid() !== true || metadata.dimensionUnknown === true) { + throw new Error(`milsymbol does not support generated SIDC ${symbol.sidc} (${symbol.name}).`); + } + + if (metadata.numberSIDC !== true) { + throw new Error(`milsymbol did not treat generated SIDC ${symbol.sidc} as a number SIDC.`); + } + + if (metadata.functionid !== expected.functionId) { + throw new Error( + `milsymbol read function ID ${String(metadata.functionid)} for ${symbol.sidc}; expected ${expected.functionId}.` + ); + } + + if (symbol.sidc.slice(ECHELON_START, ECHELON_END) !== expected.echelonCode) { + throw new Error(`Generated SIDC ${symbol.sidc} changed the expected echelon field.`); + } + + const svg = rendered.asSVG(); + if (!svg.startsWith("")) { + throw new Error(`milsymbol rendered unexpected SVG output for ${symbol.sidc}.`); + } +} + +function validateGeneratedSymbols(symbols) { + const sidcs = new Set(); + const names = new Set(); + const aliases = new Set(); + for (const symbol of symbols) { + if (sidcs.has(symbol.sidc)) { + throw new Error(`Duplicate generated SIDC ${symbol.sidc}.`); + } + sidcs.add(symbol.sidc); + + if (names.has(symbol.name)) { + throw new Error(`Duplicate fixture name ${symbol.name}.`); + } + names.add(symbol.name); + + for (const alias of symbol.aliases) { + const normalizedAlias = alias.toLowerCase(); + if (aliases.has(normalizedAlias)) { + throw new Error(`Duplicate fixture alias ${alias}.`); + } + aliases.add(normalizedAlias); + } + } +} + +function normalizeLineEndings(value) { + return value.replace(/\r\n/g, "\n"); +} + +function renderSymbolsFile(symbols) { + return `// Generated by scripts/generate-curated-fixtures.mjs from fixtures/curated-land-units.json. +// Run npm run generate:fixtures after editing fixture source. + +import type { CuratedSymbol } from "./types.js"; + +export const landUnitSymbols = ${renderSymbols(symbols)} as const satisfies readonly CuratedSymbol[]; +`; +} + +function renderSymbols(symbols) { + return `[ +${symbols.map(renderSymbol).join(",\n")} +]`; +} + +function renderSymbol(symbol) { + return ` { + sidc: ${quote(symbol.sidc)}, + name: ${quote(symbol.name)}, + aliases: ${renderStringArray(symbol.aliases, 4)}, + parts: { +${renderPartLines(symbol.parts)} + } + }`; +} + +function renderStringArray(values, indent) { + const spaces = " ".repeat(indent); + return `[ +${values.map((value) => `${spaces} ${quote(value)}`).join(",\n")} +${spaces}]`; +} + +function renderPartLines(parts) { + const partEntries = [ + ["standard", parts.standard], + ["symbolSet", parts.symbolSet], + ["affiliation", parts.affiliation], + ["status", parts.status], + ["domain", parts.domain], + ["entity", parts.entity], + ...(parts.entityType ? [["entityType", parts.entityType]] : []), + ...(parts.entitySubtype ? [["entitySubtype", parts.entitySubtype]] : []), + ["echelon", parts.echelon] + ]; + + return partEntries.map(([key, value]) => ` ${key}: ${quote(value)}`).join(",\n"); +} + +function quote(value) { + return JSON.stringify(value); +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/build.ts b/src/build.ts new file mode 100644 index 0000000..f7ef925 --- /dev/null +++ b/src/build.ts @@ -0,0 +1,53 @@ +import { curatedSymbols, type CuratedSymbol } from "./data/symbols.js"; +import { SidcKitError } from "./errors.js"; +import { normalizeParts } from "./sidc.js"; +import type { BuildSidcInput } from "./types.js"; + +const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const; +type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number]; + +export function buildSidc(parts: BuildSidcInput): string { + const wanted = normalizeParts(parts); + const matches = curatedSymbols.filter((candidate) => { + const candidateParts = normalizeParts(candidate.parts); + return Object.entries(wanted).every(([key, value]) => candidateParts[key] === value); + }); + + if (matches.length === 0) { + throw new SidcKitError( + "UNSUPPORTED_COMBINATION", + `No curated SIDC matches affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}${ + parts.echelon ? `, echelon=${parts.echelon}` : "" + }.` + ); + } + + if (matches.length > 1) { + throw new SidcKitError( + "AMBIGUOUS_COMBINATION", + `Multiple curated SIDCs match affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}. ${buildAmbiguitySuggestion( + wanted, + matches + )}` + ); + } + + return matches[0].sidc; +} + +function buildAmbiguitySuggestion(wanted: Record, matches: readonly CuratedSymbol[]): string { + const helpfulKeys = disambiguatingPartKeys.filter((key) => !wanted[key] && hasVariation(matches, key)); + if (helpfulKeys.length === 0) { + return "The provided parts are still ambiguous."; + } + + if (helpfulKeys.length === 1) { + return `Add ${helpfulKeys[0]}.`; + } + + return `Add enough distinguishing parts from: ${helpfulKeys.join(", ")}.`; +} + +function hasVariation(matches: readonly CuratedSymbol[], key: DisambiguatingPartKey): boolean { + return new Set(matches.map((match) => normalizeParts(match.parts)[key] ?? "")).size > 1; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4ecdecf --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,413 @@ +#!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + SidcKitError, + buildSidc, + explainSidc, + renderSymbol, + searchSymbols, + type BuildSidcInput, + type RenderSymbolOptions +} from "./index.js"; + +type OptionSpec = { + kind: "flag" | "value"; +}; + +type ParsedArgs = { + positionals: string[]; + options: Map; +}; + +class UsageError extends Error { + readonly code = "USAGE_ERROR"; + + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} + +const commonOptions = { + help: { kind: "flag" }, + json: { kind: "flag" } +} satisfies Record; + +const maxRenderSize = 4096; + +const helpText = `Usage: + sidc-kit search [--limit ] [--json] + sidc-kit explain [--json] + sidc-kit render [--size ] [--fill|--no-fill] [--frame|--no-frame] [--json] + sidc-kit build --affiliation --domain --entity [--entity-type ] [--entity-subtype ] [--echelon ] [--json] + +Commands: + search Search curated symbols by plain-language terms. + explain Explain a curated SIDC into structured parts. + render Render any milsymbol-supported 30-digit SIDC to SVG. + build Build a curated SIDC from structured parts. +`; + +export function run(argv: readonly string[]): number { + const wantsJson = hasJsonFlagBeforeTerminator(argv); + + try { + if (argv.length === 0) { + throw new UsageError("Missing command. Run sidc-kit --help for usage."); + } + + const [command, ...rest] = argv; + if (command === "--help" || command === "-h") { + writeOutput(helpText); + return 0; + } + + switch (command) { + case "search": + return runSearch(rest); + case "explain": + return runExplain(rest); + case "render": + return runRender(rest); + case "build": + return runBuild(rest); + default: + throw new UsageError(`Unknown command: ${command}`); + } + } catch (error) { + reportError(error, wantsJson); + return error instanceof UsageError ? 2 : 1; + } +} + +function runSearch(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + limit: { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit search [--limit ] [--json]\n"); + return 0; + } + if (parsed.positionals.length === 0) { + throw new UsageError("search requires one or more query terms."); + } + + const limitValue = getOptionalValue(parsed, "limit"); + const results = searchSymbols(parsed.positionals.join(" "), { + ...(limitValue === undefined ? {} : { limit: parseNonNegativeInteger(limitValue, "limit") }) + }); + + if (hasFlag(parsed, "json")) { + writeJson(results); + return 0; + } + + writeOutput(results.map((result) => `${result.sidc}\t${result.name}\tscore=${result.score}`).join("\n")); + if (results.length > 0) { + writeOutput("\n"); + } + return 0; +} + +function runExplain(args: readonly string[]): number { + const parsed = parseArgs(args, commonOptions); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit explain [--json]\n"); + return 0; + } + if (parsed.positionals.length !== 1) { + throw new UsageError("explain requires exactly one SIDC."); + } + + const result = explainSidc(parsed.positionals[0]); + if (hasFlag(parsed, "json")) { + writeJson(result); + return 0; + } + + if (result.coverage === "curated") { + writeOutput(`${result.name}\n`); + } + writeOutput(`SIDC: ${result.sidc}\n`); + writeOutput(`Coverage: ${result.coverage}\n`); + writeOutput("Parts:\n"); + for (const [key, value] of Object.entries(result.parts)) { + writeOutput(` ${key}: ${value}\n`); + } + return 0; +} + +function runRender(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + fill: { kind: "flag" }, + frame: { kind: "flag" }, + "no-fill": { kind: "flag" }, + "no-frame": { kind: "flag" }, + size: { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit render [--size ] [--fill|--no-fill] [--frame|--no-frame] [--json]\n"); + return 0; + } + if (parsed.positionals.length !== 1) { + throw new UsageError("render requires exactly one SIDC."); + } + if (hasFlag(parsed, "fill") && hasFlag(parsed, "no-fill")) { + throw new UsageError("render accepts only one of --fill or --no-fill."); + } + if (hasFlag(parsed, "frame") && hasFlag(parsed, "no-frame")) { + throw new UsageError("render accepts only one of --frame or --no-frame."); + } + + const options: RenderSymbolOptions = {}; + const size = getOptionalValue(parsed, "size"); + if (size !== undefined) { + options.size = parsePositiveInteger(size, "size", maxRenderSize); + } + if (hasFlag(parsed, "fill")) { + options.fill = true; + } + if (hasFlag(parsed, "no-fill")) { + options.fill = false; + } + if (hasFlag(parsed, "frame")) { + options.frame = true; + } + if (hasFlag(parsed, "no-frame")) { + options.frame = false; + } + + const result = renderSymbol(parsed.positionals[0], options); + if (hasFlag(parsed, "json")) { + writeJson(result); + return 0; + } + + writeOutput(`${result.svg}\n`); + return 0; +} + +function runBuild(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + affiliation: { kind: "value" }, + domain: { kind: "value" }, + echelon: { kind: "value" }, + entity: { kind: "value" }, + "entity-subtype": { kind: "value" }, + "entity-type": { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput( + "Usage: sidc-kit build --affiliation --domain --entity [--entity-type ] [--entity-subtype ] [--echelon ] [--json]\n" + ); + return 0; + } + if (parsed.positionals.length > 0) { + throw new UsageError("build accepts options only; pass parts with --affiliation, --domain, and --entity."); + } + + const input: BuildSidcInput = { + affiliation: getRequiredValue(parsed, "affiliation"), + domain: getRequiredValue(parsed, "domain"), + entity: getRequiredValue(parsed, "entity") + }; + const echelon = getOptionalValue(parsed, "echelon"); + const entityType = getOptionalValue(parsed, "entity-type"); + const entitySubtype = getOptionalValue(parsed, "entity-subtype"); + if (echelon !== undefined) { + input.echelon = echelon; + } + if (entityType !== undefined) { + input.entityType = entityType; + } + if (entitySubtype !== undefined) { + input.entitySubtype = entitySubtype; + } + + const sidc = buildSidc(input); + if (hasFlag(parsed, "json")) { + writeJson({ sidc }); + return 0; + } + + writeOutput(`${sidc}\n`); + return 0; +} + +function parseArgs(args: readonly string[], specs: Record): ParsedArgs { + const positionals: string[] = []; + const options = new Map(); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--") { + positionals.push(...args.slice(index + 1)); + break; + } + + if (arg === "-h") { + setOption(options, "help", true); + continue; + } + + if (!arg.startsWith("--")) { + if (arg.startsWith("-")) { + throw new UsageError(`Unknown option: ${arg}`); + } + positionals.push(arg); + continue; + } + + const { name, inlineValue } = splitOption(arg); + const spec = specs[name]; + if (!spec) { + throw new UsageError(`Unknown option: --${name}`); + } + + if (spec.kind === "flag") { + if (inlineValue !== undefined) { + throw new UsageError(`--${name} does not take a value.`); + } + setOption(options, name, true); + continue; + } + + if (inlineValue !== undefined) { + setOption(options, name, inlineValue); + continue; + } + + const next = args[index + 1]; + if (next === undefined || next === "--" || next.startsWith("--")) { + throw new UsageError(`--${name} requires a value.`); + } + setOption(options, name, next); + index += 1; + } + + return { positionals, options }; +} + +function splitOption(arg: string): { name: string; inlineValue?: string } { + const withoutPrefix = arg.slice(2); + const separator = withoutPrefix.indexOf("="); + if (separator === -1) { + return { name: withoutPrefix }; + } + + return { + name: withoutPrefix.slice(0, separator), + inlineValue: withoutPrefix.slice(separator + 1) + }; +} + +function setOption(options: Map, name: string, value: string | true): void { + if (options.has(name)) { + throw new UsageError(`Option --${name} was provided more than once.`); + } + options.set(name, value); +} + +function hasFlag(parsed: ParsedArgs, name: string): boolean { + return parsed.options.get(name) === true; +} + +function getRequiredValue(parsed: ParsedArgs, name: string): string { + const value = getOptionalValue(parsed, name); + if (value === undefined) { + throw new UsageError(`--${name} is required.`); + } + return value; +} + +function getOptionalValue(parsed: ParsedArgs, name: string): string | undefined { + const value = parsed.options.get(name); + if (value === undefined) { + return undefined; + } + if (value === true) { + throw new UsageError(`--${name} requires a value.`); + } + if (value.trim() === "") { + throw new UsageError(`--${name} cannot be empty.`); + } + return value; +} + +function parseNonNegativeInteger(value: string, name: string): number { + if (!/^\d+$/.test(value)) { + throw new UsageError(`--${name} must be a non-negative integer.`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new UsageError(`--${name} must be a safe integer.`); + } + return parsed; +} + +function parsePositiveInteger(value: string, name: string, max?: number): number { + const parsed = parseNonNegativeInteger(value, name); + if (parsed === 0) { + throw new UsageError(`--${name} must be greater than zero.`); + } + if (max !== undefined && parsed > max) { + throw new UsageError(`--${name} must be no greater than ${max}.`); + } + return parsed; +} + +function hasJsonFlagBeforeTerminator(argv: readonly string[]): boolean { + for (const arg of argv) { + if (arg === "--") { + return false; + } + if (arg === "--json") { + return true; + } + } + return false; +} + +function writeJson(value: unknown): void { + writeOutput(`${JSON.stringify(value, null, 2)}\n`); +} + +function writeOutput(value: string): void { + process.stdout.write(value); +} + +function reportError(error: unknown, wantsJson: boolean): void { + const code = error instanceof SidcKitError || error instanceof UsageError ? error.code : "UNEXPECTED_ERROR"; + const message = error instanceof Error ? error.message : String(error); + + if (wantsJson) { + process.stderr.write(`${JSON.stringify({ error: { code, message } }, null, 2)}\n`); + return; + } + + process.stderr.write(`${code}: ${message}\n`); +} + +function isCliEntryPoint(): boolean { + const entryPoint = process.argv[1]; + if (!entryPoint) { + return false; + } + + try { + return realpathSync(resolve(entryPoint)) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isCliEntryPoint()) { + process.exitCode = run(process.argv.slice(2)); +} diff --git a/src/data/catalog/air.ts b/src/data/catalog/air.ts new file mode 100644 index 0000000..97fba42 --- /dev/null +++ b/src/data/catalog/air.ts @@ -0,0 +1,61 @@ +import type { CuratedSymbol } from "./types.js"; + +export const airSymbols = [ + { + sidc: "130301000011010400000000000000", + name: "Friendly Air Fighter Aircraft", + aliases: [ + "friendly fighter", + "friendly fighter aircraft", + "friend fighter jet", + "blue fighter aircraft" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "friend", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "fighter" + } + }, + { + sidc: "130301000011020000000000000000", + name: "Friendly Air Rotary-Wing Aircraft", + aliases: [ + "friendly rotary wing aircraft", + "friendly helicopter", + "friend helicopter", + "blue rotary wing aircraft" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "friend", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "rotary wing" + } + }, + { + sidc: "130601000011030000000000000000", + name: "Hostile Air Unmanned Aerial Vehicle", + aliases: [ + "hostile uav", + "enemy uav", + "enemy drone", + "red unmanned aerial vehicle" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "air", + affiliation: "hostile", + status: "present", + domain: "air", + entity: "aircraft", + entityType: "unmanned aerial vehicle" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/control-measures.ts b/src/data/catalog/control-measures.ts new file mode 100644 index 0000000..60621b4 --- /dev/null +++ b/src/data/catalog/control-measures.ts @@ -0,0 +1,42 @@ +import type { CuratedSymbol } from "./types.js"; + +export const controlMeasureSymbols = [ + { + sidc: "130325000013030000000000000000", + name: "Friendly Control Measure Checkpoint", + aliases: [ + "friendly checkpoint", + "friend checkpoint", + "blue checkpoint", + "control measure checkpoint" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "control measure", + affiliation: "friend", + status: "present", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + } + }, + { + sidc: "130325000013180000000000000000", + name: "Friendly Control Measure Waypoint", + aliases: [ + "friendly waypoint", + "friend waypoint", + "blue waypoint", + "control measure waypoint" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "control measure", + affiliation: "friend", + status: "present", + domain: "control measure", + entity: "command and control point", + entityType: "waypoint" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/index.ts b/src/data/catalog/index.ts new file mode 100644 index 0000000..891a5e5 --- /dev/null +++ b/src/data/catalog/index.ts @@ -0,0 +1,20 @@ +import { airSymbols } from "./air.js"; +import { controlMeasureSymbols } from "./control-measures.js"; +import { landEquipmentSymbols } from "./land-equipment.js"; +import { landInstallationSymbols } from "./land-installations.js"; +import { landUnitSymbols } from "./land-units.js"; +import { seaSubsurfaceSymbols } from "./sea-subsurface.js"; +import { seaSurfaceSymbols } from "./sea-surface.js"; +import type { CuratedSymbol } from "./types.js"; + +export type { CuratedSymbol, SymbolParts } from "./types.js"; + +export const curatedSymbols = [ + ...landUnitSymbols, + ...airSymbols, + ...seaSurfaceSymbols, + ...seaSubsurfaceSymbols, + ...landEquipmentSymbols, + ...landInstallationSymbols, + ...controlMeasureSymbols +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/land-equipment.ts b/src/data/catalog/land-equipment.ts new file mode 100644 index 0000000..b80b9fa --- /dev/null +++ b/src/data/catalog/land-equipment.ts @@ -0,0 +1,40 @@ +import type { CuratedSymbol } from "./types.js"; + +export const landEquipmentSymbols = [ + { + sidc: "130315000012020000000000000000", + name: "Friendly Land Equipment Tank", + aliases: [ + "friendly tank", + "friend tank", + "blue tank", + "land equipment tank" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land equipment", + affiliation: "friend", + status: "present", + domain: "land equipment", + entity: "tank" + } + }, + { + sidc: "130315000012010300000000000000", + name: "Friendly Land Equipment Armored Personnel Carrier", + aliases: [ + "friendly armored personnel carrier", + "friendly armoured personnel carrier", + "friend apc", + "blue apc" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land equipment", + affiliation: "friend", + status: "present", + domain: "land equipment", + entity: "armored personnel carrier" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/land-installations.ts b/src/data/catalog/land-installations.ts new file mode 100644 index 0000000..8120d73 --- /dev/null +++ b/src/data/catalog/land-installations.ts @@ -0,0 +1,42 @@ +import type { CuratedSymbol } from "./types.js"; + +export const landInstallationSymbols = [ + { + sidc: "130320000012080200000000000000", + name: "Friendly Land Installation Base", + aliases: [ + "friendly base", + "friendly military base", + "friend base", + "blue military base" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land installation", + affiliation: "friend", + status: "present", + domain: "land installation", + entity: "military infrastructure", + entityType: "base" + } + }, + { + sidc: "130320000012080300000000000000", + name: "Friendly Land Installation Airport", + aliases: [ + "friendly airport", + "friendly airfield", + "friend airport", + "land installation airport" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land installation", + affiliation: "friend", + status: "present", + domain: "land installation", + entity: "military infrastructure", + entityType: "airport" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/land-units.ts b/src/data/catalog/land-units.ts new file mode 100644 index 0000000..191a5f2 --- /dev/null +++ b/src/data/catalog/land-units.ts @@ -0,0 +1,133 @@ +// Generated by scripts/generate-curated-fixtures.mjs from fixtures/curated-land-units.json. +// Run npm run generate:fixtures after editing fixture source. + +import type { CuratedSymbol } from "./types.js"; + +export const landUnitSymbols = [ + { + sidc: "130310001412110000000000000000", + name: "Friendly Land Unit Infantry Platoon", + aliases: [ + "friendly infantry platoon", + "friendly inf platoon", + "friend infantry platoon", + "friend inf platoon", + "blue infantry platoon", + "land infantry platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "infantry", + echelon: "platoon" + } + }, + { + sidc: "130310001512110000000000000000", + name: "Friendly Land Unit Infantry Company", + aliases: [ + "friendly infantry company", + "friendly inf company", + "friend infantry company", + "friend inf company", + "blue infantry company", + "land infantry company" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "infantry", + echelon: "company" + } + }, + { + sidc: "130610001412110000000000000000", + name: "Hostile Land Unit Infantry Platoon", + aliases: [ + "hostile infantry platoon", + "hostile inf platoon", + "enemy infantry platoon", + "enemy inf platoon", + "red infantry platoon", + "land infantry platoon hostile" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "hostile", + status: "present", + domain: "land", + entity: "infantry", + echelon: "platoon" + } + }, + { + sidc: "130310001412050000000000000000", + name: "Friendly Land Unit Armor Platoon", + aliases: [ + "friendly armor platoon", + "friendly armour platoon", + "friendly tank platoon", + "friendly armoured platoon", + "friend tank platoon", + "blue armor platoon", + "land armor platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "armor", + entityType: "tank", + echelon: "platoon" + } + }, + { + sidc: "130310001413030000000000000000", + name: "Friendly Land Unit Artillery Platoon", + aliases: [ + "friendly artillery platoon", + "friendly arty platoon", + "friend field artillery platoon", + "friend arty platoon", + "blue artillery platoon", + "land artillery platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "artillery", + echelon: "platoon" + } + }, + { + sidc: "130310001412130000000000000000", + name: "Friendly Land Unit Reconnaissance Platoon", + aliases: [ + "friendly reconnaissance platoon", + "friend recon platoon", + "blue reconnaissance platoon", + "land recon platoon" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "land unit", + affiliation: "friend", + status: "present", + domain: "land", + entity: "reconnaissance", + echelon: "platoon" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/sea-subsurface.ts b/src/data/catalog/sea-subsurface.ts new file mode 100644 index 0000000..4730627 --- /dev/null +++ b/src/data/catalog/sea-subsurface.ts @@ -0,0 +1,40 @@ +import type { CuratedSymbol } from "./types.js"; + +export const seaSubsurfaceSymbols = [ + { + sidc: "130635000011010000000000000000", + name: "Hostile Sea Subsurface Submarine", + aliases: [ + "hostile submarine", + "enemy submarine", + "red submarine", + "sea subsurface submarine" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea subsurface", + affiliation: "hostile", + status: "present", + domain: "sea subsurface", + entity: "submarine" + } + }, + { + sidc: "130335000011040000000000000000", + name: "Friendly Sea Subsurface Unmanned Underwater Vehicle", + aliases: [ + "friendly unmanned underwater vehicle", + "friendly uuv", + "friend uuv", + "blue underwater drone" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea subsurface", + affiliation: "friend", + status: "present", + domain: "sea subsurface", + entity: "unmanned underwater vehicle" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/sea-surface.ts b/src/data/catalog/sea-surface.ts new file mode 100644 index 0000000..210ea51 --- /dev/null +++ b/src/data/catalog/sea-surface.ts @@ -0,0 +1,61 @@ +import type { CuratedSymbol } from "./types.js"; + +export const seaSurfaceSymbols = [ + { + sidc: "130330000012020300000000000000", + name: "Friendly Sea Surface Destroyer", + aliases: [ + "friendly destroyer", + "friend destroyer", + "blue destroyer", + "surface combatant destroyer" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "friend", + status: "present", + domain: "sea surface", + entity: "surface combatant", + entityType: "destroyer" + } + }, + { + sidc: "130330000012020400000000000000", + name: "Friendly Sea Surface Frigate", + aliases: [ + "friendly frigate", + "friend frigate", + "blue frigate", + "surface combatant frigate" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "friend", + status: "present", + domain: "sea surface", + entity: "surface combatant", + entityType: "frigate" + } + }, + { + sidc: "130430000014010000000000000000", + name: "Neutral Sea Surface Merchant Ship", + aliases: [ + "neutral merchant ship", + "merchant ship general", + "sea surface merchant ship", + "civilian merchant ship" + ], + parts: { + standard: "MIL-STD-2525D/APP-6D", + symbolSet: "sea surface", + affiliation: "neutral", + status: "present", + domain: "sea surface", + entity: "merchant ship", + entityType: "general" + } + } +] as const satisfies readonly CuratedSymbol[]; diff --git a/src/data/catalog/types.ts b/src/data/catalog/types.ts new file mode 100644 index 0000000..0bb6366 --- /dev/null +++ b/src/data/catalog/types.ts @@ -0,0 +1,18 @@ +export type SymbolParts = { + standard: "MIL-STD-2525D/APP-6D"; + symbolSet: string; + affiliation: string; + status: string; + domain: string; + entity: string; + entityType?: string; + entitySubtype?: string; + echelon?: string; +}; + +export type CuratedSymbol = { + sidc: string; + name: string; + aliases: readonly string[]; + parts: SymbolParts; +}; diff --git a/src/data/symbols.ts b/src/data/symbols.ts index 1d28f6a..b77d306 100644 --- a/src/data/symbols.ts +++ b/src/data/symbols.ts @@ -1,136 +1,2 @@ -export type SymbolParts = { - standard: "MIL-STD-2525D/APP-6D"; - symbolSet: string; - affiliation: string; - status: string; - domain: string; - entity: string; - entityType?: string; - entitySubtype?: string; - echelon?: string; -}; - -export type CuratedSymbol = { - sidc: string; - name: string; - aliases: readonly string[]; - parts: SymbolParts; -}; - -export const curatedSymbols = [ - { - sidc: "130310001412110000000000000000", - name: "Friendly Land Unit Infantry Platoon", - aliases: [ - "friendly infantry platoon", - "friend infantry platoon", - "blue infantry platoon", - "land infantry platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "infantry", - echelon: "platoon" - } - }, - { - sidc: "130310001512110000000000000000", - name: "Friendly Land Unit Infantry Company", - aliases: [ - "friendly infantry company", - "friend infantry company", - "blue infantry company", - "land infantry company" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "infantry", - echelon: "company" - } - }, - { - sidc: "130610001412110000000000000000", - name: "Hostile Land Unit Infantry Platoon", - aliases: [ - "hostile infantry platoon", - "enemy infantry platoon", - "red infantry platoon", - "land infantry platoon hostile" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "hostile", - status: "present", - domain: "land", - entity: "infantry", - echelon: "platoon" - } - }, - { - sidc: "130310001412050000000000000000", - name: "Friendly Land Unit Armor Platoon", - aliases: [ - "friendly armor platoon", - "friendly armoured platoon", - "friend tank platoon", - "blue armor platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "armor", - entityType: "tank", - echelon: "platoon" - } - }, - { - sidc: "130310001413030000000000000000", - name: "Friendly Land Unit Artillery Platoon", - aliases: [ - "friendly artillery platoon", - "friend field artillery platoon", - "blue artillery platoon", - "land artillery platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "artillery", - echelon: "platoon" - } - }, - { - sidc: "130310001412130000000000000000", - name: "Friendly Land Unit Reconnaissance Platoon", - aliases: [ - "friendly reconnaissance platoon", - "friend recon platoon", - "blue reconnaissance platoon", - "land recon platoon" - ], - parts: { - standard: "MIL-STD-2525D/APP-6D", - symbolSet: "land unit", - affiliation: "friend", - status: "present", - domain: "land", - entity: "reconnaissance", - echelon: "platoon" - } - } -] as const satisfies readonly CuratedSymbol[]; +export { curatedSymbols } from "./catalog/index.js"; +export type { CuratedSymbol, SymbolParts } from "./catalog/index.js"; diff --git a/src/explain.ts b/src/explain.ts new file mode 100644 index 0000000..2dfba82 --- /dev/null +++ b/src/explain.ts @@ -0,0 +1,201 @@ +import { curatedSymbols, type CuratedSymbol, type SymbolParts } from "./data/symbols.js"; +import { + createSupportedSymbol, + normalizeDimension, + normalizeMetadataLabel, + type SymbolMetadata +} from "./milsymbol.js"; +import { getFunctionId, getSymbolSetCode, normalizeSidc } from "./sidc.js"; +import type { + CuratedExplainSidcResult, + ExplainSidcFields, + ExplainSidcResult, + PartialExplainSidcResult, + PartialSymbolParts, + SidcField, + SidcFieldCoverage, + SidcFieldName +} from "./types.js"; + +type CatalogSymbolSetLabels = Pick; +type EntityParts = Pick & Partial>; + +const typedCuratedSymbols: readonly CuratedSymbol[] = curatedSymbols; + +const curatedSymbolsBySidc = new Map( + typedCuratedSymbols.map((symbol) => [symbol.sidc, symbol]) +); + +const catalogSymbolSetLabelsByCode = new Map( + typedCuratedSymbols.map((symbol) => [ + getSymbolSetCode(symbol.sidc), + { + symbolSet: symbol.parts.symbolSet, + domain: symbol.parts.domain + } + ]) +); + +const functionEntityParts = new Map( + typedCuratedSymbols.map((symbol) => [ + buildFunctionEntityKey(symbol.parts.symbolSet, getFunctionId(symbol.sidc)), + { + entity: symbol.parts.entity, + ...(symbol.parts.entityType ? { entityType: symbol.parts.entityType } : {}), + ...(symbol.parts.entitySubtype ? { entitySubtype: symbol.parts.entitySubtype } : {}) + } + ]) +); + +export function explainSidc(sidc: string): ExplainSidcResult { + const normalizedSidc = normalizeSidc(sidc); + const symbol = findCuratedSidc(normalizedSidc); + if (symbol) { + return explainSymbol(symbol); + } + + const { metadata } = createSupportedSymbol(normalizedSidc, {}, "UNSUPPORTED_SIDC", "explain"); + return explainPartialSidc(normalizedSidc, metadata); +} + +export function explainSymbol(symbol: CuratedSymbol): CuratedExplainSidcResult { + const fields = buildFields(symbol.sidc, symbol.parts, "curated"); + return { + sidc: symbol.sidc, + name: symbol.name, + aliases: [...symbol.aliases], + parts: { ...symbol.parts }, + coverage: "curated", + fields, + unknownFields: getUnknownFields(fields) + }; +} + +function findCuratedSidc(sidc: string): CuratedSymbol | undefined { + return curatedSymbolsBySidc.get(sidc); +} + +function explainPartialSidc(sidc: string, metadata: SymbolMetadata): PartialExplainSidcResult { + const parts = buildPartialParts(sidc, metadata); + const fields = buildFields(sidc, parts, "known"); + + return { + sidc, + aliases: [], + parts, + coverage: "partial", + fields, + unknownFields: getUnknownFields(fields) + }; +} + +function buildPartialParts(sidc: string, metadata: SymbolMetadata): PartialSymbolParts { + const catalogLabels = catalogSymbolSetLabelsByCode.get(getSymbolSetCode(sidc)); + const domain = catalogLabels?.domain ?? normalizeDimension(metadata.dimension); + const symbolSet = catalogLabels?.symbolSet ?? getSymbolSetLabel(domain, metadata); + const entityParts = symbolSet + ? functionEntityParts.get(buildFunctionEntityKey(symbolSet, metadata.functionid)) + : undefined; + const affiliation = normalizeMetadataLabel(metadata.affiliation); + const status = getStatusLabel(sidc.slice(6, 7), metadata); + const echelon = normalizeMetadataLabel(metadata.echelon); + const parts: PartialSymbolParts = {}; + + if (symbolSet) { + parts.symbolSet = symbolSet; + } + if (affiliation) { + parts.affiliation = affiliation; + } + if (status) { + parts.status = status; + } + if (domain) { + parts.domain = domain; + } + if (entityParts) { + Object.assign(parts, entityParts); + } + if (echelon) { + parts.echelon = echelon; + } + + return parts; +} + +function buildFields( + sidc: string, + parts: PartialSymbolParts, + knownCoverage: Exclude +): ExplainSidcFields { + return { + affiliation: buildField(sidc.slice(3, 4), parts.affiliation, knownCoverage), + symbolSet: buildField(sidc.slice(4, 6), parts.symbolSet, knownCoverage), + status: buildField(sidc.slice(6, 7), parts.status, knownCoverage), + domain: buildField(sidc.slice(4, 6), parts.domain, knownCoverage), + echelon: buildField(sidc.slice(8, 10), parts.echelon, knownCoverage), + entity: buildField(getFunctionId(sidc), parts.entity, knownCoverage) + }; +} + +function buildField( + code: string, + value: string | undefined, + knownCoverage: Exclude +): SidcField { + if (!value) { + return { + code, + coverage: "unknown" + }; + } + + return { + code, + value, + coverage: knownCoverage + }; +} + +function getUnknownFields(fields: ExplainSidcFields): SidcFieldName[] { + return Object.entries(fields) + .filter(([, field]) => field.coverage === "unknown") + .map(([fieldName]) => fieldName as SidcFieldName); +} + +function buildFunctionEntityKey(symbolSet: string, functionId: string): string { + return `${symbolSet}:${functionId}`; +} + +function getSymbolSetLabel(domain: string | undefined, metadata: SymbolMetadata): string | undefined { + if (!domain) { + return undefined; + } + + if (metadata.unit === true) { + return `${domain} unit`; + } + + if (metadata.installation === true) { + return `${domain} installation`; + } + + if (metadata.activity === true) { + return `${domain} activity`; + } + + return undefined; +} + +function getStatusLabel(statusCode: string, metadata: SymbolMetadata): string | undefined { + const condition = normalizeMetadataLabel(metadata.condition); + if (condition) { + return condition; + } + + if (statusCode === "0") { + return "present"; + } + + return undefined; +} diff --git a/src/identify.ts b/src/identify.ts new file mode 100644 index 0000000..74157c6 --- /dev/null +++ b/src/identify.ts @@ -0,0 +1,197 @@ +import { curatedSymbols } from "./data/symbols.js"; +import { explainSymbol } from "./explain.js"; +import { renderSymbol } from "./render.js"; +import type { IdentifySymbolOptions, IdentifySymbolResult } from "./types.js"; + +const defaultIdentifyMinConfidence = 0.99; +const maxIdentifySvgInputLength = 10_000; +const maxIdentifyFuzzySvgLength = 4_000; +const maxIdentifyFuzzyDistanceCells = 500_000; +const maxIdentifyCandidateRenderSize = 4096; + +export function identifySymbol(input: string, options: IdentifySymbolOptions = {}): IdentifySymbolResult[] { + const normalizedInput = normalizeSvgInput(input); + if (!normalizedInput) { + return []; + } + + const { limit = curatedSymbols.length, minConfidence = defaultIdentifyMinConfidence, ...renderOptions } = options; + const cappedLimit = Math.max(0, limit); + const threshold = clampConfidence(minConfidence); + if (cappedLimit === 0) { + return []; + } + if (!hasSafeCandidateRenderOptions(renderOptions)) { + return []; + } + + return curatedSymbols + .map((symbol) => { + const normalizedCandidate = normalizeSvgInput(renderSymbol(symbol.sidc, renderOptions).svg); + if (!normalizedCandidate) { + return undefined; + } + + const similarity = svgSimilarity(normalizedInput, normalizedCandidate, threshold); + const exact = normalizedInput === normalizedCandidate; + if (similarity === undefined || similarity < threshold) { + return undefined; + } + + const confidence = roundConfidence(similarity); + + return { + similarity, + result: { + ...explainSymbol(symbol), + confidence, + evidence: { + input: "svg" as const, + method: "normalized-svg" as const, + similarity: confidence, + exact, + notes: [ + exact + ? "Input SVG matches this curated milsymbol rendering after normalization." + : "Input SVG is only similar to this curated milsymbol rendering." + ] + } + } + }; + }) + .filter((candidate): candidate is { similarity: number; result: IdentifySymbolResult } => candidate !== undefined) + .sort((left, right) => right.similarity - left.similarity || left.result.name.localeCompare(right.result.name)) + .slice(0, cappedLimit) + .map((candidate) => candidate.result); +} + +function hasSafeCandidateRenderOptions(options: Pick): boolean { + return options.size === undefined || + (Number.isFinite(options.size) && options.size > 0 && options.size <= maxIdentifyCandidateRenderSize); +} + +function normalizeSvgInput(input: string): string | undefined { + const normalized = decodeInlineSvgDataUrl(input) + .replace(/^\uFEFF/, "") + .replace(/<\?xml[\s\S]*?\?>/gi, "") + .replace(//g, "") + .trim(); + + if (normalized.length > maxIdentifySvgInputLength) { + return undefined; + } + + if (!/^)/i.test(normalized)) { + return undefined; + } + + return normalized + .replace(/>\s+<") + .replace(/\s+\/>/g, "/>") + .replace(/\s+>/g, ">") + .replace(/\s*=\s*/g, "=") + .replace(/\s{2,}/g, " ") + .trim(); +} + +function decodeInlineSvgDataUrl(input: string): string { + const trimmed = input.trim(); + const dataUrl = /^data:image\/svg\+xml((?:;[^,]+)*),([\s\S]*)$/i.exec(trimmed); + if (!dataUrl) { + return trimmed; + } + + const metadata = dataUrl[1]; + const payload = dataUrl[2]; + if (/(?:^|;)base64(?:;|$)/i.test(metadata)) { + return decodeBase64Utf8(payload) ?? payload; + } + + try { + return decodeURIComponent(payload); + } catch { + return payload; + } +} + +function decodeBase64Utf8(payload: string): string | undefined { + if (typeof globalThis.atob !== "function") { + return undefined; + } + + try { + const binary = globalThis.atob(decodeUriComponentOrOriginal(payload).replace(/\s/g, "")); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return undefined; + } +} + +function decodeUriComponentOrOriginal(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function svgSimilarity(left: string, right: string, minSimilarity: number): number | undefined { + if (left === right) { + return 1; + } + + const maxLength = Math.max(left.length, right.length); + if (maxLength === 0) { + return 1; + } + if (maxLength > maxIdentifyFuzzySvgLength) { + return undefined; + } + if (left.length * right.length > maxIdentifyFuzzyDistanceCells) { + return undefined; + } + + const lengthSimilarity = 1 - Math.abs(left.length - right.length) / maxLength; + if (lengthSimilarity < minSimilarity) { + return lengthSimilarity; + } + + return 1 - levenshteinDistance(left, right) / maxLength; +} + +function levenshteinDistance(left: string, right: string): number { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + const current = new Array(right.length + 1); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + current[0] = leftIndex; + + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const cost = left.charCodeAt(leftIndex - 1) === right.charCodeAt(rightIndex - 1) ? 0 : 1; + current[rightIndex] = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + previous[rightIndex - 1] + cost + ); + } + + for (let index = 0; index <= right.length; index += 1) { + previous[index] = current[index]; + } + } + + return previous[right.length]; +} + +function clampConfidence(value: number): number { + if (Number.isNaN(value)) { + return defaultIdentifyMinConfidence; + } + + return Math.max(0, Math.min(1, value)); +} + +function roundConfidence(value: number): number { + return Number(clampConfidence(value).toFixed(6)); +} diff --git a/src/index.ts b/src/index.ts index 8d9ffdf..ced055f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,261 +1,26 @@ -import ms from "milsymbol"; - -import { curatedSymbols, type CuratedSymbol, type SymbolParts } from "./data/symbols.js"; -import { SidcKitError } from "./errors.js"; - +export { buildSidc } from "./build.js"; +export { explainSidc } from "./explain.js"; +export { identifySymbol } from "./identify.js"; +export { renderSymbol } from "./render.js"; +export { searchSymbols } from "./search.js"; export { SidcKitError } from "./errors.js"; export type { CuratedSymbol, SymbolParts } from "./data/symbols.js"; - -export type RenderSymbolOptions = { - size?: number; - fill?: boolean; - frame?: boolean; -}; - -export type RenderSymbolResult = { - sidc: string; - svg: string; - anchor?: { - x: number; - y: number; - }; - size?: { - width: number; - height: number; - }; -}; - -export type ExplainSidcResult = { - sidc: string; - name: string; - aliases: string[]; - parts: SymbolParts; - coverage: "curated"; -}; - -export type SymbolSearchOptions = { - limit?: number; -}; - -export type SymbolSearchResult = ExplainSidcResult & { - score: number; -}; - -export type BuildSidcInput = Partial> & { - affiliation: string; - domain: string; - entity: string; -}; - -const sidcPattern = /^\d{30}$/; -const disambiguatingPartKeys = ["entityType", "entitySubtype", "echelon"] as const; -type DisambiguatingPartKey = (typeof disambiguatingPartKeys)[number]; - -export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult { - const normalizedSidc = normalizeSidc(sidc); - - try { - const symbol = new ms.Symbol(normalizedSidc, options); - const metadata = symbol.getMetadata(); - if (symbol.isValid() !== true || metadata.dimensionUnknown) { - throw new SidcKitError("RENDER_FAILED", `milsymbol does not support SIDC ${normalizedSidc}.`); - } - - const svg = symbol.asSVG(); - const anchor = toPoint(symbol.getAnchor?.()); - const size = toSize(symbol.getSize?.()); - - return { - sidc: normalizedSidc, - svg, - ...(anchor ? { anchor } : {}), - ...(size ? { size } : {}) - }; - } catch (error) { - if (error instanceof SidcKitError) { - throw error; - } - - throw new SidcKitError( - "RENDER_FAILED", - `Failed to render SIDC ${normalizedSidc}: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -export function explainSidc(sidc: string): ExplainSidcResult { - const normalizedSidc = normalizeSidc(sidc); - const symbol = requireCuratedSidc(normalizedSidc); - return explainSymbol(symbol); -} - -export function searchSymbols(query: string, options: SymbolSearchOptions = {}): SymbolSearchResult[] { - const terms = tokenize(query); - if (terms.length === 0) { - return []; - } - - const limit = Math.max(0, options.limit ?? curatedSymbols.length); - - return curatedSymbols - .map((symbol) => { - const score = scoreSymbol(symbol, terms); - return { - ...explainSymbol(symbol), - score - }; - }) - .filter((result) => result.score > 0) - .sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)) - .slice(0, limit); -} - -export function buildSidc(parts: BuildSidcInput): string { - const wanted = normalizeParts(parts); - const matches = curatedSymbols.filter((candidate) => { - const candidateParts = normalizeParts(candidate.parts); - return Object.entries(wanted).every(([key, value]) => candidateParts[key] === value); - }); - - if (matches.length === 0) { - throw new SidcKitError( - "UNSUPPORTED_COMBINATION", - `No curated SIDC matches affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}${ - parts.echelon ? `, echelon=${parts.echelon}` : "" - }.` - ); - } - - if (matches.length > 1) { - throw new SidcKitError( - "AMBIGUOUS_COMBINATION", - `Multiple curated SIDCs match affiliation=${parts.affiliation}, domain=${parts.domain}, entity=${parts.entity}. ${buildAmbiguitySuggestion( - wanted, - matches - )}` - ); - } - - return matches[0].sidc; -} - -function normalizeSidc(sidc: string): string { - const normalizedSidc = sidc.trim(); - if (!sidcPattern.test(normalizedSidc)) { - throw new SidcKitError("INVALID_SIDC", "SIDC must be exactly 30 digits."); - } - return normalizedSidc; -} - -function requireCuratedSidc(sidc: string): CuratedSymbol { - const symbol = curatedSymbols.find((candidate) => candidate.sidc === sidc); - if (!symbol) { - throw new SidcKitError("UNSUPPORTED_SIDC", `SIDC ${sidc} is not in the curated V0 fixture set.`); - } - return symbol; -} - -function explainSymbol(symbol: CuratedSymbol): ExplainSidcResult { - return { - sidc: symbol.sidc, - name: symbol.name, - aliases: [...symbol.aliases], - parts: { ...symbol.parts }, - coverage: "curated" - }; -} - -function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[]): number { - const fields = [ - symbol.name, - ...symbol.aliases, - ...Object.values(symbol.parts).filter((value): value is string => typeof value === "string") - ]; - const exactText = fields.join(" ").toLowerCase(); - const fieldTerms = new Set(fields.flatMap(tokenize)); - - return queryTerms.reduce((score, term) => { - if (fieldTerms.has(term)) { - return score + 3; - } - if (exactText.includes(term)) { - return score + 1; - } - return score; - }, 0); -} - -function tokenize(value: string): string[] { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim() - .split(/\s+/) - .filter(Boolean); -} - -function normalizeParts(parts: BuildSidcInput | SymbolParts): Record { - return Object.entries(parts).reduce>((normalized, [key, value]) => { - if (typeof value === "string") { - normalized[key] = value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); - } - return normalized; - }, {}); -} - -function buildAmbiguitySuggestion(wanted: Record, matches: readonly CuratedSymbol[]): string { - const helpfulKeys = disambiguatingPartKeys.filter((key) => !wanted[key] && hasVariation(matches, key)); - if (helpfulKeys.length === 0) { - return "The provided parts are still ambiguous."; - } - - return `Add ${formatPartList(helpfulKeys)}.`; -} - -function hasVariation(matches: readonly CuratedSymbol[], key: DisambiguatingPartKey): boolean { - return new Set(matches.map((match) => normalizeParts(match.parts)[key] ?? "")).size > 1; -} - -function formatPartList(parts: readonly string[]): string { - if (parts.length === 1) { - return parts[0]; - } - - if (parts.length === 2) { - return `${parts[0]} or ${parts[1]}`; - } - - return `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}`; -} - -function toPoint(value: unknown): RenderSymbolResult["anchor"] { - if (!isObject(value)) { - return undefined; - } - - const x = value.x; - const y = value.y; - if (typeof x === "number" && typeof y === "number") { - return { x, y }; - } - - return undefined; -} - -function toSize(value: unknown): RenderSymbolResult["size"] { - if (!isObject(value)) { - return undefined; - } - - const width = value.width; - const height = value.height; - if (typeof width === "number" && typeof height === "number") { - return { width, height }; - } - - return undefined; -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} +export type { + BuildSidcInput, + CuratedExplainSidcResult, + ExplainSidcCoverage, + ExplainSidcFields, + ExplainSidcResult, + IdentifySymbolEvidence, + IdentifySymbolOptions, + IdentifySymbolResult, + PartialExplainSidcResult, + PartialSymbolParts, + RenderSymbolOptions, + RenderSymbolResult, + SidcField, + SidcFieldCoverage, + SidcFieldName, + SymbolSearchOptions, + SymbolSearchResult +} from "./types.js"; diff --git a/src/milsymbol.ts b/src/milsymbol.ts new file mode 100644 index 0000000..3adc55a --- /dev/null +++ b/src/milsymbol.ts @@ -0,0 +1,51 @@ +import ms, { type SymbolMetadata } from "milsymbol"; + +import { SidcKitError, type SidcKitErrorCode } from "./errors.js"; +import type { RenderSymbolOptions } from "./types.js"; + +export type { SymbolMetadata } from "milsymbol"; + +export type MilsymbolSymbol = InstanceType; + +export function createSupportedSymbol( + sidc: string, + options: RenderSymbolOptions, + failureCode: SidcKitErrorCode, + action: "explain" | "render" +): { symbol: MilsymbolSymbol; metadata: SymbolMetadata } { + try { + const symbol = new ms.Symbol(sidc, options); + const metadata = symbol.getMetadata(); + if (symbol.isValid() !== true || metadata.dimensionUnknown) { + throw new SidcKitError(failureCode, `milsymbol does not support SIDC ${sidc}.`); + } + + return { symbol, metadata }; + } catch (error) { + if (error instanceof SidcKitError) { + throw error; + } + + throw new SidcKitError( + failureCode, + `Failed to ${action} SIDC ${sidc}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export function normalizeDimension(value: SymbolMetadata["dimension"]): string | undefined { + if (value === "Ground") { + return "land"; + } + + return normalizeMetadataLabel(value); +} + +export function normalizeMetadataLabel(value: string | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized || normalized === "undefined") { + return undefined; + } + + return normalized.toLowerCase(); +} diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..d57c74f --- /dev/null +++ b/src/render.ts @@ -0,0 +1,64 @@ +import { SidcKitError } from "./errors.js"; +import { createSupportedSymbol } from "./milsymbol.js"; +import { normalizeSidc } from "./sidc.js"; +import type { RenderSymbolOptions, RenderSymbolResult } from "./types.js"; + +export function renderSymbol(sidc: string, options: RenderSymbolOptions = {}): RenderSymbolResult { + const normalizedSidc = normalizeSidc(sidc); + + try { + const { symbol } = createSupportedSymbol(normalizedSidc, options, "RENDER_FAILED", "render"); + + const svg = symbol.asSVG(); + const anchor = toPoint(symbol.getAnchor?.()); + const size = toSize(symbol.getSize?.()); + + return { + sidc: normalizedSidc, + svg, + ...(anchor ? { anchor } : {}), + ...(size ? { size } : {}) + }; + } catch (error) { + if (error instanceof SidcKitError) { + throw error; + } + + throw new SidcKitError( + "RENDER_FAILED", + `Failed to render SIDC ${normalizedSidc}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function toPoint(value: unknown): RenderSymbolResult["anchor"] { + if (!isObject(value)) { + return undefined; + } + + const x = value.x; + const y = value.y; + if (typeof x === "number" && typeof y === "number") { + return { x, y }; + } + + return undefined; +} + +function toSize(value: unknown): RenderSymbolResult["size"] { + if (!isObject(value)) { + return undefined; + } + + const width = value.width; + const height = value.height; + if (typeof width === "number" && typeof height === "number") { + return { width, height }; + } + + return undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..ea409d6 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,154 @@ +import { curatedSymbols, type CuratedSymbol } from "./data/symbols.js"; +import { explainSymbol } from "./explain.js"; +import { normalizeText, tokenize } from "./sidc.js"; +import type { SymbolSearchOptions, SymbolSearchResult } from "./types.js"; + +type SearchFieldGroup = "names" | "aliases" | "parts"; +type SearchField = { + text: string; + terms: Set; +}; +type SearchFields = Record; +type FieldScoreWeights = Record; + +const exactPhraseWeights = { + names: 100, + aliases: 90, + parts: 70 +} satisfies FieldScoreWeights; + +const exactTokenWeights = { + names: 12, + aliases: 10, + parts: 8 +} satisfies FieldScoreWeights; + +const relatedTokenWeights = { + names: 7, + aliases: 6, + parts: 5 +} satisfies FieldScoreWeights; + +const partialTokenWeights = { + names: 2, + aliases: 2, + parts: 1 +} satisfies FieldScoreWeights; + +const synonymGroups = [ + ["armor", "armour", "armored", "armoured", "tank"], + ["recon", "reconnaissance"], + ["arty", "artillery"], + ["inf", "infantry"] +] as const; + +export function searchSymbols(query: string, options: SymbolSearchOptions = {}): SymbolSearchResult[] { + const terms = tokenize(query); + if (terms.length === 0) { + return []; + } + + const limit = Math.max(0, options.limit ?? curatedSymbols.length); + if (limit === 0) { + return []; + } + const normalizedQuery = normalizeText(query); + + return curatedSymbols + .map((symbol, index) => { + const score = scoreSymbol(symbol, terms, normalizedQuery); + return { + index, + result: { + ...explainSymbol(symbol), + score + } + }; + }) + .filter(({ result }) => result.score > 0) + .sort((left, right) => right.result.score - left.result.score || left.index - right.index) + .map(({ result }) => result) + .slice(0, limit); +} + +function scoreSymbol(symbol: CuratedSymbol, queryTerms: readonly string[], normalizedQuery: string): number { + const fields = buildSearchFields(symbol); + + return ( + scoreExactPhrase(normalizedQuery, fields) + + queryTerms.reduce((score, term) => score + scoreQueryTerm(term, fields), 0) + ); +} + +function buildSearchFields(symbol: CuratedSymbol): SearchFields { + const partValues = Object.values(symbol.parts).filter((value): value is string => typeof value === "string"); + + return { + names: [toSearchField(symbol.name)], + aliases: symbol.aliases.map(toSearchField), + parts: partValues.map(toSearchField) + }; +} + +function toSearchField(value: string): SearchField { + return { + text: normalizeText(value), + terms: new Set(tokenize(value)) + }; +} + +function scoreExactPhrase(query: string, fields: SearchFields): number { + return scoreFieldPhrase(query, fields.names, exactPhraseWeights.names) + + scoreFieldPhrase(query, fields.aliases, exactPhraseWeights.aliases) + + scoreFieldPhrase(query, fields.parts, exactPhraseWeights.parts); +} + +function scoreFieldPhrase(query: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.text === query) ? weight : 0; +} + +function scoreQueryTerm(term: string, fields: SearchFields): number { + const exactScore = scoreTerm(term, fields, exactTokenWeights); + const relatedScore = scoreRelatedTerms(term, fields); + const partialScore = exactScore > 0 ? 0 : scorePartialTerm(term, fields); + + return exactScore + relatedScore + partialScore; +} + +function scoreRelatedTerms(term: string, fields: SearchFields): number { + const relatedTerms = expandTerm(term).filter((candidate) => candidate !== term); + if (relatedTerms.length === 0) { + return 0; + } + + return Math.max(0, ...relatedTerms.map((relatedTerm) => scoreTerm(relatedTerm, fields, relatedTokenWeights))); +} + +function expandTerm(term: string): string[] { + const group = synonymGroups.find((terms) => (terms as readonly string[]).includes(term)); + return group ? [...group] : [term]; +} + +function scoreTerm(term: string, fields: SearchFields, weights: FieldScoreWeights): number { + return scoreFieldTerm(term, fields.names, weights.names) + + scoreFieldTerm(term, fields.aliases, weights.aliases) + + scoreFieldTerm(term, fields.parts, weights.parts); +} + +function scoreFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.terms.has(term)) ? weight : 0; +} + +function scorePartialTerm(term: string, fields: SearchFields): number { + if (term.length < 3) { + return 0; + } + + return scorePartialFieldTerm(term, fields.names, partialTokenWeights.names) + + scorePartialFieldTerm(term, fields.aliases, partialTokenWeights.aliases) + + scorePartialFieldTerm(term, fields.parts, partialTokenWeights.parts); +} + +function scorePartialFieldTerm(term: string, fields: readonly SearchField[], weight: number): number { + return fields.some((field) => field.text.includes(term)) ? weight : 0; +} diff --git a/src/sidc.ts b/src/sidc.ts new file mode 100644 index 0000000..d9f3287 --- /dev/null +++ b/src/sidc.ts @@ -0,0 +1,41 @@ +import type { SymbolParts } from "./data/symbols.js"; +import { SidcKitError } from "./errors.js"; +import type { BuildSidcInput } from "./types.js"; + +const sidcPattern = /^\d{30}$/; + +export function normalizeSidc(sidc: string): string { + const normalizedSidc = sidc.trim(); + if (!sidcPattern.test(normalizedSidc)) { + throw new SidcKitError("INVALID_SIDC", "SIDC must be exactly 30 digits."); + } + return normalizedSidc; +} + +export function getFunctionId(sidc: string): string { + return sidc.slice(10, 20); +} + +export function getSymbolSetCode(sidc: string): string { + return sidc.slice(4, 6); +} + +export function tokenize(value: string): string[] { + return normalizeText(value) + .trim() + .split(/\s+/) + .filter(Boolean); +} + +export function normalizeText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +export function normalizeParts(parts: BuildSidcInput | SymbolParts): Record { + return Object.entries(parts).reduce>((normalized, [key, value]) => { + if (typeof value === "string") { + normalized[key] = normalizeText(value); + } + return normalized; + }, {}); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3070be9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,89 @@ +import type { SymbolParts } from "./data/symbols.js"; + +export type RenderSymbolOptions = { + size?: number; + fill?: boolean; + frame?: boolean; +}; + +export type RenderSymbolResult = { + sidc: string; + svg: string; + anchor?: { + x: number; + y: number; + }; + size?: { + width: number; + height: number; + }; +}; + +export type ExplainSidcCoverage = "curated" | "partial"; + +export type SidcFieldCoverage = "curated" | "known" | "unknown"; + +export type SidcField = { + code: string; + coverage: SidcFieldCoverage; + value?: string; +}; + +export type SidcFieldName = "affiliation" | "symbolSet" | "status" | "domain" | "echelon" | "entity"; + +export type ExplainSidcFields = Record; + +export type PartialSymbolParts = Partial; + +type BaseExplainSidcResult = { + sidc: string; + aliases: string[]; + fields: ExplainSidcFields; + unknownFields: SidcFieldName[]; +}; + +export type CuratedExplainSidcResult = BaseExplainSidcResult & { + name: string; + parts: SymbolParts; + coverage: "curated"; +}; + +export type PartialExplainSidcResult = BaseExplainSidcResult & { + name?: never; + parts: PartialSymbolParts; + coverage: "partial"; +}; + +export type ExplainSidcResult = CuratedExplainSidcResult | PartialExplainSidcResult; + +export type SymbolSearchOptions = { + limit?: number; +}; + +export type SymbolSearchResult = CuratedExplainSidcResult & { + score: number; +}; + +export type IdentifySymbolOptions = RenderSymbolOptions & { + limit?: number; + minConfidence?: number; +}; + +export type IdentifySymbolEvidence = { + input: "svg"; + method: "normalized-svg"; + similarity: number; + exact: boolean; + notes: string[]; +}; + +export type IdentifySymbolResult = CuratedExplainSidcResult & { + confidence: number; + evidence: IdentifySymbolEvidence; +}; + +export type BuildSidcInput = Partial> & { + affiliation: string; + domain: string; + entity: string; +}; diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..d4c7f1d --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cliPath = path.join(rootDir, "dist", "cli.js"); +const infantryPlatoonSidc = "130310001412110000000000000000"; +const nonCuratedRenderableSidc = "130410001412110000000000000000"; + +function runCli(args) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: rootDir, + encoding: "utf8" + }); +} + +test("CLI module import does not execute the command runner", async () => { + const originalArgv = process.argv; + const originalExitCode = process.exitCode; + const originalStderrWrite = process.stderr.write; + let stderr = ""; + + try { + process.argv = [process.execPath, "--test"]; + process.exitCode = undefined; + process.stderr.write = (chunk, ...args) => { + stderr += String(chunk); + const callback = args.find((arg) => typeof arg === "function"); + if (callback) { + callback(); + } + return true; + }; + + await import(`${new URL("../dist/cli.js", import.meta.url).href}?import-side-effect=${Date.now()}`); + + assert.equal(process.exitCode, undefined); + assert.equal(stderr, ""); + } finally { + process.argv = originalArgv; + process.exitCode = originalExitCode; + process.stderr.write = originalStderrWrite; + } +}); + +test("CLI searches curated symbols with plain text output", () => { + const result = runCli(["search", "friendly", "infantry", "platoon", "--limit", "1"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, new RegExp(`^${infantryPlatoonSidc}\\tFriendly Land Unit Infantry Platoon\\tscore=\\d+\\n$`)); +}); + +test("CLI explains a curated SIDC as JSON", () => { + const result = runCli(["explain", infantryPlatoonSidc, "--json"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.sidc, infantryPlatoonSidc); + assert.equal(parsed.name, "Friendly Land Unit Infantry Platoon"); + assert.equal(parsed.parts.echelon, "platoon"); +}); + +test("CLI explains a partial SIDC without an undefined text heading", () => { + const result = runCli(["explain", nonCuratedRenderableSidc]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stdout, /undefined/); + assert.match(result.stdout, new RegExp(`^SIDC: ${nonCuratedRenderableSidc}\\nCoverage: partial\\nParts:\\n`)); + assert.match(result.stdout, / entity: infantry\n/); +}); + +test("CLI builds a curated SIDC from structured options", () => { + const result = runCli([ + "build", + "--affiliation", + "friend", + "--domain", + "land", + "--entity", + "infantry", + "--echelon", + "platoon" + ]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal(result.stdout, `${infantryPlatoonSidc}\n`); +}); + +test("CLI renders SVG to stdout by default", () => { + const result = runCli(["render", infantryPlatoonSidc, "--size", "32"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^\n$/); +}); + +test("CLI returns usage failures with exit code 2", () => { + const result = runCli(["build", "--affiliation", "friend", "--domain", "land"]); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^USAGE_ERROR: --entity is required\.\n$/); +}); + +test("CLI returns typed API failures as JSON when requested", () => { + const result = runCli(["explain", "not-a-sidc", "--json"]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + + const parsed = JSON.parse(result.stderr); + assert.equal(parsed.error.code, "INVALID_SIDC"); + assert.match(parsed.error.message, /30 digits/); +}); + +test("CLI ignores --json after the positional terminator", () => { + const result = runCli(["explain", "--", "--json"]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^INVALID_SIDC: SIDC must be exactly 30 digits\.\n$/); + assert.throws(() => JSON.parse(result.stderr), SyntaxError); +}); + +test("CLI gives consistent diagnostics for separated negative numeric values", () => { + const separated = runCli(["search", "infantry", "--limit", "-1"]); + const inline = runCli(["search", "infantry", "--limit=-1"]); + + assert.equal(separated.status, 2); + assert.equal(inline.status, 2); + assert.equal(separated.stdout, ""); + assert.equal(inline.stdout, ""); + assert.equal(separated.stderr, inline.stderr); + assert.match(separated.stderr, /^USAGE_ERROR: --limit must be a non-negative integer\.\n$/); +}); + +test("CLI rejects unsafe and oversized integer values", () => { + const unsafe = runCli(["render", infantryPlatoonSidc, "--size", "999999999999999999999999999999"]); + const oversized = runCli(["render", infantryPlatoonSidc, "--size", "4097"]); + + assert.equal(unsafe.status, 2); + assert.equal(unsafe.stdout, ""); + assert.match(unsafe.stderr, /^USAGE_ERROR: --size must be a safe integer\.\n$/); + + assert.equal(oversized.status, 2); + assert.equal(oversized.stdout, ""); + assert.match(oversized.stderr, /^USAGE_ERROR: --size must be no greater than 4096\.\n$/); +}); diff --git a/test/sidc-kit.test.mjs b/test/sidc-kit.test.mjs index 1f9aabc..1187353 100644 --- a/test/sidc-kit.test.mjs +++ b/test/sidc-kit.test.mjs @@ -5,19 +5,100 @@ import { SidcKitError, buildSidc, explainSidc, + identifySymbol, renderSymbol, searchSymbols } from "../dist/index.js"; +import * as sidcKit from "../dist/index.js"; +import { curatedSymbols } from "../dist/data/catalog/index.js"; const infantryPlatoonSidc = "130310001412110000000000000000"; const infantryCompanySidc = "130310001512110000000000000000"; const armorPlatoonSidc = "130310001412050000000000000000"; const artilleryPlatoonSidc = "130310001413030000000000000000"; const reconnaissancePlatoonSidc = "130310001412130000000000000000"; +const friendlyAirFighterSidc = "130301000011010400000000000000"; +const friendlyAirRotaryWingSidc = "130301000011020000000000000000"; +const hostileAirUavSidc = "130601000011030000000000000000"; +const friendlySeaDestroyerSidc = "130330000012020300000000000000"; +const friendlySeaFrigateSidc = "130330000012020400000000000000"; +const neutralMerchantShipSidc = "130430000014010000000000000000"; +const hostileSubmarineSidc = "130635000011010000000000000000"; +const friendlyUuvSidc = "130335000011040000000000000000"; +const friendlyTankSidc = "130315000012020000000000000000"; +const friendlyApcSidc = "130315000012010300000000000000"; +const friendlyBaseSidc = "130320000012080200000000000000"; +const friendlyAirportSidc = "130320000012080300000000000000"; +const friendlyCheckpointSidc = "130325000013030000000000000000"; +const friendlyWaypointSidc = "130325000013180000000000000000"; const nonCuratedRenderableSidc = "130410001412110000000000000000"; +const neutralArtilleryPlatoonSidc = "130410001413030000000000000000"; +const neutralAirFighterSidc = "130401000011010400000000000000"; +const neutralSeaDestroyerSidc = "130430000012020300000000000000"; +const neutralSubmarineSidc = "130435000011010000000000000000"; +const neutralTankSidc = "130415000012020000000000000000"; +const neutralBaseSidc = "130420000012080200000000000000"; +const neutralCheckpointSidc = "130425000013030000000000000000"; +const nonCuratedUnlabeledStatusSidc = "130410101412110000000000000000"; +const unknownEntitySidc = "130310001400000000000000000000"; const unknownDimensionFallbackSidc = "000000000000000000000000000000"; const invalidIconFallbackSidc = "999999999999999999999999999999"; +function encodeBase64Utf8(value) { + const bytes = new TextEncoder().encode(value); + let binary = ""; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary); +} + +const expandedCatalogSidcs = [ + friendlyAirFighterSidc, + friendlyAirRotaryWingSidc, + hostileAirUavSidc, + friendlySeaDestroyerSidc, + friendlySeaFrigateSidc, + neutralMerchantShipSidc, + hostileSubmarineSidc, + friendlyUuvSidc, + friendlyTankSidc, + friendlyApcSidc, + friendlyBaseSidc, + friendlyAirportSidc, + friendlyCheckpointSidc, + friendlyWaypointSidc +]; + +function buildInputFor(parts) { + const input = { + affiliation: parts.affiliation, + domain: parts.domain, + entity: parts.entity + }; + + for (const key of ["entityType", "entitySubtype", "echelon"]) { + if (parts[key]) { + input[key] = parts[key]; + } + } + + return input; +} + +test("public runtime facade exposes the intended API", () => { + assert.deepEqual(Object.keys(sidcKit).sort(), [ + "SidcKitError", + "buildSidc", + "explainSidc", + "identifySymbol", + "renderSymbol", + "searchSymbols" + ]); +}); + test("renderSymbol returns SVG for a known SIDC", () => { const result = renderSymbol(infantryPlatoonSidc, { size: 40 }); @@ -36,6 +117,35 @@ test("renderSymbol returns SVG for a non-curated SIDC supported by milsymbol", ( assert.match(result.svg, /<\/svg>$/); }); +test("renderSymbol returns SVG for expanded curated SIDCs", () => { + for (const sidc of expandedCatalogSidcs) { + const result = renderSymbol(sidc, { size: 40 }); + + assert.equal(result.sidc, sidc); + assert.match(result.svg, /^$/); + } +}); + +test("all curated catalog entries round-trip through public APIs", () => { + for (const symbol of curatedSymbols) { + const rendered = renderSymbol(symbol.sidc, { size: 32 }); + assert.match(rendered.svg, /^$/); + + const explanation = explainSidc(symbol.sidc); + assert.equal(explanation.name, symbol.name); + assert.deepEqual(explanation.parts, symbol.parts); + + assert.ok( + searchSymbols(symbol.name, { limit: curatedSymbols.length }).some((result) => result.sidc === symbol.sidc), + `Expected searchSymbols to find ${symbol.name}` + ); + + assert.equal(buildSidc(buildInputFor(symbol.parts)), symbol.sidc); + } +}); + test("renderSymbol rejects unsupported 30-digit SIDCs instead of returning fallback SVG", () => { for (const sidc of [unknownDimensionFallbackSidc, invalidIconFallbackSidc]) { assert.throws( @@ -57,15 +167,163 @@ test("explainSidc returns expected curated parts", () => { assert.equal(result.parts.domain, "land"); assert.equal(result.parts.entity, "infantry"); assert.equal(result.parts.echelon, "platoon"); + assert.equal(result.fields.entity.code, "1211000000"); + assert.equal(result.fields.entity.coverage, "curated"); + assert.deepEqual(result.unknownFields, []); +}); + +test("explainSidc returns expanded curated parts", () => { + const merchantShip = explainSidc(neutralMerchantShipSidc); + assert.equal(merchantShip.coverage, "curated"); + assert.equal(merchantShip.name, "Neutral Sea Surface Merchant Ship"); + assert.equal(merchantShip.parts.affiliation, "neutral"); + assert.equal(merchantShip.parts.symbolSet, "sea surface"); + assert.equal(merchantShip.parts.domain, "sea surface"); + assert.equal(merchantShip.parts.entity, "merchant ship"); + assert.equal(merchantShip.parts.entityType, "general"); + + const checkpoint = explainSidc(friendlyCheckpointSidc); + assert.equal(checkpoint.name, "Friendly Control Measure Checkpoint"); + assert.equal(checkpoint.parts.symbolSet, "control measure"); + assert.equal(checkpoint.parts.domain, "control measure"); + assert.equal(checkpoint.parts.entity, "command and control point"); + assert.equal(checkpoint.parts.entityType, "checkpoint"); +}); + +test("explainSidc returns partial decomposition for non-curated renderable SIDCs", () => { + const result = explainSidc(nonCuratedRenderableSidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.name, undefined); + assert.deepEqual(result.aliases, []); + assert.equal(result.parts.affiliation, "neutral"); + assert.equal(result.parts.symbolSet, "land unit"); + assert.equal(result.parts.status, "present"); + assert.equal(result.parts.domain, "land"); + assert.equal(result.parts.entity, "infantry"); + assert.equal(result.parts.echelon, "platoon/detachment"); + assert.deepEqual(result.unknownFields, []); + assert.deepEqual(result.fields.affiliation, { + code: "4", + value: "neutral", + coverage: "known" + }); +}); + +test("explainSidc scopes partial entity labels by symbol set", () => { + const result = explainSidc(neutralArtilleryPlatoonSidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.parts.symbolSet, "land unit"); + assert.equal(result.parts.entity, "artillery"); + assert.equal(result.parts.entityType, undefined); + assert.equal(result.fields.entity.code, "1303000000"); + assert.equal(result.fields.entity.coverage, "known"); +}); + +test("explainSidc reuses catalog symbol-set labels for non-curated expanded variants", () => { + const cases = [ + [ + neutralAirFighterSidc, + { + symbolSet: "air", + domain: "air", + entity: "aircraft", + entityType: "fighter" + } + ], + [ + neutralSeaDestroyerSidc, + { + symbolSet: "sea surface", + domain: "sea surface", + entity: "surface combatant", + entityType: "destroyer" + } + ], + [ + neutralSubmarineSidc, + { + symbolSet: "sea subsurface", + domain: "sea subsurface", + entity: "submarine" + } + ], + [ + neutralTankSidc, + { + symbolSet: "land equipment", + domain: "land equipment", + entity: "tank" + } + ], + [ + neutralBaseSidc, + { + symbolSet: "land installation", + domain: "land installation", + entity: "military infrastructure", + entityType: "base" + } + ], + [ + neutralCheckpointSidc, + { + symbolSet: "control measure", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + } + ] + ]; + + for (const [sidc, expected] of cases) { + const result = explainSidc(sidc); + + assert.equal(result.coverage, "partial", sidc); + assert.equal(result.parts.affiliation, "neutral", sidc); + assert.equal(result.parts.symbolSet, expected.symbolSet, sidc); + assert.equal(result.parts.domain, expected.domain, sidc); + assert.equal(result.parts.entity, expected.entity, sidc); + assert.equal(result.parts.entityType, expected.entityType, sidc); + assert.equal(result.fields.entity.coverage, "known", sidc); + assert.ok(!result.unknownFields.includes("entity"), sidc); + } }); -test("explainSidc remains limited to curated SIDCs", () => { +test("explainSidc marks unlabeled non-present status unknown", () => { + const result = explainSidc(nonCuratedUnlabeledStatusSidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.parts.status, undefined); + assert.deepEqual(result.fields.status, { + code: "1", + coverage: "unknown" + }); + assert.deepEqual(result.unknownFields, ["status"]); +}); + +test("explainSidc rejects unsupported 30-digit SIDCs with a typed error", () => { assert.throws( - () => explainSidc(nonCuratedRenderableSidc), + () => explainSidc(unknownDimensionFallbackSidc), (error) => error instanceof SidcKitError && error.code === "UNSUPPORTED_SIDC" ); }); +test("explainSidc marks unknown partial fields instead of guessing", () => { + const result = explainSidc(unknownEntitySidc); + + assert.equal(result.coverage, "partial"); + assert.equal(result.parts.affiliation, "friend"); + assert.equal(result.parts.domain, "land"); + assert.equal(result.parts.entity, undefined); + assert.deepEqual(result.unknownFields, ["entity"]); + assert.deepEqual(result.fields.entity, { + code: "0000000000", + coverage: "unknown" + }); +}); + test("searchSymbols finds symbols by natural-language alias", () => { const results = searchSymbols("friendly infantry platoon"); @@ -74,6 +332,159 @@ test("searchSymbols finds symbols by natural-language alias", () => { assert.ok(results[0].score > 0); }); +test("searchSymbols finds expanded symbols by natural-language aliases", () => { + assert.equal(searchSymbols("enemy drone")[0]?.sidc, hostileAirUavSidc); + assert.equal(searchSymbols("surface combatant destroyer")[0]?.sidc, friendlySeaDestroyerSidc); + assert.equal(searchSymbols("military base")[0]?.sidc, friendlyBaseSidc); + assert.equal(searchSymbols("control measure waypoint")[0]?.sidc, friendlyWaypointSidc); +}); + +test("identifySymbol matches a clean curated SVG rendering", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const results = identifySymbol(svg, { size: 40 }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, infantryPlatoonSidc); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); + assert.equal(results[0].evidence.method, "normalized-svg"); +}); + +test("identifySymbol normalizes near-exact SVG input", () => { + const svg = renderSymbol(armorPlatoonSidc, { size: 40 }).svg; + const nearExactSvg = `\n\n${svg.replaceAll("><", ">\n <")}\n`; + const dataUrlSvg = `data:image/svg+xml;utf8,${encodeURIComponent(nearExactSvg)}`; + const results = identifySymbol(dataUrlSvg, { size: 40 }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, armorPlatoonSidc); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); +}); + +test("identifySymbol normalizes base64 SVG data URLs", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const exportedSvg = `\n${svg}`; + const dataUrlSvg = `data:image/svg+xml;charset=utf-8;base64,${encodeBase64Utf8(exportedSvg)}`; + const results = identifySymbol(dataUrlSvg, { size: 40 }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, infantryPlatoonSidc); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); +}); + +test("identifySymbol returns stable ranked candidates when a broad threshold is requested", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const results = identifySymbol(svg, { size: 40, minConfidence: 0.93, limit: 4 }); + + assert.deepEqual( + results.map((result) => result.sidc), + [infantryPlatoonSidc, reconnaissancePlatoonSidc, artilleryPlatoonSidc, armorPlatoonSidc] + ); + assert.equal(results[0].confidence, 1); + assert.equal(results[0].evidence.exact, true); + assert.ok(results[1].confidence < results[0].confidence); + assert.equal(results[1].evidence.exact, false); +}); + +test("identifySymbol applies minConfidence to raw similarity before rounding", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const nearMatchSvg = svg.replace('baseProfile="tiny"', 'baseProfile="Tiny"'); + const threshold = 0.998301; + const results = identifySymbol(nearMatchSvg, { size: 40, minConfidence: threshold }); + + assert.equal(results.length, 1); + assert.equal(results[0].sidc, infantryPlatoonSidc); + assert.ok(results[0].confidence >= threshold); + assert.equal(results[0].evidence.exact, false); +}); + +test("identifySymbol returns no candidates for non-milsymbol SVG input", () => { + const results = identifySymbol(''); + + assert.deepEqual(results, []); +}); + +test("identifySymbol rejects oversized SVG input before fuzzy comparison", () => { + const oversizedSvg = `${"x".repeat(10_001)}`; + + assert.deepEqual(identifySymbol(oversizedSvg, { minConfidence: 0 }), []); +}); + +test("identifySymbol skips expensive fuzzy scoring for large edited SVG input", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + const largeEditedSvg = svg.replace("", `${"x".repeat(4_500)}`); + + assert.ok(largeEditedSvg.length < 10_000); + assert.deepEqual(identifySymbol(largeEditedSvg, { minConfidence: 0 }), []); +}); + +test("identifySymbol caps near-limit fuzzy SVG comparison work", () => { + const nearLimitSvg = `${"x".repeat(3_900)}`; + + assert.ok(nearLimitSvg.length < 4_000); + assert.deepEqual(identifySymbol(nearLimitSvg, { minConfidence: 0 }), []); +}); + +test("identifySymbol returns no candidates when limit is zero", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + + assert.deepEqual(identifySymbol(svg, { limit: 0, minConfidence: 0 }), []); +}); + +test("identifySymbol rejects unsafe candidate render sizes before catalog matching", () => { + const svg = renderSymbol(infantryPlatoonSidc, { size: 40 }).svg; + + assert.deepEqual(identifySymbol(svg, { size: 4097, minConfidence: 0 }), []); + assert.deepEqual(identifySymbol(svg, { size: Number.POSITIVE_INFINITY, minConfidence: 0 }), []); + assert.deepEqual(identifySymbol(svg, { size: 0, minConfidence: 0 }), []); +}); + +test("searchSymbols weights exact names, aliases, and parts predictably", () => { + assert.equal(searchSymbols("Friendly Land Unit Infantry Platoon")[0]?.sidc, infantryPlatoonSidc); + assert.equal(searchSymbols("friend infantry company")[0]?.sidc, infantryCompanySidc); + assert.equal(searchSymbols("tank")[0]?.sidc, armorPlatoonSidc); +}); + +test("searchSymbols supports curated synonyms and abbreviations", () => { + const cases = [ + ["friendly inf company", infantryCompanySidc], + ["friendly arty platoon", artilleryPlatoonSidc], + ["friendly recon platoon", reconnaissancePlatoonSidc], + ["friendly armour platoon", armorPlatoonSidc], + ["tank platoon", armorPlatoonSidc] + ]; + + for (const [query, sidc] of cases) { + assert.equal(searchSymbols(query)[0]?.sidc, sidc, query); + } +}); + +test("searchSymbols returns no results for empty queries", () => { + assert.deepEqual(searchSymbols(""), []); + assert.deepEqual(searchSymbols(" \t\n"), []); +}); + +test("searchSymbols respects limits", () => { + assert.deepEqual(searchSymbols("infantry", { limit: 2 }).map((result) => result.sidc), [ + infantryPlatoonSidc, + infantryCompanySidc + ]); + assert.deepEqual(searchSymbols("infantry", { limit: 0 }), []); + assert.deepEqual(searchSymbols("infantry", { limit: -1 }), []); +}); + +test("searchSymbols uses catalog order as a stable tie-breaker", () => { + assert.deepEqual(searchSymbols("friendly land", { limit: 5 }).map((result) => result.sidc), [ + infantryPlatoonSidc, + infantryCompanySidc, + armorPlatoonSidc, + artilleryPlatoonSidc, + reconnaissancePlatoonSidc + ]); +}); + test("buildSidc creates the expected known SIDC from structured parts", () => { assert.equal( buildSidc({ @@ -86,6 +497,70 @@ test("buildSidc creates the expected known SIDC from structured parts", () => { ); }); +test("buildSidc creates expanded curated SIDCs from structured parts", () => { + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "air", + entity: "aircraft", + entityType: "fighter" + }), + friendlyAirFighterSidc + ); + assert.equal( + buildSidc({ + affiliation: "hostile", + domain: "air", + entity: "aircraft", + entityType: "unmanned aerial vehicle" + }), + hostileAirUavSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "sea surface", + entity: "surface combatant", + entityType: "frigate" + }), + friendlySeaFrigateSidc + ); + assert.equal( + buildSidc({ + affiliation: "hostile", + domain: "sea subsurface", + entity: "submarine" + }), + hostileSubmarineSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "land equipment", + entity: "tank" + }), + friendlyTankSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "land installation", + entity: "military infrastructure", + entityType: "airport" + }), + friendlyAirportSidc + ); + assert.equal( + buildSidc({ + affiliation: "friend", + domain: "control measure", + entity: "command and control point", + entityType: "checkpoint" + }), + friendlyCheckpointSidc + ); +}); + test("buildSidc uses the correct field positions for land-unit fixtures", () => { assert.equal( buildSidc({ diff --git a/tsconfig.json b/tsconfig.json index 5ea7fff..429ccbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "declaration": true, - "declarationMap": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "lib": ["ES2022"],