diff --git a/client/package.json b/client/package.json index 37f0b585..9d1330f3 100644 --- a/client/package.json +++ b/client/package.json @@ -46,6 +46,7 @@ "@types/node": "^18.16.1", "@types/sortablejs": "^1.15.0", "@types/svg-injector": "^0.0.29", + "ajv": "^8.12.0", "babelify": "^10.0.0", "browserify": "^17.0.0", "concurrently": "^7.6.0", diff --git a/client/public/databases/airbases/sinaimap.json b/client/public/databases/airbases/sinaimap.json index 87f5bae6..c1f87765 100644 --- a/client/public/databases/airbases/sinaimap.json +++ b/client/public/databases/airbases/sinaimap.json @@ -1037,7 +1037,7 @@ "01R": { "magHeading": "11", "Heading": "16", - "ILS": "109.7" + "ILS": "109.70" } }, { diff --git a/client/public/stylesheets/leaflet/leaflet.css b/client/public/stylesheets/leaflet/leaflet.css index 9ade8dc4..1981009f 100644 --- a/client/public/stylesheets/leaflet/leaflet.css +++ b/client/public/stylesheets/leaflet/leaflet.css @@ -60,11 +60,6 @@ padding: 0; } -.leaflet-container img.leaflet-tile { - /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ - mix-blend-mode: plus-lighter; -} - .leaflet-container.leaflet-touch-zoom { -ms-touch-action: pan-x pan-y; touch-action: pan-x pan-y; @@ -651,7 +646,7 @@ svg.leaflet-image-layer.leaflet-interactive path { } /* Printing */ - + @media print { /* Prevent printers from removing background-images of controls. */ .leaflet-control { diff --git a/client/src/olympusapp.ts b/client/src/olympusapp.ts index aa1d9d7e..0f38ebfb 100644 --- a/client/src/olympusapp.ts +++ b/client/src/olympusapp.ts @@ -18,6 +18,7 @@ import { Manager } from "./other/manager"; import { SVGInjector } from "@tanem/svg-injector"; import { ServerManager } from "./server/servermanager"; import { sha256 } from 'js-sha256'; +import Ajv from "ajv" import { BLUE_COMMANDER, FILL_SELECTED_RING, GAME_MASTER, HIDE_UNITS_SHORT_RANGE_RINGS, RED_COMMANDER, SHOW_UNITS_ACQUISITION_RINGS, SHOW_UNITS_ENGAGEMENT_RINGS, SHOW_UNIT_LABELS } from "./constants/constants"; import { aircraftDatabase } from "./unit/databases/aircraftdatabase"; @@ -27,6 +28,8 @@ import { navyUnitDatabase } from "./unit/databases/navyunitdatabase"; import { UnitListPanel } from "./panels/unitlistpanel"; import { ContextManager } from "./context/contextmanager"; import { Context } from "./context/context"; +import { AirDefenceUnitSpawnMenu } from "./controls/unitspawnmenu"; +import { AirbasesJSONSchemaValidator } from "./schemas/schema"; var VERSION = "{{OLYMPUS_VERSION_NUMBER}}"; @@ -193,6 +196,9 @@ export class OlympusApp { this.#unitsManager = new UnitsManager(); this.#weaponsManager = new WeaponsManager(); + /* Validate data */ + this.#validateData(); + // Toolbars this.getToolbarsManager().add("primaryToolbar", new PrimaryToolbar("primary-toolbar")) .add("commandModeToolbar", new CommandModeToolbar("command-mode-toolbar")); @@ -447,4 +453,28 @@ export class OlympusApp { img.addEventListener("load", () => { SVGInjector(img); }); }) } + + #validateData() { + + const airbasesValidator = new AirbasesJSONSchemaValidator(); + + /* + const validator = new Ajv(); + const schema = { + type: "object", + properties: { + foo: {type: "integer"}, + bar: {type: "string"}, + }, + required: ["foo"], + additionalProperties: false, + } + + const data = this.#getRunwayData(); + + const validate = validator.compile(schema); + const valid = validate(data); + if (!valid) console.log(validate.errors); + //*/ + } } \ No newline at end of file diff --git a/client/src/schemas/airbases.schema.json b/client/src/schemas/airbases.schema.json new file mode 100644 index 00000000..c43f57d3 --- /dev/null +++ b/client/src/schemas/airbases.schema.json @@ -0,0 +1,67 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "airfields": { + "type": "object", + "minProperties": 1, + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "elevation": { + "type": "string", + "pattern": "^(0|([1-9][0-9]*))?$" + }, + "ICAO": { + "type": "string" + }, + "runways": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "headings": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "ILS": { + "type": "string", + "pattern": "^(1[0-9]{1,2}\\.[0-9][05])?$" + }, + "magHeading": { + "type": "string", + "pattern": "^([0-2][0-9]{2})|(3(([0-5][0-9])|(60)))?$" + } + }, + "required": ["magHeading"] + } + } + }, + "minItems": 1 + }, + "length": { + "type": "string", + "pattern": "^[1-9][0-9]{3,4}$" + } + }, + "required": [ "headings", "length" ] + } + }, + "TACAN": { + "type": "string", + "pattern": "^([1-9][0-9]{1,2}X)?$" + } + }, + "required": [ "elevation", "runways" ] + } + } + } + }, + "required": ["airfields"] +} \ No newline at end of file diff --git a/client/src/schemas/importdata.schema.json b/client/src/schemas/importdata.schema.json new file mode 100644 index 00000000..bfb1943a --- /dev/null +++ b/client/src/schemas/importdata.schema.json @@ -0,0 +1,425 @@ +{ + "$defs": { + "coalitionName": { + "enum": [ + "blue", + "neutral", + "red" + ], + "type": "string" + }, + "lat": { + "maximum": 90, + "minimum": -90, + "type": "number" + }, + "lng": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "vec2": { + "additionalProperties": false, + "properties": { + "lat": { + "$ref": "#/$defs/lat" + }, + "lng": { + "$ref": "#/$defs/lng" + } + }, + "required": [ + "lat", + "lng" + ], + "type": "object" + }, + "vec3": { + "additionalProperties": false, + "properties": { + "alt": { + "type": "number" + }, + "lat": { + "$ref": "#/$defs/lat" + }, + "lng": { + "$ref": "#/$defs/lng" + } + }, + "required": [ + "alt", + "lat", + "lng" + ], + "type": "object" + } + }, + "patternProperties": { + ".*": { + "items": { + "additionalProperties": false, + "properties": { + "activePath": { + "items": { + "$ref": "#/$defs/vec3" + }, + "type": "array" + }, + "alive": { + "type": "boolean" + }, + "ammo": { + "items": { + "additionalProperties": false, + "properties": { + "category": { + "minimum": 0, + "type": "number" + }, + "guidance": { + "minimum": 0, + "type": "number" + }, + "missileCategory": { + "minimum": 0, + "type": "number" + }, + "name": { + "minLength": 3, + "type": "string" + }, + "quantity": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "quantity", + "name", + "guidance", + "category", + "missileCategory" + ], + "type": "object" + }, + "type": "array" + }, + "category": { + "type": "string" + }, + "categoryDisplayName": { + "type": "string" + }, + "coalition": { + "$ref": "#/$defs/coalitionName" + }, + "contacts": { + "type": "array" + }, + "controlled": { + "type": "boolean" + }, + "country": { + "type": "number" + }, + "desiredAltitude": { + "minimum": 0, + "type": "number" + }, + "desiredAltitudeType": { + "enum": [ + "AGL", + "ASL" + ], + "type": "string" + }, + "desiredSpeed": { + "minimum": 0, + "type": "number" + }, + "desiredSpeedType": { + "enum": [ + "CAS", + "GS" + ], + "type": "string" + }, + "emissionsCountermeasures": { + "enum": [ + "attac", + "defend", + "free", + "silent" + ], + "type": "string" + }, + "followRoads": { + "type": "boolean" + }, + "formationOffset": { + "additionalProperties": false, + "properties": { + "x": { + "minimum": 0, + "type": "number" + }, + "y": { + "minimum": 0, + "type": "number" + }, + "z": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "type": "object" + }, + "fuel": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "generalSettings": { + "additionalProperties": false, + "properties": { + "prohibitAA": { + "type": "boolean" + }, + "prohibitAfterburner": { + "type": "boolean" + }, + "prohibitAG": { + "type": "boolean" + }, + "prohibitAirWpn": { + "type": "boolean" + }, + "prohibitJettison": { + "type": "boolean" + } + }, + "required": [ + "prohibitAA", + "prohibitAfterburner", + "prohibitAG", + "prohibitAirWpn", + "prohibitJettison" + ], + "type": "object" + }, + "groupName": { + "type": "string" + }, + "hasTask": { + "type": "boolean" + }, + "heading": { + "type": "number" + }, + "health": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "horizontalVelocity": { + "minimum": 0, + "type": "number" + }, + "human": { + "type": "boolean" + }, + "ID": { + "type": "number" + }, + "isActiveAWACS": { + "type": "boolean" + }, + "isActiveTanker": { + "type": "boolean" + }, + "isLeader": { + "type": "boolean" + }, + "leaderID": { + "minimum": 0, + "type": "number" + }, + "name": { + "type": "string" + }, + "onOff": { + "type": "boolean" + }, + "operateAs": { + "$ref": "#/$defs/coalitionName" + }, + "position": { + "$ref": "#/$defs/vec3" + }, + "radio": { + "additionalProperties": false, + "properties": { + "callsign": { + "type": "number" + }, + "callsignNumber": { + "type": "number" + }, + "frequency": { + "type": "number" + } + }, + "required": [ + "frequency", + "callsign", + "callsignNumber" + ], + "type": "object" + }, + "reactionToThreat": { + "enum": [ + "evade", + "maneouvre", + "none", + "passive" + ], + "type": "string" + }, + "ROE": { + "enum": [ + "designated", + "free", + "hold", + "return" + ], + "type": "string" + }, + "shotsIntensity": { + "maximum": 3, + "minimum": 1, + "type": "number" + }, + "shotsScatter": { + "maximum": 3, + "minimum": 1, + "type": "number" + }, + "speed": { + "minimum": 0, + "type": "number" + }, + "state": { + "type": "string" + }, + "TACAN": { + "properties": { + "callsign": { + "type": "string" + }, + "channel": { + "minimum": 0, + "type": "number" + }, + "isOn": { + "type": "boolean" + }, + "XY": { + "enum": [ + "X", + "Y" + ], + "type": "string" + } + }, + "required": [ + "callsign", + "channel", + "isOn", + "XY" + ], + "type": "object" + }, + "targetID": { + "minimum": 0, + "type": "number" + }, + "targetPosition": { + "$ref": "#/$defs/vec2" + }, + "task": { + "type": "string" + }, + "track": { + "type": "number" + }, + "unitName": { + "type": "string" + }, + "verticalVelocity": { + "minimum": 0, + "type": "number" + } + }, + "type": "object", + "required": [ + "activePath", + "alive", + "ammo", + "category", + "categoryDisplayName", + "coalition", + "contacts", + "controlled", + "country", + "desiredAltitude", + "desiredAltitudeType", + "desiredSpeed", + "desiredSpeedType", + "emissionsCountermeasures", + "followRoads", + "formationOffset", + "fuel", + "generalSettings", + "groupName", + "hasTask", + "heading", + "health", + "horizontalVelocity", + "human", + "ID", + "isActiveAWACS", + "isActiveTanker", + "isLeader", + "leaderID", + "name", + "onOff", + "operateAs", + "position", + "radio", + "reactionToThreat", + "ROE", + "shotsIntensity", + "shotsScatter", + "speed", + "state", + "TACAN", + "targetID", + "targetPosition", + "task", + "track", + "unitName", + "verticalVelocity" + ] + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" +} \ No newline at end of file diff --git a/client/src/schemas/schema.ts b/client/src/schemas/schema.ts new file mode 100644 index 00000000..5ad4c527 --- /dev/null +++ b/client/src/schemas/schema.ts @@ -0,0 +1,82 @@ +import Ajv from "ajv"; +import { AnySchemaObject } from "ajv/dist/core"; + + +// For future extension +abstract class JSONSchemaValidator { + + #ajv:Ajv; + #compiledValidator:any; + #schema!:AnySchemaObject; + + constructor( schema:AnySchemaObject ) { + this.#schema = schema; + + this.#ajv = new Ajv({ + "allErrors": true + }); + + this.#compiledValidator = this.getAjv().compile(this.getSchema()); + + } + + getAjv() { + return this.#ajv; + } + + getCompiledValidator() { + return this.#compiledValidator; + } + + getErrors() { + return this.getCompiledValidator().errors; + } + + getSchema() { + return this.#schema; + } + + validate(data:any) { + return (this.getCompiledValidator())(data); + } + +} + + +export class AirbasesJSONSchemaValidator extends JSONSchemaValidator { + + constructor() { + + const schema = require("../schemas/airbases.schema.json"); + + super( schema ); + + [ + require( "../../public/databases/airbases/caucasus.json" ), + require( "../../public/databases/airbases/falklands.json" ), + require( "../../public/databases/airbases/marianas.json" ), + require( "../../public/databases/airbases/nevada.json" ), + require( "../../public/databases/airbases/normandy.json" ), + require( "../../public/databases/airbases/persiangulf.json" ), + require( "../../public/databases/airbases/sinaimap.json" ), + require( "../../public/databases/airbases/syria.json" ), + require( "../../public/databases/airbases/thechannel.json" ) + ].forEach( data => { + const validate = this.getAjv().compile(this.getSchema()); + const valid = validate(data); + + if (!valid) console.error(validate.errors); + }); + } + +} + + +export class ImportFileJSONSchemaValidator extends JSONSchemaValidator { + + constructor() { + const schema = require("../schemas/importdata.schema.json"); + super( schema ); + } + +} \ No newline at end of file diff --git a/client/src/unit/importexport/unitdatafileexport.ts b/client/src/unit/importexport/unitdatafileexport.ts index 1bdb0d22..2a4f7a82 100644 --- a/client/src/unit/importexport/unitdatafileexport.ts +++ b/client/src/unit/importexport/unitdatafileexport.ts @@ -25,6 +25,10 @@ export class UnitDataFileExport extends UnitDataFile { * Show the form to start the export journey */ showForm(units: Unit[]) { + this.dialog.getElement().querySelectorAll("[data-on-error]").forEach((el:Element) => { + el.classList.toggle("hide", el.getAttribute("data-on-error") === "show"); + }); + const data: any = {}; const unitCanBeExported = (unit: Unit) => !["Aircraft", "Helicopter"].includes(unit.getCategory()); diff --git a/client/src/unit/importexport/unitdatafileimport.ts b/client/src/unit/importexport/unitdatafileimport.ts index 5a0e2221..6cebeef9 100644 --- a/client/src/unit/importexport/unitdatafileimport.ts +++ b/client/src/unit/importexport/unitdatafileimport.ts @@ -1,6 +1,7 @@ import { getApp } from "../.."; import { Dialog } from "../../dialog/dialog"; import { UnitData } from "../../interfaces"; +import { ImportFileJSONSchemaValidator } from "../../schemas/schema"; import { UnitDataFile } from "./unitdatafile"; export class UnitDataFileImport extends UnitDataFile { @@ -48,21 +49,65 @@ export class UnitDataFileImport extends UnitDataFile { unitsManager.spawnUnits(category, unitsToSpawn, coalition, false); } + } - /* - for (let groupName in groups) { - if (groupName !== "" && groups[groupName].length > 0 && (groups[groupName].every((unit: UnitData) => { return unit.category == "GroundUnit"; }) || groups[groupName].every((unit: any) => { return unit.category == "NavyUnit"; }))) { - var aliveUnits = groups[groupName].filter((unit: UnitData) => { return unit.alive }); - var units = aliveUnits.map((unit: UnitData) => { - return { unitType: unit.name, location: unit.position, liveryID: "" } - }); - getApp().getUnitsManager().spawnUnits(groups[groupName][0].category, units, groups[groupName][0].coalition, true); + selectFile() { + var input = document.createElement("input"); + input.type = "file"; + input.addEventListener("change", (e: any) => { + var file = e.target.files[0]; + if (!file) { + return; } - } - //*/ + var reader = new FileReader(); + reader.onload = (e: any) => { + + try { + this.#fileData = JSON.parse(e.target.result); + + const validator = new ImportFileJSONSchemaValidator(); + if (!validator.validate(this.#fileData)) { + const errors = validator.getErrors().reduce((acc:any, error:any) => { + let errorString = error.instancePath.substring(1) + ": " + error.message; + if (error.params) { + const {allowedValues} = error.params; + if (allowedValues) + errorString += ": " + allowedValues.join(', '); + } + acc.push(errorString); + return acc; + }, [] as string[]); + this.#showFileDataErrors(errors); + } else { + this.#showForm(); + } + } catch(e:any) { + this.#showFileDataErrors([e]); + } + }; + reader.readAsText(file); + }) + input.click(); + } + + #showFileDataErrors( reasons:string[]) { + + this.dialog.getElement().querySelectorAll("[data-on-error]").forEach((el:Element) => { + el.classList.toggle("hide", el.getAttribute("data-on-error") === "hide"); + }); + + const reasonsList = this.dialog.getElement().querySelector(".import-error-reasons"); + if (reasonsList instanceof HTMLElement) + reasonsList.innerHTML = `
<%= textContent %>
- <% if (showFilenameInput) { %> -<%= textContent %>
+ + <% if (showFilenameInput) { %> +