From eb6981a687267faa85c67f1eecc35c351cc6a1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Sun, 4 Feb 2024 23:00:54 +0100 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20get=20vehicles=20and=20buildi?= =?UTF-8?q?ngs=20by=20dispatch=20center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stores/api.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/stores/api.ts b/src/stores/api.ts index 6226ecd47d..15c1ec0523 100644 --- a/src/stores/api.ts +++ b/src/stores/api.ts @@ -106,6 +106,31 @@ export const defineAPIStore = defineStore('api', { }); return buildings; }, + vehiclesByDispatchCenter: (state): Record => { + const dispatchCenters: Record = {}; + const buildingDispatchCache: Record = {}; + + const resolveDispatchId = (buildingId: number): number => { + const building = state.buildings.find( + building => building.id === buildingId + ); + + // we group buildings without dispatch center as -1 + return building?.leitstelle_building_id ?? -1; + }; + + state.vehicles.forEach(vehicle => { + const dispatchId = (buildingDispatchCache[ + vehicle.building_id + ] ??= resolveDispatchId(vehicle.building_id)); + + if (!dispatchCenters.hasOwnProperty(dispatchId)) + dispatchCenters[dispatchId] = []; + + dispatchCenters[dispatchId].push(vehicle); + }); + return dispatchCenters; + }, participatedMissions(state): number[] { return Array.from( new Set([ @@ -140,6 +165,17 @@ export const defineAPIStore = defineStore('api', { }); return types; }, + buildingsByDispatchCenter: (state): Record => { + const dispatchCenters: Record = {}; + state.buildings.forEach(building => { + const dispatchId = building.leitstelle_building_id ?? -1; + + if (!dispatchCenters.hasOwnProperty(dispatchId)) + dispatchCenters[dispatchId] = []; + dispatchCenters[dispatchId].push(building); + }); + return dispatchCenters; + }, buildingsByCategory() { const LSSM = window[PREFIX] as Vue; const categories = LSSM.$t( From 5c2a4e7b60e82b1a6760e75f06b56417dc9b8ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Sun, 4 Feb 2024 23:10:41 +0100 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20remove=20renameFz?= =?UTF-8?q?=20module=20leftovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintignore | 1 - src/modules/renameFz/README.md | 1 - src/modules/renameFz/docs/de_DE.md | 0 src/modules/renameFz/docs/en_US.md | 0 src/modules/renameFz/docs/fr_FR.md | 3 - src/modules/renameFz/docs/nl_NL.md | 3 - src/modules/renameFz/i18n/de_DE.json | 10 -- src/modules/renameFz/i18n/de_DE.root.json | 4 - src/modules/renameFz/i18n/en_AU.json | 10 -- src/modules/renameFz/i18n/en_AU.root.json | 4 - src/modules/renameFz/i18n/en_GB.json | 10 -- src/modules/renameFz/i18n/en_GB.root.json | 4 - src/modules/renameFz/i18n/en_US.json | 10 -- src/modules/renameFz/i18n/en_US.root.json | 4 - src/modules/renameFz/i18n/es_ES.json | 10 -- src/modules/renameFz/i18n/es_ES.root.json | 4 - src/modules/renameFz/i18n/fr_FR.json | 10 -- src/modules/renameFz/i18n/fr_FR.root.json | 4 - src/modules/renameFz/i18n/it_IT.json | 10 -- src/modules/renameFz/i18n/it_IT.root.json | 4 - src/modules/renameFz/i18n/nb_NO.json | 10 -- src/modules/renameFz/i18n/nb_NO.root.json | 4 - src/modules/renameFz/i18n/nl_NL.json | 10 -- src/modules/renameFz/i18n/nl_NL.root.json | 4 - src/modules/renameFz/i18n/pl_PL.json | 10 -- src/modules/renameFz/i18n/pl_PL.root.json | 4 - src/modules/renameFz/i18n/sv_SE.json | 10 -- src/modules/renameFz/i18n/sv_SE.root.json | 4 - src/modules/renameFz/main.js | 30 ------ src/modules/renameFz/register.json | 1 - src/modules/renameFz/renameFz.vue | 113 ---------------------- 31 files changed, 306 deletions(-) delete mode 100644 src/modules/renameFz/README.md delete mode 100644 src/modules/renameFz/docs/de_DE.md delete mode 100644 src/modules/renameFz/docs/en_US.md delete mode 100644 src/modules/renameFz/docs/fr_FR.md delete mode 100644 src/modules/renameFz/docs/nl_NL.md delete mode 100644 src/modules/renameFz/i18n/de_DE.json delete mode 100644 src/modules/renameFz/i18n/de_DE.root.json delete mode 100644 src/modules/renameFz/i18n/en_AU.json delete mode 100644 src/modules/renameFz/i18n/en_AU.root.json delete mode 100644 src/modules/renameFz/i18n/en_GB.json delete mode 100644 src/modules/renameFz/i18n/en_GB.root.json delete mode 100644 src/modules/renameFz/i18n/en_US.json delete mode 100644 src/modules/renameFz/i18n/en_US.root.json delete mode 100644 src/modules/renameFz/i18n/es_ES.json delete mode 100644 src/modules/renameFz/i18n/es_ES.root.json delete mode 100644 src/modules/renameFz/i18n/fr_FR.json delete mode 100644 src/modules/renameFz/i18n/fr_FR.root.json delete mode 100644 src/modules/renameFz/i18n/it_IT.json delete mode 100644 src/modules/renameFz/i18n/it_IT.root.json delete mode 100644 src/modules/renameFz/i18n/nb_NO.json delete mode 100644 src/modules/renameFz/i18n/nb_NO.root.json delete mode 100644 src/modules/renameFz/i18n/nl_NL.json delete mode 100644 src/modules/renameFz/i18n/nl_NL.root.json delete mode 100644 src/modules/renameFz/i18n/pl_PL.json delete mode 100644 src/modules/renameFz/i18n/pl_PL.root.json delete mode 100644 src/modules/renameFz/i18n/sv_SE.json delete mode 100644 src/modules/renameFz/i18n/sv_SE.root.json delete mode 100644 src/modules/renameFz/main.js delete mode 100644 src/modules/renameFz/register.json delete mode 100644 src/modules/renameFz/renameFz.vue diff --git a/.eslintignore b/.eslintignore index 51101d922f..0aadd73479 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,4 +15,3 @@ src/config.js static/fontawesome_*.min.js src/modules/support/* -src/modules/renameFz/* diff --git a/src/modules/renameFz/README.md b/src/modules/renameFz/README.md deleted file mode 100644 index 39cc8391e2..0000000000 --- a/src/modules/renameFz/README.md +++ /dev/null @@ -1 +0,0 @@ -Provide Bulk-renaming of all vehicles in a station or a dispatch center by a template. diff --git a/src/modules/renameFz/docs/de_DE.md b/src/modules/renameFz/docs/de_DE.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/modules/renameFz/docs/en_US.md b/src/modules/renameFz/docs/en_US.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/modules/renameFz/docs/fr_FR.md b/src/modules/renameFz/docs/fr_FR.md deleted file mode 100644 index ef6ee46c5b..0000000000 --- a/src/modules/renameFz/docs/fr_FR.md +++ /dev/null @@ -1,3 +0,0 @@ -:::danger Renommer les véhicules -Ce module est encore en cours de développement et n'est donc pas encore disponible ! -::: diff --git a/src/modules/renameFz/docs/nl_NL.md b/src/modules/renameFz/docs/nl_NL.md deleted file mode 100644 index 12f8567ab9..0000000000 --- a/src/modules/renameFz/docs/nl_NL.md +++ /dev/null @@ -1,3 +0,0 @@ -:::danger Hernoem voertuigen -Deze module is nog in ontwikkeling en daarom nog niet beschikbaar! -::: diff --git a/src/modules/renameFz/i18n/de_DE.json b/src/modules/renameFz/i18n/de_DE.json deleted file mode 100644 index a89be23530..0000000000 --- a/src/modules/renameFz/i18n/de_DE.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Template wählen...", - "selectionHint": "Neues Template anlegen? Einfach den Namen eingeben und auswählen!", - "status": { - "template": "Warte auf Template-Auswahl…", - "waiting": "Warte auf Eingabe…" - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/de_DE.root.json b/src/modules/renameFz/i18n/de_DE.root.json deleted file mode 100644 index f16d60924e..0000000000 --- a/src/modules/renameFz/i18n/de_DE.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Benenne viele Fahrzeuge auf einmal nach deinem System!", - "name": "Fahrzeuge umbenennen" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/en_AU.json b/src/modules/renameFz/i18n/en_AU.json deleted file mode 100644 index a406b651e6..0000000000 --- a/src/modules/renameFz/i18n/en_AU.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Select template...", - "selectionHint": "Create a new template? Just enter the name and select!", - "status": { - "template": "Wait for template selection...", - "waiting": "Waiting for input..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/en_AU.root.json b/src/modules/renameFz/i18n/en_AU.root.json deleted file mode 100644 index cb3265b8ab..0000000000 --- a/src/modules/renameFz/i18n/en_AU.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Name many vehicles at once according to your system!", - "name": "Rename vehicles" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/en_GB.json b/src/modules/renameFz/i18n/en_GB.json deleted file mode 100644 index a406b651e6..0000000000 --- a/src/modules/renameFz/i18n/en_GB.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Select template...", - "selectionHint": "Create a new template? Just enter the name and select!", - "status": { - "template": "Wait for template selection...", - "waiting": "Waiting for input..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/en_GB.root.json b/src/modules/renameFz/i18n/en_GB.root.json deleted file mode 100644 index cb3265b8ab..0000000000 --- a/src/modules/renameFz/i18n/en_GB.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Name many vehicles at once according to your system!", - "name": "Rename vehicles" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/en_US.json b/src/modules/renameFz/i18n/en_US.json deleted file mode 100644 index a406b651e6..0000000000 --- a/src/modules/renameFz/i18n/en_US.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Select template...", - "selectionHint": "Create a new template? Just enter the name and select!", - "status": { - "template": "Wait for template selection...", - "waiting": "Waiting for input..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/en_US.root.json b/src/modules/renameFz/i18n/en_US.root.json deleted file mode 100644 index cb3265b8ab..0000000000 --- a/src/modules/renameFz/i18n/en_US.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Name many vehicles at once according to your system!", - "name": "Rename vehicles" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/es_ES.json b/src/modules/renameFz/i18n/es_ES.json deleted file mode 100644 index 55261c56e1..0000000000 --- a/src/modules/renameFz/i18n/es_ES.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Seleccionar plantilla...", - "selectionHint": "¿Crear una nueva plantilla? ¡Simplemente ingrese el nombre y seleccione!", - "status": { - "template": "Espere la selección de la plantilla ...", - "waiting": "Esperando entrada ..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/es_ES.root.json b/src/modules/renameFz/i18n/es_ES.root.json deleted file mode 100644 index e1772b7d2e..0000000000 --- a/src/modules/renameFz/i18n/es_ES.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "¡Nombra muchos vehículos a la vez de acuerdo con tu sistema!", - "name": "Renombrar a los vehículos" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/fr_FR.json b/src/modules/renameFz/i18n/fr_FR.json deleted file mode 100644 index 0349f5ae5e..0000000000 --- a/src/modules/renameFz/i18n/fr_FR.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Choisissez un modèle...", - "selectionHint": "Créer un nouveau modèle ? Entrez un nom et selectionnez !", - "status": { - "template": "Attendez la séléction du modèle...", - "waiting": "En attente..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/fr_FR.root.json b/src/modules/renameFz/i18n/fr_FR.root.json deleted file mode 100644 index 5cc9f52c80..0000000000 --- a/src/modules/renameFz/i18n/fr_FR.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Renommer un groupe de véhicules en une seule fois", - "name": "Renommer les véhicules" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/it_IT.json b/src/modules/renameFz/i18n/it_IT.json deleted file mode 100644 index 320fac17ab..0000000000 --- a/src/modules/renameFz/i18n/it_IT.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Seleziona il template ...", - "selectionHint": "Creare un nuovo template? Basta inserire il nome e selezionare!", - "status": { - "template": "Aspetta per la selezione del template...", - "waiting": "Attendi l'input..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/it_IT.root.json b/src/modules/renameFz/i18n/it_IT.root.json deleted file mode 100644 index a047b63665..0000000000 --- a/src/modules/renameFz/i18n/it_IT.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Assegna un nome a molti veicoli contemporaneamente in base al tuo sistema!", - "name": "Rinomina i veicoli" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/nb_NO.json b/src/modules/renameFz/i18n/nb_NO.json deleted file mode 100644 index ffa5ec8f9e..0000000000 --- a/src/modules/renameFz/i18n/nb_NO.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Velg mal...", - "selectionHint": "Opprette en ny mal? Bare skriv inn navnet og velg!", - "status": { - "template": "Vent på malvalg...", - "waiting": "Vent på innspill..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/nb_NO.root.json b/src/modules/renameFz/i18n/nb_NO.root.json deleted file mode 100644 index 7e5d12cd2e..0000000000 --- a/src/modules/renameFz/i18n/nb_NO.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Navngi mange kjøretøy samtidig i henhold til systemet ditt!", - "name": "Gi nytt navn til kjøretøy" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/nl_NL.json b/src/modules/renameFz/i18n/nl_NL.json deleted file mode 100644 index 8e86ae6d29..0000000000 --- a/src/modules/renameFz/i18n/nl_NL.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Selecteer template...", - "selectionHint": "Creëer een nieuwe template? Voer de naam in en selecteer!", - "status": { - "template": "Wacht op template selectie...", - "waiting": "Wacht op invoer..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/nl_NL.root.json b/src/modules/renameFz/i18n/nl_NL.root.json deleted file mode 100644 index 352016848f..0000000000 --- a/src/modules/renameFz/i18n/nl_NL.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Benoem vele voertuigen tegelijk volgens uw systeem!", - "name": "Hernoem voertuigen" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/pl_PL.json b/src/modules/renameFz/i18n/pl_PL.json deleted file mode 100644 index d5ddc20658..0000000000 --- a/src/modules/renameFz/i18n/pl_PL.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Wybierz szablon...", - "selectionHint": "Utworzyć nowy szablon? Wystarczy wpisać nazwę i wybrać!", - "status": { - "template": "Poczekaj na wybór szablonu...", - "waiting": "Czekam na dane wejściowe..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/pl_PL.root.json b/src/modules/renameFz/i18n/pl_PL.root.json deleted file mode 100644 index 31a588c7fd..0000000000 --- a/src/modules/renameFz/i18n/pl_PL.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Nazwij wiele pojazdów jednocześnie zgodnie z Twoim systemem!", - "name": "Zmień nazwy pojazdów" -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/sv_SE.json b/src/modules/renameFz/i18n/sv_SE.json deleted file mode 100644 index 86f70627fd..0000000000 --- a/src/modules/renameFz/i18n/sv_SE.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "templates": { - "selection": "Välj mall ...", - "selectionHint": "Skapa en ny mall? Ange bara namnet och välj!", - "status": { - "template": "Vänta på mallval ...", - "waiting": "Väntar på inmatning ..." - } - } -} \ No newline at end of file diff --git a/src/modules/renameFz/i18n/sv_SE.root.json b/src/modules/renameFz/i18n/sv_SE.root.json deleted file mode 100644 index 05dba9a926..0000000000 --- a/src/modules/renameFz/i18n/sv_SE.root.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "description": "Namnge många fordon samtidigt enligt ditt system!", - "name": "Byt namn på fordon" -} \ No newline at end of file diff --git a/src/modules/renameFz/main.js b/src/modules/renameFz/main.js deleted file mode 100644 index 8c21dc4d0f..0000000000 --- a/src/modules/renameFz/main.js +++ /dev/null @@ -1,30 +0,0 @@ -import renameFz from './renameFz.vue'; - -const mount = () => { - const vehicleTable = document.querySelector('#vehicle_table'); - const clear = document.createElement('div'); - clear.classList.add('clear'); - vehicleTable.parentNode.insertBefore(clear, vehicleTable); - new window.lssmv4.Vue({ - store: window.lssmv4.$store, - i18n: window.lssmv4.$i18n, - render: h => h(renameFz), - }).$mount(clear); -}; - -if (!document.querySelector('img.online_icon')) { - if (document.getElementById('tab_vehicle')) { - const observer = new MutationObserver(mutations => { - mutations.forEach(record => { - Array.from(record.addedNodes).find( - node => node.tagName === 'SCRIPT' - ) && mount(); - }); - }); - observer.observe(document.querySelector('#tab_vehicle'), { - childList: true, - }); - } else { - mount(); - } -} diff --git a/src/modules/renameFz/register.json b/src/modules/renameFz/register.json deleted file mode 100644 index 707d4189c1..0000000000 --- a/src/modules/renameFz/register.json +++ /dev/null @@ -1 +0,0 @@ -{ "dev": true, "github": 26, "location": "^/buildings/\\d+$", "noapp": true } \ No newline at end of file diff --git a/src/modules/renameFz/renameFz.vue b/src/modules/renameFz/renameFz.vue deleted file mode 100644 index 376aaafa5c..0000000000 --- a/src/modules/renameFz/renameFz.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - From 73e54c9faac961147ca43eefa16bf972e165969a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Sun, 4 Feb 2024 23:17:05 +0100 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=93=9D=20Update=20v3=20comparison?= =?UTF-8?q?=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vuepress/utils/v3Comparison.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/.vuepress/utils/v3Comparison.json b/docs/.vuepress/utils/v3Comparison.json index 4d5edc3ce0..558ccc149d 100644 --- a/docs/.vuepress/utils/v3Comparison.json +++ b/docs/.vuepress/utils/v3Comparison.json @@ -39,6 +39,7 @@ "overview": { "module": "overview" }, "Redesign01": { "module": "redesign" }, "releaseNotes": { "module": "" }, + "RenameFz": { "module": "nameSchema" }, "ShareAlliancePost": { "module": "shareAlliancePost" }, "showChatButtonAbove": { "module": "chatExtras", @@ -65,7 +66,6 @@ "Layout02", "Layout03", "Layout04", - "RenameFZ", "WachenplanungOnMap", "fms7Target", "geoBorders", From b8f2200171f43bdc9c14934fd87299d2a3a53c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurens=20St=C3=B6tzel?= Date: Sun, 4 Feb 2024 23:18:09 +0100 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=8E=89=20nameSchema=20module=20for?= =?UTF-8?q?=20template-based=20renaming=20of=20vehicles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/nameSchema/README.md | 3 + .../nameSchema/assets/helper/exclusion.ts | 39 ++ .../nameSchema/assets/helper/numeral.ts | 138 +++++++ .../nameSchema/assets/helper/rename.ts | 56 +++ .../nameSchema/assets/helper/template.ts | 310 +++++++++++++++ src/modules/nameSchema/assets/helper/types.ts | 13 + src/modules/nameSchema/assets/pom/base.ts | 9 + .../nameSchema/assets/pom/building_show.ts | 352 ++++++++++++++++++ .../nameSchema/assets/pom/vehicle_base.ts | 34 ++ .../nameSchema/assets/pom/vehicle_edit.ts | 80 ++++ .../nameSchema/assets/pom/vehicle_show.ts | 81 ++++ src/modules/nameSchema/docs/de_DE.md | 65 ++++ src/modules/nameSchema/i18n/de_DE.root.json | 52 +++ src/modules/nameSchema/main.ts | 56 +++ src/modules/nameSchema/register.json | 1 + src/modules/nameSchema/settings.ts | 206 ++++++++++ 16 files changed, 1495 insertions(+) create mode 100644 src/modules/nameSchema/README.md create mode 100644 src/modules/nameSchema/assets/helper/exclusion.ts create mode 100644 src/modules/nameSchema/assets/helper/numeral.ts create mode 100644 src/modules/nameSchema/assets/helper/rename.ts create mode 100644 src/modules/nameSchema/assets/helper/template.ts create mode 100644 src/modules/nameSchema/assets/helper/types.ts create mode 100644 src/modules/nameSchema/assets/pom/base.ts create mode 100644 src/modules/nameSchema/assets/pom/building_show.ts create mode 100644 src/modules/nameSchema/assets/pom/vehicle_base.ts create mode 100644 src/modules/nameSchema/assets/pom/vehicle_edit.ts create mode 100644 src/modules/nameSchema/assets/pom/vehicle_show.ts create mode 100644 src/modules/nameSchema/docs/de_DE.md create mode 100644 src/modules/nameSchema/i18n/de_DE.root.json create mode 100644 src/modules/nameSchema/main.ts create mode 100644 src/modules/nameSchema/register.json create mode 100644 src/modules/nameSchema/settings.ts diff --git a/src/modules/nameSchema/README.md b/src/modules/nameSchema/README.md new file mode 100644 index 0000000000..cde38bd64b --- /dev/null +++ b/src/modules/nameSchema/README.md @@ -0,0 +1,3 @@ +# Name Schema + +Provides bulk-renaming of units and buildings. diff --git a/src/modules/nameSchema/assets/helper/exclusion.ts b/src/modules/nameSchema/assets/helper/exclusion.ts new file mode 100644 index 0000000000..7c215656f6 --- /dev/null +++ b/src/modules/nameSchema/assets/helper/exclusion.ts @@ -0,0 +1,39 @@ +import type { ModuleMainFunction } from 'typings/Module'; + +export default class ExclusionHelper { + private excludedBuildings: string[] = []; + private excludedUnits: string[] = []; + + constructor( + private readonly moduleParams: Parameters[0] + ) {} + + public async init() { + const { + MODULE_ID: moduleId, + LSSM: { + $stores: { settings }, + }, + } = this.moduleParams; + + this.excludedBuildings = await settings.getSetting({ + moduleId, + settingId: 'excludeBuildings', + defaultValue: [], + }); + + this.excludedUnits = await settings.getSetting({ + moduleId, + settingId: 'excludeUnits', + defaultValue: [], + }); + } + + public isBuildingTypeExcluded(typeId: number) { + return this.excludedBuildings.some(id => Number(id) === typeId); + } + + public isVehicleTypeExcluded(typeId: number) { + return this.excludedUnits.some(id => Number(id) === typeId); + } +} diff --git a/src/modules/nameSchema/assets/helper/numeral.ts b/src/modules/nameSchema/assets/helper/numeral.ts new file mode 100644 index 0000000000..4cce235d95 --- /dev/null +++ b/src/modules/nameSchema/assets/helper/numeral.ts @@ -0,0 +1,138 @@ +/** + * Convert a number to a roman numeral. + * @param num - Number to convert. + * @returns Roman numeral. + * @see https://stackoverflow.com/a/41358305 + */ +export const convertNumberToRoman = (num: number): string => { + const roman: Record = { + M: 1000, + CM: 900, + D: 500, + CD: 400, + C: 100, + XC: 90, + L: 50, + XL: 40, + X: 10, + IX: 9, + V: 5, + IV: 4, + I: 1, + }; + let str = ''; + let rest = num; + + for (const i of Object.keys(roman)) { + const q = Math.floor(rest / roman[i]); + rest -= q * roman[i]; + str += i.repeat(q); + } + + return str; +}; + +/** + * Convert a number to a string of letters. + * @param num - Number to convert. + * @returns String of letters. + */ +export const convertNumberToAlpha = (num: number): string => { + let str = ''; + let rest = num; + + do { + const q = rest % 26; + rest = Math.floor(rest / 26); + str = String.fromCharCode(65 + q) + str; + } while (rest > 0); + + return str; +}; + +/** + * Convert a number to ICAO alphabet word. + * @param num - Number to convert. + * @returns ICAO alphabet word. + */ +export const convertNumberToICAOAlpha = (num: number): string => { + const alphabet = [ + 'Alpha', + 'Bravo', + 'Charlie', + 'Delta', + 'Echo', + 'Foxtrot', + 'Golf', + 'Hotel', + 'India', + 'Juliett', + 'Kilo', + 'Lima', + 'Mike', + 'November', + 'Oscar', + 'Papa', + 'Quebec', + 'Romeo', + 'Sierra', + 'Tango', + 'Uniform', + 'Victor', + 'Whiskey', + 'X-ray', + 'Yankee', + 'Zulu', + ]; + return alphabet[num % alphabet.length]; +}; + +/** + * Convert a number to a greek letter. + * @param num - Number to convert. + * @returns Greek letter. + */ +export const convertNumberToGreek = (num: number): string => { + const alphabet = [ + 'Alpha', + 'Beta', + 'Gamma', + 'Delta', + 'Epsilon', + 'Zeta', + 'Eta', + 'Theta', + 'Iota', + 'Kappa', + 'Lambda', + 'My', + 'Ny', + 'Xi', + 'Omikron', + 'Pi', + 'Rho', + 'Sigma', + 'Tau', + 'Ypsilon', + 'Phi', + 'Chi', + 'Psi', + 'Omega', + ]; + return alphabet[num % alphabet.length]; +}; + +/** + * Convert a string number to a string of emoji digits. + * @param string - String number to convert. + * @returns String of emoji digits. + */ +export const convertStringNumberToEmoji = (string: string): string => { + const emojiNum = (digit: number) => + `${String.fromCodePoint(48 + (digit % 10))}\ufe0f\u20e3`; + + let str = ''; + for (const element of string) str += emojiNum(Number(element)); + + return str; +}; diff --git a/src/modules/nameSchema/assets/helper/rename.ts b/src/modules/nameSchema/assets/helper/rename.ts new file mode 100644 index 0000000000..3145eb69eb --- /dev/null +++ b/src/modules/nameSchema/assets/helper/rename.ts @@ -0,0 +1,56 @@ +import type { ModuleMainFunction } from 'typings/Module'; + +export default class RenameHelper { + constructor( + private readonly moduleParams: Parameters[0] + ) {} + + public async renameVehicle(vehicleId: number, newCaption: string) { + const { LSSM, MODULE_ID } = this.moduleParams; + const url = new URL(`/vehicles/${vehicleId}`, window.location.origin); + + url.searchParams.append('_method', 'put'); + url.searchParams.append('utf8', '✓'); + url.searchParams.append( + 'authenticity_token', + document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content') || '' + ); + url.searchParams.append('vehicle[caption]', newCaption); + + try { + await LSSM.$stores.api.request({ + url: `/vehicles/${vehicleId}`, + feature: `${MODULE_ID}-rename-vehicle`, + dialogOnError: false, + init: { + redirect: 'manual', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Upgrade-Insecure-Requests': '1', + }, + referrer: window.location.href, + body: url.searchParams.toString(), + method: 'POST', + mode: 'cors', + }, + }); + + return { + newCaption, + }; + } catch (e: unknown) { + if (e instanceof Response && e.type === 'opaqueredirect') { + // expected redirect + return { + newCaption, + }; + } + + // rethrow error + throw e; + } + } +} diff --git a/src/modules/nameSchema/assets/helper/template.ts b/src/modules/nameSchema/assets/helper/template.ts new file mode 100644 index 0000000000..3d336e1cc2 --- /dev/null +++ b/src/modules/nameSchema/assets/helper/template.ts @@ -0,0 +1,310 @@ +import { + convertNumberToAlpha, + convertNumberToGreek, + convertNumberToICAOAlpha, + convertNumberToRoman, + convertStringNumberToEmoji, +} from './numeral'; + +import type { Building } from 'typings/Building'; +import type { defineAPIStore } from '@stores/api'; +import type { ModuleMainFunction } from 'typings/Module'; +import type { Vehicle } from 'typings/Vehicle'; +import type { + AliasedBuilding, + AliasedInternalBuilding, + AliasedInternalVehicle, + AliasedVehicle, +} from './types'; + +const unitIndexMatcher = + /\{\{\s*unitIndex(?::(?\d+)?(?::(?\d+)?(?::(?(?:alpha|arabic|emoji|greek|icao|roman)(?:-(?:lower|upper)(?:case)?)?)?(?::(?building|buildingUnitType|dispatch|dispatchUnitType|none|unitType)?)?)?)?)?\s*\}\}/gu; + +export default class TemplateHelper { + private defaultUnitTemplate: string = ''; + private buildingAliases: AliasedInternalBuilding[] = []; + private unitTypeAliases: AliasedInternalVehicle[] = []; + private unitTypeTemplates: { + enabled: boolean; + value: { type: string; template: string }[]; + } = { enabled: false, value: [] }; + + constructor( + private readonly moduleParameters: Parameters[0] + ) {} + + public async init() { + const { LSSM, MODULE_ID } = this.moduleParameters; + + // get the default unit template + this.defaultUnitTemplate = + await LSSM.$stores.settings.getSetting({ + moduleId: MODULE_ID, + settingId: 'defaultUnitTemplate', + defaultValue: '', + }); + + const buildingAliasesSetting = await LSSM.$stores.settings.getSetting<{ + enabled: boolean; + value: { type: string; alias: string }[]; + }>({ + moduleId: MODULE_ID, + settingId: 'buildingAliases', + defaultValue: { + enabled: false, + value: [], + }, + }); + + // construct alias tables by loading the default aliases and replacing them with the user-defined aliases + this.buildingAliases = Object.entries( + LSSM.$stores.translations.buildings + ).map(([id, type]) => { + let alias = type.caption; + + if (buildingAliasesSetting.enabled) { + alias = + buildingAliasesSetting.value.find(({ type: t }) => t === id) + ?.alias ?? type.caption; + } + + return { + id: Number(id), + ...type, + alias, + }; + }); + + const unitAliasesSetting = await LSSM.$stores.settings.getSetting<{ + enabled: boolean; + value: { type: string; alias: string }[]; + }>({ + moduleId: MODULE_ID, + settingId: 'unitAliases', + defaultValue: { + enabled: false, + value: [], + }, + }); + + this.unitTypeAliases = Object.entries( + LSSM.$stores.translations.vehicles + ).map(([id, type]) => { + let alias = type.caption; + + if (unitAliasesSetting.enabled) { + alias = + unitAliasesSetting.value.find(({ type: t }) => t === id) + ?.alias ?? type.caption; + } + + return { + id: Number(id), + ...type, + alias, + }; + }); + + // get the user-defined unit-type templates + this.unitTypeTemplates = await LSSM.$stores.settings.getSetting<{ + enabled: boolean; + value: { type: string; template: string }[]; + }>({ + moduleId: MODULE_ID, + settingId: 'unitTemplates', + defaultValue: { + enabled: false, + value: [], + }, + }); + } + + public getNewUnitName(building: Building, vehicle: Vehicle) { + // get the specific template for this unit type or use the default template + let unitTemplate = this.defaultUnitTemplate; + if (this.unitTypeTemplates.enabled) { + unitTemplate = + this.unitTypeTemplates.value.find( + ({ type }) => Number(type) === vehicle.vehicle_type + )?.template ?? this.defaultUnitTemplate; + } + + const buildingAlias = this.buildingAliases.find( + a => a.id === building.building_type + ); + const unitAlias = this.unitTypeAliases.find( + a => a.id === vehicle.vehicle_type + ); + + return this.fillTemplate(unitTemplate, { + building: { + ...building, + alias: + buildingAlias?.alias ?? + buildingAlias?.caption ?? + building.caption, + } as AliasedBuilding, + vehicle: { + ...vehicle, + alias: + unitAlias?.alias ?? unitAlias?.caption ?? vehicle.caption, + } as AliasedVehicle, + }); + } + + private fillTemplate( + template: string, + { + building, + vehicle, + }: { building: AliasedBuilding; vehicle?: AliasedVehicle } + ): string { + const { LSSM } = this.moduleParameters; + let result = template; + + const replacementVariables: Map< + RegExp | string, + string | ((substring: string) => string) + > = new Map(); + + // construct the replacement variables + replacementVariables.set('buildingId', String(building.id)); + replacementVariables.set( + 'buildingType', + LSSM.$stores.translations.buildings[building.building_type].caption + ); + replacementVariables.set('buildingAlias', building.alias); + + // if a vehicle is given, add the vehicle-specific replacement variables + if (vehicle) { + replacementVariables.set('buildingCaption', building.caption); + + replacementVariables.set('unitId', String(vehicle.id)); + replacementVariables.set( + 'unitType', + LSSM.$stores.translations.vehicles[vehicle.vehicle_type].caption + ); + replacementVariables.set( + 'unitTypeCustom', + vehicle.vehicle_type_caption ?? '' + ); + replacementVariables.set('unitAlias', vehicle.alias); + replacementVariables.set(unitIndexMatcher, matchedString => { + const match = unitIndexMatcher.exec(matchedString); + // reset regex + unitIndexMatcher.lastIndex = 0; + + return this.numberUnit(building, vehicle, { + padding: Number(match?.groups?.padding ?? 0), + start: Number(match?.groups?.start ?? 1), + system: match?.groups?.system ?? 'arabic', + group: match?.groups?.group ?? 'building', + }); + }); + } + + replacementVariables.forEach((replacement, key) => { + const search = + typeof key === 'string' + ? new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'gu') + : key; + + // typescript type quirk + if (typeof replacement === 'function') + result = result.replace(search, replacement); + else result = result.replace(search, replacement); + }); + + return result; + } + + private numberUnit( + building: AliasedBuilding, + vehicle: Vehicle, + params: { + padding: number; + system: string; + start: number; + group: string; + } + ): string { + const api: ReturnType = + this.moduleParameters.LSSM.$stores.api; + + let vehiclesInGroup: Vehicle[] = []; + + if (params.group === 'building') { + vehiclesInGroup = api.vehiclesByBuilding[building.id]; + } else if ( + params.group === 'dispatch' && + building.leitstelle_building_id + ) { + vehiclesInGroup = + api.vehiclesByDispatchCenter[building.leitstelle_building_id]; + } else if ( + params.group === 'dispatchUnitType' && + building.leitstelle_building_id + ) { + vehiclesInGroup = api.vehiclesByDispatchCenter[ + building.leitstelle_building_id + ].filter(v => v.vehicle_type === vehicle.vehicle_type); + } else if (params.group === 'unitType') { + vehiclesInGroup = api.vehicles.filter( + v => v.vehicle_type === vehicle.vehicle_type + ); + } else if (params.group === 'buildingUnitType') { + vehiclesInGroup = api.vehiclesByBuilding[building.id].filter( + v => v.vehicle_type === vehicle.vehicle_type + ); + } else if (params.group === 'none') { + vehiclesInGroup = api.vehicles; + } + + // sort ascending by id to have a stable order + vehiclesInGroup.sort((a, b) => a.id - b.id); + + const vehicleIndex = + vehiclesInGroup.findIndex(v => v.id === vehicle.id) + + params.start - + 1; + + const modifier = params.system.match(/^-(lower|upper)(?:case)?$/u)?.[1]; + const modifierFn = (str: string) => { + if (modifier === 'lower') return str.toLowerCase(); + if (modifier === 'upper') return str.toUpperCase(); + return str; + }; + + const padStringLeft = ( + str: string, + length: number, + pad: string = '0' + ) => { + return (pad.repeat(length) + str).slice( + Math.max(length, str.length) * -1 + ); + }; + + switch (params.system) { + case 'alpha': + return modifierFn(convertNumberToAlpha(vehicleIndex + 1)); + case 'roman': + return modifierFn(convertNumberToRoman(vehicleIndex)); + case 'icao': + return modifierFn(convertNumberToICAOAlpha(vehicleIndex)); + case 'greek': + return modifierFn(convertNumberToGreek(vehicleIndex)); + case 'emoji': + return convertStringNumberToEmoji( + padStringLeft((vehicleIndex + 1).toString(), params.padding) + ); + + // arabic with optional padding + default: + return padStringLeft( + (vehicleIndex + 1).toString(), + params.padding + ); + } + } +} diff --git a/src/modules/nameSchema/assets/helper/types.ts b/src/modules/nameSchema/assets/helper/types.ts new file mode 100644 index 0000000000..8791a9db6c --- /dev/null +++ b/src/modules/nameSchema/assets/helper/types.ts @@ -0,0 +1,13 @@ +import type { Building, InternalBuilding } from 'typings/Building'; +import type { InternalVehicle, Vehicle } from 'typings/Vehicle'; + +interface AliasProps { + id: number; + alias: string; +} + +export type AliasedBuilding = AliasProps & Building; +export type AliasedInternalBuilding = AliasProps & InternalBuilding; + +export type AliasedVehicle = AliasProps & Vehicle; +export type AliasedInternalVehicle = AliasProps & InternalVehicle; diff --git a/src/modules/nameSchema/assets/pom/base.ts b/src/modules/nameSchema/assets/pom/base.ts new file mode 100644 index 0000000000..483f5dcc42 --- /dev/null +++ b/src/modules/nameSchema/assets/pom/base.ts @@ -0,0 +1,9 @@ +import type { ModuleMainFunction } from 'typings/Module'; + +export default abstract class PageObject { + constructor( + protected readonly moduleParams: Parameters[0] + ) {} + + public abstract init(): Promise; +} diff --git a/src/modules/nameSchema/assets/pom/building_show.ts b/src/modules/nameSchema/assets/pom/building_show.ts new file mode 100644 index 0000000000..f4c33ad436 --- /dev/null +++ b/src/modules/nameSchema/assets/pom/building_show.ts @@ -0,0 +1,352 @@ +import ExclusionHelper from '../helper/exclusion'; +import PageObject from './base'; +import RenameHelper from '../helper/rename'; +import TemplateHelper from '../helper/template'; + +import type { Building } from 'typings/Building'; +import type { ModuleMainFunction } from 'typings/Module'; +import type { Vehicle } from 'typings/Vehicle'; + +type CaptionedVehicle = Vehicle & { newCaption: string }; +type CaptionedVehicleWithNode = CaptionedVehicle & { node: HTMLElement }; + +export default class BuildingShowPageObject extends PageObject { + private readonly _currentBuilding: Building; + private _renameableVehicles: Map = + new Map(); + private renameHelper?: RenameHelper; + private exclusionHelper?: ExclusionHelper; + private templateHelper?: TemplateHelper; + + constructor( + protected readonly moduleParams: Parameters[0] + ) { + super(moduleParams); + + const match = document.location.pathname.match(/^\/buildings\/(\d+)$/u); + if (!match) throw new Error('Unknown building page'); + + const buildingId = Number(match[1]); + const currentBuilding: Building | undefined = + this.moduleParams.LSSM.$stores.api.buildings.find( + ({ id }) => id === buildingId + ); + if (!currentBuilding) + throw new Error(`Unknown building id ${buildingId}`); + + this._currentBuilding = currentBuilding; + } + + public async init() { + this.renameHelper = new RenameHelper(this.moduleParams); + this.exclusionHelper = new ExclusionHelper(this.moduleParams); + this.templateHelper = new TemplateHelper(this.moduleParams); + + await this.exclusionHelper.init(); + await this.templateHelper.init(); + + await this.waitForVehiclesTable(); + await this.augmentVehiclesTable(); + } + + private get isCurrentBuildingDispatchCenter(): boolean { + return this._currentBuilding.building_type === 7; + } + + private async waitForVehiclesTable(): Promise { + if (!this.isCurrentBuildingDispatchCenter) return; + + // special case for dispatch page: wait for vehicles table to be loaded + await new Promise(resolve => { + const check = () => { + const vehicleTable = document.querySelector( + '#vehicle_table tbody tr' + ); + if (vehicleTable) resolve(vehicleTable); + else window.setTimeout(check, 250); + }; + + check(); + }); + } + + private async augmentVehiclesTable() { + if ( + !this.isCurrentBuildingDispatchCenter && + this.exclusionHelper!.isBuildingTypeExcluded( + this._currentBuilding.building_type + ) + ) { + // short circuit if building type is excluded + return; + } + + await this.collectRenameableVehicles(); + await this.augmentTableHeader(); + await this.augmentTableRows(); + } + + private async collectRenameableVehicles() { + const { LSSM } = this.moduleParams; + + this._renameableVehicles = new Map( + Array.from( + this.isCurrentBuildingDispatchCenter + ? LSSM.$stores.api.vehiclesByDispatchCenter[ + this._currentBuilding.id + ] + : LSSM.$stores.api.vehiclesByBuilding[ + this._currentBuilding.id + ] + ) + // remove any invalid or excluded vehicle type + .filter( + vehicle => + !this.exclusionHelper!.isVehicleTypeExcluded( + vehicle.vehicle_type + ) + ) + + // remove vehicles from excluded building types + .filter(vehicle => { + const building = this.isCurrentBuildingDispatchCenter + ? LSSM.$stores.api.buildingsById[vehicle.building_id] + : this._currentBuilding; + + return ( + building && + !this.exclusionHelper!.isBuildingTypeExcluded( + building.building_type + ) + ); + }) + + // add new caption to vehicle data + .map(vehicle => { + const building = this.isCurrentBuildingDispatchCenter + ? LSSM.$stores.api.buildingsById[vehicle.building_id] + : this._currentBuilding; + + return { + ...vehicle, + newCaption: this.templateHelper!.getNewUnitName( + building!, + vehicle + ), + } as CaptionedVehicle; + }) + + // remove vehicles with unchanged name + .filter(vehicle => vehicle.caption !== vehicle.newCaption) + + // map to table row + .map(vehicle => { + const vehicleLink = document.querySelector( + `#vehicle_table tbody tr td[sortvalue] a[href$="/vehicles/${vehicle.id}"]` + ); + + return { + ...vehicle, + node: vehicleLink?.closest('tr'), + } as CaptionedVehicle & { node?: HTMLElement }; + }) + + // remove vehicles without a table row + .filter((vehicle): vehicle is CaptionedVehicleWithNode => { + return vehicle.node !== undefined; + }) + + // map to Map entry format + .map(vehicle => [String(vehicle.id), vehicle]) + ); + } + + private async renameVehicleButtonListener( + e: Event, + vehicleLink: HTMLAnchorElement | null | undefined + ) { + const { currentTarget } = e; + if (!(currentTarget instanceof HTMLElement)) return; + + const { vehicleId } = currentTarget.dataset; + if (!vehicleId) return; + const vehicle = this._renameableVehicles.get(vehicleId); + if (!vehicle) return; + + currentTarget + .querySelector(':scope .btn-icon') + ?.classList.add('fa-spin'); + + try { + await this.renameHelper!.renameVehicle( + Number(vehicleId), + vehicle.newCaption + ); + + if (vehicleLink) vehicleLink.textContent = vehicle.newCaption; + + currentTarget.classList.add('btn-success'); + window.setTimeout(() => { + currentTarget.remove(); + }, 1000); + } catch (err: unknown) { + currentTarget.classList.add('btn-danger'); + } finally { + currentTarget + .querySelector(':scope .btn-icon') + ?.classList.remove('fa-spin'); + } + } + + private async augmentTableRows() { + const { $m } = this.moduleParams; + + // Rename button per vehicle + this._renameableVehicles.forEach(vehicle => { + const vehicleLink = vehicle.node.querySelector( + ':scope a[href*="/vehicles/"]' + ); + if (!vehicleLink) return null; + + // add button to rename the vehicles + const renameButton = document.createElement('span'); + renameButton.classList.add( + 'btn', + 'btn-default', + 'btn-xs', + 'ns-action-rename' + ); + renameButton.style.marginLeft = '0.5rem'; + renameButton.dataset.vehicleId = String(vehicle.id); + renameButton.title = String( + $m('action.rename', { + caption: vehicle.newCaption, + }) + ); + + renameButton.addEventListener('click', e => + this.renameVehicleButtonListener(e, vehicleLink) + ); + + const buttonIcon = document.createElement('i'); + buttonIcon.classList.add( + 'fa-solid', + 'fa-wand-magic-sparkles', + 'btn-icon' + ); + + renameButton.append(buttonIcon); + vehicleLink.after(renameButton); + }); + } + + private async augmentTableHeader() { + if (this._renameableVehicles.size === 0) return; + + const { $m } = this.moduleParams; + + const renameAll = async ( + renameableVehicles: CaptionedVehicleWithNode[], + e: UIEvent + ) => { + const { currentTarget: button } = e; + if (!(button instanceof HTMLElement)) return; + + const buttonIcon = button.querySelector(':scope .btn-icon'); + buttonIcon?.classList.add('fa-spin', 'btn-disabled'); + + for (const vehicle of renameableVehicles.values()) { + const vehicleLink = vehicle.node.querySelector( + `:scope a[href="/vehicles/${vehicle.id}"]` + ); + const renameButton = vehicle.node.querySelector( + `:scope .ns-action-rename` + ); + + renameButton?.classList.add('btn-disabled'); + renameButton + ?.querySelector(':scope .btn-icon') + ?.classList.add('fa-spin'); + + try { + await this.renameHelper!.renameVehicle( + Number(vehicle.id), + vehicle.newCaption + ); + + if (vehicleLink) + vehicleLink.textContent = vehicle.newCaption; + + renameButton?.classList.add('btn-success'); + window.setTimeout(() => { + renameButton?.remove(); + }, 200); + } catch (err: unknown) { + renameButton?.classList.add('btn-danger'); + } finally { + renameButton + ?.querySelector(':scope .btn-icon') + ?.classList.remove('fa-spin'); + } + + // delay requests to not get rate limited + await new Promise(resolve => setTimeout(resolve, 200)); + } + + button.remove(); + + return false; + }; + + // Rename all vehicles button + const renameAllButton = document.createElement('button'); + renameAllButton.classList.add('btn', 'btn-default', 'btn-xs'); + renameAllButton.style.marginLeft = '0.5rem'; + renameAllButton.title = String( + $m('action.rename_all', { count: this._renameableVehicles.size }) + ); + renameAllButton.addEventListener('mouseup', e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + const allVehicleRows = document.querySelectorAll( + '#vehicle_table tbody tr' + ); + + renameAll( + Array.from(this._renameableVehicles.values()).toSorted( + (a, b) => { + const aIndex = Array.from(allVehicleRows).indexOf( + a.node + ); + const bIndex = Array.from(allVehicleRows).indexOf( + b.node + ); + + return aIndex - bIndex; + } + ), + e + ); + + return false; + }); + + const buttonIcon = document.createElement('i'); + buttonIcon.classList.add( + 'fa-solid', + 'fa-wand-magic-sparkles', + 'btn-icon' + ); + + renameAllButton.append(buttonIcon); + + // find "name" column + const nameColumn = document.querySelector( + '#vehicle_table th[role=columnheader]:not(.sorter-false) .tablesorter-header-inner' + ); + + if (nameColumn) nameColumn.append(renameAllButton); + } +} diff --git a/src/modules/nameSchema/assets/pom/vehicle_base.ts b/src/modules/nameSchema/assets/pom/vehicle_base.ts new file mode 100644 index 0000000000..12ca9aa8c4 --- /dev/null +++ b/src/modules/nameSchema/assets/pom/vehicle_base.ts @@ -0,0 +1,34 @@ +import PageObject from './base'; + +import type { Building } from 'typings/Building'; +import type { ModuleMainFunction } from 'typings/Module'; +import type { Vehicle } from 'typings/Vehicle'; + +export default abstract class VehicleBasePageObject extends PageObject { + protected readonly _currentBuilding: Building; + protected readonly _currentVehicle: Vehicle; + + constructor( + protected readonly moduleParams: Parameters[0] + ) { + super(moduleParams); + + const match = document.location.pathname.match( + /^\/vehicles\/(\d+)(?:\/edit)?$/u + ); + if (!match) throw new Error('Unknown vehicle page'); + + const vehicleId = Number(match[1]); + const currentVehicle: Vehicle | undefined = + this.moduleParams.LSSM.$stores.api.vehicles.find( + ({ id }) => id === vehicleId + ); + if (!currentVehicle) throw new Error(`Unknown vehicle id ${vehicleId}`); + + this._currentVehicle = currentVehicle; + this._currentBuilding = + this.moduleParams.LSSM.$stores.api.buildingsById[ + currentVehicle.building_id + ]; + } +} diff --git a/src/modules/nameSchema/assets/pom/vehicle_edit.ts b/src/modules/nameSchema/assets/pom/vehicle_edit.ts new file mode 100644 index 0000000000..ff72e9e1a4 --- /dev/null +++ b/src/modules/nameSchema/assets/pom/vehicle_edit.ts @@ -0,0 +1,80 @@ +import ExclusionHelper from '../helper/exclusion'; +import RenameHelper from '../helper/rename'; +import TemplateHelper from '../helper/template'; +import VehicleBasePageObject from './vehicle_base'; + +export default class VehicleEditPageObject extends VehicleBasePageObject { + private renameHelper?: RenameHelper; + private exclusionHelper?: ExclusionHelper; + private templateHelper?: TemplateHelper; + + public async init() { + this.renameHelper = new RenameHelper(this.moduleParams); + this.exclusionHelper = new ExclusionHelper(this.moduleParams); + this.templateHelper = new TemplateHelper(this.moduleParams); + + await this.exclusionHelper.init(); + await this.templateHelper.init(); + + await this.augmentForm(); + } + + private async augmentForm() { + const inputContainer = document.querySelector( + '.vehicle_caption > div:not(.control-label)' + ); + if (!inputContainer) return; + + const newUnitName = this.templateHelper!.getNewUnitName( + this._currentBuilding, + this._currentVehicle + ); + + const { $m } = this.moduleParams; + const button = this.injectButton(inputContainer); + button.title = String( + $m('action.rename', { + caption: newUnitName, + }) + ); + button.addEventListener('click', () => { + const inputControl = + document.querySelector('#vehicle_caption'); + + if (inputControl) { + inputControl.value = newUnitName; + inputControl.dispatchEvent( + new Event('input', { bubbles: true }) + ); + } + }); + } + + private injectButton(inputContainer: Element) { + const inputGroup = document.createElement('div'); + inputGroup.classList.add('input-group'); + + inputContainer.childNodes.forEach(node => inputGroup.append(node)); + inputContainer.append(inputGroup); + + const setNameButton = document.createElement('span'); + setNameButton.classList.add( + 'input-group-addon', + 'btn', + 'btn-sm', + 'btn-default' + ); + + const buttonIcon = document.createElement('i'); + buttonIcon.classList.add( + 'fa-solid', + 'fa-wand-magic-sparkles', + 'btn-icon' + ); + + setNameButton.append(buttonIcon); + inputGroup.append(setNameButton); + + return setNameButton; + } +} diff --git a/src/modules/nameSchema/assets/pom/vehicle_show.ts b/src/modules/nameSchema/assets/pom/vehicle_show.ts new file mode 100644 index 0000000000..d3c25bebee --- /dev/null +++ b/src/modules/nameSchema/assets/pom/vehicle_show.ts @@ -0,0 +1,81 @@ +import ExclusionHelper from '../helper/exclusion'; +import RenameHelper from '../helper/rename'; +import TemplateHelper from '../helper/template'; +import VehicleBasePageObject from './vehicle_base'; + +export default class VehicleShowPageObject extends VehicleBasePageObject { + private renameHelper?: RenameHelper; + private exclusionHelper?: ExclusionHelper; + private templateHelper?: TemplateHelper; + + public async init() { + this.renameHelper = new RenameHelper(this.moduleParams); + this.exclusionHelper = new ExclusionHelper(this.moduleParams); + this.templateHelper = new TemplateHelper(this.moduleParams); + + await this.exclusionHelper.init(); + await this.templateHelper.init(); + + await this.augmentHeader(); + } + + private async augmentHeader() { + const titleTag = document.querySelector('h1'); + if (!titleTag) return; + + const newUnitName = this.templateHelper!.getNewUnitName( + this._currentBuilding, + this._currentVehicle + ); + + // hide button if new name is the same as the current name + if (newUnitName === this._currentVehicle.caption) return; + + const { $m } = this.moduleParams; + const button = this.injectButton(titleTag); + button.title = String( + $m('action.rename', { + caption: newUnitName, + }) + ); + button.addEventListener('click', async () => { + button.classList.add('btn-disabled'); + button.querySelector('.btn-icon')?.classList.add('fa-spin'); + + try { + await this.renameHelper?.renameVehicle( + this._currentVehicle.id, + newUnitName + ); + button.classList.replace('btn-default', 'btn-success'); + titleTag.textContent = newUnitName; + + window.setTimeout(() => { + button.remove(); + }, 1000); + } catch (e: unknown) { + button.classList.replace('btn-default', 'btn-danger'); + } finally { + button.querySelector('.btn-icon')?.classList.remove('fa-spin'); + } + }); + } + + private injectButton(container: Element) { + const setNameButton = document.createElement('span'); + setNameButton.classList.add('btn', 'btn-sm', 'btn-default'); + setNameButton.style.marginLeft = '0.5rem'; + + const buttonIcon = document.createElement('i'); + buttonIcon.classList.add( + 'fa-solid', + 'fa-wand-magic-sparkles', + 'btn-icon' + ); + + setNameButton.append(buttonIcon); + container.append(setNameButton); + + return setNameButton; + } +} diff --git a/src/modules/nameSchema/docs/de_DE.md b/src/modules/nameSchema/docs/de_DE.md new file mode 100644 index 0000000000..3ca5dbe59f --- /dev/null +++ b/src/modules/nameSchema/docs/de_DE.md @@ -0,0 +1,65 @@ +# Namens-Schemata + +## Variablen + +| Variable | Beschreibung | Verwendbar in Templates für | +|-----------------------|--------------------------------------------------------------------------------------|-----------------------------| +| `{{unitId}}` | ID der Einheit, z.B. `78020362` | Einheiten | +| `{{unitType}}` | Typ der Einheit (z.B. `LF 20/01`). | Einheiten | +| `{{unitTypeCustom}}` | Benutzerdefinierte Fahrzeugklasse. | Einheiten | +| `{{unitAlias}}` | Alias-Bezeichnung der Einheit (vgl. Aliase), ansonsten identisch zu `unitType`. | Einheiten | +| `{{unitIndex}}` | Aufsteigende Nummerierung des Fahrzeugs. Parametrisierung möglich. | Einheiten | +| `{{buildingId}}` | ID des Gebäudes, z.B. `11221784` | Einheiten, Gebäude | +| `{{buildingType}}` | Typ der Einheit (z.B. `Feuerwehrschule`) | Einheiten, Gebäude | +| `{{buildingAlias}}` | Alias-Bezeichnung des Gebäudes (vgl. Aliase), ansonsten identisch zu `buildingType`. | Einheiten, Gebäude | +| `{{buildingIndex}}` | Aufsteigende Nummerierung des Gebäudes. Parametrisierung möglich. | Einheiten, Gebäude | +| `{{buildingCaption}}` | Name des Gebäudes | Einheiten | + +### Parametrisierung + +Die Variablen `{{unitIndex}}` und `{{buildingIndex}}` unterstützen weitere Parameter, um die Art der Nummerierung zu beeinflussen. + +Das Format ist `{{unitIndex:?:?:?:?}}` mit folgender Bedeutung: + +| Parameter | Bedeutung | Beispiel | Standard | +|------------------|----------------------------------------------------------------------------------------------------------------------------------------|------------------------------------|------------| +| `padding` | Anzahl der Stellen, die die Nummerierung haben soll. Führende Nullen werden hinzugefügt. Nur sinnvoll mit der Verwendung von `arabic`. | `{{unitIndex:2}}` ergibt `01` | `0` | +| `start` | Startwert der Nummerierung. | `{{unitIndex::10}}` ergibt `10` | `1` | +| `numeral system` | System, in dem die Nummerierung erfolgen soll. | `{{unitIndex:::roman}}` ergibt `I` | `arabic` | +| `group` | Gruppierung der Fahrzeuge für die Nummerierung. | `{{unitIndex::::dispatch}}` | `building` | + +#### `unitIndex` + +| Template | Ergebnis | +|--------------------------|-----------------------| +| `{{unitIndex}}` | `1`, `2`, `3`, ... | +| `{{unitIndex:2}}` | `01`, `02`, `03`, ... | +| `{{unitIndex::10}}` | `10`, `11`, `12`, ... | +| `{{unitIndex:::roman}}` | `I`, `II`, `III`, ... | +| `{{unitIndex:2:8}}` | `08`, `09`, `10`, ... | +| `{{unitIndex::5:roman}}` | `V`, `VI`, `VII`, ... | +| `{{unitIndex::5:alpha}}` | `V`, `VI`, `VII`, ... | + +#### `numeral system` + +Alle Systeme außer `arabic` unterstützen die Suffixe `-lower` und `-upper` für Klein- und Großschreibung (z.B. `roman-lower`). + +| Wert | Beschreibung | Beispiel | +|----------|----------------------------------|----------------------------------| +| `arabic` | Arabische Zahlen (Standard) | `1`, `2`, `3`, ... | +| `roman` | Römische Zahlen | `I`, `II`, `III`, ... | +| `alpha` | Buchstaben | `A`, `B`, `C`, ... | +| `greek` | Griechische Buchstaben (max. 23) | `Alpha`, `Beta`, `Gamma`, ... | +| `icao` | ICAO-Phonetic (max. 25) | `Alpha`, `Bravo`, `Charlie`, ... | +| `emoji` | Emoji Ziffern | 0️⃣, 1️⃣2️⃣3️⃣, 6️⃣6️⃣6️⃣, ... | + +#### `group` + +| Wert | Gruppieren nach | Verwendbar in Templates für | +|--------------------|-----------------------------------------------------------------------|-----------------------------| +| `none` | Ohne Gruppierung, Fahrzeuge werden global durchnummeriert (Standard). | Einheiten, Gebäuden | +| `building` | Gruppierung pro Gebäude. | Einheiten | +| `unitType` | Gruppierung global nach Einheitentyp. | Einheiten | +| `dispatch` | Gruppierung nach Leitstelle | Einheiten, Gebäuden | +| `buildingUnitType` | Kombinierte Gruppierung nach Gebäude und Einheitentyp. | Einheiten | +| `dispatchUnitType` | Kombinierte Gruppierung nach Leitstelle und Einheitentyp. | Einheiten | diff --git a/src/modules/nameSchema/i18n/de_DE.root.json b/src/modules/nameSchema/i18n/de_DE.root.json new file mode 100644 index 0000000000..feb0f4404c --- /dev/null +++ b/src/modules/nameSchema/i18n/de_DE.root.json @@ -0,0 +1,52 @@ +{ + "action": { + "rename": "Umbenennen in: {caption}", + "rename_all": "Alle Fahrzeuge umbenennen" + }, + "description": "Benenne Gebäude und Fahrzeuge nach deinem System oder nach einem Standard um.", + "name": "Namens-Schema", + "settings": { + "buildingAliases": { + "alias": "Alias-Bezeichnung", + "description": "Hier kannst du für jeden Gebäudetyp ein Alias definieren. Dieser Alias wird dann im Template für buildingAlias verwendet.
Wenn du hier nichts einträgst, wird die Standard-Bezeichnung des Gebäudetyps verwendet.", + "title": "Aliase für Gebäudetypen", + "type": "Gebäudetyp" + }, + "buildingTemplates": { + "description": "Hier kannst du für jedes Gebäude ein eigenes Template definieren. Die verfügbaren Platzhalter findest du im Wiki.", + "template": "Template", + "title": "Gebäudespezifische Templates", + "type": "Gebäudetyp" + }, + "defaultBuildingTemplate": { + "defaultValue": "{{buildingAlias}} {{buildingTypeIndex}}", + "description": "Dieses Template wird für alle Gebäude verwendet, für die kein spezifisches Template definiert wurde.", + "title": "Standard Template für Gebäude" + }, + "defaultUnitTemplate": { + "defaultValue": "{{unitAlias}} {{unitTypeIndex:building}}", + "description": "Dieses Template wird für alle Einheiten verwendet, für die kein spezifisches Template definiert wurde.", + "title": "Standard Template für Einheiten" + }, + "excludeBuildings": { + "description": "Hier kannst du Gebäude ausschließen, die nicht umbenannt werden sollen.", + "title": "Gebäude ausschließen" + }, + "excludeUnits": { + "description": "Hier kannst du Einheiten ausschließen, die nicht umbenannt werden sollen.", + "title": "Einheiten ausschließen" + }, + "unitAliases": { + "alias": "Alias-Bezeichnung", + "description": "Hier kannst du für jeden Einheitentypen ein Alias definieren. Dieser Alias wird dann im Template für unitAlias verwendet.
Wenn du hier nichts einträgst, wird die Standard-Bezeichnung des Einheitentyps verwendet.", + "title": "Aliase für Einheitentypen", + "type": "Einheitentyp" + }, + "unitTemplates": { + "description": "Hier kannst du für jede Einheit ein eigenes Template definieren. Die verfügbaren Platzhalter findest du in der Hilfe.", + "template": "Template", + "title": "Einheitenspezifische Templates", + "type": "Einheitentyp" + } + } +} \ No newline at end of file diff --git a/src/modules/nameSchema/main.ts b/src/modules/nameSchema/main.ts new file mode 100644 index 0000000000..b384ca8d9e --- /dev/null +++ b/src/modules/nameSchema/main.ts @@ -0,0 +1,56 @@ +import type { ModuleMainFunction } from 'typings/Module'; +import type PageObject from './assets/pom/base'; + +type PageObjectConstructor = new ( + ...params: ConstructorParameters +) => PageObject; + +export default (async moduleParams => { + const { MODULE_ID, LSSM } = moduleParams; + + await LSSM.$stores.api.getBuildings(MODULE_ID); + await LSSM.$stores.api.getVehicles(MODULE_ID); + + const urlMap = new Map< + RegExp, + { + (): Promise; + } + >([ + [ + /^\/buildings\/\d+$/u, + async () => + ( + await import( + /* webpackChunkName: "modules/nameSchema/pom/building_show" */ './assets/pom/building_show' + ) + ).default, + ], + [ + /^\/vehicles\/\d+$/u, + async () => + ( + await import( + /* webpackChunkName: "modules/nameSchema/pom/vehicle_show" */ './assets/pom/vehicle_show' + ) + ).default, + ], + [ + /^\/vehicles\/\d+\/edit$/u, + async () => + ( + await import( + /* webpackChunkName: "modules/nameSchema/pom/vehicle_edit" */ './assets/pom/vehicle_edit' + ) + ).default, + ], + ]); + + for (const [url, pageObject] of Array.from(urlMap.entries())) { + if (url.test(window.location.pathname)) { + const modelConstructor = await pageObject(); + const pageModel = new modelConstructor(moduleParams); + await pageModel.init(); + } + } +}); diff --git a/src/modules/nameSchema/register.json b/src/modules/nameSchema/register.json new file mode 100644 index 0000000000..c67d016fe5 --- /dev/null +++ b/src/modules/nameSchema/register.json @@ -0,0 +1 @@ +{ "location": "^/(buildings|vehicles)/\\d+(/edit)?$", "settings": true } \ No newline at end of file diff --git a/src/modules/nameSchema/settings.ts b/src/modules/nameSchema/settings.ts new file mode 100644 index 0000000000..0057ae5f24 --- /dev/null +++ b/src/modules/nameSchema/settings.ts @@ -0,0 +1,206 @@ +import type Vue from 'vue'; + +import type { $m, ModuleSettingFunction } from 'typings/Module'; +import type { + AppendableList, + AppendableListSetting, + MultiSelect, + RegisterSettings, + Select, + Text, + Textarea, +} from 'typings/Setting'; + +const settings: ModuleSettingFunction = async ( + MODULE_ID: string, + LSSM: Vue, + $m: $m +) => { + const buildingTypes = Object.entries(LSSM.$stores.translations.buildings) + .map(([id, type]) => { + const copy = Object.freeze({ ...type }); + return { id, label: copy.caption }; + }) + .toSorted((a, b) => a.label.localeCompare(b.label)); + const unitTypes = Object.entries(LSSM.$stores.translations.vehicles) + .map(([id, type]) => { + const copy = Object.freeze({ ...type }); + return { id, label: copy.caption }; + }) + .toSorted((a, b) => a.label.localeCompare(b.label)); + + const uniqueField = + (field: string) => + ( + row: Record, + rowIndex: number, + settings: Record[] + ) => { + if (!row[field]) return false; + + const sameType = settings + .filter((_, i) => i !== rowIndex) + .filter(value => value[field] === row[field]); + + return sameType.length === 0 ? false : $m('unique').toString(); + }; + + const settings: RegisterSettings = { + defaultBuildingTemplate: