mirror of
https://github.com/chuot/rdio-scanner.git
synced 2026-08-14 17:03:02 -06:00
Compare commits
87 commits
v6.0.6-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e813924647 | ||
|
|
7edf0df1ac | ||
|
|
b7975763cf | ||
|
|
3c4da551c9 | ||
|
|
7d97763f8f | ||
|
|
45151574ae | ||
|
|
f18df8a98f | ||
|
|
c4827ac78b | ||
|
|
76ce9dbd13 | ||
|
|
58cd89f816 | ||
|
|
7c90168160 | ||
|
|
5f527eb27d | ||
|
|
3b6246c39f | ||
|
|
e0ff30a43c | ||
|
|
a49040eaaa | ||
|
|
a34971064f | ||
|
|
2fe0758c22 | ||
|
|
36fe8e80a6 | ||
|
|
0d0023c827 | ||
|
|
a97f43252a | ||
|
|
53a426bea9 | ||
|
|
c9cc7f0a3c | ||
|
|
93b18b87f5 | ||
|
|
94c0d70c97 | ||
|
|
ccc2cfab2c | ||
|
|
5e104046f5 | ||
|
|
a57b784e97 | ||
|
|
61c67ef652 | ||
|
|
d39578e9b5 | ||
|
|
c9c4ea4530 | ||
|
|
afbd174f5f | ||
|
|
12a0d0a3d6 | ||
|
|
6356c3b780 | ||
|
|
af41604322 | ||
|
|
2e772c0e1e | ||
|
|
d03f0b1fef | ||
|
|
bf75f92f47 | ||
|
|
f0ada59456 | ||
|
|
7e9679ef5a | ||
|
|
f92355592a | ||
|
|
bd560fe57d | ||
|
|
2dae2606b9 | ||
|
|
0e088ad534 | ||
|
|
05747a0428 | ||
|
|
f8122b06c9 | ||
|
|
dab1c540e6 | ||
|
|
839f634ec5 | ||
|
|
351913e92e | ||
|
|
2bcbc52dad | ||
|
|
2814950106 | ||
|
|
47f125060f | ||
|
|
164957899f | ||
|
|
60fcf1e066 | ||
|
|
4d0dbd0e8e | ||
|
|
6beae3869b | ||
|
|
c57489f376 | ||
|
|
508ab1d700 | ||
|
|
0fbe97469b | ||
|
|
228437cb96 | ||
|
|
ef549189b4 | ||
|
|
5921f2fb96 | ||
|
|
dd169d83a4 | ||
|
|
58a84164ef | ||
|
|
919ce44059 | ||
|
|
c215ba37f1 | ||
|
|
662c5f9627 | ||
|
|
f8fef4ada2 | ||
|
|
e09a227590 | ||
|
|
2d28bf0428 | ||
|
|
9d37fba776 | ||
|
|
99b0696341 | ||
|
|
d47169eaf4 | ||
|
|
97b5834734 | ||
|
|
1eebbc1154 | ||
|
|
0534693937 | ||
|
|
0246605379 | ||
|
|
7f537689bc | ||
|
|
f2f45945df | ||
|
|
ef372a36ce | ||
|
|
2a6f721f4f | ||
|
|
7b527b1b00 | ||
|
|
79827e3619 | ||
|
|
96c965512b | ||
|
|
702ed5e1e2 | ||
|
|
d2ab9235d7 | ||
|
|
4f3b77e87f | ||
|
|
7fecfe660a |
76
API_ACCESS_POLICY.md
Normal file
76
API_ACCESS_POLICY.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# WebSocket API Terms of Use
|
||||
|
||||
**Effective Date:** January 3rd, 2026
|
||||
**Project:** Rdio Scanner open source project
|
||||
**Sponsor:** Saubeo Solutions
|
||||
|
||||
---
|
||||
|
||||
### 1. License
|
||||
|
||||
This software is distributed under the GNU General Public License (GPL). You are free to use, modify, and distribute the source code under the terms of the GPL.
|
||||
|
||||
**Exception:** The WebSocket API implementation is proprietary and subject to the terms outlined in this document.
|
||||
|
||||
---
|
||||
|
||||
### 2. Scope of Restriction
|
||||
|
||||
The HTTP REST API and all other features are fully available under GPL terms.
|
||||
|
||||
The **WebSocket API** is a proprietary real-time communication channel provided exclusively by Saubeo Solutions. This distinction supports the commercial model that funds ongoing project development.
|
||||
|
||||
---
|
||||
|
||||
### 3. Authorized Access
|
||||
|
||||
**Official Saubeo Solutions Service:**
|
||||
|
||||
- The WebSocket API is exclusively available through Saubeo Solutions' hosted service for its native applications and the integrated web application.
|
||||
|
||||
**Self-Hosted Deployments:**
|
||||
|
||||
- Self-hosted instances can use the HTTP REST API without restriction.
|
||||
- Access to the WebSocket API in self-hosted instances requires a prior written license agreement with Saubeo Solutions.
|
||||
- Contact [rdio-scanner@saubeo.solutions](mailto:rdio-scanner@saubeo.solutions) to inquire about licensing.
|
||||
|
||||
---
|
||||
|
||||
### 4. Authentication
|
||||
|
||||
Authorized clients (including self-hosted licensees) must:
|
||||
|
||||
- Use valid authentication credentials provided by Saubeo Solutions.
|
||||
- Ensure secure handling of credentials. Sharing credentials with unauthorized parties is strictly prohibited.
|
||||
|
||||
---
|
||||
|
||||
### 5. Prohibited Use
|
||||
|
||||
You may not:
|
||||
|
||||
- Attempt to use the WebSocket API without explicit authorization (either through the official hosted service or a valid license agreement).
|
||||
- Use the WebSocket API in commercial applications or services without a license agreement.
|
||||
- Reverse-engineer, replicate, or redistribute the WebSocket API service.
|
||||
- Bypass or disable WebSocket API restrictions through code modification.
|
||||
|
||||
### 6. HTTP REST API
|
||||
|
||||
The HTTP REST API provided by Rdio Scanner is **not restricted** and fully available under GPL terms for all uses, including self-hosted deployments and third-party applications.
|
||||
|
||||
---
|
||||
|
||||
### 7. Enforcement
|
||||
|
||||
Violation of these terms may result in:
|
||||
|
||||
- Immediate revocation of WebSocket API access.
|
||||
- Termination of any license agreement.
|
||||
- Legal action for unauthorized use.
|
||||
|
||||
---
|
||||
|
||||
### 8. Commercial Licensing
|
||||
|
||||
For WebSocket API access in self-hosted or commercial deployments, contact:
|
||||
[rdio-scanner@saubeo.solutions](mailto:rdio-scanner@saubeo.solutions)
|
||||
424
CHANGELOG.md
424
CHANGELOG.md
|
|
@ -1,5 +1,321 @@
|
|||
# Change log
|
||||
|
||||
## Version 7.0
|
||||
|
||||
**Make sure to backup your config and your database before updating to Version 7.0.**
|
||||
|
||||
- New database schema now compatible with SQLite, MariaDB, MySQL and PostgreSQL.
|
||||
- New Delayed feature which allows to delay ingested audio broadcasting for a specified amount of minutes.
|
||||
- New alert sounds that can be assigned to groups, tags, systems and talkgroups.
|
||||
- New system and talkgroup types to help identify the radio system.
|
||||
- Talkgroups can now be assigned to more than one group.
|
||||
- LED colors can now be assigned to groups, tags, systems and talkgroups.
|
||||
- Better call duplicates detection, thanks to the new database schema.
|
||||
- Tags toggle removed in favor of multi groups assignment for talkgroups.
|
||||
- AFS systems option remove and replace by system/talkgroup type provoice.
|
||||
- Newer API while retaining backward compatility.
|
||||
- Integrated web app migrated to Angular 15.
|
||||
- Simplified talkgroup importation to a specific system.
|
||||
- New /reset url path that allow reseting the user access code and talkgroups selection.
|
||||
- New #UNITLBL metatag for dirwatch.
|
||||
|
||||
- TODO: Hold TG SYS persistent in local storage
|
||||
- TODO: Ingest Site ID and API Site ID
|
||||
- TODO: Ingest call DBFS and API call DBFS
|
||||
- TODO: search by uid
|
||||
- TODO: Display site # or label on main screen
|
||||
|
||||
## Version 6.6
|
||||
|
||||
- From now on precompiled versions of macOS will be named as such instead of darwin.
|
||||
- Better example for rtlsdr-airband that leverage the new #TGHZ, #TGKHZ and #TGMHZ meta tags for dirwatch.
|
||||
- Fixed dirwatch definition not always showing mask field when type is default.
|
||||
- Fixed truncated source ids with SDRTrunk parser (issue #265).
|
||||
- Fixed admin logs not updating if no results are found.
|
||||
- New parameter http://host:port/?id=xyz added to URL that allows multiple client instances with different talkgroup selections to be retained accross sessions.
|
||||
|
||||
_v6.6.1_
|
||||
|
||||
- Fixed search issue (issue #267).
|
||||
|
||||
_v6.6.2_
|
||||
|
||||
- Fixed authentication endless loop if wrong access code is entered.
|
||||
|
||||
_v6.6.3_
|
||||
|
||||
- Fixed dirwatch validation for type trunk-recorder (issue #280).
|
||||
|
||||
## Version 6.5
|
||||
|
||||
- Fixed API looping on malformed or invalid multipart content (issue #181, #212).
|
||||
- Source code updated to GO 1.18 with `interface{}` replaced by `any`.
|
||||
- Removed ingest mutex for performance reasons.
|
||||
- Replaced all `path.Base()` by `filepath.Base()` to fix an issue with audio filenames on Windows.
|
||||
- New `Branding Label` and `Email Support` options to show on main screen (issue #220).
|
||||
- New temporary avoid feature (discussion #218).
|
||||
- Fixed remote address regexp (issue #225).
|
||||
- Added the `ident` to `new listener` log message (discussion #226).
|
||||
- New populated talkgroups won't be activated on the client if its group (or tag) is turned off (issue #227).
|
||||
- Removed the duplicated webapp section from the PDF document.
|
||||
|
||||
_v6.5.1_
|
||||
|
||||
- Fixed broken functionality for `HOLD SYS` and `HOLD TG` (issue #228).
|
||||
|
||||
_v6.5.2_
|
||||
|
||||
- Fixed erratic listeners count.
|
||||
- Show call date on main screen when call is older than one day (issue #229).
|
||||
- Fixed dirwatch #DATE, #TIME and #ZTIME regexp to accomodate filenames like 20220711082833 (issue #235).
|
||||
|
||||
_v6.5.3_
|
||||
|
||||
- Return of the -admin_password option to reset the administrator password in case of forgetting.
|
||||
- New `<iframe>` wrapper in `docs/examples/iframe` for those who want to give more information to their users.
|
||||
- Fixed systems.write constraint failed (issue #241).
|
||||
- Add filename to dirwatch error messages (issue #248).
|
||||
- Dirwatch.type=default now defaults to the current date and time if none are provided by the metatags (discussion #250).
|
||||
|
||||
_v6.5.4_
|
||||
|
||||
- Fixed some warnings when linting server code.
|
||||
- New dirwatch type `DSDPlus Fast Lane` (discussion #244).
|
||||
- Added new error catches on dirwatch (issue 254).
|
||||
- Fixed search by inaccurate time (issue #258).
|
||||
- Reverted sync.Map to regular map with sync.Mutex.
|
||||
|
||||
_v6.5.5_
|
||||
|
||||
- Fixed concurrent map read and map write on dirwatch.
|
||||
|
||||
_v6.5.6_
|
||||
|
||||
- Fixed Clients lockup by removing mutex on some unecessary Clients methods.
|
||||
- Better `DSDPlus Fast Lane` parser. Tested with `ConP(BS)`, `DMR(BS)`, `NEXEDGE48(CS)`, `NEXEDGE48(CB)`, `NEXEDGE48(TB)`, `NEXEDGE96(CB)`, `NEXEDGE96(CS)`, `NEXEDGE96(TB)`, `P25(BS)` and `P25`.
|
||||
- Fixed unit aliases not displaying on the main screen under certain circumstances.
|
||||
- New incremental debouncer for emitting listeners count.
|
||||
|
||||
## Version 6.4
|
||||
|
||||
- New `-cmd` command line options to allow advanced administrative tasks.
|
||||
- New playback mode goes live options which is not enabled by default (issue #175).
|
||||
- Fixed logs retrieval from administrative dashboard (issue #193).
|
||||
- Improved field conversions when retrieving calls from a mysql/mariadb database (issue #194, #198).
|
||||
- Highlight replayed call on the history list (issue #196).
|
||||
|
||||
_v6.4.1_
|
||||
|
||||
- New 12-Hour time format option (issue #205).
|
||||
- New audio conversion options which replace the disable audio conversion option.
|
||||
- Keep database connections open and don't close them when idle.
|
||||
- Log the origin of listeners.
|
||||
- Fixed timestamp format when checking for call duplicates.
|
||||
- Fixed http timeouts on call ingestions or admin config save when dowstream takes too long (issue #197).
|
||||
|
||||
_v6.4.2_
|
||||
|
||||
- Revert the last changes to the SDR Trunk parser (issue #206).
|
||||
|
||||
_v6.4.3_
|
||||
|
||||
- Add a note on the dirwatch admin screen about sdr-trunk.
|
||||
- Starts client read/write pumps before registering with the controller (issue #181, #212).
|
||||
|
||||
_v6.4.4_
|
||||
|
||||
- Don't emit calls to listeners in separate GO routine to stay in sync with call ingestion.
|
||||
|
||||
_v6.4.5_
|
||||
|
||||
- SQL idle connections now expiring after 1 minute.
|
||||
- Reverted defer rows.Close() to simple rows.Close().
|
||||
|
||||
## Version 6.3
|
||||
|
||||
- Changed scroll speed when drag droping talkgroups or units in a system (discussion #170).
|
||||
- System Ids listed in the `Config / Options / AFS Systems` will have their talkgroup Ids displayed in AFS format (issue #163).
|
||||
- New dirwatch meta tags #GROUP #SYSLBL #TAG #TGAFS and #UNIT for better ProScan compatibility (issue #164).
|
||||
- Playback mode will now catch up to live (issue #175).
|
||||
- Dirwatch code rewrite (issue #177).
|
||||
|
||||
_v6.3.1_
|
||||
|
||||
- Playback mode catch up to live, then swith to livefeed mode.
|
||||
- Removed the mutex lock on Clients.Count which led to a deadlock and froze call ingestion.
|
||||
|
||||
_v6.3.2_
|
||||
|
||||
- New #TGLBL metatag for dirwatch for ProScan (%C) or alike.
|
||||
- Fixed `semacquire` lockup in Clients (issue #177, #181, #182).
|
||||
- Replay button now replays from history if pressed multiple times quickly (issue #186).
|
||||
|
||||
_v6.3.3_
|
||||
|
||||
- Fixed concurrent map writes fatal error in dirwatch (issue #187).
|
||||
- Brighter LED colors and new orange color.
|
||||
- Fixed call id when retrieved from a MySQL database.
|
||||
- Add loudnorm audio filter to the ffmpeg audio conversion.
|
||||
- Show the real IP address in the logs taking into account if behind a proxy.
|
||||
- Fixed panic when emitting a call to clients.
|
||||
|
||||
_v6.3.4_
|
||||
|
||||
- Fixed ffmpeg audio filter not available on older version (issue #189).
|
||||
- Improved logging when run as a service, Windows users can now see these logs in the events viewer.
|
||||
- Dirwatch now catches panic errors and logs them.
|
||||
|
||||
_v6.3.5_
|
||||
|
||||
- Replace standard map with sync.map in dirwatch.
|
||||
- Fixed the ffmpeg version test.
|
||||
- Fixed led color type, orage -> orange.
|
||||
- Fixed incorrect options when reading from a mysql database (issue #190).
|
||||
|
||||
_v6.3.6_
|
||||
|
||||
- Fixed systems order properties not sent to clients.
|
||||
- Fixed side panels not scrolling to top when opened.
|
||||
|
||||
## Version 6.2
|
||||
|
||||
- New max clients options which is 200 by default.
|
||||
- New show listeners count options which is disabled by default (issue #125).
|
||||
- Fixed panic: concurrent write to websocket connection on goroutine.
|
||||
- Fixed units import from SDR Trunk (issue #150).
|
||||
|
||||
_v6.2.1_
|
||||
|
||||
- Fixed SIGSEGV error in Units.Merge (issue #151).
|
||||
|
||||
_v6.2.2_
|
||||
|
||||
- Fixed another SIGSEGV error in Units.Merge (issue #151).
|
||||
|
||||
_v6.2.3_
|
||||
|
||||
- New random UUID in the JSON-Web Token payload.
|
||||
- Fixed dirwatch not properly shutting down when a new configuration is applied.
|
||||
- Fixed dashboard logout not sending HTTP 200 OK status.
|
||||
- Clear the active dirwatch list when stopped.
|
||||
- Pauses calls ingestion before database pruning.
|
||||
- Fixed regex for units in driwatch type SDRTrunk (discussion #155).
|
||||
- Update SQLite driver.
|
||||
|
||||
_v6.2.4_
|
||||
|
||||
- Fixed call frequencies table not being transmitted to downstream.
|
||||
- Avoid using setInterval and setTimeout in the webapp.
|
||||
- Fixed talkgroup search filter upon new configuration (issue #158).
|
||||
|
||||
_v6.2.5_
|
||||
|
||||
- Fixed unnecessary auto populate of unit id/label (issue #160).
|
||||
|
||||
## Version 6.1
|
||||
|
||||
- Calls now support patched talkgroups.
|
||||
- New search patched talkgroups option which is disabled by default.
|
||||
- Talkgroups and units are now stored in their own database table.
|
||||
- New units CSV importer.
|
||||
- Fixed blacklisted talkgroups being created anyway when autopopulate is enabled.
|
||||
- Fixed compatibility with mysql/mariadb (default sqlite is still recommended).
|
||||
|
||||
_v6.1.1_
|
||||
|
||||
- Fixed `unknown datetime format sql.NullString` error.
|
||||
|
||||
_v6.1.2_
|
||||
|
||||
- Fixed image links in webapp.md (issue #76).
|
||||
- Fixed SIGSEGV when trying to autopopulate (issue #77).
|
||||
- Fixed parsing SDRTrunk meta data.
|
||||
- Dirwatch type trunk-recorder now deletes json files without audio (when deleteAfter is set).
|
||||
- Add a new `docs/update-from-v5.md` document.
|
||||
|
||||
_v6.1.3_
|
||||
|
||||
- Fixed concurrent config write when autopopulate is enabled (issue #77).
|
||||
- Fixed API in regards to audio filename and audio type (issue #78).
|
||||
- Fixed migration error on mysql database (issue #86).
|
||||
- Fixed some calls not playing on the native app (issue #87).
|
||||
- Fixed admin password not read from mysql.
|
||||
|
||||
_v6.1.4_
|
||||
|
||||
- Talkgroup label now syncs with the talkgroup_tag from the API or dirwatch (issue #80).
|
||||
- Fixed more migration errors on mysql database (issue #86).
|
||||
- Fixed config export not working with non latin-1 characters (issue #89).
|
||||
- Fixed talkgroup label from dirwatch type sdrtrunk (discussion #98).
|
||||
- Fixed SIGSEGV (issue #100).
|
||||
- New `patch` indicator for patched talkgroups.
|
||||
|
||||
_v6.1.5_
|
||||
|
||||
- Fixed trunk-recorder API (issue #104).
|
||||
- Fixed for avoid/patch flags on main display not beaving as expected.
|
||||
- Fixed downstream not sending sources data.
|
||||
- Fixed dirwatch crashing when config is updated.
|
||||
|
||||
_v6.1.6_
|
||||
|
||||
- Fixed webapp not reporting the correct version.
|
||||
|
||||
_v6.1.7_
|
||||
|
||||
- More concurrency mutexes to resolve SQL_BUSY errors.
|
||||
- Better internal management of dirwatches.
|
||||
- Fixed SDRTrunk files not being ingested (discussion #108).
|
||||
- Fixed Trunk Recorder talkgroup_tag assign to the wrong property (issue #115).
|
||||
- Improved the way the talkgroup label and name are autopopulated. If Trunk Recorder sends a talkgroup_tag with an empty value or with a single `-`, it will not overwrite the talkgroup label.
|
||||
|
||||
_v6.1.8_
|
||||
|
||||
- New dirwatch masks #TGHZ, #TGKHZ and #TGMHZ which allow to set talkgroup id based on frequency.
|
||||
|
||||
_v6.1.9_
|
||||
|
||||
- Fixed talkgroup sorting issue when importing from a CSV file (issue #119).
|
||||
- Fixed SIGSEGV (issue #120).
|
||||
|
||||
_v6.1.10_
|
||||
|
||||
- Backport dirwatch delay value from v5.1.
|
||||
|
||||
_v6.1.11_
|
||||
|
||||
- Fixed connection errors when behind a reverse-proxy.
|
||||
- Fixed disappearing talkgroups (issue #127).
|
||||
|
||||
_v6.1.12_
|
||||
|
||||
- Fixed too many open files (issue #129).
|
||||
- Cosmetic: AVOID and PATCH flags now only appear when needed.
|
||||
|
||||
_v6.1.13_
|
||||
|
||||
- Better handling of dead client connections.
|
||||
- Fixed too many open files (issue #129).
|
||||
- Remove net.http error messages from the output (issue #131).
|
||||
|
||||
_v6.1.14_
|
||||
|
||||
- Fixed FAQ section not being added to the PDF documents.
|
||||
- Bump delay before killing unauthenticated clients from 10 seconds to 60 seconds.
|
||||
- Remove the gitter.im support forum from the documentation and prefer github discussions.
|
||||
|
||||
_v6.1.15_
|
||||
|
||||
- Fixed access and downstreams order not retained.
|
||||
- Remove the self-signed certificate generator (-ssl create) as it was causing more problems than solutions.
|
||||
- Client handling and call ingestion now run on 2 different threads (issue #135).
|
||||
- Fixed downstream talkgroup select keeps reverting to all talkgroups (issue #136).
|
||||
|
||||
_v6.1.16_
|
||||
|
||||
- Fixed concurrent map access for clients.
|
||||
- Some tweaks to websocket management.
|
||||
|
||||
## Version 6.0
|
||||
|
||||
- Backend server rewritten in Go for better performance and ease of installation.
|
||||
|
|
@ -22,24 +338,24 @@ The backend server has been completely rewritten in GO language. Therefore, all
|
|||
|
||||
_v6.0.1_
|
||||
|
||||
- Fix button sound on select panel for TG (beep state inverted)
|
||||
- Fixed button sound on select panel for TG (beep state inverted)
|
||||
- Auto populate system units (issue #66)
|
||||
|
||||
_v6.0.2_
|
||||
|
||||
- Try to fix the SQL_BUSY error (issue #67).
|
||||
- Fix `-service stop` timing out before exiting.
|
||||
- Fixed `-service stop` timing out before exiting.
|
||||
- Drop the ApiKey uniqueness of the downstreams database table.
|
||||
- Fix auto-populating the database with empty units tag.
|
||||
- Fixed auto-populating the database with empty units tag.
|
||||
|
||||
_v6.0.3_
|
||||
|
||||
- Fix strconv.Atoi: invalid syntax for dirwatch type sdrtrunk.
|
||||
- Fix the new version available dialog opening more than once.
|
||||
- Fixed strconv.Atoi: invalid syntax for dirwatch type sdrtrunk.
|
||||
- Fixed the new version available dialog opening more than once.
|
||||
|
||||
_v6.0.4_
|
||||
|
||||
- Fix wrong time calculation in prune scheduler.
|
||||
- Fixed wrong time calculation in prune scheduler.
|
||||
- More fix on the SQL_BUSY error (issue #67).
|
||||
- Support files (certs, db, ini) are now created in the same folder as the executable, if the folder is writable, or under a `Rdio Scanner` folder in the user's home folder.
|
||||
- Some code refactoring.
|
||||
|
|
@ -52,22 +368,26 @@ _v6.0.5_
|
|||
|
||||
_v6.0.6_
|
||||
|
||||
- Fix an issue with not closing the database when restarting the host platform (issue #71).
|
||||
- Fixed an issue with not closing the database when restarting the host platform (issue #71).
|
||||
- Fixed SDRTunk parser when artist tag contains CTCSS tones.
|
||||
- Platforms linux/amd64, linux/arm and linux/arm64 are now available for the Docker container.
|
||||
|
||||
_v6.0.7_
|
||||
|
||||
- Fixed dropped connections when going through a proxy.
|
||||
|
||||
## Version 5.2
|
||||
|
||||
- Change to how the server reports version.
|
||||
- Fix cmd.js exiting on inexistant session token keystore.
|
||||
- Fix issue with iframe.
|
||||
- Fixed cmd.js exiting on inexistant session token keystore.
|
||||
- Fixed issue with iframe.
|
||||
- Node modules updated for security fixes.
|
||||
|
||||
_v5.2.1_
|
||||
|
||||
- Fix talkgroup header on the search panel (issue #47).
|
||||
- Fixed talkgroup header on the search panel (issue #47).
|
||||
- Update dirwatch meta tags #DATE, #TIME and #ZTIME for SDRSharp compatibility (issue #48).
|
||||
- Fix dirwath date and time parsing bug.
|
||||
- Fixed dirwath date and time parsing bug.
|
||||
- Configurable call duplicate detection time frame.
|
||||
|
||||
_v5.2.2_
|
||||
|
|
@ -78,13 +398,13 @@ _v5.2.2_
|
|||
_v5.2.3_
|
||||
|
||||
- Change history columns padding from 1px to 6px on the main screen.
|
||||
- Fix a bug in the admin api where the server crash when saving new config from the admin dashboard.
|
||||
- Fixed a bug in the admin api where the server crash when saving new config from the admin dashboard.
|
||||
|
||||
_v5.2.4_
|
||||
|
||||
- Updated to Angular 12.2.
|
||||
- New update prompt for clients when server is updated.
|
||||
- Fix unaligned back arrow on the search panel.
|
||||
- Fixed unaligned back arrow on the search panel.
|
||||
|
||||
_v5.2.5_
|
||||
|
||||
|
|
@ -95,20 +415,20 @@ _v5.2.5_
|
|||
|
||||
_v5.2.6_
|
||||
|
||||
- Fix crash when when options.pruneDays = 0.
|
||||
- Fixed crash when when options.pruneDays = 0.
|
||||
|
||||
_v5.2.7_
|
||||
|
||||
- Fix handling of JSON datatypes on MySQL/MariaDB database backend.
|
||||
- Fix listeners count.
|
||||
- Fixed handling of JSON datatypes on MySQL/MariaDB database backend.
|
||||
- Fixed listeners count.
|
||||
|
||||
_V5.2.8_
|
||||
|
||||
- Fix SQLite does not support TEXT with options.
|
||||
- Fixed SQLite does not support TEXT with options.
|
||||
|
||||
_V5.2.9_
|
||||
|
||||
- Fix bad code for server options parsing.
|
||||
- Fixed bad code for server options parsing.
|
||||
- Increase dirwatch polling interval from 1000ms to 2500ms.
|
||||
|
||||
## Version 5.1
|
||||
|
|
@ -131,15 +451,15 @@ This one is a big one... **Be sure to backup your config.json and your database.
|
|||
|
||||
_v5.1.1_
|
||||
|
||||
- Fix database migration script to version 5.1 to filter out duplicate property values on unique fields.
|
||||
- Fix payload too large error message when saving configuration from the administrative dashboard.
|
||||
- Fixed database migration script to version 5.1 to filter out duplicate property values on unique fields.
|
||||
- Fixed payload too large error message when saving configuration from the administrative dashboard.
|
||||
- Bring back the load-rrdb, load-tr and random uuid command line tools.
|
||||
|
||||
_v5.1.2_
|
||||
|
||||
- Fix config class not returning proper id properties when new records are added.
|
||||
- Fix database migration script to version 5.1 when on mysql.
|
||||
- Fix bad logic in apiKey validation.
|
||||
- Fixed config class not returning proper id properties when new records are added.
|
||||
- Fixed database migration script to version 5.1 when on mysql.
|
||||
- Fixed bad logic in apiKey validation.
|
||||
- Remove the autoJsonMap from the sequelize dialectOptions.
|
||||
- Client updated to angular 12.
|
||||
|
||||
|
|
@ -153,15 +473,15 @@ _v5.1.2_
|
|||
_v5.0.1_
|
||||
|
||||
- Remove the EBU R128 loudness normalization as it's not working as intended.
|
||||
- Fix the API key validation when using the complex syntax.
|
||||
- Fixed the API key validation when using the complex syntax.
|
||||
|
||||
_v5.0.2_
|
||||
|
||||
- Fix rdioScanner.options.disableAudioConversion which was ignored when true.
|
||||
- Fixed rdioScanner.options.disableAudioConversion which was ignored when true.
|
||||
|
||||
_v5.0.3_
|
||||
|
||||
- Fix error with docker builds where sequelize can't find the sqlite database.
|
||||
- Fixed error with docker builds where sequelize can't find the sqlite database.
|
||||
|
||||
_v5.0.4_
|
||||
|
||||
|
|
@ -188,7 +508,7 @@ _v4.9.1_
|
|||
|
||||
- Add EBU R128 loudness normalization.
|
||||
- dirWatch.type="trunk-recorder" now deletes the JSON file in case the audio file is missing.
|
||||
- Fix downstream sending wrong talkgroup id.
|
||||
- Fixed downstream sending wrong talkgroup id.
|
||||
|
||||
_v4.9.2_
|
||||
|
||||
|
|
@ -198,8 +518,8 @@ _v4.9.2_
|
|||
|
||||
- Add downstream.system.id_as property to allow export system with a different id.
|
||||
- Add system.order for system list ordering on the client side.
|
||||
- Fix client main screen unscrollable overflow while in landscape.
|
||||
- Fix issue 26 - date in documentation for mask isn't clear.
|
||||
- Fixed client main screen unscrollable overflow while in landscape.
|
||||
- Fixed issue 26 - date in documentation for mask isn't clear.
|
||||
- The skip button now also allows you to skip the one second delay between calls.
|
||||
- Node modules update.
|
||||
|
||||
|
|
@ -210,26 +530,26 @@ _v4.8.1_
|
|||
|
||||
_v4.8.2_
|
||||
|
||||
- Fix dirWatch.type='sdr-trunk' metatag artist as source is now optional.
|
||||
- Fix dirWatch.type='sdr-trunk' metatag title as talkgroup.id.
|
||||
- Fixed dirWatch.type='sdr-trunk' metatag artist as source is now optional.
|
||||
- Fixed dirWatch.type='sdr-trunk' metatag title as talkgroup.id.
|
||||
- Web app now running with Angular 11.
|
||||
- Node modules update.
|
||||
|
||||
_v4.8.3_
|
||||
|
||||
- Add the ability to overwrite the default dirWatch extension for type sdr-trunk and trunk-recorder.
|
||||
- Fix dirWatch.disabled being ignored.
|
||||
- Fixed dirWatch.disabled being ignored.
|
||||
- Node modules update.
|
||||
|
||||
_v4.8.4_
|
||||
|
||||
- Fix the timezone issue when on mariadb.
|
||||
- Fix downstream sending wrong talkgroup id.
|
||||
- Fixed the timezone issue when on mariadb.
|
||||
- Fixed downstream sending wrong talkgroup id.
|
||||
- Node modules security update.
|
||||
|
||||
_v4.8.5_
|
||||
|
||||
- Fix broken dirwatch.delay.
|
||||
- Fixed broken dirwatch.delay.
|
||||
- Node modules update.
|
||||
|
||||
## Version 4.7
|
||||
|
|
@ -243,37 +563,37 @@ _v4.8.5_
|
|||
|
||||
_v4.7.1_
|
||||
|
||||
- Fix crash on client when access to talkgroups is restricted with a password.
|
||||
- Fixed crash on client when access to talkgroups is restricted with a password.
|
||||
|
||||
_v4.7.2_
|
||||
|
||||
- Fix Keypad beeps not working on iOS.
|
||||
- Fix pause not going off due to the above bug.
|
||||
- Fixed Keypad beeps not working on iOS.
|
||||
- Fixed pause not going off due to the above bug.
|
||||
|
||||
_v4.7.3_
|
||||
|
||||
- Fix websocket not connection on ssl.
|
||||
- Fixed websocket not connection on ssl.
|
||||
|
||||
_v4.7.4_
|
||||
|
||||
- Fix display width too wide when long talkgroup name.
|
||||
- Fixed display width too wide when long talkgroup name.
|
||||
|
||||
_v4.7.5_
|
||||
|
||||
- Fix playback mode getting mixed up if clicking too fast on play.
|
||||
- Fix side panels background color inheritance.
|
||||
- Fixed playback mode getting mixed up if clicking too fast on play.
|
||||
- Fixed side panels background color inheritance.
|
||||
- Node modules update.
|
||||
|
||||
_v4.7.6_
|
||||
|
||||
- Fix search results not going back to page 1 when search filters are modified.
|
||||
- Fixed search results not going back to page 1 when search filters are modified.
|
||||
- Skip next button no longer emit a denied beeps sequence when pushed while there's no audio playing.
|
||||
- Node modules update.
|
||||
|
||||
## Version 4.6
|
||||
|
||||
- Fix documentation in regards to load-rrd in install-github.md.
|
||||
- Fix database absolute path in config.json.
|
||||
- Fixed documentation in regards to load-rrd in install-github.md.
|
||||
- Fixed database absolute path in config.json.
|
||||
- Remove config.options.useLed.
|
||||
- Rename Config.options.keyBeep to Config.options.keypadBeeps.
|
||||
- Config.options.keypadBeeps now with presets instead of full pattern declaration.
|
||||
|
|
@ -289,7 +609,7 @@ _v4.7.6_
|
|||
- Config.systems.talkgroups.patches to group many talkgroups (patches) into one talkgroup.id.
|
||||
- Config.options now groups allowDownloads, disableAudioConversion, pruneDays, useDimmer, useGroup and useLed options instead of having them spread all over the config file.
|
||||
- Client will always display talkgroup id on the right side instead of 0 when call is analog.
|
||||
- Fix annoying bug when next call queued to play is still played even though offline continuous play mode is turned off.
|
||||
- Fixed annoying bug when next call queued to play is still played even though offline continuous play mode is turned off.
|
||||
- Talkgroup ID is displayed no matter what and unit ID is displayed only if known.
|
||||
|
||||
## Version 4.3
|
||||
|
|
@ -298,22 +618,22 @@ _v4.7.6_
|
|||
- Automatic database migration on startup.
|
||||
- Client now on Angular 10 in strict mode.
|
||||
- Dockerized.
|
||||
- Fix downstream not being triggered when a new call imported.
|
||||
- Fix dirWatch mask parser and new mask metatags.
|
||||
- Fix stop button on the search panel when in offline play mode.
|
||||
- Fix SSL certificate handling.
|
||||
- Fixed downstream not being triggered when a new call imported.
|
||||
- Fixed dirWatch mask parser and new mask metatags.
|
||||
- Fixed stop button on the search panel when in offline play mode.
|
||||
- Fixed SSL certificate handling.
|
||||
- Rewritten documentation.
|
||||
|
||||
## Version 4.2
|
||||
|
||||
- Fix possible race conditions....
|
||||
- Fixed possible race conditions....
|
||||
- Added websocket keepalive which helps mobile clients when switching from/to wifi/wan.
|
||||
- Better playback offline mode animations and queue count.
|
||||
- New dirWatch.mask option to simplify meta data import.
|
||||
|
||||
## Version 4.1
|
||||
|
||||
- New offline playback mode.
|
||||
- New playback mode.
|
||||
|
||||
## Version 4.0
|
||||
|
||||
|
|
|
|||
19
COMPILING.md
19
COMPILING.md
|
|
@ -1,5 +1,7 @@
|
|||
# How to compile
|
||||
|
||||
> RDIO SCANNER REPRESENTS A LOT OF INVESTMENT IN TIME, SUPPORT, TESTS AND MATERIALS. BY INTENTIONALLY MODIFYING ITS CODE TO REMOVE ITS IDENTITY, YOU HARM THE OPEN SOURCE PROJECT AND ITS AUTHOR. PLEASE BE RESPECTFUL, SPONSOR THIS PROJECT, USE NATIVE APPS IF POSSIBLE.
|
||||
|
||||
The official version of [Rdio Scanner](https://github.com/chuot/rdio-scanner) is compiled on a PC using the current version of Fedora Workstation. You should have no problem building on another platform as long as the prerequisites are available and installed.
|
||||
|
||||
## Install the prerequisites
|
||||
|
|
@ -11,6 +13,7 @@ Your os distribution may have all of the following prerequisites available in it
|
|||
- latest long-term support version of Node.js ([here](https://nodejs.org/en/))
|
||||
- latest version of Go ([here](https://go.dev/dl/))
|
||||
- latest version of Pandoc with pandoc-pdf ([here](https://pandoc.org/installing.html))
|
||||
- latest version of TeX Live ([here](https://www.tug.org/texlive/))
|
||||
- latest version of Info-Zip ([here](http://infozip.sourceforge.net/))
|
||||
- latest version of podman ([here](https://podman.io/)), only for building containers
|
||||
|
||||
|
|
@ -24,13 +27,13 @@ Clone the official repository on your computer and start the build process.
|
|||
|
||||
When finished, you will find the precompiled versions for various platforms in the `dist` folder.
|
||||
|
||||
rdio-scanner-darwin-amd64-v6.0.6.zip
|
||||
rdio-scanner-darwin-arm64-v6.0.6.zip
|
||||
rdio-scanner-freebsd-amd64-v6.0.6.zip
|
||||
rdio-scanner-linux-386-v6.0.6.zip
|
||||
rdio-scanner-linux-amd64-v6.0.6.zip
|
||||
rdio-scanner-linux-arm64-v6.0.6.zip
|
||||
rdio-scanner-linux-arm-v6.0.6.zip
|
||||
rdio-scanner-windows-amd64-v6.0.6.zip
|
||||
rdio-scanner-darwin-amd64-v7.0.0.zip
|
||||
rdio-scanner-darwin-arm64-v7.0.0.zip
|
||||
rdio-scanner-freebsd-amd64-v7.0.0.zip
|
||||
rdio-scanner-linux-386-v7.0.0.zip
|
||||
rdio-scanner-linux-amd64-v7.0.0.zip
|
||||
rdio-scanner-linux-arm64-v7.0.0.zip
|
||||
rdio-scanner-linux-arm-v7.0.0.zip
|
||||
rdio-scanner-windows-amd64-v7.0.0.zip
|
||||
|
||||
**Happy Rdio scanning !**
|
||||
|
|
@ -1,22 +1,10 @@
|
|||
# Guide lines for contributing to [Rdio Scanner](https://github.com/chuot/rdio-scanner)
|
||||
|
||||
Please follow these simple guidelines to contribute to [Rdio Scanner](https://github.com/chuot/rdio-scanner). Discuss the change first through [GitHub Issues](https://github.com/chuot/rdio-scanner/issues), email, or any other method with the [owner and project manager](https://github.com/chuot).
|
||||
Please follow these simple guidelines to contribute to [Rdio Scanner](https://github.com/chuot/rdio-scanner). Discuss the change first through [GitHub Discussions](https://github.com/chuot/rdio-scanner/discussions), email, or any other method with the [owner and project manager](https://github.com/chuot).
|
||||
|
||||
## Support requests or general question
|
||||
|
||||
Any support request or general question is welcome in the [Gitter Rdio Scanner Lobby](https://gitter.im/rdio-scanner/Lobby). Do not create an issue for this, it will be rejected and closed immediately.
|
||||
|
||||
Helping others on the (https://github.com/chuot/rdio-scanner/wiki) is a good way for contributing to the project.
|
||||
|
||||
## Wiki pages
|
||||
|
||||
Any contribution to the [Rdio Scanner Wiki](https://github.com/chuot/rdio-scanner/wiki) is welcome and encouraged. It is impossible for the [owner and project manager](https://github.com/chuot) to cover all the [Rdio Scanner](https://github.com/chuot/rdio-scanner) use cases, sharing your own use cases with guided instructions will for sure benefit others.
|
||||
|
||||
It is also not possible for the [owner and project manager](https://github.com/chuot) to cover all the [Rdio Scanner](https://github.com/chuot/rdio-scanner) use cases, sharing your own use cases with guided instructions is sure to benefit others.
|
||||
|
||||
## Random Pull Requests
|
||||
|
||||
To keep the project stable and well organized, random pull requests will be rejected if no issue have been previously opened and assigned first by the [owner and project manager](https://github.com/chuot).
|
||||
Support requests or general questions are welcome on the [Rdio Scanner Discussions](https://github.com/chuot/rdio-scanner/discussions). Do not create an issue for this, it will be rejected and closed immediately.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
|
|
@ -29,3 +17,11 @@ For **bugs**, describe the environment in which you encounter it and explain ste
|
|||
For **feature requests**, describe the reason and use case.
|
||||
|
||||
Most of the time, the issue will be assigned to the [owner and project manager](https://github.com/chuot) and then resolved in a timely manner. In some cases, the issue will be identified as _help needed_ and then assigned to a volonteer contributor. Again, pull requests will be rejected if they are not related to a previously opened and assigned issue.
|
||||
|
||||
## Contributor Agreement
|
||||
|
||||
By submitting a contribution to this repository, you agree to the terms of the [Individual Contributor Assignment Agreement](./ICAA.md).
|
||||
|
||||
## Random Pull Requests
|
||||
|
||||
To keep the project stable and well organized, random pull requests will be rejected if no issue have been previously opened and assigned first by the [owner and project manager](https://github.com/chuot).
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@ FROM docker.io/alpine:latest
|
|||
LABEL maintainer="Chrystian Huot <chrystian.huot@saubeo.solutions>"
|
||||
WORKDIR /app
|
||||
ENV DOCKER=1
|
||||
COPY dist/linux-amd64/rdio-scanner.pdf .
|
||||
COPY server/. server/.
|
||||
RUN mkdir -p /app/data && \
|
||||
apk --no-cache --no-progress --virtual .build add go && \
|
||||
cd server && \
|
||||
go build -o ../rdio-scanner && \
|
||||
cd .. && \
|
||||
rm -fr server && \
|
||||
rm -fr server /root/.cache /root/go && \
|
||||
apk del .build && \
|
||||
apk --no-cache --no-progress add ffmpeg tzdata
|
||||
apk --no-cache --no-progress add ffmpeg mailcap tzdata
|
||||
VOLUME [ "/app/data" ]
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT [ "./rdio-scanner", "-base_dir", "/app/data" ]
|
||||
102
ICAA.md
Normal file
102
ICAA.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Individual Contributor Assignment Agreement (ICAA)
|
||||
|
||||
_For contributions to the rdio-scanner project (<https://github.com/chuot/rdio-scanner>)_
|
||||
|
||||
Thank you for your interest in contributing to **rdio-scanner**, an open-source project owned and maintained by **Saubeo Solutions** (<https://saubeo.solutions>).
|
||||
This Contributor Agreement explains the rights you transfer to Saubeo Solutions when you submit contributions.
|
||||
|
||||
By submitting a pull request, patch, or contribution to this Project, **you accept and agree to the terms of this Agreement**. Maintainers may also require explicit sign-off for some or all contributions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
- **"You"** means the individual submitting a Contribution.
|
||||
- **"Contribution"** means any code, documentation, design, data, or other material submitted to the rdio-scanner project in any form.
|
||||
- **"Project"** means the rdio-scanner repository and all related materials maintained by Saubeo Solutions.
|
||||
- **"Saubeo Solutions"** means the legal owner of the Project and its copyrights.
|
||||
|
||||
---
|
||||
|
||||
## 2. Assignment of Copyright and Related Rights
|
||||
|
||||
You hereby irrevocably assign to Saubeo Solutions, and agree to assign upon creation to the fullest extent permitted by law, all right, title, and interest in and to your Contribution, including all copyrights and related rights, worldwide and for the full duration of those rights.
|
||||
|
||||
This assignment includes the right for Saubeo Solutions to use, reproduce, modify, adapt, publish, distribute, sublicense, relicense, and otherwise commercialize your Contribution as part of the Project or any derivative work, including under open-source or commercial terms.
|
||||
|
||||
To the extent any right cannot be assigned under applicable law, you grant Saubeo Solutions an exclusive, perpetual, worldwide, royalty-free, irrevocable, sublicensable license to that right.
|
||||
|
||||
---
|
||||
|
||||
## 3. Further Assurances
|
||||
|
||||
You agree to execute and deliver any additional documents reasonably requested by Saubeo Solutions to confirm, register, perfect, or enforce the rights transferred under this Agreement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Patent License
|
||||
|
||||
You grant Saubeo Solutions a **perpetual, worldwide, non-exclusive, royalty-free, irrevocable patent license** to:
|
||||
|
||||
- make, have made, use, offer to sell, sell, import, and otherwise transfer
|
||||
- any Contribution you submit,
|
||||
- including any patent claims you own or control that would be infringed by your Contribution.
|
||||
|
||||
If you initiate patent litigation against Saubeo Solutions over your Contribution, this patent license terminates immediately.
|
||||
|
||||
---
|
||||
|
||||
## 5. Moral Rights
|
||||
|
||||
To the extent permitted by law, you waive any **moral rights** you may have in your Contribution, such as rights of attribution or integrity.
|
||||
Where such waiver is not legally permitted, you agree not to assert those rights against Saubeo Solutions or downstream recipients to the extent necessary to exercise the rights granted under this Agreement.
|
||||
|
||||
Saubeo Solutions will make reasonable efforts to credit contributors in accordance with open-source norms.
|
||||
|
||||
---
|
||||
|
||||
## 6. Your Representations
|
||||
|
||||
By submitting a Contribution, you confirm that:
|
||||
|
||||
1. **You wrote the Contribution yourself**, or you otherwise have sufficient rights to submit it under this Agreement.
|
||||
2. **You are legally entitled to transfer the rights in this Agreement**.
|
||||
3. If your employer or another entity has rights in your Contribution, **you have obtained all necessary permissions** to submit and transfer rights under this Agreement.
|
||||
4. **Your Contribution does not knowingly violate any third-party rights**, including copyrights, patents, or trade secrets.
|
||||
5. If your Contribution includes third-party code or content, **you will clearly identify it and its license terms**, and you represent that such inclusion is permitted and license-compatible with the Project.
|
||||
|
||||
---
|
||||
|
||||
## 7. No Warranty
|
||||
|
||||
Your Contribution is provided **"as is"**, without warranties or conditions of any kind.
|
||||
|
||||
---
|
||||
|
||||
## 8. Governing Law and Dispute Resolution
|
||||
|
||||
This Agreement is intended for worldwide use.
|
||||
|
||||
Any dispute arising from this Agreement will first be addressed through good-faith negotiation.
|
||||
If no resolution is reached, the dispute will be finally settled by binding arbitration in a mutually agreed venue and language.
|
||||
If arbitration is not legally available, disputes will be resolved by a court of competent jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
## 9. Limitation of Liability
|
||||
|
||||
To the maximum extent permitted by law, neither party will be liable to the other for any indirect, incidental, special, exemplary, or consequential damages arising out of this Agreement.
|
||||
|
||||
---
|
||||
|
||||
## 10. Severability and Survival
|
||||
|
||||
If any provision of this Agreement is held unenforceable, the remaining provisions will remain in full force and effect.
|
||||
|
||||
Sections 2, 3, 4, 5, 6, 7, 9, and 10 survive termination of this Agreement.
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance
|
||||
|
||||
By submitting a Contribution to the Project (including via GitHub pull request), **you agree to the terms of this Contributor Agreement**.
|
||||
75
Makefile
75
Makefile
|
|
@ -1,5 +1,5 @@
|
|||
################################################################################
|
||||
## Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
## Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
##
|
||||
## This program is free software: you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -16,20 +16,20 @@
|
|||
################################################################################
|
||||
|
||||
app := rdio-scanner
|
||||
date := 2021/12/13
|
||||
ver := 6.0.6
|
||||
date := 2026/05/16
|
||||
ver := 7.0.0-dev
|
||||
|
||||
client := $(wildcard client/*.json client/*.ts)
|
||||
server := $(wildcard server/*.go)
|
||||
|
||||
build = @cd server && GOOS=$(1) GOARCH=$(2) go build -o ../dist/$(1)-$(2)/$(3)
|
||||
pandoc = @test -d dist/$(1)-$(2) || mkdir -p dist/$(1)-$(2) && pandoc -f markdown -o dist/$(1)-$(2)/$(3) --resource-path docs:docs/platforms $(4) docs/webapp.md CHANGELOG.md
|
||||
build = @cd server && GOOS=$(1) GOARCH=$(3) go build -o ../dist/$(2)-$(3)/$(4)
|
||||
pandoc = @test -d dist/$(1)-$(2) || mkdir -p dist/$(1)-$(2) && pandoc -f markdown -o dist/$(1)-$(2)/$(3) --resource-path docs:docs/platforms $(4) docs/webapp.md docs/faq.md CHANGELOG.md
|
||||
zip = @cd dist/$(1)-$(2) && zip -q ../$(app)-$(1)-$(2)-v$(ver).zip * && cd ..
|
||||
|
||||
.PHONY: all clean container dist sed
|
||||
.PHONY: darwin darwin-amd64 darwin-arm64
|
||||
.PHONY: freebsd freebsd-amd64
|
||||
.PHONY: linux linux-386 linux-amd64 linux-arm linux-arm64
|
||||
.PHONY: macos macos-amd64 macos-arm64
|
||||
.PHONY: windows windows-amd64
|
||||
|
||||
all: clean dist
|
||||
|
|
@ -38,15 +38,18 @@ clean:
|
|||
@rm -fr client/node_modules dist server/webapp
|
||||
|
||||
container: webapp linux-amd64
|
||||
@podman build --format docker --platform linux/amd64,linux/arm,linux/arm64 --manifest rdio-scanner:latest-beta .
|
||||
@podman login docker.io
|
||||
@podman manifest rm localhost/rdio-scanner:latest || true
|
||||
@podman build --platform linux/amd64,linux/arm,linux/arm64 --pull --manifest rdio-scanner:latest .
|
||||
@podman manifest push --format v2s2 localhost/rdio-scanner:latest docker://docker.io/chuot/rdio-scanner:latest
|
||||
|
||||
dist: darwin freebsd linux windows
|
||||
dist: freebsd linux macos windows
|
||||
|
||||
sed:
|
||||
@sed -i -re "s|^(\s*\"version\":).*$$|\1 \"$(ver)\"|" client/package.json
|
||||
@sed -i -re "s|^(const\s+Version\s+=).*$$|\1 \"$(ver)\"|" server/version.go
|
||||
@sed -i -re "s|v[0-9]\.[0-9]\.[0-9]|v$(ver)|" COMPILING.md docs/platforms/*.md
|
||||
@sed -i -re "s|[0-9]{4}/[0-9]{2}/[0-9]{2}|$(date)|" docs/platforms/*.md
|
||||
@sed -i -re "s|v[0-9]+\.[0-9]+\.[0-9]+|v$(ver)|" COMPILING.md README.md docs/docker/README.md docs/platforms/*.md
|
||||
@sed -i -re "s|[0-9]{4}/[0-9]{2}/[0-9]{2}|$(date)|" docs/docker/README.md docs/platforms/*.md
|
||||
|
||||
webapp: server/webapp/index.html
|
||||
|
||||
|
|
@ -54,26 +57,12 @@ server/webapp/index.html: $(client)
|
|||
@cd client && test -d node_modules || npm ci --loglevel=error --no-progress
|
||||
@cd client && npm run build
|
||||
|
||||
darwin: darwin-amd64 darwin-arm64
|
||||
darwin-amd64: webapp dist/$(app)-darwin-amd64-v$(ver).zip
|
||||
darwin-arm64: webapp dist/$(app)-darwin-arm64-v$(ver).zip
|
||||
|
||||
dist/$(app)-darwin-amd64-v$(ver).zip: $(server)
|
||||
$(call pandoc,darwin,amd64,rdio-scanner.pdf,docs/platforms/darwin.md,CHANGELOG.md)
|
||||
$(call build,darwin,amd64,$(app))
|
||||
$(call zip,darwin,amd64,$(app))
|
||||
|
||||
dist/$(app)-darwin-arm64-v$(ver).zip: $(server)
|
||||
$(call pandoc,darwin,arm64,rdio-scanner.pdf,docs/platforms/darwin.md,CHANGELOG.md)
|
||||
$(call build,darwin,arm64,$(app))
|
||||
$(call zip,darwin,arm64,$(app))
|
||||
|
||||
freebsd: freebsd-amd64
|
||||
freebsd-amd64: webapp dist/$(app)-freebsd-amd64-v$(ver).zip
|
||||
|
||||
dist/$(app)-freebsd-amd64-v$(ver).zip: $(server)
|
||||
$(call pandoc,freebsd,amd64,rdio-scanner.pdf,docs/platforms/freebsd.md,CHANGELOG.md)
|
||||
$(call build,freebsd,amd64,$(app))
|
||||
$(call pandoc,freebsd,amd64,rdio-scanner.pdf,docs/platforms/freebsd.md)
|
||||
$(call build,freebsd,freebsd,amd64,$(app))
|
||||
$(call zip,freebsd,amd64,$(app))
|
||||
|
||||
linux: linux-386 linux-amd64 linux-arm linux-arm64
|
||||
|
|
@ -83,29 +72,43 @@ linux-arm: webapp dist/$(app)-linux-arm-v$(ver).zip
|
|||
linux-arm64: webapp dist/$(app)-linux-arm64-v$(ver).zip
|
||||
|
||||
dist/$(app)-linux-386-v$(ver).zip: $(server)
|
||||
$(call pandoc,linux,386,rdio-scanner.pdf,docs/platforms/linux.md,CHANGELOG.md)
|
||||
$(call build,linux,386,$(app))
|
||||
$(call pandoc,linux,386,rdio-scanner.pdf,docs/platforms/linux.md)
|
||||
$(call build,linux,linux,386,$(app))
|
||||
$(call zip,linux,386,$(app))
|
||||
|
||||
dist/$(app)-linux-amd64-v$(ver).zip: $(server)
|
||||
$(call pandoc,linux,amd64,rdio-scanner.pdf,docs/platforms/linux.md,CHANGELOG.md)
|
||||
$(call build,linux,amd64,$(app))
|
||||
$(call pandoc,linux,amd64,rdio-scanner.pdf,docs/platforms/linux.md)
|
||||
$(call build,linux,linux,amd64,$(app))
|
||||
$(call zip,linux,amd64,$(app))
|
||||
|
||||
dist/$(app)-linux-arm-v$(ver).zip: $(server)
|
||||
$(call pandoc,linux,arm,rdio-scanner.pdf,docs/platforms/linux.md,CHANGELOG.md)
|
||||
$(call build,linux,arm,$(app))
|
||||
$(call pandoc,linux,arm,rdio-scanner.pdf,docs/platforms/linux.md)
|
||||
$(call build,linux,linux,arm,$(app))
|
||||
$(call zip,linux,arm,$(app))
|
||||
|
||||
dist/$(app)-linux-arm64-v$(ver).zip: $(server)
|
||||
$(call pandoc,linux,arm64,rdio-scanner.pdf,docs/platforms/linux.md,CHANGELOG.md)
|
||||
$(call build,linux,arm64,$(app))
|
||||
$(call pandoc,linux,arm64,rdio-scanner.pdf,docs/platforms/linux.md)
|
||||
$(call build,linux,linux,arm64,$(app))
|
||||
$(call zip,linux,arm64,$(app))
|
||||
|
||||
macos: macos-amd64 macos-arm64
|
||||
macos-amd64: webapp dist/$(app)-macos-amd64-v$(ver).zip
|
||||
macos-arm64: webapp dist/$(app)-macos-arm64-v$(ver).zip
|
||||
|
||||
dist/$(app)-macos-amd64-v$(ver).zip: $(server)
|
||||
$(call pandoc,macos,amd64,rdio-scanner.pdf,docs/platforms/macos.md)
|
||||
$(call build,darwin,macos,amd64,$(app))
|
||||
$(call zip,macos,amd64,$(app))
|
||||
|
||||
dist/$(app)-macos-arm64-v$(ver).zip: $(server)
|
||||
$(call pandoc,macos,arm64,rdio-scanner.pdf,docs/platforms/macos.md)
|
||||
$(call build,darwin,macos,arm64,$(app))
|
||||
$(call zip,macos,arm64,$(app))
|
||||
|
||||
windows: windows-amd64
|
||||
windows-amd64: webapp dist/$(app)-windows-amd64-v$(ver).zip
|
||||
|
||||
dist/$(app)-windows-amd64-v$(ver).zip: $(server)
|
||||
$(call pandoc,windows,amd64,rdio-scanner.pdf,docs/platforms/windows.md,CHANGELOG.md)
|
||||
$(call build,windows,amd64,$(app).exe)
|
||||
$(call pandoc,windows,amd64,rdio-scanner.pdf,docs/platforms/windows.md)
|
||||
$(call build,windows,windows,amd64,$(app).exe)
|
||||
$(call zip,windows,amd64,$(app))
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -1,12 +1,12 @@
|
|||

|
||||
|
||||
# What is it ?
|
||||
## What is it ?
|
||||
|
||||
[Rdio Scanner](https://github.com/chuot/rdio-scanner) is an open source software that ingest and distribute audio files generated by various software-defined radio recorders. Its interface tries to reproduce the user experience of a real police scanner, while adding its own touch.
|
||||
|
||||
You can listen to [Rdio Scanner](https://github.com/chuot/rdio-scanner) on any modern browsers using the integrated web app.
|
||||
|
||||
# Recorders compatibility
|
||||
## Recorders compatibility
|
||||
|
||||
[Rdio Scanner](https://github.com/chuot/rdio-scanner) works with any radio recorder, as long as they can create audio files separated by conversations or transmissions.
|
||||
|
||||
|
|
@ -16,39 +16,73 @@ Here is a list of recorders known to work with [Rdio Scanner](https://github.com
|
|||
| -------------------------------------------------------------- | --- | -------- |
|
||||
| [Trunk Recorder](https://github.com/robotastic/trunk-recorder) | X | X |
|
||||
| [RTLSDR-Airband](https://github.com/szpajder/RTLSDR-Airband) | | X |
|
||||
| [sdrtrunk](https://github.com/DSheirer/sdrtrunk) | | X |
|
||||
| [SDRTrunk](https://github.com/DSheirer/sdrtrunk) | X | |
|
||||
| [voxcall](https://github.com/aaknitt/voxcall) | X | |
|
||||
| [ProScan](https://www.proscan.org/) | | X |
|
||||
| [DSDPlus Fast Lane](https://https://www.dsdplus.com/) | | X |
|
||||
|
||||
# Quick start
|
||||
## Quick start
|
||||
|
||||
ALWAYS DOWNLOAD THE LATEST VERSION OF [RDIO SCANNER](https://github.com/chuot/rdio-scanner) FROM ITS OFFICIAL REPOSITORY AT **[HTTPS://GITHUB.COM/CHUOT/RDIO-SCANNER](https://github.com/chuot/rdio-scanner)**.
|
||||
|
||||
1. Download the the latest precompiled version of [Rdio Scanner](https://github.com/chuot/rdio-scanner) from the [releases tab](https://github.com/chuot/rdio-scanner/releases).
|
||||
1. Download the latest precompiled version of [Rdio Scanner](https://github.com/chuot/rdio-scanner) from the [releases tab](https://github.com/chuot/rdio-scanner/releases).
|
||||
|
||||
| Operating system | Architecture | Use package |
|
||||
| ---------------- | ------------ | ------------------------------------- |
|
||||
| FreeBSD | amd64 | rdio-scanner-freebsd-amd64-v7.0.0.zip |
|
||||
| Linux | 386 | rdio-scanner-linux-386-v7.0.0.zip |
|
||||
| Linux | amd64 | rdio-scanner-linux-amd64-v7.0.0.zip |
|
||||
| Linux | arm | rdio-scanner-linux-arm-v7.0.0.zip |
|
||||
| Linux | arm64 | rdio-scanner-linux-arm64-v7.0.0.zip |
|
||||
| macOS | amd64 | rdio-scanner-macos-amd64-v7.0.0.zip |
|
||||
| macOS | arm64 | rdio-scanner-macos-arm64-v7.0.0.zip |
|
||||
| Windows | amd64 | rdio-scanner-macos-amd64-v7.0.0.zip |
|
||||
|
||||
2. Extract the contents of the archive somewhere on your computer.
|
||||
3. Run the [Rdio Scanner](https://github.com/chuot/rdio-scanner) executable.
|
||||
4. Access the administrative dashboard to finalize the configuration.
|
||||
|
||||
More detailed instructions are available in the `rdio-scanner.pdf` file provided in the precompiled archives.
|
||||
|
||||
# Need help ?
|
||||
## Docker
|
||||
|
||||
You can ask your questions or post your comments on the [Gitter Rdio Scanner Lobby](https://gitter.im/rdio-scanner/Lobby) at **[https://gitter.im/rdio-scanner/Lobby](https://gitter.im/rdio-scanner/Lobby)** where the community keeps growing.
|
||||
As a courtesy to Docker users, [Rdio Scanner](https://github.com/chuot/rdio-scanner) is also distributed as a Docker image where a new version is generated with each new release. More information available at **[https://hub.docker.com/repository/docker/chuot/rdio-scanner](https://hub.docker.com/repository/docker/chuot/rdio-scanner)**.
|
||||
|
||||
# Show your appreciation, support the author
|
||||
## Need help ?
|
||||
|
||||
If you like [Rdio Scanner](https://github.com/chuot/rdio-scanner), **[consider starring the GitHub repository](https://github.com/chuot/rdio-scanner)** to show you appreciation to the author for his hard work. It cost nothing but is really appreciated.
|
||||
### GitHub Wiki
|
||||
|
||||
If you use [Rdio Scanner](https://github.com/chuot/rdio-scanner) for commercial purposes or derive income from it, **[consider sponsoring the project](https://github.com/sponsors/chuot)** to help support continued development.
|
||||
Your question might already be addressed on the [Rdio Scanner GitHub Wiki](https://github.com/chuot/rdio-scanner/wiki), be sure to check it out at **[https://github.com/chuot/rdio-scanner/wiki](https://github.com/chuot/rdio-scanner/wiki)**.
|
||||
|
||||
# Improve your experience on the go
|
||||
### GitHub Discussions
|
||||
|
||||
Feel free to ask your questions or share your comments on the [Rdio Scanner GitHub discussions](https://github.com/chuot/rdio-scanner/discussions) at **[https://github.com/chuot/rdio-scanner/discussions](https://github.com/chuot/rdio-scanner/discussions)**.
|
||||
|
||||
## Show your appreciation, support the author
|
||||
|
||||
If you like [Rdio Scanner](https://github.com/chuot/rdio-scanner), **[consider starring the GitHub repository](https://github.com/chuot/rdio-scanner/stargazers)** to show you appreciation to the author for his hard work. It cost nothing but is really appreciated.
|
||||
|
||||
If you use [Rdio Scanner](https://github.com/chuot/rdio-scanner) for commercial purposes or derive income from it, **[sponsor the project](https://github.com/sponsors/chuot)** to help support continued development.
|
||||
|
||||
## Improve your experience on the go
|
||||
|
||||
You can enjoy your [Rdio Scanner](https://github.com/chuot/rdio-scanner) on the go on your mobile device with the native app.
|
||||
|
||||
[](https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone)
|
||||
[](https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner)
|
||||
|
||||
# Commercial support
|
||||
## Contributing
|
||||
|
||||
Commercial support is available at **[https://saubeo.solutions/](https://saubeo.solutions)**.
|
||||
By submitting a contribution to this repository, you agree to the terms of the [Individual Contributor Assignment Agreement](./ICAA.md).
|
||||
|
||||
**Happy Rdio scanning !**
|
||||
## WebSocket API - Commercial Use
|
||||
|
||||
This project is licensed under the GNU GPL. The **HTTP REST API** is fully available for all uses.
|
||||
|
||||
However, the **WebSocket API** is a proprietary service provided exclusively by Saubeo Solutions for its native applications and the integrated web application. This restriction supports the commercial model that funds this project's development.
|
||||
|
||||
Self-hosted deployments can use the HTTP REST API without limitation. Access to the WebSocket API in self-hosted instances requires a license agreement. For details, see [API_ACCESS_POLICY.md](API_ACCESS_POLICY.md).
|
||||
|
||||
---
|
||||
|
||||
**Happy Rdio scanning !**
|
||||
|
|
|
|||
55
SECURITY.md
Normal file
55
SECURITY.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest version of **rdio-scanner**.
|
||||
Older versions may not receive patches unless the issue is critical.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, we appreciate your help in responsibly disclosing it.
|
||||
|
||||
**Please do NOT open a public GitHub issue.**
|
||||
|
||||
Instead, contact us privately at: **<rdio-scanner@saubeo.solutions>**
|
||||
|
||||
Include as much detail as possible:
|
||||
|
||||
- Steps to reproduce
|
||||
- Impact assessment
|
||||
- Affected versions
|
||||
- Any proof-of-concept code (if applicable)
|
||||
|
||||
We will acknowledge your report within **72 hours**.
|
||||
|
||||
## Disclosure Process
|
||||
|
||||
1. We investigate the report and confirm the vulnerability.
|
||||
2. We work with you (if needed) to understand the issue fully.
|
||||
3. We prepare a fix and coordinate a release.
|
||||
4. We publish a security advisory once the fix is available.
|
||||
|
||||
We aim to resolve validated security issues within **14 days**, depending on severity and complexity.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to:
|
||||
|
||||
- The rdio-scanner codebase
|
||||
- Documentation
|
||||
- Any related scripts or tooling included in the repository
|
||||
|
||||
It does **not** cover:
|
||||
|
||||
- Third-party dependencies
|
||||
- External services or infrastructure not maintained by Saubeo Solutions
|
||||
|
||||
## Responsible Disclosure
|
||||
|
||||
We ask that you:
|
||||
|
||||
- Give us reasonable time to fix the issue before public disclosure
|
||||
- Avoid actions that could harm users or data
|
||||
- Follow ethical security research practices
|
||||
|
||||
We greatly appreciate your contribution to the safety and reliability of rdio-scanner.
|
||||
|
|
@ -16,14 +16,15 @@
|
|||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "rdio-scanner",
|
||||
"architect": {
|
||||
"targets": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "../server/webapp",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"polyfills": [
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
|
|
@ -36,20 +37,21 @@
|
|||
],
|
||||
"scripts": [],
|
||||
"serviceWorker": true,
|
||||
"ngswConfigPath": "ngsw-config.json"
|
||||
"ngswConfigPath": "ngsw-config.json",
|
||||
"main": "src/main.ts"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1500kb",
|
||||
"maximumError": "1500kb"
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "2mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "3kb",
|
||||
"maximumError": "4kb"
|
||||
"maximumWarning": "4kb",
|
||||
"maximumError": "5kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
|
|
@ -61,9 +63,7 @@
|
|||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
|
|
@ -75,11 +75,11 @@
|
|||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "rdio-scanner:build:production"
|
||||
"buildTarget": "rdio-scanner:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "rdio-scanner:build:development",
|
||||
"proxyConfig": "src/proxy.conf.js"
|
||||
"proxyConfig": "src/proxy.conf.js",
|
||||
"buildTarget": "rdio-scanner:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
|
@ -87,14 +87,16 @@
|
|||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "rdio-scanner:build"
|
||||
"buildTarget": "rdio-scanner:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"polyfills": [
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"inlineStyleLanguage": "scss",
|
||||
|
|
@ -112,5 +114,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "rdio-scanner"
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
39191
client/package-lock.json
generated
39191
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,43 +1,44 @@
|
|||
{
|
||||
"author": "Chrystian Huot <chrystian.huot@saubeo.solutions>",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^13.1.0",
|
||||
"@angular/cdk": "^13.1.0",
|
||||
"@angular/common": "~13.1.0",
|
||||
"@angular/compiler": "~13.1.0",
|
||||
"@angular/core": "~13.1.0",
|
||||
"@angular/forms": "~13.1.0",
|
||||
"@angular/material": "^13.1.0",
|
||||
"@angular/platform-browser": "~13.1.0",
|
||||
"@angular/platform-browser-dynamic": "~13.1.0",
|
||||
"@angular/router": "^13.1.0",
|
||||
"@angular/service-worker": "^13.1.0",
|
||||
"rxjs": "~7.4.0",
|
||||
"tslib": "^2.3.1",
|
||||
"zone.js": "~0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~13.1.0",
|
||||
"@angular/cli": "^13.1.0",
|
||||
"@angular/compiler-cli": "~13.1.0",
|
||||
"@types/node": "~16.11.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"ajv-keywords": "^5.1.0",
|
||||
"eslint": "^8.4.1",
|
||||
"npm-check-updates": "^12.0.3",
|
||||
"ts-node": "~10.4.0",
|
||||
"typescript": "~4.4.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0"
|
||||
},
|
||||
"license": "LICENSE",
|
||||
"name": "rdio-scanner",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "ng build --base-href ./ --configuration production",
|
||||
"start": "ng serve"
|
||||
},
|
||||
"version": "6.0.6"
|
||||
"author": "Chrystian Huot <chrystian@huot.qc.ca>",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.0.6",
|
||||
"@angular/cdk": "^21.0.5",
|
||||
"@angular/common": "^21.0.6",
|
||||
"@angular/compiler": "^21.0.6",
|
||||
"@angular/core": "^21.0.6",
|
||||
"@angular/forms": "^21.0.6",
|
||||
"@angular/material": "^21.0.5",
|
||||
"@angular/platform-browser": "^21.0.6",
|
||||
"@angular/platform-browser-dynamic": "^21.0.6",
|
||||
"@angular/router": "^21.0.6",
|
||||
"@angular/service-worker": "^21.0.6",
|
||||
"material-icons": "^1.13.14",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^21.0.4",
|
||||
"@angular/build": "^21.0.4",
|
||||
"@angular/cli": "^21.0.4",
|
||||
"@angular/compiler-cli": "^21.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.50.0",
|
||||
"ajv-keywords": "^5.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"ts-node": "~10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0"
|
||||
},
|
||||
"license": "LICENSE",
|
||||
"name": "rdio-scanner",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "ng build --base-href ./ --configuration production",
|
||||
"serve": "ng serve"
|
||||
},
|
||||
"version": "7.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -23,5 +23,6 @@ import { Component } from '@angular/core';
|
|||
selector: 'app-root',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
templateUrl: './app.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AppComponent { }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
<ng-container *ngIf="!authenticated">
|
||||
<rdio-scanner-admin-login></rdio-scanner-admin-login>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="authenticated">
|
||||
<rdio-scanner-admin-todos></rdio-scanner-admin-todos>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel #configPanel (afterCollapse)="configComponent?.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>settings</mat-icon>
|
||||
Config
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterExpand)="logsComponent.reload()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>article</mat-icon>
|
||||
Logs
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="toolsComponent?.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>build</mat-icon>
|
||||
Tools
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel hideToggle (click)="logout()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>logout</mat-icon>
|
||||
Logout
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</ng-container>
|
||||
@if (!authenticated) {
|
||||
<rdio-scanner-admin-login></rdio-scanner-admin-login>
|
||||
}
|
||||
@if (authenticated) {
|
||||
<rdio-scanner-admin-todos></rdio-scanner-admin-todos>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel #configPanel (afterCollapse)="configComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>settings</mat-icon>
|
||||
Config
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterExpand)="logsComponent.reload()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>article</mat-icon>
|
||||
Logs
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="toolsComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>build</mat-icon>
|
||||
Tools
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel hideToggle (click)="logout()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>logout</mat-icon>
|
||||
Logout
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
rdio-scanner-admin {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
|
||||
.mat-expansion-panel-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -25,21 +25,24 @@ import { AdminEvent, RdioScannerAdminService, Group, Tag } from './admin.service
|
|||
selector: 'rdio-scanner-admin',
|
||||
styleUrls: ['./admin.component.scss'],
|
||||
templateUrl: './admin.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminComponent implements OnDestroy {
|
||||
authenticated = this.adminService.authenticated;
|
||||
authenticated = true;
|
||||
|
||||
groups: Group[] = [];
|
||||
|
||||
tags: Tag[] = [];
|
||||
|
||||
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
|
||||
if ('authenticated' in event) {
|
||||
this.authenticated = event.authenticated || false;
|
||||
}
|
||||
});
|
||||
private eventSubscription;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
constructor(private adminService: RdioScannerAdminService) {
|
||||
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
|
||||
if ('authenticated' in event) {
|
||||
this.authenticated = event.authenticated || false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventSubscription.unsubscribe();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,18 +17,19 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { AppSharedModule } from '../../../shared/shared.module';
|
||||
import { RdioScannerAdminComponent } from './admin.component';
|
||||
import { RdioScannerAdminService } from './admin.service';
|
||||
import { RdioScannerAdminConfigComponent } from './config/config.component';
|
||||
import { RdioScannerAdminAccessComponent } from './config/access/access.component';
|
||||
import { RdioScannerAdminApiKeysComponent } from './config/api-keys/api-keys.component';
|
||||
import { RdioScannerAdminDirWatchComponent } from './config/dir-watch/dir-watch.component';
|
||||
import { RdioScannerAdminApikeysComponent } from './config/apikeys/apikeys.component';
|
||||
import { RdioScannerAdminDirwatchComponent } from './config/dirwatch/dirwatch.component';
|
||||
import { RdioScannerAdminDownstreamsComponent } from './config/downstreams/downstreams.component';
|
||||
import { RdioScannerAdminGroupsComponent } from './config/groups/groups.component';
|
||||
import { RdioScannerAdminOptionsComponent } from './config/options/options.component';
|
||||
import { RdioScannerAdminSiteComponent } from './config/systems/site/site.component';
|
||||
import { RdioScannerAdminSystemsSelectComponent } from './config/systems/select/select.component';
|
||||
import { RdioScannerAdminSystemComponent } from './config/systems/system/system.component';
|
||||
import { RdioScannerAdminSystemsComponent } from './config/systems/systems.component';
|
||||
|
|
@ -40,24 +41,26 @@ import { RdioScannerAdminLogsComponent } from './logs/logs.component';
|
|||
import { RdioScannerAdminTodosComponent } from './todos/todos.component';
|
||||
import { RdioScannerAdminToolsComponent } from './tools/tools.component';
|
||||
import { RdioScannerAdminImportExportConfigComponent } from './tools/import-export-config/import-export-config.component';
|
||||
import { RdioScannerAdminImportCsvComponent } from './tools/import-csv/import-csv.component';
|
||||
import { RdioScannerAdminImportTalkgroupsComponent } from './tools/import-talkgroups/import-talkgroups.component';
|
||||
import { RdioScannerAdminImportUnitsComponent } from './tools/import-units/import-units.component';
|
||||
import { RdioScannerAdminPasswordComponent } from './tools/password/password.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@NgModule({ declarations: [
|
||||
RdioScannerAdminComponent,
|
||||
RdioScannerAdminConfigComponent,
|
||||
RdioScannerAdminAccessComponent,
|
||||
RdioScannerAdminApiKeysComponent,
|
||||
RdioScannerAdminDirWatchComponent,
|
||||
RdioScannerAdminApikeysComponent,
|
||||
RdioScannerAdminDirwatchComponent,
|
||||
RdioScannerAdminDownstreamsComponent,
|
||||
RdioScannerAdminGroupsComponent,
|
||||
RdioScannerAdminImportExportConfigComponent,
|
||||
RdioScannerAdminImportCsvComponent,
|
||||
RdioScannerAdminImportTalkgroupsComponent,
|
||||
RdioScannerAdminImportUnitsComponent,
|
||||
RdioScannerAdminLoginComponent,
|
||||
RdioScannerAdminLogsComponent,
|
||||
RdioScannerAdminOptionsComponent,
|
||||
RdioScannerAdminPasswordComponent,
|
||||
RdioScannerAdminSiteComponent,
|
||||
RdioScannerAdminSystemComponent,
|
||||
RdioScannerAdminSystemsComponent,
|
||||
RdioScannerAdminSystemsSelectComponent,
|
||||
|
|
@ -67,9 +70,5 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
|
|||
RdioScannerAdminToolsComponent,
|
||||
RdioScannerAdminUnitComponent,
|
||||
],
|
||||
entryComponents: [RdioScannerAdminSystemsSelectComponent],
|
||||
exports: [RdioScannerAdminComponent],
|
||||
imports: [AppSharedModule, HttpClientModule],
|
||||
providers: [RdioScannerAdminService],
|
||||
})
|
||||
exports: [RdioScannerAdminComponent], imports: [AppSharedModule], providers: [RdioScannerAdminService, provideHttpClient(withInterceptorsFromDi())] })
|
||||
export class RdioScannerAdminModule { }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -25,7 +25,7 @@ import { firstValueFrom, timer } from 'rxjs';
|
|||
import { AppUpdateService } from '../../../shared/update/update.service';
|
||||
|
||||
export interface Access {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
code?: string;
|
||||
expiration?: Date;
|
||||
ident?: string;
|
||||
|
|
@ -39,8 +39,26 @@ export interface Access {
|
|||
}[] | number[] | '*';
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
_id?: string;
|
||||
export interface Alert {
|
||||
begin: number;
|
||||
end: number;
|
||||
frequency: number;
|
||||
type: OscillatorType;
|
||||
}
|
||||
|
||||
export interface Alerts {
|
||||
[key: string]: Alert[];
|
||||
}
|
||||
|
||||
export interface AdminEvent {
|
||||
authenticated?: boolean;
|
||||
config?: Config;
|
||||
docker?: boolean;
|
||||
passwordNeedChange?: boolean;
|
||||
}
|
||||
|
||||
export interface Apikey {
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
ident?: string;
|
||||
key?: string;
|
||||
|
|
@ -51,8 +69,21 @@ export interface ApiKey {
|
|||
}[] | number[] | '*';
|
||||
}
|
||||
|
||||
export interface DirWatch {
|
||||
_id?: string;
|
||||
export interface Config {
|
||||
access?: Access[];
|
||||
apikeys?: Apikey[];
|
||||
dirwatch?: Dirwatch[];
|
||||
downstreams?: Downstream[];
|
||||
groups?: Group[];
|
||||
options?: Options;
|
||||
systems?: System[];
|
||||
tags?: Tag[];
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface Dirwatch {
|
||||
id?: string;
|
||||
delay?: number;
|
||||
deleteAfter?: boolean;
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -60,14 +91,15 @@ export interface DirWatch {
|
|||
frequency?: number;
|
||||
mask?: string;
|
||||
order?: number;
|
||||
siteId?: number;
|
||||
systemId?: number;
|
||||
talkgroupId?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface Downstream {
|
||||
_id?: string;
|
||||
apiKey?: string;
|
||||
id?: string;
|
||||
apikey?: string;
|
||||
disabled?: boolean;
|
||||
order?: number;
|
||||
systems?: {
|
||||
|
|
@ -81,25 +113,16 @@ export interface Downstream {
|
|||
url?: string;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
autoPopulate?: boolean;
|
||||
dimmerDelay?: number;
|
||||
disableAudioConversion?: boolean;
|
||||
disableDuplicateDetection?: boolean;
|
||||
duplicateDetectionTimeFrame?: number;
|
||||
keypadBeeps?: string;
|
||||
pruneDays?: number;
|
||||
sortTalkgroups?: boolean;
|
||||
tagsToggle?: boolean;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
_id?: number;
|
||||
id?: number;
|
||||
alert?: string;
|
||||
label?: string;
|
||||
led?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface Log {
|
||||
_id: number;
|
||||
id?: number;
|
||||
dateTime: Date;
|
||||
level: number;
|
||||
message: string;
|
||||
|
|
@ -121,58 +144,80 @@ export interface LogsQueryOptions {
|
|||
sort: number;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
_id?: number;
|
||||
export interface Options {
|
||||
audioConversion?: 0 | 1 | 2 | 3;
|
||||
autoPopulate?: boolean;
|
||||
branding?: string;
|
||||
dimmerDelay?: number;
|
||||
disableDuplicateDetection?: boolean;
|
||||
duplicateDetectionTimeFrame?: number;
|
||||
email?: string;
|
||||
keypadBeeps?: string;
|
||||
maxClients?: number;
|
||||
playbackGoesLive?: boolean;
|
||||
pruneDays?: number;
|
||||
showListenersCount?: boolean;
|
||||
sortTalkgroups?: boolean;
|
||||
time12hFormat?: boolean;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id?: number | null;
|
||||
label?: string;
|
||||
order?: number;
|
||||
siteRef?: number;
|
||||
}
|
||||
|
||||
export interface System {
|
||||
id?: number | null;
|
||||
alert?: string;
|
||||
autoPopulate?: boolean;
|
||||
blacklists?: string;
|
||||
delay?: number;
|
||||
label?: string;
|
||||
led?: string | null;
|
||||
order?: number | null;
|
||||
sites?: Site[];
|
||||
systemRef?: number;
|
||||
talkgroups?: Talkgroup[];
|
||||
type?: string;
|
||||
units?: Unit[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id?: number;
|
||||
alert?: string;
|
||||
label?: string;
|
||||
led?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface Talkgroup {
|
||||
id?: number | null;
|
||||
alert?: string;
|
||||
delay?: number;
|
||||
frequency?: number | null;
|
||||
groupId?: number;
|
||||
id?: number;
|
||||
groupIds?: number[];
|
||||
label?: string;
|
||||
led?: string | null;
|
||||
name?: string;
|
||||
patches?: number[];
|
||||
order?: number;
|
||||
tagId?: number;
|
||||
talkgroupRef?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id?: number | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface System {
|
||||
_id?: number;
|
||||
autoPopulate?: boolean;
|
||||
blacklists?: string;
|
||||
id?: number;
|
||||
label?: string;
|
||||
led?: string | null;
|
||||
order?: number | null;
|
||||
talkgroups?: Talkgroup[];
|
||||
units?: Unit[];
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
access?: Access[];
|
||||
apiKeys?: ApiKey[];
|
||||
dirWatch?: DirWatch[];
|
||||
downstreams?: Downstream[];
|
||||
groups?: Group[];
|
||||
options?: Options;
|
||||
systems?: System[];
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export interface AdminEvent {
|
||||
authenticated?: boolean;
|
||||
config?: Config;
|
||||
docker?: boolean;
|
||||
passwordNeedChange?: boolean;
|
||||
order?: number;
|
||||
unitRef?: number;
|
||||
unitFrom?: number;
|
||||
unitTo?: number;
|
||||
}
|
||||
|
||||
enum url {
|
||||
alerts = 'alerts',
|
||||
config = 'config',
|
||||
login = 'login',
|
||||
logout = 'logout',
|
||||
|
|
@ -182,8 +227,16 @@ enum url {
|
|||
|
||||
const SESSION_STORAGE_KEY = 'rdio-scanner-admin-token';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RdioScannerAdminService implements OnDestroy {
|
||||
Alerts: Alerts | undefined;
|
||||
|
||||
event = new EventEmitter<AdminEvent>();
|
||||
|
||||
get authenticated() {
|
||||
|
|
@ -198,6 +251,8 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
return this._passwordNeedChange;
|
||||
}
|
||||
|
||||
private audioContext: AudioContext | undefined;
|
||||
|
||||
private configWebSocket: WebSocket | undefined;
|
||||
|
||||
private _docker = false;
|
||||
|
|
@ -280,7 +335,7 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
}
|
||||
|
||||
getLeds(): string[] {
|
||||
return ['blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow'];
|
||||
return ['blue', 'cyan', 'green', 'magenta', 'orange', 'red', 'white', 'yellow'];
|
||||
}
|
||||
|
||||
async getLogs(options: LogsQueryOptions): Promise<LogsQuery | undefined> {
|
||||
|
|
@ -300,6 +355,19 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
async loadAlerts(): Promise<void> {
|
||||
try {
|
||||
this.Alerts = await firstValueFrom(this.ngHttpClient.get<Alerts>(
|
||||
this.getUrl(url.alerts),
|
||||
{ headers: this.getHeaders(), responseType: 'json' },
|
||||
));
|
||||
|
||||
|
||||
} catch (error) {
|
||||
this.errorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
async login(password: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await firstValueFrom(this.ngHttpClient.post<{
|
||||
|
|
@ -354,6 +422,47 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
async playAlert(name: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.audioContext === undefined) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
|
||||
}
|
||||
|
||||
if (this.Alerts !== undefined && name in this.Alerts) {
|
||||
const ctx = this.audioContext;
|
||||
|
||||
const seq = this.Alerts[name];
|
||||
|
||||
const gn = ctx.createGain();
|
||||
|
||||
gn.gain.value = .1;
|
||||
|
||||
gn.connect(ctx.destination);
|
||||
|
||||
seq.forEach((beep, index) => {
|
||||
const osc = ctx.createOscillator();
|
||||
|
||||
osc.connect(gn);
|
||||
|
||||
osc.frequency.value = beep.frequency;
|
||||
|
||||
osc.type = beep.type;
|
||||
|
||||
if (index === seq.length - 1) {
|
||||
osc.onended = () => resolve();
|
||||
}
|
||||
|
||||
osc.start(ctx.currentTime + beep.begin);
|
||||
|
||||
osc.stop(ctx.currentTime + beep.end);
|
||||
});
|
||||
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveConfig(config: Config): Promise<Config> {
|
||||
try {
|
||||
const res = await firstValueFrom(this.ngHttpClient.put<{ config: Config }>(
|
||||
|
|
@ -373,126 +482,161 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
|
||||
newAccessForm(access?: Access): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [access?._id],
|
||||
code: [access?.code, [Validators.required, this.validateAccessCode()]],
|
||||
expiration: [access?.expiration],
|
||||
ident: [access?.ident, Validators.required],
|
||||
limit: [access?.limit],
|
||||
order: [access?.order],
|
||||
systems: [access?.systems, Validators.required],
|
||||
id: this.ngFormBuilder.nonNullable.control(access?.id),
|
||||
code: this.ngFormBuilder.nonNullable.control(access?.code, [Validators.required, this.validateAccessCode()]),
|
||||
expiration: this.ngFormBuilder.nonNullable.control(access?.expiration),
|
||||
ident: this.ngFormBuilder.nonNullable.control(access?.ident, Validators.required),
|
||||
limit: this.ngFormBuilder.nonNullable.control(access?.limit),
|
||||
order: this.ngFormBuilder.nonNullable.control(access?.order),
|
||||
systems: this.ngFormBuilder.nonNullable.control(access?.systems),
|
||||
});
|
||||
}
|
||||
|
||||
newApiKeyForm(apiKey?: ApiKey): FormGroup {
|
||||
newApikeyForm(apikey?: Apikey): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [apiKey?._id],
|
||||
disabled: [apiKey?.disabled],
|
||||
ident: [apiKey?.ident, Validators.required],
|
||||
key: [apiKey?.key, [Validators.required, this.validateApiKey()]],
|
||||
order: [apiKey?.order],
|
||||
systems: [apiKey?.systems, Validators.required],
|
||||
id: this.ngFormBuilder.control(apikey?.id),
|
||||
disabled: this.ngFormBuilder.control(apikey?.disabled),
|
||||
ident: this.ngFormBuilder.control(apikey?.ident, Validators.required),
|
||||
key: this.ngFormBuilder.control(apikey?.key, [Validators.required, this.validateApikey()]),
|
||||
order: this.ngFormBuilder.control(apikey?.order),
|
||||
systems: this.ngFormBuilder.control(apikey?.systems, Validators.required),
|
||||
});
|
||||
}
|
||||
|
||||
newConfigForm(config?: Config): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
access: this.ngFormBuilder.array(config?.access?.map((access) => this.newAccessForm(access)) || []),
|
||||
apiKeys: this.ngFormBuilder.array(config?.apiKeys?.map((apiKey) => this.newApiKeyForm(apiKey)) || []),
|
||||
dirWatch: this.ngFormBuilder.array(config?.dirWatch?.map((dirWatch) => this.newDirWatchForm(dirWatch)) || []),
|
||||
apikeys: this.ngFormBuilder.array(config?.apikeys?.map((apikey) => this.newApikeyForm(apikey)) || []),
|
||||
dirwatch: this.ngFormBuilder.array(config?.dirwatch?.map((dirwatch) => this.newDirwatchForm(dirwatch)) || []),
|
||||
downstreams: this.ngFormBuilder.array(config?.downstreams?.map((downstream) => this.newDownstreamForm(downstream)) || []),
|
||||
groups: this.ngFormBuilder.array(config?.groups?.map((group) => this.newGroupForm(group)) || []),
|
||||
options: this.newOptionsForm(config?.options),
|
||||
systems: this.ngFormBuilder.array(config?.systems?.map((system) => this.newSystemForm(system)) || []),
|
||||
tags: this.ngFormBuilder.array(config?.tags?.map((tag) => this.newTagForm(tag)) || []),
|
||||
version: this.ngFormBuilder.control(config?.version),
|
||||
});
|
||||
}
|
||||
|
||||
newDirWatchForm(dirWatch?: DirWatch): FormGroup {
|
||||
newDirwatchForm(dirwatch?: Dirwatch): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [dirWatch?._id],
|
||||
deleteAfter: [dirWatch?.deleteAfter],
|
||||
directory: [dirWatch?.directory, [Validators.required, this.validateDirectory()]],
|
||||
disabled: [dirWatch?.disabled],
|
||||
extension: [dirWatch?.extension, this.validateExtension()],
|
||||
frequency: [dirWatch?.frequency, Validators.min(0)],
|
||||
mask: [dirWatch?.mask, this.validateMask()],
|
||||
order: [dirWatch?.order],
|
||||
systemId: [dirWatch?.systemId, this.validateDirwatchSystemId()],
|
||||
talkgroupId: [dirWatch?.talkgroupId, this.validateDirwatchTalkgroupId()],
|
||||
type: [dirWatch?.type],
|
||||
id: this.ngFormBuilder.control(dirwatch?.id),
|
||||
delay: this.ngFormBuilder.control(typeof dirwatch?.delay === 'number' ? Math.max(2000, dirwatch?.delay) : 2000),
|
||||
deleteAfter: this.ngFormBuilder.control(dirwatch?.deleteAfter),
|
||||
directory: this.ngFormBuilder.control(dirwatch?.directory, [Validators.required, this.validateDirectory()]),
|
||||
disabled: this.ngFormBuilder.control(dirwatch?.disabled),
|
||||
extension: this.ngFormBuilder.control(dirwatch?.extension, this.validateExtension()),
|
||||
frequency: this.ngFormBuilder.control(dirwatch?.frequency, Validators.min(1)),
|
||||
mask: this.ngFormBuilder.control(dirwatch?.mask, this.validateMask()),
|
||||
order: this.ngFormBuilder.control(dirwatch?.order),
|
||||
siteId: this.ngFormBuilder.control(dirwatch?.siteId),
|
||||
systemId: this.ngFormBuilder.control(dirwatch?.systemId, this.validateDirwatchSystemId()),
|
||||
talkgroupId: this.ngFormBuilder.control(dirwatch?.talkgroupId, this.validateDirwatchTalkgroupId()),
|
||||
type: this.ngFormBuilder.control(dirwatch?.type ?? 'default'),
|
||||
});
|
||||
}
|
||||
|
||||
newDownstreamForm(downstream?: Downstream): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [downstream?._id],
|
||||
apiKey: [downstream?.apiKey, [Validators.required, this.validateApiKey()]],
|
||||
disabled: [downstream?.disabled],
|
||||
order: [downstream?.order],
|
||||
systems: [downstream?.systems, Validators.required],
|
||||
url: [downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]],
|
||||
id: this.ngFormBuilder.control(downstream?.id),
|
||||
apikey: this.ngFormBuilder.control(downstream?.apikey, [Validators.required, this.validateApikey()]),
|
||||
disabled: this.ngFormBuilder.control(downstream?.disabled),
|
||||
order: this.ngFormBuilder.control(downstream?.order),
|
||||
systems: this.ngFormBuilder.control(downstream?.systems, Validators.required),
|
||||
url: this.ngFormBuilder.control(downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]),
|
||||
});
|
||||
}
|
||||
|
||||
newGroupForm(group?: Group): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [group?._id],
|
||||
label: [group?.label, Validators.required],
|
||||
});
|
||||
}
|
||||
|
||||
newTagForm(tag?: Tag): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [tag?._id],
|
||||
label: [tag?.label, Validators.required],
|
||||
});
|
||||
}
|
||||
|
||||
newSystemForm(system?: System): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
_id: [system?._id],
|
||||
autoPopulate: [system?.autoPopulate],
|
||||
blacklists: [system?.blacklists, this.validateBlacklists()],
|
||||
id: [system?.id, [Validators.required, Validators.min(1), this.validateId()]],
|
||||
label: [system?.label, Validators.required],
|
||||
led: [system?.led],
|
||||
order: [system?.order],
|
||||
talkgroups: this.ngFormBuilder.array(system?.talkgroups?.map((talkgroup) => this.newTalkgroupForm(talkgroup)) || []),
|
||||
units: this.ngFormBuilder.array(system?.units?.map((unit) => this.newUnitForm(unit)) || []),
|
||||
});
|
||||
}
|
||||
|
||||
newTalkgroupForm(talkgroup?: Talkgroup): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
frequency: [talkgroup?.frequency, Validators.min(0)],
|
||||
groupId: [talkgroup?.groupId, [Validators.required, this.validateGroup()]],
|
||||
id: [talkgroup?.id, [Validators.required, Validators.min(1), this.validateId()]],
|
||||
label: [talkgroup?.label, Validators.required],
|
||||
led: [talkgroup?.led],
|
||||
name: [talkgroup?.name, Validators.required],
|
||||
patches: [talkgroup?.patches, this.validatePatches()],
|
||||
tagId: [talkgroup?.tagId, [Validators.required, this.validateTag()]],
|
||||
});
|
||||
}
|
||||
|
||||
newUnitForm(unit?: Unit): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
id: [unit?.id, [Validators.required, Validators.min(0), this.validateId()]],
|
||||
label: [unit?.label, Validators.required],
|
||||
id: this.ngFormBuilder.control(group?.id),
|
||||
alert: this.ngFormBuilder.control(group?.alert || ''),
|
||||
label: this.ngFormBuilder.control(group?.label, Validators.required),
|
||||
led: this.ngFormBuilder.control(group?.led || ''),
|
||||
order: this.ngFormBuilder.control(group?.order),
|
||||
});
|
||||
}
|
||||
|
||||
newOptionsForm(options?: Options): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
autoPopulate: [options?.autoPopulate],
|
||||
dimmerDelay: [options?.dimmerDelay, [Validators.required, Validators.min(0)]],
|
||||
disableAudioConversion: [options?.disableAudioConversion],
|
||||
disableDuplicateDetection: [options?.disableDuplicateDetection],
|
||||
duplicateDetectionTimeFrame: [options?.duplicateDetectionTimeFrame, [Validators.required, Validators.min(0)]],
|
||||
keypadBeeps: [options?.keypadBeeps, Validators.required],
|
||||
pruneDays: [options?.pruneDays, [Validators.required, Validators.min(0)]],
|
||||
sortTalkgroups: [options?.sortTalkgroups],
|
||||
tagsToggle: [options?.tagsToggle],
|
||||
audioConversion: this.ngFormBuilder.control(options?.audioConversion),
|
||||
autoPopulate: this.ngFormBuilder.control(options?.autoPopulate),
|
||||
branding: this.ngFormBuilder.control(options?.branding),
|
||||
dimmerDelay: this.ngFormBuilder.control(options?.dimmerDelay, [Validators.required, Validators.min(0)]),
|
||||
disableDuplicateDetection: this.ngFormBuilder.control(options?.disableDuplicateDetection),
|
||||
duplicateDetectionTimeFrame: this.ngFormBuilder.control(options?.duplicateDetectionTimeFrame, [Validators.required, Validators.min(0)]),
|
||||
email: this.ngFormBuilder.control(options?.email),
|
||||
keypadBeeps: this.ngFormBuilder.control(options?.keypadBeeps, Validators.required),
|
||||
maxClients: this.ngFormBuilder.control(options?.maxClients, [Validators.required, Validators.min(1)]),
|
||||
playbackGoesLive: this.ngFormBuilder.control(options?.playbackGoesLive),
|
||||
pruneDays: this.ngFormBuilder.control(options?.pruneDays, [Validators.required, Validators.min(0)]),
|
||||
showListenersCount: this.ngFormBuilder.control(options?.showListenersCount),
|
||||
sortTalkgroups: this.ngFormBuilder.control(options?.sortTalkgroups),
|
||||
time12hFormat: this.ngFormBuilder.control(options?.time12hFormat),
|
||||
});
|
||||
}
|
||||
|
||||
newSiteForm(site?: Site): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
id: this.ngFormBuilder.control(site?.id),
|
||||
label: this.ngFormBuilder.control(site?.label, Validators.required),
|
||||
order: this.ngFormBuilder.control(site?.order),
|
||||
siteRef: this.ngFormBuilder.control(site?.siteRef, [Validators.required, Validators.min(1), this.validateSiteRef()]),
|
||||
});
|
||||
}
|
||||
|
||||
newSystemForm(system?: System): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
id: this.ngFormBuilder.control(system?.id),
|
||||
alert: this.ngFormBuilder.control(system?.alert),
|
||||
autoPopulate: this.ngFormBuilder.control(system?.autoPopulate),
|
||||
blacklists: this.ngFormBuilder.control(system?.blacklists, this.validateBlacklists()),
|
||||
delay: this.ngFormBuilder.control(system?.delay),
|
||||
label: this.ngFormBuilder.control(system?.label, Validators.required),
|
||||
led: this.ngFormBuilder.control(system?.led || ''),
|
||||
order: this.ngFormBuilder.control(system?.order),
|
||||
sites: this.ngFormBuilder.array(system?.sites?.map((site) => this.newSiteForm(site)) || []),
|
||||
systemRef: this.ngFormBuilder.control(system?.systemRef, [Validators.required, Validators.min(1), this.validateSystemRef()]),
|
||||
talkgroups: this.ngFormBuilder.array(system?.talkgroups?.map((talkgroup) => this.newTalkgroupForm(talkgroup)) || []),
|
||||
type: this.ngFormBuilder.control(system?.type || ''),
|
||||
units: this.ngFormBuilder.array(system?.units?.map((unit) => this.newUnitForm(unit)) || []),
|
||||
});
|
||||
}
|
||||
|
||||
newTagForm(tag?: Tag): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
id: this.ngFormBuilder.control(tag?.id),
|
||||
alert: this.ngFormBuilder.control(tag?.alert || ''),
|
||||
label: this.ngFormBuilder.control(tag?.label, Validators.required),
|
||||
led: this.ngFormBuilder.control(tag?.led || ''),
|
||||
order: this.ngFormBuilder.control(tag?.order),
|
||||
});
|
||||
}
|
||||
|
||||
newTalkgroupForm(talkgroup?: Talkgroup): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
id: this.ngFormBuilder.control(talkgroup?.id),
|
||||
alert: this.ngFormBuilder.control(talkgroup?.alert),
|
||||
delay: this.ngFormBuilder.control(talkgroup?.delay),
|
||||
frequency: this.ngFormBuilder.control(talkgroup?.frequency, Validators.min(0)),
|
||||
groupIds: this.ngFormBuilder.control(talkgroup?.groupIds, [Validators.required, this.validateGroup()]),
|
||||
label: this.ngFormBuilder.control(talkgroup?.label, Validators.required),
|
||||
led: this.ngFormBuilder.control(talkgroup?.led || ''),
|
||||
name: this.ngFormBuilder.control(talkgroup?.name, Validators.required),
|
||||
order: this.ngFormBuilder.control(talkgroup?.order),
|
||||
tagId: this.ngFormBuilder.control(talkgroup?.tagId, [Validators.required, this.validateTag()]),
|
||||
talkgroupRef: this.ngFormBuilder.control(talkgroup?.talkgroupRef, [Validators.required, Validators.min(1), this.validateTalkgroupRef()]),
|
||||
type: this.ngFormBuilder.control(talkgroup?.type || ''),
|
||||
});
|
||||
}
|
||||
|
||||
newUnitForm(unit?: Unit): FormGroup {
|
||||
return this.ngFormBuilder.group({
|
||||
id: this.ngFormBuilder.control(unit?.id),
|
||||
label: this.ngFormBuilder.control(unit?.label, Validators.required),
|
||||
order: this.ngFormBuilder.control(unit?.order),
|
||||
unitRef: this.ngFormBuilder.control(unit?.unitRef, [Validators.min(1), this.validateUnitRef()]),
|
||||
unitFrom: this.ngFormBuilder.control(unit?.unitFrom, [Validators.min(1), this.validateUnitFrom()]),
|
||||
unitTo: this.ngFormBuilder.control(unit?.unitTo, [Validators.min(1), this.validateUnitTo()])
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -585,15 +729,15 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
};
|
||||
}
|
||||
|
||||
private validateApiKey(): ValidatorFn {
|
||||
private validateApikey(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (typeof control.value !== 'string' || !control.value.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKeys: ApiKey[] = control.parent?.parent?.getRawValue() || [];
|
||||
const apikeys: Apikey[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = apiKeys.reduce((c, a) => c += a.key === control.value ? 1 : 0, 0);
|
||||
const count = apikeys.reduce((c, a) => c += a.key === control.value ? 1 : 0, 0);
|
||||
|
||||
return count > 1 ? { duplicate: true } : null;
|
||||
};
|
||||
|
|
@ -611,14 +755,42 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
return null;
|
||||
}
|
||||
|
||||
const dirWatch: DirWatch[] = control.parent?.parent?.getRawValue() || [];
|
||||
if (control.value.startsWith('\\')) {
|
||||
return { network: true }
|
||||
}
|
||||
|
||||
const count = dirWatch.reduce((c, a) => c += a.directory === control.value ? 1 : 0, 0);
|
||||
const dirwatch: Dirwatch[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = dirwatch.reduce((c, a) => c += a.directory === control.value ? 1 : 0, 0);
|
||||
|
||||
return count > 1 ? { duplicate: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateDirwatchSystemId(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const dirwatch = control.parent?.getRawValue() || {};
|
||||
|
||||
const mask = dirwatch.mask || '';
|
||||
|
||||
const type = dirwatch.type;
|
||||
|
||||
return ['dsdplus', 'sdr-trunk', 'trunk-recorder'].includes(type) || control.value !== null || /#SYS/.test(mask) ? null : { required: true };
|
||||
};
|
||||
}
|
||||
|
||||
private validateDirwatchTalkgroupId(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const dirwatch = control.parent?.getRawValue() || {};
|
||||
|
||||
const mask = dirwatch.mask || '';
|
||||
|
||||
const type = dirwatch.type;
|
||||
|
||||
return ['dsdplus', 'sdr-trunk', 'trunk-recorder'].includes(type) || control.value !== null || /#TG/.test(mask) ? null : { required: true };
|
||||
};
|
||||
}
|
||||
|
||||
private validateDownstreamUrl(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (typeof control.value !== 'string' || !control.value.length) {
|
||||
|
|
@ -633,30 +805,6 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
};
|
||||
}
|
||||
|
||||
private validateDirwatchSystemId(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const dirwatch = control.parent?.getRawValue() || {};
|
||||
|
||||
const mask = dirwatch.mask || '';
|
||||
|
||||
const type = dirwatch.type;
|
||||
|
||||
return ['sdr-trunk'].includes(type) || control.value !== null || /#SYS/.test(mask) ? null : { required: true };
|
||||
};
|
||||
}
|
||||
|
||||
private validateDirwatchTalkgroupId(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const dirwatch = control.parent?.getRawValue() || {};
|
||||
|
||||
const mask = dirwatch.mask || '';
|
||||
|
||||
const type = dirwatch.type;
|
||||
|
||||
return ['trunk-recorder', 'sdr-trunk'].includes(type) || control.value !== null || /#TG/.test(mask) ? null : { required: true };
|
||||
};
|
||||
}
|
||||
|
||||
private validateExtension(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (typeof control.value !== 'string' || !control.value.length) {
|
||||
|
|
@ -673,35 +821,21 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
return null;
|
||||
}
|
||||
|
||||
const groupIds = control.root.get('groups')?.value.map((group: Group) => group._id);
|
||||
const groupIds = control.root.get('groups')?.value.map((group: Group) => group.id);
|
||||
|
||||
return groupIds ? groupIds.includes(control.value) ? null : { required: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateId(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (control.value === null || typeof control.value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const systems: System[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = systems.reduce((c, s) => c += s.id === control.value ? 1 : 0, 0);
|
||||
|
||||
return count > 1 ? { duplicate: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateMask(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (typeof control.value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const masks = ['#DATE', '#HZ', '#KHZ', '#MHZ', '#SYS', '#TIME', '#TG', '#UNIT', '#ZTIME'];
|
||||
const masks = ['#DATE', '#GROUP', '#HZ', '#KHZ', '#MHZ', '#SITE', '#SITELBL', '#SYS', '#SYSLBL', '#TAG', '#TG', '#TGAFS', '#TGHZ', '#TGKHZ', '#TGLBL', '#TGMHZ', '#TIME', '#UNIT', '#UNITLBL', '#ZTIME'];
|
||||
|
||||
const metas = control.value.match(/(#[A-Z]+)/g) || [];
|
||||
const metas = control.value.match(/(#[A-Z]+)/g) || [] as string[];
|
||||
|
||||
const count = metas.reduce((c, m) => {
|
||||
if (masks.includes(m)) {
|
||||
|
|
@ -715,9 +849,31 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
};
|
||||
}
|
||||
|
||||
private validatePatches(): ValidatorFn {
|
||||
private validateSiteRef(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
return typeof control.value === 'string' && control.value.length ? /^[0-9]+(,[0-9]+)*$/.test(control.value) ? null : { invalid: true } : null;
|
||||
if (control.value === null || typeof control.value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sites: Site[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = sites.reduce((c, s) => c += s.siteRef === control.value ? 1 : 0, 0);
|
||||
|
||||
return count > 1 ? { duplicate: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateSystemRef(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (control.value === null || typeof control.value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const systems: System[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = systems.reduce((c, s) => c += control.value !== null && control.value > 0 && s.systemRef === control.value ? 1 : 0, 0);
|
||||
|
||||
return count > 1 ? { duplicate: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -727,12 +883,84 @@ export class RdioScannerAdminService implements OnDestroy {
|
|||
return null;
|
||||
}
|
||||
|
||||
const tagIds = control.root.get('tags')?.value.map((tag: Tag) => tag._id);
|
||||
const tagIds = control.root.get('tags')?.value.map((tag: Tag) => tag.id);
|
||||
|
||||
return tagIds ? tagIds.includes(control.value) ? null : { required: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateTalkgroupRef(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (control.value === null || typeof control.value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const talkgroups: Talkgroup[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = talkgroups.reduce((c, t) => c += t.talkgroupRef === control.value ? 1 : 0, 0);
|
||||
|
||||
return count > 1 ? { duplicate: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateUnitRef(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const unitRef = control.parent?.get('unitRef')?.value;
|
||||
|
||||
const unitFrom = control.parent?.get('unitFrom')?.value;
|
||||
|
||||
const unitTo = control.parent?.get('unitTo')?.value;
|
||||
|
||||
const units: Unit[] = control.parent?.parent?.getRawValue() || [];
|
||||
|
||||
const count = units.reduce((c, u) => c += u.unitRef === unitRef ? 1 : 0, 0);
|
||||
|
||||
return unitRef === null && (unitFrom === null || unitTo === null)
|
||||
? { required: true }
|
||||
: count > 1
|
||||
? { duplicate: true }
|
||||
: null;
|
||||
};
|
||||
}
|
||||
|
||||
private validateUnitFrom(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const unitFrom = control.value;
|
||||
|
||||
const unitTo = control.parent?.get('unitTo')?.value;
|
||||
|
||||
if (typeof unitFrom === 'number' && typeof unitTo === 'number' && unitFrom >= unitTo) {
|
||||
return { range: true };
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
control.parent?.get('unitRef')?.updateValueAndValidity();
|
||||
control.parent?.get('unitTo')?.updateValueAndValidity();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private validateUnitTo(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const unitFrom = control.parent?.get('unitFrom')?.value;
|
||||
|
||||
const unitTo = control.value;
|
||||
|
||||
if (typeof unitFrom === 'number' && typeof unitTo === 'number' && unitFrom >= unitTo) {
|
||||
return { range: true };
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
control.parent?.get('unitRef')?.updateValueAndValidity();
|
||||
control.parent?.get('unitFrom')?.updateValueAndValidity();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private validateUrl(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
if (typeof control.value !== 'string' || !control.value.length) {
|
||||
|
|
|
|||
|
|
@ -1,95 +1,111 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with
|
||||
specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored
|
||||
internally on the client side and reused thereafter.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New access</button>
|
||||
<p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with
|
||||
specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored
|
||||
internally on the client side and reused thereafter.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New access</button>
|
||||
</div>
|
||||
<p *ngIf="!accesses.length" class="mat-small text-center">No access is defined, your instance is open and accessible to
|
||||
all</p>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let access of accesses; index as i" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ access.value.ident || 'NewAccess' }}
|
||||
<mat-icon *ngIf="access.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="access">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Code</span><br>
|
||||
<span class="mat-caption">The access code to unlock the client.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
|
||||
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
<mat-error *ngIf="access.get('code')?.hasError('duplicate')">
|
||||
Code is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="access.get('code')?.hasError('required')">
|
||||
Code is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Ident</span><br>
|
||||
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
|
||||
whom.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="ident" placeholder="Ident">
|
||||
<mat-error *ngIf="access.get('ident')?.hasError('required')">
|
||||
Ident is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Expiration</span><br>
|
||||
<span class="mat-caption">Expiration date for this access code.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
|
||||
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
|
||||
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
|
||||
<mat-datepicker #expirationDate></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Limit</span><br>
|
||||
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
|
||||
with this access code exceeds the defined limit.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Access</span><br>
|
||||
<span class="mat-caption">
|
||||
The access code allows access to <u>
|
||||
<ng-container *ngIf="access.value.systems === '*'">all</ng-container>
|
||||
<ng-container *ngIf="access.value.systems !== '*'">some</ng-container>
|
||||
</u> systems and talkgroups.
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)">
|
||||
Choose systems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete access
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
@if (!accesses.length) {
|
||||
<p class="mat-small text-center">No access is defined, your instance is open and accessible to
|
||||
all</p>
|
||||
}
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)">
|
||||
@for (access of accesses; track access; let i = $index) {
|
||||
<mat-expansion-panel cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ access.value.ident || 'NewAccess' }}
|
||||
@if (access.invalid) {
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
}
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="access">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Code</span><br>
|
||||
<span class="mat-caption">The access code to unlock the client.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
|
||||
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (access.get('code')?.hasError('duplicate')) {
|
||||
<mat-error>
|
||||
Code is already defined
|
||||
</mat-error>
|
||||
}
|
||||
@if (access.get('code')?.hasError('required')) {
|
||||
<mat-error>
|
||||
Code is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Ident</span><br>
|
||||
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
|
||||
whom.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="ident" placeholder="Ident">
|
||||
@if (access.get('ident')?.hasError('required')) {
|
||||
<mat-error>
|
||||
Ident is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Expiration</span><br>
|
||||
<span class="mat-caption">Expiration date for this access code.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
|
||||
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
|
||||
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
|
||||
<mat-datepicker #expirationDate></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Limit</span><br>
|
||||
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
|
||||
with this access code exceeds the defined limit.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Access</span><br>
|
||||
<span class="mat-caption">
|
||||
The access code allows access to <u>
|
||||
@if (access.value.systems === '*') {
|
||||
all
|
||||
}
|
||||
@if (access.value.systems !== '*') {
|
||||
some
|
||||
}
|
||||
</u> systems and talkgroups.
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)">
|
||||
Choose systems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete access
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
|
|||
@Component({
|
||||
selector: 'rdio-scanner-admin-access',
|
||||
templateUrl: './access.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminAccessComponent {
|
||||
@Input() form: FormArray | undefined;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<button type="button" mat-button color="accent" (click)="add()">New API key</button>
|
||||
</div>
|
||||
<p *ngIf="!apiKeys.length" class="mat-small text-center">No defined API keys</p>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="apiKeys" (cdkDropListDropped)="drop($event)">
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="apiKeys" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let apiKey of apiKeys; index as i" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
|
|
@ -33,10 +33,10 @@
|
|||
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
<mat-error *ngIf="apiKey.get('key')?.hasError('duplicate')">
|
||||
<mat-error *ngIf="apiKey.get('key').hasError('duplicate')">
|
||||
Key is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="apiKey.get('key')?.hasError('required')">
|
||||
<mat-error *ngIf="apiKey.get('key').hasError('required')">
|
||||
Key is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="ident" placeholder="Ident">
|
||||
<mat-error *ngIf="apiKey.get('ident')?.hasError('required')">
|
||||
<mat-error *ngIf="apiKey.get('ident').hasError('required')">
|
||||
Ident is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">API keys are used for recorder upload scripts and downstream instances. Each of them must
|
||||
authenticate with an API key to exchange audio files.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New API key</button>
|
||||
</div>
|
||||
@if (!apikeys.length) {
|
||||
<p class="mat-small text-center">No defined API keys</p>
|
||||
}
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="apikeys" (cdkDropListDropped)="drop($event)">
|
||||
@for (apikey of apikeys; track apikey; let i = $index) {
|
||||
<mat-expansion-panel cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ apikey.value.ident || 'NewApikey' }}
|
||||
@if (apikey.invalid) {
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
}
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="apikey">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Disabled</span><br>
|
||||
<span class="mat-caption">Disable the API key.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Key</span><br>
|
||||
<span class="mat-caption">Api key.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input #key type="text" matInput formControlName="key" placeholder="Key">
|
||||
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
@if (apikey.get('key')!.hasError('duplicate')) {
|
||||
<mat-error>
|
||||
Key is already defined
|
||||
</mat-error>
|
||||
}
|
||||
@if (apikey.get('key')!.hasError('required')) {
|
||||
<mat-error>
|
||||
Key is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Ident</span><br>
|
||||
<span class="mat-caption">Dummy identifier which serves only to identify this API key is for
|
||||
whom.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="ident" placeholder="Ident">
|
||||
@if (apikey.get('ident')!.hasError('required')) {
|
||||
<mat-error>
|
||||
Ident is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Access</span><br>
|
||||
<span class="mat-caption">
|
||||
This API key allows access to <u>
|
||||
@if (apikey.value.systems === '*') {
|
||||
all
|
||||
}
|
||||
@if (apikey.value.systems !== '*') {
|
||||
some
|
||||
}
|
||||
</u> systems and talkgroups.
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" mat-button [disabled]="apikey.disabled" (click)="select(apikey)">
|
||||
Choose systems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete API key
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian@huot.qc.ca>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FormArray, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { RdioScannerAdminService } from '../../admin.service';
|
||||
import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select.component';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-apikeys',
|
||||
templateUrl: './apikeys.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminApikeysComponent {
|
||||
@Input() form: FormArray | undefined;
|
||||
|
||||
get apikeys(): FormGroup[] {
|
||||
return this.form?.controls
|
||||
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
|
||||
}
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) { }
|
||||
|
||||
add(): void {
|
||||
const apikey = this.adminService.newApikeyForm({
|
||||
key: this.uuid(),
|
||||
systems: '*',
|
||||
});
|
||||
|
||||
apikey.markAllAsTouched();
|
||||
|
||||
this.form?.insert(0, apikey);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
this.panels?.forEach((panel) => panel.close());
|
||||
}
|
||||
|
||||
copy(inputElement: HTMLInputElement): void {
|
||||
navigator.clipboard.writeText(inputElement.value)
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<FormGroup[]>): void {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
||||
|
||||
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
remove(index: number): void {
|
||||
this.form?.removeAt(index);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
|
||||
select(access: FormGroup): void {
|
||||
const matDialogRef = this.matDialog.open(RdioScannerAdminSystemsSelectComponent, { data: access });
|
||||
|
||||
matDialogRef.afterClosed().subscribe((data) => {
|
||||
if (data) {
|
||||
access.get('systems')?.setValue(data);
|
||||
|
||||
access.markAsDirty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private uuid() {
|
||||
let dt = new Date().getTime();
|
||||
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = (dt + Math.random() * 16) % 16 | 0;
|
||||
|
||||
dt = Math.floor(dt / 16);
|
||||
|
||||
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +1,90 @@
|
|||
<form *ngIf="form" autocomplete="off" [formGroup]="form" (ngSubmit)="save()">
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>manage_accounts</mat-icon>
|
||||
Access
|
||||
<mat-icon *ngIf="form?.get('access')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="apiKeyComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
ApiKey
|
||||
<mat-icon *ngIf="form?.get('apiKey')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-api-keys #apiKeyComponent [form]="apiKeys"></rdio-scanner-admin-api-keys>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirWatchComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>folder</mat-icon>
|
||||
Dirwatch
|
||||
<mat-icon *ngIf="form?.get('dirWatch')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-dir-watch #dirWatchComponent [form]="dirWatch"></rdio-scanner-admin-dir-watch>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>share</mat-icon>
|
||||
Downstreams
|
||||
<mat-icon *ngIf="form?.get('downstreams')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>workspaces</mat-icon>
|
||||
Groups
|
||||
<mat-icon *ngIf="form?.get('groups')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>tune</mat-icon>
|
||||
Options
|
||||
<mat-icon *ngIf="form?.get('options')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>podcasts</mat-icon>
|
||||
Systems
|
||||
<mat-icon *ngIf="form?.get('systems')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>sell</mat-icon>
|
||||
Tags
|
||||
<mat-icon *ngIf="form?.get('tags')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-raised-button [disabled]="form?.disabled || form?.pristine"
|
||||
(click)="reset()">Reset</button>
|
||||
<button type="submit" mat-raised-button color="primary"
|
||||
[disabled]="form?.disabled || form?.pristine || !form?.valid">Save</button>
|
||||
</div>
|
||||
<form *ngIf="form as f" autocomplete="off" [formGroup]="f" (ngSubmit)="save()">
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>manage_accounts</mat-icon>
|
||||
Access
|
||||
<mat-icon *ngIf="f.get('access')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="apikeyComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
ApiKey
|
||||
<mat-icon *ngIf="f.get('apikey')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-apikeys #apikeyComponent [form]="apikeys"></rdio-scanner-admin-apikeys>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirwatchComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>folder</mat-icon>
|
||||
Dirwatch
|
||||
<mat-icon *ngIf="f.get('dirwatch')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-dirwatch #dirwatchComponent [form]="dirwatch"></rdio-scanner-admin-dirwatch>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>share</mat-icon>
|
||||
Downstreams
|
||||
<mat-icon *ngIf="f.get('downstreams')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>workspaces</mat-icon>
|
||||
Groups
|
||||
<mat-icon *ngIf="f.get('groups')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>tune</mat-icon>
|
||||
Options
|
||||
<mat-icon *ngIf="f.get('options')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>podcasts</mat-icon>
|
||||
Systems
|
||||
<mat-icon *ngIf="f.get('systems')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>sell</mat-icon>
|
||||
Tags
|
||||
<mat-icon *ngIf="f.get('tags')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-raised-button [disabled]="f.disabled || f.pristine"
|
||||
(click)="reset()">Reset</button>
|
||||
<button type="submit" mat-raised-button color="primary"
|
||||
[disabled]="f.disabled || f.pristine || !f.valid">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -25,7 +25,7 @@ rdio-scanner-admin-config {
|
|||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.mat-button:last-child {
|
||||
.mat-mdc-button:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ rdio-scanner-admin-config {
|
|||
}
|
||||
|
||||
@media (max-width: 719px) {
|
||||
rdio-scanner-admin-config .mat-form-field {
|
||||
rdio-scanner-admin-config .mat-mdc-form-field {
|
||||
flex: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -60,13 +60,13 @@ rdio-scanner-admin-config {
|
|||
@media (min-width: 720px) {
|
||||
.row {
|
||||
> p ~ div,
|
||||
.mat-form-field {
|
||||
.mat-mdc-form-field {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
> p ~ div > {
|
||||
.mat-button,
|
||||
.mat-slide-toggle {
|
||||
.mat-mdc-button,
|
||||
.mat-mdc-slide-toggle {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -21,6 +21,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
|
|||
import { FormArray, FormControl, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
|
@ -28,9 +30,10 @@ import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
|
|||
selector: 'rdio-scanner-admin-config',
|
||||
styleUrls: ['./config.component.scss'],
|
||||
templateUrl: './config.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
|
||||
docker: boolean = false;
|
||||
docker = false;
|
||||
|
||||
form: FormGroup | undefined;
|
||||
|
||||
|
|
@ -38,12 +41,12 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
|
|||
return this.form?.get('access') as FormArray;
|
||||
}
|
||||
|
||||
get apiKeys(): FormArray {
|
||||
return this.form?.get('apiKeys') as FormArray;
|
||||
get apikeys(): FormArray {
|
||||
return this.form?.get('apikeys') as FormArray;
|
||||
}
|
||||
|
||||
get dirWatch(): FormArray {
|
||||
return this.form?.get('dirWatch') as FormArray;
|
||||
get dirwatch(): FormArray {
|
||||
return this.form?.get('dirwatch') as FormArray;
|
||||
}
|
||||
|
||||
get downstreams(): FormArray {
|
||||
|
|
@ -68,38 +71,42 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
|
|||
|
||||
private config: Config | undefined;
|
||||
|
||||
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
|
||||
if ('authenticated' in event && event.authenticated === true) {
|
||||
this.config = await this.adminService.getConfig();
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
if ('config' in event) {
|
||||
this.config = event.config;
|
||||
|
||||
if (this.form?.pristine) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
if ('docker' in event) {
|
||||
this.docker = event.docker!;
|
||||
}
|
||||
});
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(
|
||||
private adminService: RdioScannerAdminService,
|
||||
private ngChangeDetectorRef: ChangeDetectorRef,
|
||||
) { }
|
||||
) {
|
||||
this.adminService.event.pipe(takeUntil(this.destroy$)).subscribe(async (event: AdminEvent) => {
|
||||
if ('authenticated' in event && event.authenticated === true) {
|
||||
this.config = await this.adminService.getConfig();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
if ('config' in event) {
|
||||
this.config = event.config;
|
||||
|
||||
if (this.form?.pristine) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
if ('docker' in event) {
|
||||
this.docker = event.docker ?? false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventSubscription.unsubscribe();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.adminService.loadAlerts();
|
||||
|
||||
this.config = await this.adminService.getConfig();
|
||||
|
||||
this.reset();
|
||||
|
|
@ -112,50 +119,60 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
|
|||
reset(config = this.config, options?: { dirty?: boolean }): void {
|
||||
this.form = this.adminService.newConfigForm(config);
|
||||
|
||||
this.form.statusChanges.subscribe(() => this.ngChangeDetectorRef.markForCheck());
|
||||
this.form.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||
this.ngChangeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
if (options?.dirty === true) {
|
||||
this.form.markAsDirty();
|
||||
}
|
||||
const systemsArray = this.systems;
|
||||
const groupsArray = this.groups;
|
||||
const tagsArray = this.tags;
|
||||
|
||||
this.groups.valueChanges.subscribe(() => {
|
||||
this.systems.controls.forEach((system) => {
|
||||
groupsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||
if (!systemsArray) { return; }
|
||||
systemsArray.controls.forEach((system) => {
|
||||
const talkgroups = system.get('talkgroups') as FormArray;
|
||||
|
||||
talkgroups.controls.forEach((talkgroup) => {
|
||||
const groupId = talkgroup.get('groupId') as FormControl;
|
||||
const groupIds = talkgroup.get('groupIds') as FormArray;
|
||||
|
||||
groupId.updateValueAndValidity();
|
||||
groupIds.updateValueAndValidity({ onlySelf: true });
|
||||
|
||||
if (groupId.errors) {
|
||||
groupId.markAsTouched();
|
||||
if (groupIds.errors) {
|
||||
groupIds.markAsTouched({ onlySelf: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.tags.valueChanges.subscribe(() => {
|
||||
this.systems.controls.forEach((system) => {
|
||||
tagsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||
if (!systemsArray) { return; }
|
||||
systemsArray.controls.forEach((system) => {
|
||||
const talkgroups = system.get('talkgroups') as FormArray;
|
||||
|
||||
talkgroups.controls.forEach((talkgroup) => {
|
||||
const tagId = talkgroup.get('tagId') as FormControl;
|
||||
|
||||
tagId.updateValueAndValidity();
|
||||
tagId.updateValueAndValidity({ onlySelf: true });
|
||||
|
||||
if (tagId.errors) {
|
||||
tagId.markAsTouched();
|
||||
tagId.markAsTouched({ onlySelf: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (options?.dirty === true) {
|
||||
this.form.markAsDirty();
|
||||
}
|
||||
|
||||
this.ngChangeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
this.form?.markAsPristine();
|
||||
if (!this.form) { return; }
|
||||
|
||||
await this.adminService.saveConfig(this.form?.getRawValue());
|
||||
this.form.markAsPristine();
|
||||
|
||||
await this.adminService.saveConfig(this.form.getRawValue());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
<p class="mat-body">Define a dirwatch to monitor new audio files from any directory.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New dir watch</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="dirWatches" (cdkDropListDropped)="drop($event)">
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="dirWatches"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let dirWatch of dirWatches; index as i" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
|
|
@ -32,11 +33,48 @@
|
|||
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Type</span><br>
|
||||
<span class="mat-caption">
|
||||
Dirwatch type defines how the metadata are obtained.
|
||||
<ul>
|
||||
<li><b>Default</b> - Extract the metadata from a custom mask.</li>
|
||||
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
|
||||
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's aliases tab.</li>
|
||||
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
|
||||
</ul>
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="type" placeholder="Type">
|
||||
<mat-option value="default">Default</mat-option>
|
||||
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
|
||||
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
|
||||
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Directory</span><br>
|
||||
<span class="mat-caption">
|
||||
Absolute or relatives to the server, path of the directory to be monitored.
|
||||
Path of the
|
||||
<ng-container [ngSwitch]="dirWatch.get('type')?.value">
|
||||
<ng-container *ngSwitchCase="'dsdplus'">
|
||||
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="'sdr-trunk'">
|
||||
<b>Recordings</b>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="'trunk-recorder'">
|
||||
<b>captureDir</b>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchDefault>
|
||||
local
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked disk.
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
|
|
@ -47,9 +85,12 @@
|
|||
<mat-error *ngIf="dirWatch.get('directory')?.hasError('duplicate')">
|
||||
Directory is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="dirWatch.get('directory')?.hasError('network')">
|
||||
Incompatible network folder
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" *ngIf="['default','dsdplus','trunk-recorder'].includes(dirWatch.get('type')?.value)">
|
||||
<p>
|
||||
<span class="mat-body">Extension</span><br>
|
||||
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
|
||||
|
|
@ -62,29 +103,14 @@
|
|||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Type</span><br>
|
||||
<span class="mat-caption">When SDR Trunk, metadata are obtained from the MP3 tags. Note that
|
||||
the label of the SDR Trunk system must match the label of the Rdio Scanner system label. When
|
||||
trunk-recorder, metadata are obtained from the JSON file.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="type" placeholder="Type">
|
||||
<mat-option [value]="null">Default</mat-option>
|
||||
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
|
||||
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" *ngIf="['default','dsdplus'].includes(dirWatch.get('type')?.value)">
|
||||
<p>
|
||||
<span class="mat-body">System</span><br>
|
||||
<span class="mat-caption">System to where the audio files should go.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select #sysId formControlName="systemId" placeholder="System">
|
||||
<mat-option [value]="null">Metatag #SYS</mat-option>
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option *ngFor="let system of systems" [value]="system.value.id">
|
||||
{{ system.value.label }}
|
||||
</mat-option>
|
||||
|
|
@ -94,14 +120,14 @@
|
|||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" *ngIf="['default','dsdplus'].includes(dirWatch.get('type')?.value)">
|
||||
<p>
|
||||
<span class="mat-body">Talkgroup</span><br>
|
||||
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
|
||||
<mat-option [value]="null">Metatag #TG</mat-option>
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option *ngFor="let talkgroup of talkgroups[dirWatch.value.systemId] || []"
|
||||
[value]="talkgroup.value.id">
|
||||
{{ talkgroup.value.label }}
|
||||
|
|
@ -112,7 +138,7 @@
|
|||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
|
||||
<p>
|
||||
<span class="mat-body">Mask</span><br>
|
||||
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
|
||||
|
|
@ -121,15 +147,28 @@
|
|||
<li><b>#DATE</b> - extract the date like 20201231 (YYYYMMMDD),
|
||||
2020-12-31 (YYYY-MM-DD) or 2020_12_31 (YYYY_MM_DD).
|
||||
</li>
|
||||
<li><b>#GROUP</b> - extract the group label.</li>
|
||||
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
|
||||
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
|
||||
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
|
||||
<li><b>#SYS</b> - extract the system id like 11.</li>
|
||||
<li><b>#SYSLBL</b> - extract the system label.</li>
|
||||
<li><b>#TAG</b> - extract the tag label.</li>
|
||||
<li><b>#TG</b> - extract the talkgroup id like 1457 in decimal format.</li>
|
||||
<li><b>#TGAFS</b> - extract the talkgroup id like 11-061 in AFS (agency-fleet-subfleet)
|
||||
format.</li>
|
||||
<li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to
|
||||
119100.</li>
|
||||
<li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the
|
||||
talkgroup id to 119100.</li>
|
||||
<li><b>#TGLBL</b> - extract the talkgroup label.</li>
|
||||
<li><b>#TGMHZ</b> - extract the frequency in megahertz like 119.100000 or 119 and set the
|
||||
talkgroup id to 119100.</li>
|
||||
<li><b>#TIME</b> - extract the local time like 0853439 (HHMMSS),
|
||||
08-34-39 (HH-MM-SS) or 08:34:39 (HH:MM:SS).</li>
|
||||
<li><b>#TG</b> - extract the talkgroup id like 54241.</li>
|
||||
<li><b>#UNIT</b> - extract the unit id like 4424001.</li>
|
||||
<li><b>#ZTIME</b> - extract the zulu time like 0453439, 08-34-39 or 04:34:39.</li>
|
||||
<li><b>#ZTIME</b> - extract the zulu time like 0453439 (HHMMSS),
|
||||
08-34-39 (HH-MM-SS) or 04:34:39 (HH:MM:SS).</li>
|
||||
</ul>
|
||||
Example: cymx_#TG_#DATE_#TIME_#HZ
|
||||
</span>
|
||||
|
|
@ -141,7 +180,7 @@
|
|||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
|
||||
<p>
|
||||
<span class="mat-body">Frequency</span><br>
|
||||
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
|
||||
|
|
@ -153,6 +192,20 @@
|
|||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
|
||||
<p>
|
||||
<span class="mat-body">Delay</span><br>
|
||||
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the
|
||||
recorder has created the file. You can set a delay value in milliseconds for the audio file to
|
||||
settle before ingesting it.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="number" matInput formControlName="delay" min="2000" placeholder="Delay">
|
||||
<mat-error *ngIf="dirWatch.get('delay')?.hasError('min')">
|
||||
Delay value cannot be less than 2000 milliseconds.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete dirwatch
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -62,7 +62,10 @@ export class RdioScannerAdminDirWatchComponent implements OnChanges {
|
|||
}
|
||||
|
||||
add(): void {
|
||||
const dirWatch = this.adminService.newDirWatchForm();
|
||||
const dirWatch = this.adminService.newDirWatchForm({
|
||||
delay: 2000,
|
||||
deleteAfter: true,
|
||||
});
|
||||
|
||||
dirWatch.markAllAsTouched();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">Define a dirwatch to monitor new audio files from any directory.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New dir watch</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="dirwatches"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
@for (dirwatch of dirwatches; track dirwatch; let i = $index) {
|
||||
<mat-expansion-panel cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ dirwatch.value.directory || 'NewDirwatch' }}
|
||||
@if (dirwatch.invalid) {
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
}
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="dirwatch">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Disabled</span><br>
|
||||
<span class="mat-caption">Disable the dirwatch.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Delete After</span><br>
|
||||
<span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing
|
||||
audio files will be ingested and deleted as soon as the server starts. If not activated,
|
||||
pre-existing audio files are neither ingested nor deleted.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Type</span><br>
|
||||
<span class="mat-caption">
|
||||
Dirwatch type defines how the metadata are obtained.
|
||||
<ul>
|
||||
<li><b>Default</b> - Extract the metadata from a custom mask.</li>
|
||||
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
|
||||
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's
|
||||
aliases tab.</li>
|
||||
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
|
||||
</ul>
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="type" placeholder="Type">
|
||||
<mat-option value="default">Default</mat-option>
|
||||
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
|
||||
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
|
||||
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Directory</span><br>
|
||||
<span class="mat-caption">
|
||||
Path of the
|
||||
@switch (dirwatch.get('type')?.value) {
|
||||
@case ('dsdplus') {
|
||||
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
|
||||
}
|
||||
@case ('sdr-trunk') {
|
||||
<b>Recordings</b>
|
||||
}
|
||||
@case ('trunk-recorder') {
|
||||
<b>captureDir</b>
|
||||
}
|
||||
@default {
|
||||
local
|
||||
}
|
||||
}
|
||||
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked
|
||||
disk.
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="directory" placeholder="Directory">
|
||||
@if (dirwatch.get('directory')?.hasError('required')) {
|
||||
<mat-error>
|
||||
Directory is required
|
||||
</mat-error>
|
||||
}
|
||||
@if (dirwatch.get('directory')?.hasError('duplicate')) {
|
||||
<mat-error>
|
||||
Directory is already defined
|
||||
</mat-error>
|
||||
}
|
||||
@if (dirwatch.get('directory')?.hasError('network')) {
|
||||
<mat-error>
|
||||
Incompatible network folder
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (['default','dsdplus','trunk-recorder'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Extension</span><br>
|
||||
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
|
||||
"wav".</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="extension" placeholder="Extension">
|
||||
@if (dirwatch.get('extension')?.hasError('invalid')) {
|
||||
<mat-error>
|
||||
Invalid extension
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">System</span><br>
|
||||
<span class="mat-caption">System to where the audio files should go.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select #sysId formControlName="systemId" placeholder="System">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
@for (system of systems; track system) {
|
||||
@if (system.value.id > 0) {
|
||||
<mat-option [value]="system.value.id">
|
||||
{{ system.value.label }}
|
||||
</mat-option>
|
||||
}
|
||||
}
|
||||
</mat-select>
|
||||
@if (dirwatch.get('systemId')?.hasError('required')) {
|
||||
<mat-error>
|
||||
System is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Site</span><br>
|
||||
<span class="mat-caption">Site to where the audio files should go.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="siteId" placeholder="Site">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
@for (site of sites[dirwatch.value.systemId] || []; track site) {
|
||||
@if (site.value.id > 0) {
|
||||
<mat-option [value]="site.value.id">
|
||||
{{ site.value.label }}
|
||||
</mat-option>
|
||||
}
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Talkgroup</span><br>
|
||||
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
@for (talkgroup of talkgroups[dirwatch.value.systemId] || []; track talkgroup) {
|
||||
@if (talkgroup.value.id > 0) {
|
||||
<mat-option [value]="talkgroup.value.id">
|
||||
{{ talkgroup.value.label }}
|
||||
</mat-option>
|
||||
}
|
||||
}
|
||||
</mat-select>
|
||||
@if (dirwatch.get('talkgroupId')?.hasError('required')) {
|
||||
<mat-error>
|
||||
Talkgroup is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (['default'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Mask</span><br>
|
||||
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
|
||||
specific META tags. Possible META tags:
|
||||
<ul>
|
||||
<li><b>#DATE</b> - extract the date like 20201231 (YYYYMMMDD),
|
||||
2020-12-31 (YYYY-MM-DD) or 2020_12_31 (YYYY_MM_DD).
|
||||
</li>
|
||||
<li><b>#GROUP</b> - extract the group label.</li>
|
||||
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
|
||||
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
|
||||
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
|
||||
<li><b>#SITE</b> - extract the site id like 1.</li>
|
||||
<li><b>#SITELBL</b> - extract the site label.</li>
|
||||
<li><b>#SYS</b> - extract the system id like 11.</li>
|
||||
<li><b>#SYSLBL</b> - extract the system label.</li>
|
||||
<li><b>#TAG</b> - extract the tag label.</li>
|
||||
<li><b>#TG</b> - extract the talkgroup id like 1457 in decimal format.</li>
|
||||
<li><b>#TGAFS</b> - extract the talkgroup id like 11-061 in AFS (agency-fleet-subfleet)
|
||||
format.</li>
|
||||
<li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to
|
||||
119100.</li>
|
||||
<li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the
|
||||
talkgroup id to 119100.</li>
|
||||
<li><b>#TGLBL</b> - extract the talkgroup label.</li>
|
||||
<li><b>#TGMHZ</b> - extract the frequency in megahertz like 119.100000 or 119 and set the
|
||||
talkgroup id to 119100.</li>
|
||||
<li><b>#TIME</b> - extract the local time like 0853439 (HHMMSS),
|
||||
08-34-39 (HH-MM-SS) or 08:34:39 (HH:MM:SS).</li>
|
||||
<li><b>#UNIT</b> - extract the unit id like 4424001.</li>
|
||||
<li><b>#UNITLBL</b> - extract the unit label. Also requires #UNIT.</li>
|
||||
<li><b>#ZTIME</b> - extract the zulu time like 0453439 (HHMMSS),
|
||||
08-34-39 (HH-MM-SS) or 04:34:39 (HH:MM:SS).</li>
|
||||
</ul>
|
||||
Example: cymx_#TG_#DATE_#TIME_#HZ
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="mask" placeholder="Mask">
|
||||
@if (dirwatch.get('mask')?.hasError('invalid')) {
|
||||
<mat-error>
|
||||
Invalid mask
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (['default'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Frequency</span><br>
|
||||
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" matInput formControlName="frequency" placeholder="Frequency">
|
||||
@if (dirwatch.get('frequency')?.errors) {
|
||||
<mat-error>
|
||||
Invalid frequency
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (['default'].includes(dirwatch.get('type')?.value)) {
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Delay</span><br>
|
||||
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the
|
||||
recorder has created the file. You can set a delay value in milliseconds for the audio file to
|
||||
settle before ingesting it.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" matInput formControlName="delay" min="2000" placeholder="Delay">
|
||||
@if (dirwatch.get('delay')?.hasError('min')) {
|
||||
<mat-error>
|
||||
Delay value cannot be less than 2000 milliseconds.
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete dirwatch
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian@huot.qc.ca>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { Component, Input, OnChanges, QueryList, ViewChildren } from '@angular/core';
|
||||
import { FormArray, FormControl, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { RdioScannerAdminService } from '../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-dirwatch',
|
||||
templateUrl: './dirwatch.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminDirwatchComponent implements OnChanges {
|
||||
@Input() form: FormArray | undefined;
|
||||
|
||||
get dirwatches(): FormGroup[] {
|
||||
return this.form?.controls
|
||||
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
|
||||
}
|
||||
|
||||
get sites(): FormGroup[][] {
|
||||
return this.systems.reduce((sites, system) => {
|
||||
const faSites = system.get('sites') as FormArray;
|
||||
|
||||
sites[system.value.id] = faSites.controls as FormGroup[];
|
||||
|
||||
return sites;
|
||||
}, [] as FormGroup[][]);
|
||||
}
|
||||
|
||||
get systems(): FormGroup[] {
|
||||
const systems = this.form?.root.get('systems') as FormArray;
|
||||
|
||||
return systems.controls as FormGroup[];
|
||||
}
|
||||
|
||||
get talkgroups(): FormGroup[][] {
|
||||
return this.systems.reduce((talkgroups, system) => {
|
||||
const faTalkgroups = system.get('talkgroups') as FormArray;
|
||||
|
||||
talkgroups[system.value.id] = faTalkgroups.controls as FormGroup[];
|
||||
|
||||
return talkgroups;
|
||||
}, [] as FormGroup[][]);
|
||||
}
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (this.form) {
|
||||
this.dirwatches.flatMap((control) => this.registerOnChanges(control));
|
||||
}
|
||||
}
|
||||
|
||||
add(): void {
|
||||
const dirwatch = this.adminService.newDirwatchForm({
|
||||
delay: 2000,
|
||||
deleteAfter: true,
|
||||
});
|
||||
|
||||
dirwatch.markAllAsTouched();
|
||||
|
||||
this.registerOnChanges(dirwatch);
|
||||
|
||||
this.form?.insert(0, dirwatch);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
this.panels?.forEach((panel) => panel.close());
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<FormGroup[]>): void {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
||||
|
||||
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
remove(index: number): void {
|
||||
this.form?.removeAt(index);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
|
||||
private registerOnChanges(control: FormGroup): void {
|
||||
const mask = control.get('mask') as FormControl;
|
||||
const type = control.get('type') as FormControl;
|
||||
|
||||
mask.valueChanges.subscribe(() => this.validateIds(control));
|
||||
type.valueChanges.subscribe(() => this.validateIds(control));
|
||||
}
|
||||
|
||||
private validateIds(control: FormGroup): void {
|
||||
const systemId = control.get('systemId');
|
||||
const talkgroupId = control.get('talkgroupId');
|
||||
|
||||
systemId?.updateValueAndValidity();
|
||||
systemId?.markAsTouched();
|
||||
|
||||
talkgroupId?.updateValueAndValidity();
|
||||
talkgroupId?.markAsTouched();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +1,101 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New downstream</button>
|
||||
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New downstream</button>
|
||||
</div>
|
||||
<p *ngIf="!downstreams.length" class="mat-small text-center">No defined downstreams</p>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let downstream of downstreams; index as i" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ downstream.value.url || 'NewDownstream' }}
|
||||
<mat-icon *ngIf="downstream.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="downstream">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Disabled</span><br>
|
||||
<span class="mat-caption">Disable the downstream.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">API Key</span><br>
|
||||
<span class="mat-caption">Api key of the remote instance.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input #key type="text" matInput formControlName="apiKey" placeholder="API key">
|
||||
<mat-error *ngIf="downstream.get('apiKey')?.hasError('duplicate')">
|
||||
API key is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="downstream.get('apiKey')?.hasError('required')">
|
||||
API key is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">URL</span><br>
|
||||
<span class="mat-caption">URL of the remote instance.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="url" placeholder="URL">
|
||||
<mat-error *ngIf="downstream.get('url')?.hasError('required')">
|
||||
URL is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="downstream.get('url')?.hasError('invalid')">
|
||||
URL is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="downstream.get('url')?.hasError('duplicate')">
|
||||
URL is already defined
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Access</span><br>
|
||||
<span class="mat-caption">
|
||||
This downstream allows access to <u>
|
||||
<ng-container *ngIf="downstream.value.systems === '*'">all</ng-container>
|
||||
<ng-container *ngIf="downstream.value.systems !== '*'">some</ng-container>
|
||||
</u> systems and talkgroups.
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)">
|
||||
Choose systems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete downstream
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
@if (!downstreams.length) {
|
||||
<p class="mat-small text-center">No defined downstreams</p>
|
||||
}
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)">
|
||||
@for (downstream of downstreams; track downstream; let i = $index) {
|
||||
<mat-expansion-panel cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ downstream.value.url || 'NewDownstream' }}
|
||||
@if (downstream.invalid) {
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
}
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="downstream">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Disabled</span><br>
|
||||
<span class="mat-caption">Disable the downstream.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">API Key</span><br>
|
||||
<span class="mat-caption">Api key of the remote instance.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input #key type="text" matInput formControlName="apikey" placeholder="API key">
|
||||
@if (downstream.get('apikey')?.hasError('duplicate')) {
|
||||
<mat-error>
|
||||
API key is already defined
|
||||
</mat-error>
|
||||
}
|
||||
@if (downstream.get('apikey')?.hasError('required')) {
|
||||
<mat-error>
|
||||
API key is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">URL</span><br>
|
||||
<span class="mat-caption">URL of the remote instance.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="url" placeholder="URL">
|
||||
@if (downstream.get('url')?.hasError('required')) {
|
||||
<mat-error>
|
||||
URL is required
|
||||
</mat-error>
|
||||
}
|
||||
@if (downstream.get('url')?.hasError('invalid')) {
|
||||
<mat-error>
|
||||
URL is invalid
|
||||
</mat-error>
|
||||
}
|
||||
@if (downstream.get('url')?.hasError('duplicate')) {
|
||||
<mat-error>
|
||||
URL is already defined
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Access</span><br>
|
||||
<span class="mat-caption">
|
||||
This downstream allows access to <u>
|
||||
@if (downstream.value.systems === '*') {
|
||||
all
|
||||
}
|
||||
@if (downstream.value.systems !== '*') {
|
||||
some
|
||||
}
|
||||
</u> systems and talkgroups.
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)">
|
||||
Choose systems
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete downstream
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
|
|||
@Component({
|
||||
selector: 'rdio-scanner-admin-downstreams',
|
||||
templateUrl: './downstreams.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminDownstreamsComponent {
|
||||
@Input() form: FormArray | undefined;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,68 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
|
||||
active talkgroups.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New group</button>
|
||||
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
|
||||
active talkgroups.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New Group</button>
|
||||
</div>
|
||||
<div class="groups">
|
||||
<div *ngFor="let group of groups; index as i" [formGroup]="group">
|
||||
<button type="button" mat-icon-button color="warn" (click)="remove(i)">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="label" placeholder="Group">
|
||||
<mat-error *ngIf="groups[i].get('label')?.hasError('required')">
|
||||
Group is required
|
||||
</mat-error>
|
||||
<p *ngIf="!groups.length" class="mat-small text-center">No defined Groups</p>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="groups"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let group of groups; let i = index" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ group.value.label || 'NewGroup' }}
|
||||
<mat-icon *ngIf="group.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="group">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Group label displayed on the main screen, on the search panel and on the
|
||||
select panel.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="group.get('label')!.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Audio alert</span><br>
|
||||
<span class="mat-caption">Prefixes an alert sound to all calls assigned to this group. The alert
|
||||
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
|
||||
<mat-option value="">No Alert</mat-option>
|
||||
<mat-option *ngFor="let alert of alerts" [value]="alert">
|
||||
{{ alert | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Led Color</span><br>
|
||||
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
|
||||
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="led" placeholder="Color">
|
||||
<mat-option value="">Default</mat-option>
|
||||
<mat-option *ngFor="let led of leds" [value]="led">
|
||||
{{ led | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete Group
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,40 +17,101 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { Component, Input, QueryList, ViewChildren, OnDestroy } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FormArray, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { MatSelectChange } from '@angular/material/select';
|
||||
import { RdioScannerAdminService } from '../../admin.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-groups',
|
||||
styleUrls: ['./groups.component.scss'],
|
||||
templateUrl: './groups.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminGroupsComponent {
|
||||
@Input() form: FormArray | undefined;
|
||||
export class RdioScannerAdminGroupsComponent implements OnDestroy {
|
||||
private _form: FormArray | undefined;
|
||||
private formSub: Subscription | undefined;
|
||||
private cachedGroups: FormGroup[] | undefined;
|
||||
private cacheValid = false;
|
||||
readonly leds: string[] = [];
|
||||
readonly alertsArray: string[] = [];
|
||||
|
||||
get groups(): FormGroup[] {
|
||||
return this.form?.controls
|
||||
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[];
|
||||
@Input()
|
||||
set form(value: FormArray | undefined) {
|
||||
this._form = value;
|
||||
this.cacheValid = false;
|
||||
this.cachedGroups = undefined;
|
||||
this.formSub?.unsubscribe();
|
||||
if (this._form) {
|
||||
this.formSub = this._form.valueChanges.subscribe(() => {
|
||||
this.cacheValid = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
get form(): FormArray | undefined {
|
||||
return this._form;
|
||||
}
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
get alerts(): string[] {
|
||||
return this.alertsArray;
|
||||
}
|
||||
|
||||
get groups(): FormGroup[] {
|
||||
if (this.cacheValid && this.cachedGroups) return this.cachedGroups;
|
||||
const controls = (this._form?.controls?.slice() || []) as FormGroup[];
|
||||
this.cachedGroups = controls.sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
|
||||
this.cacheValid = true;
|
||||
return this.cachedGroups;
|
||||
}
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) {
|
||||
this.leds = this.adminService.getLeds();
|
||||
this.alertsArray = Object.keys(this.adminService.Alerts || {});
|
||||
}
|
||||
|
||||
add(): void {
|
||||
const id = this.groups.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0);
|
||||
|
||||
const group = this.adminService.newGroupForm({ _id: id });
|
||||
const group = this.adminService.newGroupForm();
|
||||
|
||||
group.markAllAsTouched();
|
||||
|
||||
this.form?.insert(0, group);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
this.cacheValid = false;
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
this.panels?.forEach((panel) => panel.close());
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<FormGroup[]>): void {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
||||
|
||||
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
|
||||
|
||||
this.form?.markAsDirty();
|
||||
this.cacheValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
async playAlert(event: MatSelectChange): Promise<void> {
|
||||
if (event.value) await this.adminService.playAlert(event.value);
|
||||
}
|
||||
|
||||
remove(index: number): void {
|
||||
this.form?.removeAt(index);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
this.cacheValid = false;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.formSub?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +1,168 @@
|
|||
<ng-container *ngIf="form" [formGroup]="form">
|
||||
<ng-container *ngIf="form">
|
||||
<ng-container [formGroup]="form">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Auto Populate</span><br>
|
||||
<span class="mat-caption">Globaly allows the automatic creation of unconfigured systems and
|
||||
talkgroups.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
|
||||
</div>
|
||||
<p>
|
||||
<span class="mat-body">12-Hour Time Format</span><br>
|
||||
<span class="mat-caption">Display time stamp in 12-hour format.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Dimmer Delay</span><br>
|
||||
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
|
||||
inactive.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
|
||||
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('required')">
|
||||
Dimmer delay is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('min')">
|
||||
Dimmer delay is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Audio Conversion</span><br>
|
||||
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="audioConversion" placeholder="Audio Conversion">
|
||||
<mat-option [value]="0">Disabled</mat-option>
|
||||
<mat-option [value]="1">Enabled without normalization</mat-option>
|
||||
<mat-option [value]="2">Enabled with normalization</mat-option>
|
||||
<mat-option [value]="3">Enabled with loud normalization</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Disable Audio Conversion</span><br>
|
||||
<span class="mat-caption">Disable the audio format conversion to m4a/aac. This is usefull if another Rdio
|
||||
Scanner instance is feeding this instance where audio has already been converted.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disableAudioConversion"></mat-slide-toggle>
|
||||
</div>
|
||||
<p>
|
||||
<span class="mat-body">Auto Populate</span><br>
|
||||
<span class="mat-caption">Globally allows the automatic creation of unconfigured systems and
|
||||
talkgroups.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Disable Duplicate Call Detection</span><br>
|
||||
<span class="mat-caption">Disable duplicate audio file detection.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
|
||||
</div>
|
||||
<p>
|
||||
<span class="mat-body">Branding Label</span><br>
|
||||
<span class="mat-caption">A simple line of text displayed above the main screen to help identify the Rdio
|
||||
Scanner instance.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="branding" placeholder="Branding">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Duplicate Call Detection Time Frame</span><br>
|
||||
<span class="mat-caption">New incoming calls will be rejected if a call already exists in the database with
|
||||
a start time of +/- this delay in milliseconds.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
|
||||
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('required')">
|
||||
Duplicate call time frame is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('min')">
|
||||
Duplicate call time frame is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Dimmer Delay</span><br>
|
||||
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
|
||||
inactive.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
|
||||
<mat-error *ngIf="form.get('dimmerDelay')?.hasError('required')">
|
||||
Dimmer delay is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('dimmerDelay')?.hasError('min')">
|
||||
Dimmer delay is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Keypad Beep Style</span><br>
|
||||
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="keypadBeeps" placeholder="Style">
|
||||
<mat-option value="uniden">Uniden</mat-option>
|
||||
<mat-option value="whistler">Whistler</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Disable Duplicate Call Detection</span><br>
|
||||
<span class="mat-caption">Disable duplicate audio file detection.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Prune Days</span><br>
|
||||
<span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to
|
||||
disable.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="pruneDays">
|
||||
<mat-error *ngIf="form?.get('pruneDays')?.hasError('required')">
|
||||
Prune days is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('pruneDays')?.hasError('min')">
|
||||
Prune days is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Duplicate Call Detection Time Frame</span><br>
|
||||
<span class="mat-caption">New incoming calls will be rejected if a call already exists in the database with
|
||||
a start time of +/- this delay in milliseconds.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
|
||||
<mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('required')">
|
||||
Duplicate call time frame is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('min')">
|
||||
Duplicate call time frame is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Sort Talkgroups</span><br>
|
||||
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
|
||||
</div>
|
||||
<p>
|
||||
<span class="mat-body">Email Support</span><br>
|
||||
<span class="mat-caption">Email address where users can write to to get support.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="email" placeholder="Email">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Toggle by tags</span><br>
|
||||
<span class="mat-caption">Allow toggling talkgroups by their tag.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="tagsToggle"></mat-slide-toggle>
|
||||
</div>
|
||||
<p>
|
||||
<span class="mat-body">Keypad Beep Style</span><br>
|
||||
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="keypadBeeps" placeholder="Style">
|
||||
<mat-option value="uniden">Uniden</mat-option>
|
||||
<mat-option value="whistler">Whistler</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Max Clients</span><br>
|
||||
<span class="mat-caption">Max number of simultaneous clients.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="maxClients">
|
||||
<mat-error *ngIf="form.get('maxClients')?.hasError('required')">
|
||||
Max clients is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('maxClients')?.hasError('min')">
|
||||
Max clients is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Playback Mode Goes Live</span><br>
|
||||
<span class="mat-caption">Playback mode changes to live stream mode when the search list is
|
||||
exhausted.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Prune Days</span><br>
|
||||
<span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to
|
||||
disable.</span>
|
||||
</p>
|
||||
<mat-form-field>
|
||||
<input type="number" min="0" step="1" matInput formControlName="pruneDays">
|
||||
<mat-error *ngIf="form.get('pruneDays')?.hasError('required')">
|
||||
Prune days is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('pruneDays')?.hasError('min')">
|
||||
Prune days is invalid
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Show Listeners Count</span><br>
|
||||
<span class="mat-caption">Show listeners count on main screen.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Sort Talkgroups</span><br>
|
||||
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
|
||||
</p>
|
||||
<div>
|
||||
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,13 +17,19 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-options',
|
||||
templateUrl: './options.component.html',
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RdioScannerAdminOptionsComponent {
|
||||
@Input() form: FormGroup | undefined;
|
||||
@Input() form: FormGroup | null = null;
|
||||
|
||||
get controls() {
|
||||
return this.form ? this.form.controls : {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,57 @@
|
|||
<div>
|
||||
<div mat-dialog-title>Systems and talkgroups selection</div>
|
||||
<div [formGroup]="select" mat-dialog-content>
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
|
||||
[indeterminate]="indeterminate.everything">
|
||||
Everything
|
||||
</mat-checkbox>
|
||||
<h3 class="mat-caption">Groups</h3>
|
||||
<div class="flex-row wrap" formArrayName="groups">
|
||||
<div *ngFor="let group of configGroups; index as groupIndex" [formGroupName]="groupIndex">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
|
||||
[indeterminate]="indeterminate.groups[groupIndex]">
|
||||
{{ group.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mat-caption">Tags</h3>
|
||||
<div class="flex-row wrap" formArrayName="tags">
|
||||
<div *ngFor="let tag of configTags; index as tagIndex" [formGroupName]="tagIndex">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
|
||||
[indeterminate]="indeterminate.tags[tagIndex]">
|
||||
{{ tag.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mat-caption">Systems</h3>
|
||||
<div formArrayName="systems">
|
||||
<div *ngFor="let system of configSystems; index as systemIndex" [formGroupName]="systemIndex">
|
||||
<div class="flex-row margin">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
|
||||
[indeterminate]="indeterminate.systems[systemIndex]">
|
||||
{{ system.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
|
||||
<div *ngFor="let talkgroup of configTalkgroups[systemIndex]; index as talkgroupIndex"
|
||||
[formGroupName]="talkgroupIndex">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
|
||||
{{ talkgroup.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div mat-dialog-title>Systems and talkgroups selection</div>
|
||||
<div [formGroup]="select" mat-dialog-content>
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
|
||||
[indeterminate]="indeterminate.everything">
|
||||
Everything
|
||||
</mat-checkbox>
|
||||
<h3 class="mat-caption">Groups</h3>
|
||||
<div class="flex-row wrap" formArrayName="groups">
|
||||
<ng-container *ngFor="let group of configGroups; let groupIndex = index">
|
||||
<div [formGroupName]="groupIndex">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
|
||||
[indeterminate]="indeterminate.groups[groupIndex]">
|
||||
{{ group.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button mat-raised-button (click)="cancel()">Cancel</button>
|
||||
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
|
||||
<h3 class="mat-caption">Tags</h3>
|
||||
<div class="flex-row wrap" formArrayName="tags">
|
||||
<ng-container *ngFor="let tag of configTags; let tagIndex = index">
|
||||
<div [formGroupName]="tagIndex">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
|
||||
[indeterminate]="indeterminate.tags[tagIndex]">
|
||||
{{ tag.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<h3 class="mat-caption">Systems</h3>
|
||||
<div formArrayName="systems">
|
||||
<ng-container *ngFor="let system of configSystems; let systemIndex = index">
|
||||
<div [formGroupName]="systemIndex">
|
||||
<div class="flex-row margin">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
|
||||
[indeterminate]="indeterminate.systems[systemIndex]">
|
||||
{{ system.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
|
||||
<ng-container *ngFor="let talkgroup of configTalkgroups[systemIndex]; let talkgroupIndex = index">
|
||||
<div [formGroupName]="talkgroupIndex">
|
||||
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
|
||||
{{ talkgroup.value.label }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button mat-raised-button (click)="cancel()">Cancel</button>
|
||||
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,18 +35,7 @@ rdio-scanner-admin-systems-selection {
|
|||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mat-checkbox,
|
||||
.mat-checkbox-layout {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mat-checkbox-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mat-dialog-actions {
|
||||
.mat-mdc-dialog-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,10 +17,11 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { ChangeDetectorRef, Component, Inject, ViewEncapsulation } from '@angular/core';
|
||||
import { Component, Inject, ViewEncapsulation, OnDestroy } from '@angular/core';
|
||||
import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Access } from '../../../admin.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
interface System {
|
||||
all: boolean;
|
||||
|
|
@ -38,8 +39,9 @@ interface Talkgroup {
|
|||
selector: 'rdio-scanner-admin-systems-selection',
|
||||
styleUrls: ['./select.component.scss'],
|
||||
templateUrl: './select.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminSystemsSelectComponent {
|
||||
export class RdioScannerAdminSystemsSelectComponent implements OnDestroy {
|
||||
indeterminate = {
|
||||
everything: false,
|
||||
groups: [] as boolean[],
|
||||
|
|
@ -47,233 +49,196 @@ export class RdioScannerAdminSystemsSelectComponent {
|
|||
tags: [] as boolean[],
|
||||
};
|
||||
|
||||
select = this.ngFormBuilder.group({
|
||||
all: this.ngFormBuilder.control(false),
|
||||
groups: this.ngFormBuilder.array([]),
|
||||
tags: this.ngFormBuilder.array([]),
|
||||
systems: this.ngFormBuilder.array([]),
|
||||
});
|
||||
select: FormGroup;
|
||||
|
||||
configTalkgroups = this.configSystems.map((fgSystem) => {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
return faTalkgroups.controls as FormGroup[];
|
||||
});
|
||||
configTalkgroups: FormGroup[][];
|
||||
|
||||
get configGroups(): FormGroup[] {
|
||||
const faGroups = this.access.root.get('groups') as FormArray;
|
||||
|
||||
return faGroups.controls as FormGroup[];
|
||||
}
|
||||
|
||||
get configSystems(): FormGroup[] {
|
||||
const faSystems = this.access.root.get('systems') as FormArray;
|
||||
|
||||
return faSystems.controls as FormGroup[];
|
||||
}
|
||||
|
||||
get configTags(): FormGroup[] {
|
||||
const faTags = this.access.root.get('tags') as FormArray;
|
||||
|
||||
return faTags.controls as FormGroup[];
|
||||
}
|
||||
|
||||
private subs = new Subscription();
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public access: FormGroup,
|
||||
private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>,
|
||||
private ngChangeDetectorRef: ChangeDetectorRef,
|
||||
private ngFormBuilder: FormBuilder,
|
||||
) {
|
||||
this.configTalkgroups = this.configSystems.map((fgSystem) => {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
return faTalkgroups.controls as FormGroup[];
|
||||
});
|
||||
|
||||
this.select = this.ngFormBuilder.group({
|
||||
all: this.ngFormBuilder.nonNullable.control(false),
|
||||
groups: this.ngFormBuilder.nonNullable.array<FormGroup>([]),
|
||||
tags: this.ngFormBuilder.nonNullable.array<FormGroup>([]),
|
||||
systems: this.ngFormBuilder.nonNullable.array<FormGroup>([]),
|
||||
});
|
||||
|
||||
const fcAll = this.select.get('all') as FormControl;
|
||||
const faGroups = this.select.get('groups') as FormArray;
|
||||
const faSystems = this.select.get('systems') as FormArray;
|
||||
const faTags = this.select.get('tags') as FormArray;
|
||||
|
||||
this.configGroups.forEach((configGroup) => {
|
||||
for (const configGroup of this.configGroups) {
|
||||
const fgGroup = this.ngFormBuilder.group({
|
||||
_id: [configGroup.value._id],
|
||||
checked: [false],
|
||||
id: this.ngFormBuilder.control(configGroup.get('id')?.value),
|
||||
checked: this.ngFormBuilder.control(false),
|
||||
});
|
||||
|
||||
faGroups.push(fgGroup);
|
||||
|
||||
fgGroup.valueChanges.subscribe((vGroup) => {
|
||||
faSystems.controls.forEach((fgSystem) => {
|
||||
this.subs.add(fgGroup.valueChanges.subscribe((vGroup) => {
|
||||
for (const fgSystem of faSystems.controls) {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
faTalkgroups.controls.forEach((fgTalkgroup) => {
|
||||
if (fgTalkgroup.value.groupId === vGroup._id && fgTalkgroup.value.checked !== vGroup.checked) {
|
||||
for (const fgTalkgroup of faTalkgroups.controls) {
|
||||
const ids = fgTalkgroup.get('groupIds')?.value;
|
||||
if (ids && ids.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== vGroup.checked) {
|
||||
fgTalkgroup.get('checked')?.setValue(vGroup.checked);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
this.configSystems.forEach((configSystem, index) => {
|
||||
for (let index = 0; index < this.configSystems.length; index++) {
|
||||
const configSystem = this.configSystems[index];
|
||||
const fcSystemAll = this.ngFormBuilder.control(false);
|
||||
|
||||
const faSystemTalkgroups = this.ngFormBuilder.array([]);
|
||||
|
||||
const faSystemTalkgroups = this.ngFormBuilder.array<FormGroup>([]);
|
||||
const fgSystem = this.ngFormBuilder.group({
|
||||
all: fcSystemAll,
|
||||
id: configSystem.value.id,
|
||||
id: this.ngFormBuilder.control(configSystem.get('systemRef')?.value),
|
||||
talkgroups: faSystemTalkgroups
|
||||
});
|
||||
|
||||
this.configTalkgroups[index].forEach((configTalkgroup) => {
|
||||
for (const configTalkgroup of this.configTalkgroups[index]) {
|
||||
const fgSystemTalkgroup = this.ngFormBuilder.group({
|
||||
checked: [false],
|
||||
groupId: configTalkgroup.value.groupId,
|
||||
id: configTalkgroup.value.id,
|
||||
tagId: configTalkgroup.value.tagId
|
||||
checked: this.ngFormBuilder.nonNullable.control(false),
|
||||
groupIds: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('groupIds')?.value),
|
||||
id: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('talkgroupRef')?.value),
|
||||
tagId: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('tagId')?.value),
|
||||
});
|
||||
|
||||
faSystemTalkgroups.push(fgSystemTalkgroup);
|
||||
|
||||
fgSystemTalkgroup.valueChanges.subscribe(() => {
|
||||
const vAll = faSystemTalkgroups.controls.every((systemTalkgroup) => systemTalkgroup.value.checked);
|
||||
|
||||
this.subs.add(fgSystemTalkgroup.valueChanges.subscribe(() => {
|
||||
const vAll = faSystemTalkgroups.controls.every((t) => t.get('checked')?.value);
|
||||
fcSystemAll.setValue(vAll, { emitEvent: false });
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
faSystems.push(fgSystem);
|
||||
|
||||
fgSystem.valueChanges.subscribe(() => {
|
||||
this.subs.add(fgSystem.valueChanges.subscribe(() => {
|
||||
this.rebuildGroupIndeterminates();
|
||||
this.rebuildTagIndeterminates();
|
||||
});
|
||||
}));
|
||||
|
||||
fcSystemAll.valueChanges.subscribe((vAll) => {
|
||||
this.subs.add(fcSystemAll.valueChanges.subscribe((vAll) => {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
faTalkgroups.controls.forEach((fgTalkgroup) => fgTalkgroup.get('checked')?.setValue(vAll));
|
||||
|
||||
for (const fgTalkgroup of faTalkgroups.controls) {
|
||||
fgTalkgroup.get('checked')?.setValue(vAll);
|
||||
}
|
||||
this.rebuildGroupIndeterminates();
|
||||
this.rebuildTagIndeterminates();
|
||||
});
|
||||
}));
|
||||
|
||||
faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
|
||||
let off = 0;
|
||||
this.subs.add(faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
|
||||
let on = 0;
|
||||
|
||||
vSystemTalkgroups.forEach((vSystemTalkgroup: Talkgroup) => {
|
||||
if (vSystemTalkgroup.checked) {
|
||||
on++;
|
||||
|
||||
} else {
|
||||
off++;
|
||||
}
|
||||
});
|
||||
|
||||
let off = 0;
|
||||
for (const v of vSystemTalkgroups) {
|
||||
if (v.checked) on++; else off++;
|
||||
}
|
||||
this.indeterminate.systems[index] = !!off && !!on;
|
||||
|
||||
faSystems.at(index).get('all')?.setValue(!off && on, { emitEvent: false });
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
this.configTags.forEach((configTag) => {
|
||||
for (const configTag of this.configTags) {
|
||||
const fgTag = this.ngFormBuilder.group({
|
||||
_id: [configTag.value._id],
|
||||
checked: [false],
|
||||
id: this.ngFormBuilder.control(configTag.value.id),
|
||||
checked: this.ngFormBuilder.control(false),
|
||||
});
|
||||
|
||||
faTags.push(fgTag);
|
||||
|
||||
fgTag.valueChanges.subscribe((vTag) => {
|
||||
faSystems.controls.forEach((fgSystem) => {
|
||||
this.subs.add(fgTag.valueChanges.subscribe((vTag) => {
|
||||
for (const fgSystem of faSystems.controls) {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
faTalkgroups.controls.forEach((fgTalkgroup) => {
|
||||
if (fgTalkgroup.value.tagId === vTag._id && fgTalkgroup.value.checked !== vTag.checked) {
|
||||
for (const fgTalkgroup of faTalkgroups.controls) {
|
||||
if (fgTalkgroup.value.tagId === vTag.id && fgTalkgroup.value.checked !== vTag.checked) {
|
||||
fgTalkgroup.get('checked')?.setValue(vTag.checked);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fcAll.valueChanges.subscribe((vAll) => {
|
||||
faSystems.controls.flatMap((fgSystem) => {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
return faTalkgroups.controls;
|
||||
}).concat(faGroups.controls, faTags.controls).forEach((control) => {
|
||||
control.get('checked')?.setValue(vAll);
|
||||
});
|
||||
});
|
||||
|
||||
faSystems.valueChanges.subscribe((vSystems) => {
|
||||
let off = 0;
|
||||
let on = 0;
|
||||
|
||||
vSystems.forEach((vSystem: System) => {
|
||||
if (vSystem.all) {
|
||||
on++;
|
||||
|
||||
} else {
|
||||
off++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
this.subs.add(fcAll.valueChanges.subscribe((vAll) => {
|
||||
for (const fgSystem of faSystems.controls) {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
for (const fgTalkgroup of faTalkgroups.controls) {
|
||||
fgTalkgroup.get('checked')?.setValue(vAll);
|
||||
}
|
||||
}
|
||||
for (const fg of faGroups.controls) fg.get('checked')?.setValue(vAll);
|
||||
for (const fg of faTags.controls) fg.get('checked')?.setValue(vAll);
|
||||
}));
|
||||
|
||||
this.subs.add(faSystems.valueChanges.subscribe((vSystems: System[]) => {
|
||||
let on = 0;
|
||||
let off = 0;
|
||||
for (const vSystem of vSystems) {
|
||||
if (vSystem.all) on++; else off++;
|
||||
}
|
||||
this.indeterminate.everything = !!off && !!on;
|
||||
|
||||
fcAll.setValue(!off && on, { emitEvent: false });
|
||||
});
|
||||
}));
|
||||
|
||||
const vAccess: Access = this.access.value;
|
||||
|
||||
if (vAccess.systems === '*') {
|
||||
this.select.get('all')?.setValue(true);
|
||||
|
||||
} else if (Array.isArray(vAccess.systems)) {
|
||||
vAccess.systems.forEach((vSystem: { id: number; talkgroups: { id: number }[] | number[] | '*' } | number) => {
|
||||
for (const vSystem of vAccess.systems) {
|
||||
if (typeof vSystem === 'number') {
|
||||
faSystems.controls.find((fgSystem) => fgSystem.value.id === vSystem)?.get('all')?.setValue(true);
|
||||
|
||||
} else if (vSystem !== null && typeof vSystem === 'object') {
|
||||
const fgSystem = faSystems.controls.find((fg) => fg.value.id === vSystem.id);
|
||||
|
||||
faSystems.controls.find((fgSystem) => fgSystem.get('id')?.value === vSystem)?.get('all')?.setValue(true);
|
||||
} else if (vSystem && typeof vSystem === 'object') {
|
||||
const fgSystem = faSystems.controls.find((fg) => fg.get('id')?.value === vSystem.id);
|
||||
if (fgSystem) {
|
||||
if (vSystem.talkgroups === '*') {
|
||||
fgSystem.get('all')?.setValue(true);
|
||||
|
||||
} else if (Array.isArray(vSystem.talkgroups)) {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
vSystem.talkgroups.forEach((talkgroup: { id: number } | number) => {
|
||||
for (const talkgroup of vSystem.talkgroups) {
|
||||
const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id;
|
||||
|
||||
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.value.id === talkgroupId);
|
||||
|
||||
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.get('id')?.value === talkgroupId);
|
||||
fgTalkgroup?.get('checked')?.setValue(true);
|
||||
});
|
||||
|
||||
fgSystem?.updateValueAndValidity();
|
||||
}
|
||||
fgSystem.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accept(): void {
|
||||
const select = this.select.value;
|
||||
|
||||
const access: Access['systems'] = select.all ? '*' : select.systems.filter((system: System) => {
|
||||
return system.all || system.talkgroups.some((talkgroup: Talkgroup) => talkgroup.checked);
|
||||
const access: Access['systems'] = this.select.get('all')?.value ? '*' : this.select.get('systems')?.value.filter((system: System) => {
|
||||
return system['all'] || system['talkgroups'].some((talkgroup: Talkgroup) => talkgroup.checked);
|
||||
}).map((system: System) => {
|
||||
if (system.all) {
|
||||
return {
|
||||
id: system.id,
|
||||
talkgroups: '*',
|
||||
};
|
||||
|
||||
if (system['all']) {
|
||||
return { id: system['id'], talkgroups: '*' };
|
||||
} else {
|
||||
return {
|
||||
id: system.id,
|
||||
talkgroups: system.talkgroups
|
||||
id: system['id'],
|
||||
talkgroups: system['talkgroups']
|
||||
.filter((talkgroup: Talkgroup) => talkgroup.checked)
|
||||
.map((talkgroup: Talkgroup) => talkgroup.id),
|
||||
};
|
||||
|
|
@ -289,61 +254,47 @@ export class RdioScannerAdminSystemsSelectComponent {
|
|||
|
||||
private rebuildGroupIndeterminates(): void {
|
||||
const faGroups = this.select.get('groups') as FormArray;
|
||||
|
||||
const faSystems = this.select.get('systems') as FormArray;
|
||||
|
||||
faGroups.controls.forEach((fgGroup, index) => {
|
||||
let off = 0;
|
||||
for (let index = 0; index < faGroups.length; index++) {
|
||||
const fgGroup = faGroups.at(index);
|
||||
let on = 0;
|
||||
|
||||
faSystems.controls.forEach((fgSystem) => {
|
||||
let off = 0;
|
||||
for (const fgSystem of faSystems.controls) {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
faTalkgroups.controls.forEach((fgTalkgroup) => {
|
||||
if (fgTalkgroup.value.groupId === fgGroup.value._id) {
|
||||
if (fgTalkgroup.value.checked) {
|
||||
on++;
|
||||
|
||||
} else {
|
||||
off++;
|
||||
}
|
||||
for (const fgTalkgroup of faTalkgroups.controls) {
|
||||
if (fgTalkgroup.get('groupIds')?.value.includes(fgGroup.get('id')?.value)) {
|
||||
if (fgTalkgroup.get('checked')?.value) on++; else off++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
this.indeterminate.groups[index] = !!off && !!on;
|
||||
|
||||
fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildTagIndeterminates(): void {
|
||||
const faTags = this.select.get('tags') as FormArray;
|
||||
|
||||
const faSystems = this.select.get('systems') as FormArray;
|
||||
|
||||
faTags.controls.forEach((fgTag, index) => {
|
||||
let off = 0;
|
||||
for (let index = 0; index < faTags.length; index++) {
|
||||
const fgTag = faTags.at(index);
|
||||
let on = 0;
|
||||
|
||||
faSystems.controls.forEach((fgSystem) => {
|
||||
let off = 0;
|
||||
for (const fgSystem of faSystems.controls) {
|
||||
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
|
||||
|
||||
faTalkgroups.controls.forEach((fgTalkgroup) => {
|
||||
if (fgTalkgroup.value.tagId === fgTag.value._id) {
|
||||
if (fgTalkgroup.value.checked) {
|
||||
on++;
|
||||
|
||||
} else {
|
||||
off++;
|
||||
}
|
||||
for (const fgTalkgroup of faTalkgroups.controls) {
|
||||
if (fgTalkgroup.value.tagId === fgTag.value.id) {
|
||||
if (fgTalkgroup.value.checked) on++; else off++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
this.indeterminate.tags[index] = !!off && !!on;
|
||||
|
||||
fgTag.get('checked')?.setValue(!off && on, { emitEvent: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<ng-container *ngIf="form as f" [formGroup]="f">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">Site identifier in decimal format.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<ng-container *ngIf="f.get('siteRef') as siteRef">
|
||||
<input type="number" min="1" step="1" matInput formControlName="siteRef" placeholder="Id" />
|
||||
<mat-error *ngIf="siteRef.hasError('duplicate')">
|
||||
Id is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="siteRef.hasError('min')">
|
||||
Id is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="siteRef.hasError('required')">
|
||||
Id is required
|
||||
</mat-error>
|
||||
</ng-container>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Site label.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<ng-container *ngIf="f.get('label') as labelCtrl">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label" />
|
||||
<mat-error *ngIf="labelCtrl.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</ng-container>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">
|
||||
Delete site
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian@huot.qc.ca>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { FormGroup, AbstractControl } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-site',
|
||||
templateUrl: './site.component.html',
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RdioScannerAdminSiteComponent {
|
||||
private _form: FormGroup | undefined;
|
||||
|
||||
@Input()
|
||||
set form(f: FormGroup | undefined) {
|
||||
this._form = f;
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
get form(): FormGroup | undefined {
|
||||
return this._form;
|
||||
}
|
||||
|
||||
get siteRef(): AbstractControl | null | undefined {
|
||||
return this._form?.get('siteRef');
|
||||
}
|
||||
|
||||
get labelControl(): AbstractControl | null | undefined {
|
||||
return this._form?.get('label');
|
||||
}
|
||||
|
||||
@Output() remove = new EventEmitter<void>();
|
||||
|
||||
constructor(private readonly cd: ChangeDetectorRef) {}
|
||||
}
|
||||
|
|
@ -1,141 +1,228 @@
|
|||
<ng-container [formGroup]="form">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">System identifier in decimal format.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="number" min="1" step="1" matInput formControlName="id" placeholder="Id">
|
||||
<mat-error *ngIf="form.get('id')?.hasError('duplicate')">
|
||||
Id is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('id')?.hasError('min')">
|
||||
Id is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('id')?.hasError('required')">
|
||||
Id is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="form.get('label')?.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Led Color</span><br>
|
||||
<span class="mat-caption">Indicator color when playing an audio file from this system. If not
|
||||
specified, the indicator will light green.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="led" placeholder="Color">
|
||||
<mat-option [value]="null">Default</mat-option>
|
||||
<mat-option *ngFor="let led of leds" [value]="led">
|
||||
{{ led | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Auto Populate</span><br>
|
||||
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
|
||||
</p>
|
||||
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Blacklists</span><br>
|
||||
<span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only
|
||||
has an effect if the auto populate option is enabled.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<textarea type="text" matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
|
||||
<mat-error *ngIf="form?.get('blacklists')?.hasError('invalid')">
|
||||
Comma separated list of talkgroup Ids
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">System identifier in decimal format.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id">
|
||||
<mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')">
|
||||
Id is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('systemRef')?.hasError('min')">
|
||||
Id is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('systemRef')?.hasError('required')">
|
||||
Id is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="form.get('label')?.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Audio alert</span><br>
|
||||
<span class="mat-caption">Prefixes an alert sound to all calls assigned from this system. The alert
|
||||
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
|
||||
<mat-option value="">No Alert</mat-option>
|
||||
<mat-option *ngFor="let alert of alerts" [value]="alert">
|
||||
{{ alert | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Auto Populate</span><br>
|
||||
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
|
||||
</p>
|
||||
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Delay</span><br>
|
||||
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Led Color</span><br>
|
||||
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
|
||||
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="led" placeholder="Color">
|
||||
<mat-option value="">Default</mat-option>
|
||||
<mat-option *ngFor="let led of leds" [value]="led">
|
||||
{{ led | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Blacklists</span><br>
|
||||
<span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only
|
||||
has an effect if the auto populate option is enabled.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<textarea matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
|
||||
<mat-error *ngIf="form.get('blacklists')!.hasError('invalid')">
|
||||
Comma separated list of talkgroup Ids
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Type</span><br>
|
||||
<span class="mat-caption">
|
||||
Type of radio system displayed on main screen.
|
||||
<ul>
|
||||
<li><b>AM</b> - Amplitude modulation.</li>
|
||||
<li><b>DMR</b> - Digital mobile radio system.</li>
|
||||
<li><b>FM</b> - Frequency modulation.</li>
|
||||
<li><b>NFM</b> - Narrow frequency modulation.</li>
|
||||
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
|
||||
<li><b>P25</b> - APCO-25 radio system.</li>
|
||||
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
|
||||
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
|
||||
<li><b>Tetra</b> - Trans european trunked radio system.</li>
|
||||
</ul>
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="type" placeholder="Type">
|
||||
<mat-option value="">No Type</mat-option>
|
||||
<mat-option value="am">AM</mat-option>
|
||||
<mat-option value="dmr">DMR</mat-option>
|
||||
<mat-option value="fm">FM</mat-option>
|
||||
<mat-option value="nfm">NFM</mat-option>
|
||||
<mat-option value="nxdn">NXDN</mat-option>
|
||||
<mat-option value="p25">P25</mat-option>
|
||||
<mat-option value="provoice">Provoice</mat-option>
|
||||
<mat-option value="smartnet">SmartNet</mat-option>
|
||||
<mat-option value="tetra">Tetra</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
Sites
|
||||
<mat-icon *ngIf="form.get('sites')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<div class="row top">
|
||||
<p class="mat-caption">System sites, you can drag and drop sites to rearrange them.</p>
|
||||
<button type="button" mat-button color="accent" (click)="addSite()">New Site</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="sites"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let site of sites; let i = index; trackBy: trackById" [formGroup]="site" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
Talkgroups
|
||||
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ site.value.label?.trim() || 'NewSite' }}
|
||||
<mat-icon *ngIf="site.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<div class="row top">
|
||||
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They
|
||||
will appear with the same order on the talkgroup selection panel unless the sort talkgroups
|
||||
option is enabled.</p>
|
||||
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="talkgroups"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; index as i" [formGroup]="talkgroup"
|
||||
cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
|
||||
<mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
|
||||
(remove)="removeTalkgroup(i)">
|
||||
</rdio-scanner-admin-talkgroup>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<rdio-scanner-admin-site [form]="site" (remove)="removeSite(i)"></rdio-scanner-admin-site>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
Talkgroups
|
||||
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<div class="row top">
|
||||
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They
|
||||
will appear with the same order on the talkgroup selection panel unless the sort talkgroups
|
||||
option is enabled.</p>
|
||||
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64"
|
||||
[cdkDropListData]="talkgroups" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; let i = index; trackBy: trackById" [formGroup]="talkgroup" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
Units
|
||||
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
|
||||
<mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<div class="row top">
|
||||
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main
|
||||
screen.<br>You can drag and drop them to rearrange</p>
|
||||
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="units"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let unit of units; index as i" [formGroup]="unit" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ unit.value.label?.trim() || 'NewUnit' }}
|
||||
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
|
||||
</rdio-scanner-admin-unit>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
|
||||
(remove)="removeTalkgroup(i)">
|
||||
</rdio-scanner-admin-talkgroup>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">
|
||||
Delete system
|
||||
</button>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<mat-accordion displayMode="flat">
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
Units
|
||||
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<div class="row top">
|
||||
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main
|
||||
screen.<br>You can drag and drop them to rearrange</p>
|
||||
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="units"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let unit of units; let i = index; trackBy: trackById" [formGroup]="unit" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ unit.value.label?.trim() || 'NewUnit' }}
|
||||
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
|
||||
</rdio-scanner-admin-unit>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">
|
||||
Delete system
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -21,11 +21,13 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
|||
import { Component, EventEmitter, Input, Output, QueryList, ViewChildren } from '@angular/core';
|
||||
import { FormArray, FormControl, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { MatSelectChange } from '@angular/material/select';
|
||||
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-system',
|
||||
templateUrl: './system.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminSystemComponent {
|
||||
@Input() form = new FormGroup({});
|
||||
|
|
@ -38,36 +40,58 @@ export class RdioScannerAdminSystemComponent {
|
|||
|
||||
@Output() remove = new EventEmitter<void>();
|
||||
|
||||
leds = this.adminService.getLeds();
|
||||
leds: string[];
|
||||
|
||||
get alerts(): string[] {
|
||||
return Object.keys(this.adminService.Alerts || {});
|
||||
}
|
||||
|
||||
get sites(): FormGroup[] {
|
||||
const sites = this.form.get('sites') as FormArray | null;
|
||||
return (sites?.controls as FormGroup[]) || [];
|
||||
}
|
||||
|
||||
get talkgroups(): FormGroup[] {
|
||||
const talkgroups = this.form.get('talkgroups') as FormArray;
|
||||
|
||||
return talkgroups.controls as FormGroup[];
|
||||
const talkgroups = this.form.get('talkgroups') as FormArray | null;
|
||||
return (talkgroups?.controls as FormGroup[]) || [];
|
||||
}
|
||||
|
||||
get units(): FormGroup[] {
|
||||
const units = this.form.get('units') as FormArray;
|
||||
const units = this.form.get('units') as FormArray | null;
|
||||
return (units?.controls as FormGroup[]) || [];
|
||||
}
|
||||
|
||||
return units.controls as FormGroup[];
|
||||
// trackBy to preserve identity in ngFor lists
|
||||
trackById(_index: number, group: FormGroup): any {
|
||||
return group.get('id')?.value ?? _index;
|
||||
}
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
constructor(private adminService: RdioScannerAdminService) {
|
||||
this.leds = this.adminService.getLeds();
|
||||
}
|
||||
|
||||
addSite(): void {
|
||||
const sites = this.form.get('sites') as FormArray | null;
|
||||
|
||||
sites?.insert(0, this.adminService.newSiteForm());
|
||||
|
||||
this.form.markAsDirty();
|
||||
}
|
||||
|
||||
addTalkgroup(): void {
|
||||
const talkgroups = this.form.get('talkgroups') as FormArray;
|
||||
const talkgroups = this.form.get('talkgroups') as FormArray | null;
|
||||
|
||||
talkgroups.insert(0, this.adminService.newTalkgroupForm());
|
||||
talkgroups?.insert(0, this.adminService.newTalkgroupForm());
|
||||
|
||||
this.form.markAsDirty();
|
||||
}
|
||||
|
||||
addUnit(): void {
|
||||
const units = this.form.get('units') as FormArray;
|
||||
const units = this.form.get('units') as FormArray | null;
|
||||
|
||||
units.insert(0, this.adminService.newUnitForm());
|
||||
units?.insert(0, this.adminService.newUnitForm());
|
||||
|
||||
this.form.markAsDirty();
|
||||
}
|
||||
|
|
@ -81,9 +105,9 @@ export class RdioScannerAdminSystemComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
const blacklists = this.form?.get('blacklists') as FormControl;
|
||||
const blacklists = this.form.get('blacklists') as FormControl | null;
|
||||
|
||||
blacklists.setValue(blacklists.value?.trim() ? `${blacklists.value},${id}` : `${id}`);
|
||||
blacklists?.setValue(blacklists.value?.trim() ? `${blacklists.value},${id}` : `${id}`);
|
||||
|
||||
this.removeTalkgroup(index);
|
||||
}
|
||||
|
|
@ -96,23 +120,37 @@ export class RdioScannerAdminSystemComponent {
|
|||
if (event.previousIndex !== event.currentIndex) {
|
||||
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
||||
|
||||
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
|
||||
|
||||
this.form.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
async playAlert(event: MatSelectChange): Promise<void> {
|
||||
if (event.value) await this.adminService.playAlert(event.value);
|
||||
}
|
||||
|
||||
removeSite(index: number): void {
|
||||
const sites = this.form.get('sites') as FormArray | null;
|
||||
|
||||
sites?.removeAt(index);
|
||||
|
||||
sites?.markAsDirty();
|
||||
}
|
||||
|
||||
removeTalkgroup(index: number): void {
|
||||
const talkgroups = this.form.get('talkgroups') as FormArray;
|
||||
const talkgroups = this.form.get('talkgroups') as FormArray | null;
|
||||
|
||||
talkgroups.removeAt(index);
|
||||
talkgroups?.removeAt(index);
|
||||
|
||||
talkgroups.markAsDirty();
|
||||
talkgroups?.markAsDirty();
|
||||
}
|
||||
|
||||
removeUnit(index: number): void {
|
||||
const units = this.form.get('units') as FormArray;
|
||||
const units = this.form.get('units') as FormArray | null;
|
||||
|
||||
units.removeAt(index);
|
||||
units?.removeAt(index);
|
||||
|
||||
units.markAsDirty();
|
||||
units?.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown
|
||||
systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop
|
||||
systems to rearrange their order.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New system</button>
|
||||
<p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown
|
||||
systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop
|
||||
systems to rearrange their order.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New system</button>
|
||||
</div>
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let system of systems; index as i" [formGroup]="system"
|
||||
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ system.value.label?.trim() || 'NewSystem' }}
|
||||
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
|
||||
</rdio-scanner-admin-system>
|
||||
</mat-expansion-panel>
|
||||
<mat-accordion *ngIf="systems" displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel *ngFor="let system of systems; let i = index" [formGroup]="system"
|
||||
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ system.value.label?.trim() || 'NewSystem' }}
|
||||
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
|
||||
</rdio-scanner-admin-system>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,26 +18,44 @@
|
|||
*/
|
||||
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
|
||||
import { Component, Input, QueryList, ViewChildren, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core';
|
||||
import { FormArray, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { RdioScannerAdminService, System } from '../../admin.service';
|
||||
import { RdioScannerAdminService } from '../../admin.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-systems',
|
||||
templateUrl: './systems.component.html',
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RdioScannerAdminSystemsComponent {
|
||||
@Input() form: FormArray | undefined;
|
||||
export class RdioScannerAdminSystemsComponent implements OnDestroy {
|
||||
private _form: FormArray | undefined;
|
||||
private subs = new Subscription();
|
||||
private _systemsCache: FormGroup[] = [];
|
||||
|
||||
@Input()
|
||||
set form(f: FormArray | undefined) {
|
||||
if (this._form === f) { return; }
|
||||
this.subs.unsubscribe();
|
||||
this.subs = new Subscription();
|
||||
this._form = f;
|
||||
this.updateCache();
|
||||
if (this._form) {
|
||||
const s = this._form.valueChanges.subscribe(() => this.updateCache());
|
||||
this.subs.add(s);
|
||||
}
|
||||
}
|
||||
get form(): FormArray | undefined { return this._form; }
|
||||
|
||||
get systems(): FormGroup[] {
|
||||
return this.form?.controls
|
||||
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
|
||||
return this._systemsCache;
|
||||
}
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
constructor(private adminService: RdioScannerAdminService, private cd: ChangeDetectorRef) { }
|
||||
|
||||
add(): void {
|
||||
const system = this.adminService.newSystemForm();
|
||||
|
|
@ -47,6 +65,8 @@ export class RdioScannerAdminSystemsComponent {
|
|||
this.form?.insert(0, system);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
|
||||
this.updateCache();
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
|
|
@ -60,6 +80,8 @@ export class RdioScannerAdminSystemsComponent {
|
|||
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
|
||||
|
||||
this.form?.markAsDirty();
|
||||
|
||||
this.updateCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,5 +89,23 @@ export class RdioScannerAdminSystemsComponent {
|
|||
this.form?.removeAt(index);
|
||||
|
||||
this.form?.markAsDirty();
|
||||
|
||||
this.updateCache();
|
||||
}
|
||||
|
||||
private updateCache(): void {
|
||||
if (!this._form) {
|
||||
this._systemsCache = [];
|
||||
this.cd.markForCheck();
|
||||
return;
|
||||
}
|
||||
const arr = [...this._form.controls] as FormGroup[];
|
||||
arr.sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
|
||||
this._systemsCache = arr;
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,123 +1,170 @@
|
|||
<ng-container *ngIf="form" [formGroup]="form">
|
||||
<ng-container *ngIf="form">
|
||||
<ng-container [formGroup]="form">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="number" min="1" step="1" matInput formControlName="id" placeholder="Id">
|
||||
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')">
|
||||
Id is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('id')?.hasError('min')">
|
||||
Id is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('id')?.hasError('required')">
|
||||
Id is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id">
|
||||
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('duplicate')">
|
||||
Id is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('min')">
|
||||
Id is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('required')">
|
||||
Id is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="form?.get('label')?.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="form.get('label')!.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Name</span><br>
|
||||
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="name" placeholder="Name">
|
||||
<mat-error *ngIf="form?.get('name')?.hasError('required')">
|
||||
Name is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Name</span><br>
|
||||
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="name" placeholder="Name">
|
||||
<mat-error *ngIf="form.get('name')!.hasError('required')">
|
||||
Name is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Group</span><br>
|
||||
<span class="mat-caption">Group to which this talkgoups belongs.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="groupId" placeholder="Group">
|
||||
<mat-option *ngFor="let group of groups" [value]="group._id">
|
||||
{{ group.label }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form?.get('groupId')?.hasError('required')">
|
||||
Group is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Groups</span><br>
|
||||
<span class="mat-caption">Groups to which this talkgroups belongs.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true">
|
||||
<mat-option *ngFor="let group of groups" [value]="group.id">
|
||||
{{ group.label }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get('groupIds')?.hasError('required')">
|
||||
Group is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Tag</span><br>
|
||||
<span class="mat-caption">Tag to which this talkgoups belongs.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="tagId" placeholder="Tag">
|
||||
<mat-option *ngFor="let tag of tags" [value]="tag._id">
|
||||
{{ tag.label }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form?.get('tagId')?.hasError('required')">
|
||||
Tag is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Tag</span><br>
|
||||
<span class="mat-caption">Tag to which this talkgroups belongs.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="tagId" placeholder="Tag">
|
||||
<mat-option *ngFor="let tag of tags" [value]="tag.id">
|
||||
{{ tag.label }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get('tagId')!.hasError('required')">
|
||||
Tag is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Led Color</span><br>
|
||||
<span class="mat-caption">Indicator color when playing an audio file from this talkgroup. If not specified,
|
||||
the color configured for the system is used.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<mat-select formControlName="led" placeholder="Color">
|
||||
<mat-option [value]="null">Default</mat-option>
|
||||
<mat-option *ngFor="let led of leds" [value]="led">
|
||||
{{ led | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Audio alert</span><br>
|
||||
<span class="mat-caption">Prefixes an alert sound to all calls assigned from this talkgroup. The alert
|
||||
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event.value)">
|
||||
<mat-option value="">No Alert</mat-option>
|
||||
<mat-option *ngFor="let alert of alerts" [value]="alert">
|
||||
{{ alert | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Frequency</span><br>
|
||||
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency">
|
||||
<mat-error *ngIf="form?.get('frequency')?.errors">
|
||||
Invalid frequency
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Delay</span><br>
|
||||
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Patches</span><br>
|
||||
<span class="mat-caption">A comma separated list of talkgroup id to patch in this one.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<textarea type="text" matInput formControlName="patches" placeholder="Patches"></textarea>
|
||||
<mat-error *ngIf="form?.get('patches')?.hasError('invalid')">
|
||||
Comma separated list of talkgroup Ids
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Frequency</span><br>
|
||||
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency">
|
||||
<mat-error *ngIf="form.get('frequency')?.errors">
|
||||
Invalid frequency
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Led Color</span><br>
|
||||
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
|
||||
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="led" placeholder="Color">
|
||||
<mat-option value="">Default</mat-option>
|
||||
<mat-option *ngFor="let led of leds" [value]="led">
|
||||
{{ led | titlecase }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Type</span><br>
|
||||
<span class="mat-caption">
|
||||
Type of radio system that will override the type defined at the system level, if any.
|
||||
<ul>
|
||||
<li><b>AM</b> - Amplitude modulation.</li>
|
||||
<li><b>DMR</b> - Digital mobile radio system.</li>
|
||||
<li><b>FM</b> - Frequency modulation.</li>
|
||||
<li><b>NFM</b> - Narrow frequency modulation.</li>
|
||||
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
|
||||
<li><b>P25</b> - APCO-25 radio system.</li>
|
||||
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
|
||||
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
|
||||
<li><b>Tetra</b> - Trans european trunked radio system.</li>
|
||||
</ul>
|
||||
</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="type" placeholder="Type">
|
||||
<mat-option value="">No Type</mat-option>
|
||||
<mat-option value="am">AM</mat-option>
|
||||
<mat-option value="dmr">DMR</mat-option>
|
||||
<mat-option value="fm">FM</mat-option>
|
||||
<mat-option value="nfm">NFM</mat-option>
|
||||
<mat-option value="nxdn">NXDN</mat-option>
|
||||
<mat-option value="p25">P25</mat-option>
|
||||
<mat-option value="provoice">Provoice</mat-option>
|
||||
<mat-option value="smartnet">SmartNet</mat-option>
|
||||
<mat-option value="tetra">Tetra</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
|
||||
Blacklist talkgroup
|
||||
</button>
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">
|
||||
Delete talkgroup
|
||||
</button>
|
||||
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
|
||||
Blacklist talkgroup
|
||||
</button>
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">
|
||||
Delete talkgroup
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,12 +18,13 @@
|
|||
*/
|
||||
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormArray, FormControl, FormGroup } from '@angular/forms';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-talkgroup',
|
||||
templateUrl: './talkgroup.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminTalkgroupComponent {
|
||||
@Input() form: FormGroup | undefined;
|
||||
|
|
@ -32,21 +33,25 @@ export class RdioScannerAdminTalkgroupComponent {
|
|||
|
||||
@Output() remove = new EventEmitter<void>();
|
||||
|
||||
leds = this.adminService.getLeds();
|
||||
leds: string[] = [];
|
||||
|
||||
get groups(): Group[] {
|
||||
return this.form?.root.get('groups')?.value as Group[];
|
||||
get alerts(): string[] {
|
||||
return Object.keys(this.adminService.Alerts || {});
|
||||
}
|
||||
|
||||
get patches(): FormControl[] {
|
||||
const patches = this.form?.get('patches') as FormArray;
|
||||
|
||||
return patches.controls as FormControl[];
|
||||
get groups(): Group[] {
|
||||
return (this.form?.root.get('groups')?.value as Group[]) || [];
|
||||
}
|
||||
|
||||
get tags(): Tag[] {
|
||||
return this.form?.root.get('tags')?.value as Tag[];
|
||||
return (this.form?.root.get('tags')?.value as Tag[]) || [];
|
||||
}
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
constructor(private adminService: RdioScannerAdminService) {
|
||||
this.leds = this.adminService.getLeds() || [];
|
||||
}
|
||||
|
||||
async playAlert(value: string | null): Promise<void> {
|
||||
if (value) await this.adminService.playAlert(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,52 @@
|
|||
<ng-container *ngIf="form" [formGroup]="form">
|
||||
<ng-container *ngIf="form as f">
|
||||
<ng-container [formGroup]="f">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">Unit identifier in decimal format.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="number" min="0" step="1" matInput formControlName="id" placeholder="Id">
|
||||
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')">
|
||||
Id is already defined
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('id')?.hasError('min')">
|
||||
Id is invalid
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form?.get('id')?.hasError('required')">
|
||||
Id is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Id</span><br>
|
||||
<span class="mat-caption">Unit identifier in decimal format. You can define a unique unit identifier here,
|
||||
but you can also use a range of identifiers using the fields below.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id">
|
||||
<mat-error *ngIf="f.get('unitRef')!.hasError('duplicate')">Id value is already defined</mat-error>
|
||||
<mat-error *ngIf="f.get('unitRef')!.hasError('min')">Id value is invalid</mat-error>
|
||||
<mat-error *ngIf="f.get('unitRef')!.hasError('required')">Id value is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Unit label.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="form?.get('label')?.hasError('required')">
|
||||
Label is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<p>
|
||||
<span class="mat-body">Range from</span><br>
|
||||
<span class="mat-caption">Unit identifier range from.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="1" step="1" matInput formControlName="unitFrom" placeholder="From">
|
||||
<mat-error *ngIf="f.get('unitFrom')!.hasError('range')">From value should be lower than To value</mat-error>
|
||||
<mat-error *ngIf="f.get('unitFrom')!.hasError('min')">From value is invalid</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Range to</span><br>
|
||||
<span class="mat-caption">Unit identifier range to.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="number" min="1" step="1" matInput formControlName="unitTo" placeholder="To">
|
||||
<mat-error *ngIf="f.get('unitTo')!.hasError('range')">To value should be higher than From value</mat-error>
|
||||
<mat-error *ngIf="f.get('unitTo')!.hasError('min')">To value is invalid</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Unit label.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
<mat-error *ngIf="f.get('label')!.hasError('required')">Label is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">
|
||||
Delete unit
|
||||
</button>
|
||||
<button type="button" mat-button color="warn" (click)="remove.emit()">Delete unit</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,15 +17,17 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-unit',
|
||||
templateUrl: './unit.component.html',
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RdioScannerAdminUnitComponent {
|
||||
@Input() form: FormGroup | undefined;
|
||||
@Input() form: FormGroup | null = null;
|
||||
|
||||
@Output() remove = new EventEmitter<void>();
|
||||
@Output() readonly remove = new EventEmitter<void>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,79 @@
|
|||
<div class="row top">
|
||||
<p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls
|
||||
based on their tag.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New tag</button>
|
||||
<p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls
|
||||
based on their tag.</p>
|
||||
<button type="button" mat-button color="accent" (click)="add()">New Tag</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<div *ngFor="let tag of tags; index as i" [formGroup]="tag">
|
||||
<button type="button" mat-icon-button color="warn" (click)="remove(i)">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
<mat-form-field floatLabel="never">
|
||||
<input type="text" matInput formControlName="label" placeholder="Tag">
|
||||
<mat-error *ngIf="tags[i].get('label')?.hasError('required')">
|
||||
Tag is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@if (!tags.length) {
|
||||
<p class="mat-small text-center">No defined Tags</p>
|
||||
}
|
||||
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="tags"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
@for (tag of tags; track tag; let i = $index) {
|
||||
<mat-expansion-panel cdkDrag>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
|
||||
{{ tag.value.label || 'NewTag' }}
|
||||
@if (tag.invalid) {
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
}
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<ng-container [formGroup]="tag">
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Label</span><br>
|
||||
<span class="mat-caption">Tag label displayed on the main screen and on the search panel.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<input type="text" matInput formControlName="label" placeholder="Label">
|
||||
@if (tag.get('label')!.hasError('required')) {
|
||||
<mat-error>
|
||||
Label is required
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Audio alert</span><br>
|
||||
<span class="mat-caption">Prefixes an alert sound to all calls assigned to this tag. The alert
|
||||
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
|
||||
<mat-option value="">No Alert</mat-option>
|
||||
@for (alert of alerts; track alert) {
|
||||
<mat-option [value]="alert">
|
||||
{{ alert | titlecase }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>
|
||||
<span class="mat-body">Led Color</span><br>
|
||||
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
|
||||
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
|
||||
</p>
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select formControlName="led" placeholder="Color">
|
||||
<mat-option [value]="''">Default</mat-option>
|
||||
@for (led of leds; track led) {
|
||||
<mat-option [value]="led">
|
||||
{{ led | titlecase }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-button color="warn" (click)="remove(i)">
|
||||
Delete Tag
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,29 +17,41 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FormArray, FormGroup } from '@angular/forms';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { MatSelectChange } from '@angular/material/select';
|
||||
import { RdioScannerAdminService } from '../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-tags',
|
||||
styleUrls: ['./tags.component.scss'],
|
||||
templateUrl: './tags.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminTagsComponent {
|
||||
@Input() form: FormArray | undefined;
|
||||
|
||||
get tags(): FormGroup[] {
|
||||
return this.form?.controls
|
||||
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[];
|
||||
leds: string[];
|
||||
|
||||
get alerts(): string[] {
|
||||
return Object.keys(this.adminService.Alerts || {});
|
||||
}
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
get tags(): FormGroup[] {
|
||||
return this.form?.controls
|
||||
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
|
||||
}
|
||||
|
||||
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) {
|
||||
this.leds = this.adminService.getLeds();
|
||||
}
|
||||
|
||||
add(): void {
|
||||
const id = this.tags.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0);
|
||||
|
||||
const tag = this.adminService.newTagForm({ _id: id });
|
||||
const tag = this.adminService.newTagForm();
|
||||
|
||||
tag.markAllAsTouched();
|
||||
|
||||
|
|
@ -48,6 +60,24 @@ export class RdioScannerAdminTagsComponent {
|
|||
this.form?.markAsDirty();
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
this.panels?.forEach((panel) => panel.close());
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<FormGroup[]>): void {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
|
||||
|
||||
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
|
||||
|
||||
this.form?.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
async playAlert(event: MatSelectChange): Promise<void> {
|
||||
if (event.value) await this.adminService.playAlert(event.value);
|
||||
}
|
||||
|
||||
remove(index: number): void {
|
||||
this.form?.removeAt(index);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
<form [formGroup]="form" (ngSubmit)="login()">
|
||||
<p class="mat-body-1">Please enter the admin password to gain access to the administrative dashboard</p>
|
||||
<p class="mat-body-2">Please enter the admin password to gain access to the administrative dashboard</p>
|
||||
<mat-form-field hideRequiredMarker>
|
||||
<mat-label>Password</mat-label>
|
||||
<input matInput formControlName="password" type="password" required>
|
||||
<mat-error>{{ message }}</mat-error>
|
||||
<input
|
||||
matInput
|
||||
formControlName="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
[attr.aria-invalid]="form.get('password')?.invalid"
|
||||
required
|
||||
>
|
||||
<mat-error *ngIf="(form.get('password')?.invalid && form.get('password')?.touched) || message">
|
||||
{{ message || 'Password is required' }}
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.disabled || form.invalid">Login</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || form.disabled">Login</button>
|
||||
</form>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
form > button {
|
||||
form > button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mat-raised-button,
|
||||
.mat-form-field {
|
||||
.mat-mdc-raised-button,
|
||||
.mat-mdc-form-field {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,27 +18,30 @@
|
|||
*/
|
||||
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { FormBuilder, Validators } from '@angular/forms';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { RdioScannerAdminService } from '../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-login',
|
||||
styleUrls: ['./login.component.scss'],
|
||||
templateUrl: './login.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminLoginComponent {
|
||||
@Output() loggedIn = new EventEmitter<void>();
|
||||
|
||||
form = this.formBuilder.group({
|
||||
password: [null, Validators.required],
|
||||
});
|
||||
form: FormGroup;
|
||||
|
||||
message = '';
|
||||
|
||||
constructor(
|
||||
private adminService: RdioScannerAdminService,
|
||||
private formBuilder: FormBuilder,
|
||||
) { }
|
||||
private ngFormBuilder: FormBuilder,
|
||||
) {
|
||||
this.form = this.ngFormBuilder.group({
|
||||
password: this.ngFormBuilder.control(null, Validators.required),
|
||||
});
|
||||
}
|
||||
|
||||
async login(password = this.form.get('password')?.value): Promise<void> {
|
||||
if (!password) {
|
||||
|
|
|
|||
|
|
@ -1,85 +1,91 @@
|
|||
<mat-table [dataSource]="logs">
|
||||
<ng-container matColumnDef="level">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Level</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<mat-icon *ngIf="log?.level === 'error'" class="error">error</mat-icon>
|
||||
<mat-icon *ngIf="log?.level === 'info'" class="info">notifications</mat-icon>
|
||||
<mat-icon *ngIf="log?.level === 'warn'" class="warn">warning</mat-icon>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Date</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<span>{{ log?.dateTime | date:'MM/dd' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="time">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Time</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<span>{{ log?.dateTime | date:'HH:mm' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="message">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Message</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<span>{{ log?.message }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']">
|
||||
</mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']">
|
||||
</mat-row>
|
||||
<ng-container matColumnDef="level">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Level</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
@if (log?.level === 'error') {
|
||||
<mat-icon class="error">error</mat-icon>
|
||||
}
|
||||
@if (log?.level === 'info') {
|
||||
<mat-icon class="info">notifications</mat-icon>
|
||||
}
|
||||
@if (log?.level === 'warn') {
|
||||
<mat-icon class="warn">warning</mat-icon>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Date</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<span>{{ log?.dateTime | date:'MM/dd' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="time">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Time</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<span>{{ log?.dateTime | date:'HH:mm' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="message">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Message</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let log">
|
||||
<span>{{ log?.message }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']">
|
||||
</mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']">
|
||||
</mat-row>
|
||||
</mat-table>
|
||||
<mat-progress-bar color="primary" [mode]="logsQueryPending ? 'query' : 'determinate'">
|
||||
</mat-progress-bar>
|
||||
<mat-paginator [disabled]="logsQueryPending" [length]="logsQuery?.count" [hidePageSize]="true"
|
||||
[pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()">
|
||||
[pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()">
|
||||
</mat-paginator>
|
||||
<form autocomplete="off" [formGroup]="form">
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Sort order
|
||||
</mat-label>
|
||||
<mat-select formControlName="sort" (selectionChange)="formHandler()">
|
||||
<mat-option [value]="1">
|
||||
Ascending
|
||||
</mat-option>
|
||||
<mat-option [value]="-1">
|
||||
Descending
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Date
|
||||
</mat-label>
|
||||
<input matInput type="datetime-local" formControlName="date" [max]="logsQuery?.dateStop"
|
||||
[min]="logsQuery?.dateStart" placeholder="Choose a date" (change)="formHandler()">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Level
|
||||
</mat-label>
|
||||
<mat-select formControlName="level" (selectionChange)="formHandler()">
|
||||
<mat-option value="info">Informative</mat-option>
|
||||
<mat-option value="warn">Warning</mat-option>
|
||||
<mat-option value="error">Error</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="reset">
|
||||
<button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()">
|
||||
Reload
|
||||
</button>
|
||||
<button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Sort order
|
||||
</mat-label>
|
||||
<mat-select formControlName="sort" (selectionChange)="formHandler()">
|
||||
<mat-option [value]="1">
|
||||
Ascending
|
||||
</mat-option>
|
||||
<mat-option [value]="-1">
|
||||
Descending
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Date
|
||||
</mat-label>
|
||||
<input matInput type="datetime-local" formControlName="date" [max]="logsQuery?.dateStop"
|
||||
[min]="logsQuery?.dateStart" placeholder="Choose a date" (change)="formHandler()">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Level
|
||||
</mat-label>
|
||||
<mat-select formControlName="level" (selectionChange)="formHandler()">
|
||||
<mat-option value="info">Informative</mat-option>
|
||||
<mat-option value="warn">Warning</mat-option>
|
||||
<mat-option value="error">Error</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="reset">
|
||||
<button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()">
|
||||
Reload
|
||||
</button>
|
||||
<button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -10,8 +10,8 @@
|
|||
color: orange;
|
||||
}
|
||||
|
||||
.mat-cell,
|
||||
.mat-header-cell {
|
||||
.mat-mdc-cell,
|
||||
.mat-mdc-header-cell {
|
||||
&:nth-child(1),
|
||||
&:nth-child(2),
|
||||
&:nth-child(3) {
|
||||
|
|
@ -25,13 +25,13 @@ form {
|
|||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.mat-form-field,
|
||||
.mat-mdc-form-field,
|
||||
.reset {
|
||||
box-sizing: border-box;
|
||||
padding: 0 0.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.mat-form-field {
|
||||
.mat-mdc-form-field {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
|
|
@ -44,13 +44,13 @@ form {
|
|||
}
|
||||
|
||||
@media (max-width: 719px) {
|
||||
form > .mat-form-field {
|
||||
form > .mat-mdc-form-field {
|
||||
flex: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
form > .mat-form-field {
|
||||
form > .mat-mdc-form-field {
|
||||
flex: 33%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import { Component, ViewChild } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../admin.service';
|
||||
|
|
@ -27,13 +27,10 @@ import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../ad
|
|||
selector: 'rdio-scanner-admin-logs',
|
||||
styleUrls: ['./logs.component.scss'],
|
||||
templateUrl: './logs.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminLogsComponent {
|
||||
form = this.ngFormBuilder.group({
|
||||
date: [null],
|
||||
level: [null],
|
||||
sort: [-1],
|
||||
});
|
||||
form: FormGroup;
|
||||
|
||||
logs = new BehaviorSubject(new Array<Log | null>(10));
|
||||
|
||||
|
|
@ -47,7 +44,13 @@ export class RdioScannerAdminLogsComponent {
|
|||
|
||||
@ViewChild(MatPaginator) private paginator: MatPaginator | undefined;
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService, private ngFormBuilder: FormBuilder) { }
|
||||
constructor(private adminService: RdioScannerAdminService, private ngFormBuilder: FormBuilder) {
|
||||
this.form = this.ngFormBuilder.group({
|
||||
date: [null],
|
||||
level: [null],
|
||||
sort: [-1],
|
||||
});
|
||||
}
|
||||
|
||||
formHandler(): void {
|
||||
this.paginator?.firstPage();
|
||||
|
|
@ -98,11 +101,11 @@ export class RdioScannerAdminLogsComponent {
|
|||
const options: LogsQueryOptions = {
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
sort: this.form.value.sort,
|
||||
sort: this.form.get('sort')?.value ?? -1,
|
||||
};
|
||||
|
||||
if (typeof this.form.value.level === 'string') {
|
||||
options.level = this.form.value.level;
|
||||
if (typeof this.form.get('level')?.value === 'string') {
|
||||
options.level = this.form.get('level')?.value ?? undefined;
|
||||
}
|
||||
|
||||
if (typeof this.form.value.date === 'string') {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
<mat-card *ngIf="todos.length">
|
||||
<ng-container *ngIf="todos.length">
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-title>Todos</mat-card-title>
|
||||
<mat-list>
|
||||
<mat-list-item *ngFor="let todo of todos">
|
||||
<mat-icon *ngIf="todo.level === 'info'" class="info" mat-list-icon>notifications</mat-icon>
|
||||
<mat-icon *ngIf="todo.level === 'warn'" class="warn" mat-list-icon>warning</mat-icon>
|
||||
<div mat-line>
|
||||
<span class="todo">{{ todo.message }}</span>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item *ngFor="let todo of todos">
|
||||
<mat-icon *ngIf="todo.level === 'info'" class="info" matListItemIcon>notifications</mat-icon>
|
||||
<mat-icon *ngIf="todo.level === 'warn'" class="warn" matListItemIcon>warning</mat-icon>
|
||||
<div mat-line>
|
||||
<span class="todo">{{ todo.message }}</span>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
</mat-card>
|
||||
</mat-card>
|
||||
</ng-container>
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
.info {
|
||||
.mat-mdc-list-item .info {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.warn {
|
||||
.mat-mdc-list-item .warn {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.todo {
|
||||
.mat-mdc-list-item .todo {
|
||||
text-overflow: unset;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -22,34 +22,39 @@ import { AdminEvent, Config, RdioScannerAdminService } from '../admin.service';
|
|||
|
||||
interface Todo {
|
||||
level: 'info' | 'warn';
|
||||
message: String;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-todos',
|
||||
styleUrls: ['./todos.component.scss'],
|
||||
templateUrl: './todos.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
|
||||
todos: Todo[] = [];
|
||||
|
||||
private config: Config | undefined;
|
||||
|
||||
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
|
||||
if ('config' in event) {
|
||||
this.config = event.config;
|
||||
}
|
||||
private eventSubscription;
|
||||
|
||||
if ('passwordNeedChange' in event) {
|
||||
this.passwordNeedChange = event.passwordNeedChange || false;
|
||||
}
|
||||
private passwordNeedChange;
|
||||
|
||||
this.rebuildTodos();
|
||||
});
|
||||
constructor(private adminService: RdioScannerAdminService) {
|
||||
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
|
||||
if ('config' in event) {
|
||||
this.config = event.config;
|
||||
}
|
||||
|
||||
private passwordNeedChange = this.adminService.passwordNeedChange;
|
||||
if ('passwordNeedChange' in event) {
|
||||
this.passwordNeedChange = event.passwordNeedChange || false;
|
||||
}
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
this.rebuildTodos();
|
||||
});
|
||||
|
||||
this.passwordNeedChange = this.adminService.passwordNeedChange;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventSubscription.unsubscribe();
|
||||
|
|
@ -76,14 +81,14 @@ export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
|
|||
if (!this.config?.systems?.length) {
|
||||
todos.push({
|
||||
level: 'info',
|
||||
message: 'No systems defined. You can define one from the systems menu, or import one from a CSV file from the tools menu, or turn on the global auto populate option from the options menu.',
|
||||
message: 'No systems defined. You can define one from the systems menu, or let the auto populate feature create it for you if the global option is turned on.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.config?.apiKeys?.length && !this.config?.dirWatch?.length) {
|
||||
if (!this.config?.apikeys?.length && !this.config?.dirwatch?.length) {
|
||||
todos.push({
|
||||
level: 'info',
|
||||
message: 'No apikeys or dirwatch defined. Please set at least one to allow ingesting audio files.',
|
||||
message: 'No apikeys or dirwatch defined. Set at least one to allow ingesting audio files.',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
<section>
|
||||
<p class="mat-body">step 1: read the CSV file</p>
|
||||
<p class="text-center">
|
||||
<ng-container *ngIf="csv.length === 0">
|
||||
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
|
||||
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
|
||||
</ng-container>
|
||||
<ng-container *ngIf="csv.length !== 0">
|
||||
<button mat-raised-button (click)="csv=[]">Reset</button>
|
||||
</ng-container>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 2: select CSV style</p>
|
||||
<p class="text-center">
|
||||
<mat-radio-group [(ngModel)]="mode">
|
||||
<mat-radio-button [value]="0">Trunk Recorder</mat-radio-button>
|
||||
<mat-radio-button [value]="1">Radio Reference</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 3: review talkgroups to import</p>
|
||||
<div class="scroll">
|
||||
<mat-table #table [dataSource]="csv">
|
||||
<ng-container matColumnDef="id">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Id</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[fields[mode][0]] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="label">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Label</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[fields[mode][1]] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Description</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[fields[mode][2]] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="tag">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Tag</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[fields[mode][3]] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="group">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Group</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[fields[mode][4]] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="action">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg; index as i">
|
||||
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
|
||||
</mat-table>
|
||||
</div>
|
||||
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 4: import to configuration</p>
|
||||
<p class="text-center">
|
||||
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
|
||||
Import to configuration
|
||||
</button>
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<div>
|
||||
<p>
|
||||
<span class="mat-body">Import</span><br>
|
||||
<span class="mat-body">Import config</span><br>
|
||||
<span class="mat-caption">Import a JSON file into the configuration panel where you can then review it before submitting it.</span>
|
||||
</p>
|
||||
<button mat-raised-button (click)="input.click()">Import</button>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<span class="mat-body">Export</span><br>
|
||||
<span class="mat-body">Export config</span><br>
|
||||
<span class="mat-caption">Export the configuration to a JSON file.</span>
|
||||
</p>
|
||||
<button mat-raised-button (click)="export()">Export</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,19 +17,23 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, EventEmitter, Inject, Output } from '@angular/core';
|
||||
|
||||
import { Component, EventEmitter, Inject, Output, DOCUMENT } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Config, RdioScannerAdminService } from '../../admin.service';
|
||||
import packageInfo from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-import-export-config',
|
||||
styleUrls: ['./import-export-config.component.scss'],
|
||||
templateUrl: './import-export-config.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminImportExportConfigComponent {
|
||||
@Output() config = new EventEmitter<Config>();
|
||||
|
||||
version = packageInfo.version;
|
||||
|
||||
constructor(
|
||||
private adminService: RdioScannerAdminService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
|
|
@ -39,10 +43,12 @@ export class RdioScannerAdminImportExportConfigComponent {
|
|||
async export(): Promise<void> {
|
||||
const config = await this.adminService.getConfig();
|
||||
|
||||
const file = JSON.stringify(config);
|
||||
const fileName = 'rdio-scanner.json';
|
||||
const file = encodeURIComponent(JSON.stringify(config)).replace(/%([0-9A-F]{2})/g, (_, c) => {
|
||||
return String.fromCharCode(parseInt(c, 16));
|
||||
});
|
||||
const fileName = `rdio-scanner-${this.version}.config.json`;
|
||||
const fileType = 'application/json';
|
||||
const fileUri = `data:${fileType};base64,${btoa(file)}`;
|
||||
const fileUri = `data:${fileType};base64,${window.btoa(file)}`;
|
||||
|
||||
const el = this.document.createElement('a');
|
||||
|
||||
|
|
@ -71,10 +77,80 @@ export class RdioScannerAdminImportExportConfigComponent {
|
|||
target.value = '';
|
||||
|
||||
try {
|
||||
const config = JSON.parse(reader.result as string);
|
||||
const res = decodeURIComponent(Array.prototype.map.call(reader.result, (c) => {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
||||
}).join(''));
|
||||
|
||||
const config = JSON.parse(res);
|
||||
|
||||
if (Array.isArray(config.access))
|
||||
config.access.forEach((access: { [key: string]: unknown }) => {
|
||||
access['id'] = access['_id'];
|
||||
});
|
||||
|
||||
config['apikeys'] = config['apiKeys'];
|
||||
if (Array.isArray(config.apikeys))
|
||||
config.access.forEach((access: { [key: string]: unknown }) => {
|
||||
access['id'] = access['_id'];
|
||||
});
|
||||
|
||||
config['dirwatch'] = config['dirWatch'];
|
||||
if (Array.isArray(config.dirwatch))
|
||||
config.dirwatch.forEach((dirwatch: { [key: string]: unknown }) => {
|
||||
dirwatch['id'] = dirwatch['_id'];
|
||||
});
|
||||
|
||||
if (Array.isArray(config.downstreams))
|
||||
config.downstreams.forEach((downstream: { [key: string]: unknown }) => {
|
||||
downstream['id'] = downstream['_id'];
|
||||
downstream['apikey'] = downstream['apiKey'];
|
||||
});
|
||||
|
||||
if (Array.isArray(config.groups))
|
||||
config.groups.forEach((group: { [key: string]: unknown }) => {
|
||||
group['id'] = group['_id'];
|
||||
});
|
||||
|
||||
config.groups = (config.groups as { label: string }[]).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
if (Array.isArray(config.tags))
|
||||
config.tags.forEach((tag: { [key: string]: unknown }) => {
|
||||
tag['id'] = tag['_id'];
|
||||
});
|
||||
|
||||
config.tags = (config.tags as { label: string }[]).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
if (Array.isArray(config.systems))
|
||||
config.systems.forEach((system: { [key:string]:unknown}) => {
|
||||
system['systemRef'] = system['id'];
|
||||
system['id'] = system['_id'];
|
||||
|
||||
const talkgroups = system['talkgroups'];
|
||||
|
||||
if (Array.isArray(talkgroups))
|
||||
talkgroups.forEach((talkgroup: {[key:string]: unknown}) => {
|
||||
const groupId = talkgroup['groupId'];
|
||||
|
||||
if (typeof groupId === 'number') talkgroup['groupIds'] = [groupId];
|
||||
|
||||
talkgroup['talkgroupRef'] = talkgroup['id'];
|
||||
delete talkgroup['id'];
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
this.config.emit(config);
|
||||
|
||||
// const versionMajor = this.version.split('.')[0];
|
||||
|
||||
// if (config['version']?.split('.')[0] === versionMajor) {
|
||||
// this.config.emit(config);
|
||||
|
||||
// } else {
|
||||
// this.matSnackBar.open('Config file version mismatch', '', { duration: 5000 });
|
||||
// }
|
||||
|
||||
|
||||
} catch (error) {
|
||||
this.matSnackBar.open(error as string, '', { duration: 5000 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
<p class="mat-body note">Before going any further, note that there is no need to preload talkgroups into a system as the
|
||||
autopopulate feature will automatically create them as audio files are ingested. So give the autopopulate feature a
|
||||
chance before trying to import talkgroups.</p>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 1: read the CSV file</p>
|
||||
<p class="text-center">
|
||||
@if (csv.length === 0) {
|
||||
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
|
||||
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
|
||||
<br>
|
||||
<p>
|
||||
<span class="mat-body-1">Column format is: </span>
|
||||
<span class="mat-body-1">Id</span>,
|
||||
<span>unused</span>,
|
||||
<span class="mat-body-1">Label</span>,
|
||||
<span>unused</span>,
|
||||
<span class="mat-body-1">Name</span>,
|
||||
<span class="mat-body-1">Tag</span>,
|
||||
<span class="mat-body-1">Group</span>
|
||||
</p>
|
||||
}
|
||||
@if (csv.length !== 0) {
|
||||
<button mat-raised-button (click)="csv=[]">Reset</button>
|
||||
}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 2: select system</p>
|
||||
<p class="text-center">
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select [(ngModel)]="system">
|
||||
@for (system of baseConfig.systems; track system) {
|
||||
<mat-option [value]="system">
|
||||
{{ system.label}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 3: review talkgroups to import</p>
|
||||
<div class="scroll">
|
||||
<mat-table #table [dataSource]="csv">
|
||||
<ng-container matColumnDef="id">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Id</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[0] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="label">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Label</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[2] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Description</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[4] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="tag">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Tag</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[5] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="group">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Group</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[6] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="action">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg; index as i">
|
||||
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
|
||||
</mat-table>
|
||||
</div>
|
||||
@if (!csv.length) {
|
||||
<p class="mat-body text-center">No CSV data yet</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 4: import to configuration</p>
|
||||
<p class="text-center">
|
||||
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
|
||||
Import to configuration
|
||||
</button>
|
||||
</p>
|
||||
@if (csv.length > 800) {
|
||||
<p class="mat-caption mat-error text-center text-uppercase">
|
||||
Warning, you are trying to import more than 800 talkgroups, {{csv.length}} in this case. The graphic interface
|
||||
has not designed for this number of talkgroups. You can continue, but expect Rdio Scanner to be quite slow, if
|
||||
not unusable.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
.mat-mdc-radio-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-table {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.mat-mdc-cell > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mat-column-action {
|
||||
max-width: 48px;
|
||||
}
|
||||
|
||||
.note {
|
||||
border: 2px solid darkred;
|
||||
color: darkred;
|
||||
margin: 0 4rem;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,70 +17,73 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { Config, Group, RdioScannerAdminService } from '../../admin.service';
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { Config, RdioScannerAdminService, System } from '../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-import-csv',
|
||||
styleUrls: ['./import-csv.component.scss'],
|
||||
templateUrl: './import-csv.component.html',
|
||||
selector: 'rdio-scanner-admin-import-talkgroups',
|
||||
styleUrls: ['./import-talkgroups.component.scss'],
|
||||
templateUrl: './import-talkgroups.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminImportCsvComponent {
|
||||
export class RdioScannerAdminImportTalkgroupsComponent implements OnInit {
|
||||
@Output() config = new EventEmitter<Config>();
|
||||
|
||||
baseConfig: Config = {};
|
||||
|
||||
csv: string[][] = [];
|
||||
|
||||
fields = [
|
||||
// [id, label, description, tag, group]
|
||||
[0, 3, 4, 5, 6], // trunk-recorder
|
||||
[0, 2, 4, 5, 6], // radioreference.com
|
||||
];
|
||||
|
||||
mode = 0;
|
||||
system: System | undefined;
|
||||
|
||||
tableColumns = ['id', 'label', 'description', 'tag', 'group', 'action'];
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.baseConfig = await this.adminService.getConfig();
|
||||
|
||||
if (Array.isArray(this.baseConfig.systems) && this.baseConfig.systems.length > 0) {
|
||||
this.system = this.baseConfig.systems[0];
|
||||
}
|
||||
}
|
||||
|
||||
async import(): Promise<void> {
|
||||
const config = await this.adminService.getConfig();
|
||||
if (this.system === undefined) return;
|
||||
|
||||
this.csv.forEach((tg) => {
|
||||
const group = tg[this.fields[this.mode][4]];
|
||||
this.csv.forEach((csv) => {
|
||||
const group = csv[6];
|
||||
|
||||
if (!config.groups?.find((g) => g.label === group)) {
|
||||
const id = config.groups?.reduce((pv, cv) => typeof cv._id === 'number' && cv._id >= pv ? cv._id + 1 : pv, 1);
|
||||
if (!this.baseConfig.groups?.find((g) => g.label === group)) {
|
||||
const id = this.baseConfig.groups?.reduce((pv, cv) => typeof cv.id === 'number' && cv.id >= pv ? cv.id + 1 : pv, 1);
|
||||
|
||||
config.groups?.push({ _id: id, label: group });
|
||||
this.baseConfig.groups?.push({ id: id, label: group });
|
||||
}
|
||||
|
||||
const tag = tg[this.fields[this.mode][3]];
|
||||
const tag = csv[5];
|
||||
|
||||
if (!config.tags?.find((t) => t.label === tag)) {
|
||||
const id = config.tags?.reduce((pv, cv) => typeof cv._id === 'number' && cv._id >= pv ? cv._id + 1 : pv, 1);
|
||||
if (!this.baseConfig.tags?.find((t) => t.label === tag)) {
|
||||
const id = this.baseConfig.tags?.reduce((pv, cv) => typeof cv.id === 'number' && cv.id >= pv ? cv.id + 1 : pv, 1);
|
||||
|
||||
config.tags?.push({ _id: id, label: tag });
|
||||
this.baseConfig.tags?.push({ id: id, label: tag });
|
||||
}
|
||||
});
|
||||
|
||||
const talkgroups = this.csv.map((csv) => {
|
||||
const groupId = config.groups?.find((g) => g.label === csv[this.fields[this.mode][4]])?._id;
|
||||
const tagId = config.tags?.find((t) => t.label === csv[this.fields[this.mode][3]])?._id;
|
||||
this.csv.forEach((csv) => {
|
||||
const groupId = this.baseConfig.groups?.find((g) => g.label === csv[6])?.id;
|
||||
const tagId = this.baseConfig.tags?.find((t) => t.label === csv[5])?.id;
|
||||
|
||||
return {
|
||||
id: +csv[this.fields[this.mode][0]],
|
||||
label: csv[this.fields[this.mode][1]],
|
||||
name: csv[this.fields[this.mode][2]],
|
||||
this.system?.talkgroups?.unshift({
|
||||
talkgroupRef: +csv[0],
|
||||
label: csv[2],
|
||||
name: csv[4],
|
||||
groupIds: groupId ? [groupId] : [],
|
||||
tagId,
|
||||
groupId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
config.systems?.unshift({ talkgroups });
|
||||
|
||||
this.csv = [];
|
||||
|
||||
this.config.emit(config);
|
||||
this.config.emit(this.baseConfig);
|
||||
}
|
||||
|
||||
async read(event: Event): Promise<void> {
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<section>
|
||||
<p class="mat-body">step 1: read the CSV file</p>
|
||||
<p class="text-center">
|
||||
@if (csv.length === 0) {
|
||||
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
|
||||
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
|
||||
<br>
|
||||
<p class="mat-body-1">
|
||||
<span>Column format is: </span>
|
||||
<span>Id</span>,
|
||||
<span>Label</span>,
|
||||
</p>
|
||||
}
|
||||
@if (csv.length !== 0) {
|
||||
<button mat-raised-button (click)="csv=[]">Reset</button>
|
||||
}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 2: select system</p>
|
||||
<p class="text-center">
|
||||
<mat-form-field floatLabel="auto">
|
||||
<mat-select [(ngModel)]="system">
|
||||
@for (system of baseConfig.systems; track system) {
|
||||
<mat-option [value]="system">
|
||||
{{ system.label}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 3: review talkgroups to import</p>
|
||||
<div class="scroll">
|
||||
<mat-table #table [dataSource]="csv">
|
||||
<ng-container matColumnDef="id">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Id</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[0] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="label">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Label</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg">
|
||||
<span>{{ tg[1] }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="action">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
<mat-cell *matCellDef="let tg; index as i">
|
||||
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
|
||||
</mat-table>
|
||||
</div>
|
||||
@if (!csv.length) {
|
||||
<p class="mat-body text-center">No CSV data yet</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="mat-body">step 4: import to configuration</p>
|
||||
<p class="text-center">
|
||||
<button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()">
|
||||
Import to configuration
|
||||
</button>
|
||||
</p>
|
||||
@if (csv.length > 1000) {
|
||||
<p class="mat-caption mat-error text-center text-uppercase">
|
||||
Warning, you are trying to import more than 1000 units, {{csv.length}} in this case. The graphic interface has
|
||||
not designed for this number of units. You can continue, but expect Rdio Scanner to be quite slow, if not
|
||||
unusable.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.mat-radio-group {
|
||||
.mat-mdc-radio-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
|
@ -8,18 +8,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
.mat-mdc-table {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.mat-cell > span {
|
||||
.mat-mdc-cell > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mat-column-action {
|
||||
max-width: 40px;
|
||||
max-width: 48px;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
|
|
@ -29,3 +29,7 @@
|
|||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { Config, RdioScannerAdminService, System } from '../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-import-units',
|
||||
styleUrls: ['./import-units.component.scss'],
|
||||
templateUrl: './import-units.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminImportUnitsComponent implements OnInit{
|
||||
@Output() config = new EventEmitter<Config>();
|
||||
|
||||
baseConfig: Config = {};
|
||||
|
||||
csv: string[][] = [];
|
||||
|
||||
system: System | undefined;
|
||||
|
||||
tableColumns = ['id', 'label', 'action'];
|
||||
|
||||
constructor(private adminService: RdioScannerAdminService) { }
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.baseConfig = await this.adminService.getConfig();
|
||||
|
||||
if (Array.isArray(this.baseConfig.systems) && this.baseConfig.systems.length > 0) {
|
||||
this.system = this.baseConfig.systems[0];
|
||||
}
|
||||
}
|
||||
|
||||
async import(): Promise<void> {
|
||||
if (this.system === undefined) return;
|
||||
|
||||
const units = this.csv.map((csv, idx) => ({
|
||||
unitRef: +csv[0],
|
||||
label: csv[1],
|
||||
order: idx + 1,
|
||||
}));
|
||||
|
||||
this.system.units = this.system?.units?.concat(units);
|
||||
|
||||
this.csv = [];
|
||||
|
||||
this.config.emit(this.baseConfig);
|
||||
}
|
||||
|
||||
async read(event: Event): Promise<void> {
|
||||
const target = (event.target as HTMLInputElement & EventTarget);
|
||||
|
||||
const file = target.files?.item(0);
|
||||
|
||||
if (!(file instanceof File)) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
target.value = '';
|
||||
|
||||
if (typeof reader.result !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.csv = reader.result
|
||||
.split(/\n|\r\n/)
|
||||
.map((tg) => tg.replace(/^"|"$/g, '').split(/"*,"*/))
|
||||
.filter((tg) => tg && /^[0-9]+$/.test(tg[0]))
|
||||
.filter((tg, idx, arr) => arr.findIndex((a) => a[0] === tg[0]) === idx);
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +1,39 @@
|
|||
<p class="mat-body">
|
||||
Change the administrator password which gives access to the server administration dashboard.
|
||||
Change the administrator password which gives access to the server administration dashboard.
|
||||
</p>
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field>
|
||||
<mat-label>Current password</mat-label>
|
||||
<input type="password" matInput formControlName="currentPassword" required>
|
||||
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
|
||||
Current password is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>New password</mat-label>
|
||||
<input type="password" matInput formControlName="newPassword" required>
|
||||
<mat-error *ngIf="form.get('newPassword')?.hasError('required')">
|
||||
New password is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('newPassword')?.hasError('minlength')">
|
||||
Password is too short
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Verify new password</mat-label>
|
||||
<input type="password" matInput formControlName="verifyNewPassword" required>
|
||||
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
|
||||
New password verification is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('invalid')">
|
||||
Password don't match
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched"
|
||||
(click)="reset();">Reset</button>
|
||||
<button type="button" mat-raised-button color="primary"
|
||||
[disabled]="form.disabled || form.pristine || !form.valid" (click)="save()">Save</button>
|
||||
</div>
|
||||
<form [formGroup]="form" (ngSubmit)="save()">
|
||||
<mat-form-field>
|
||||
<input matInput type="text" name="username" value="admin" autocomplete="username" tabindex="-1" aria-label="Username (for password manager)" readonly>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Current password</mat-label>
|
||||
<input type="password" matInput formControlName="currentPassword" required autocomplete="current-password">
|
||||
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
|
||||
Current password is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>New password</mat-label>
|
||||
<input type="password" matInput formControlName="newPassword" required minlength="8" autocomplete="new-password">
|
||||
<mat-error *ngIf="form.get('newPassword')?.hasError('required')">
|
||||
New password is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.get('newPassword')?.hasError('minlength')">
|
||||
Password is too short
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Verify new password</mat-label>
|
||||
<input type="password" matInput formControlName="verifyNewPassword" required autocomplete="new-password">
|
||||
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
|
||||
New password verification is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.hasError('passwordMismatch')">
|
||||
Passwords don't match
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<div class="row bottom">
|
||||
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched" (click)="reset()">Reset</button>
|
||||
<button type="submit" mat-raised-button color="primary" [disabled]="form.disabled || form.pristine || form.invalid">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mat-form-field {
|
||||
.mat-mdc-form-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,55 +18,68 @@
|
|||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { AbstractControl, FormBuilder, ValidatorFn, ValidationErrors, Validators } from '@angular/forms';
|
||||
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
|
||||
import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { RdioScannerAdminService } from '../../admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-admin-password',
|
||||
styleUrls: ['./password.component.scss'],
|
||||
templateUrl: './password.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminPasswordComponent {
|
||||
form = this.ngFormBuilder.group({
|
||||
currentPassword: [null, Validators.required],
|
||||
newPassword: [null, [Validators.required, Validators.minLength(8)]],
|
||||
verifyNewPassword: [null, [Validators.required, this.validatePassword()]],
|
||||
});
|
||||
form: FormGroup;
|
||||
|
||||
constructor(
|
||||
private adminService: RdioScannerAdminService,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private ngFormBuilder: FormBuilder,
|
||||
) { }
|
||||
private readonly adminService: RdioScannerAdminService,
|
||||
private readonly matSnackBar: MatSnackBar,
|
||||
private readonly ngFormBuilder: FormBuilder,
|
||||
) {
|
||||
this.form = this.ngFormBuilder.group({
|
||||
currentPassword: [null, Validators.required],
|
||||
newPassword: [null, [Validators.required, Validators.minLength(8)]],
|
||||
verifyNewPassword: [null, Validators.required],
|
||||
}, { validators: this.passwordsMatchValidator });
|
||||
|
||||
private validatePassword(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
return typeof control.value === 'string' && control.value.length
|
||||
? control.value === control.parent?.value.newPassword
|
||||
? null : { invalid: true } : null;
|
||||
}
|
||||
this.form.get('newPassword')?.valueChanges.subscribe(() =>
|
||||
this.form.get('verifyNewPassword')?.updateValueAndValidity({ onlySelf: true })
|
||||
);
|
||||
}
|
||||
|
||||
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
|
||||
const newPwd = group.get('newPassword')?.value;
|
||||
const verify = group.get('verifyNewPassword')?.value;
|
||||
return newPwd && verify && newPwd !== verify ? { passwordMismatch: true } : null;
|
||||
}
|
||||
|
||||
get currentPassword() { return this.form.get('currentPassword'); }
|
||||
get newPassword() { return this.form.get('newPassword'); }
|
||||
get verifyNewPassword() { return this.form.get('verifyNewPassword'); }
|
||||
|
||||
reset(): void {
|
||||
this.form.reset();
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const config: MatSnackBarConfig = { duration: 5000 };
|
||||
|
||||
this.form.disable();
|
||||
|
||||
try {
|
||||
await this.adminService.changePassword(this.form.value.currentPassword, this.form.value.newPassword);
|
||||
|
||||
this.matSnackBar.open('Password changed successfully', '', config);
|
||||
|
||||
this.form.reset();
|
||||
} catch (_) {
|
||||
this.matSnackBar.open('Unable to change password', '', config);
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.enable();
|
||||
const config = { duration: 5000 };
|
||||
const currentPassword = this.currentPassword?.value;
|
||||
const newPassword = this.newPassword?.value;
|
||||
|
||||
this.form.disable();
|
||||
try {
|
||||
await this.adminService.changePassword(currentPassword, newPassword);
|
||||
this.matSnackBar.open('Password changed successfully', '', config);
|
||||
this.form.reset();
|
||||
} catch {
|
||||
this.matSnackBar.open('Unable to change password', '', config);
|
||||
} finally {
|
||||
this.form.enable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,19 @@
|
|||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>description</mat-icon>
|
||||
Talkgroups CSV
|
||||
Import Talkgroups
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-import-csv (config)="config.emit($event)"></rdio-scanner-admin-import-csv>
|
||||
<rdio-scanner-admin-import-talkgroups (config)="config.emit($event)"></rdio-scanner-admin-import-talkgroups>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>description</mat-icon>
|
||||
Import Units
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<rdio-scanner-admin-import-units (config)="config.emit($event)"></rdio-scanner-admin-import-units>
|
||||
</mat-expansion-panel>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -24,6 +24,7 @@ import { Config } from '../admin.service';
|
|||
@Component({
|
||||
selector: 'rdio-scanner-admin-tools',
|
||||
templateUrl: './tools.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerAdminToolsComponent {
|
||||
@Output() config = new EventEmitter<Config>();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
.rdio-button,
|
||||
.rdio-button-mini {
|
||||
background: rgb(45, 45, 45);
|
||||
--def: rgb(45, 45, 45);
|
||||
--green: rgb(0, 230, 118);
|
||||
--red: rgb(255, 23, 68);
|
||||
--yellow: rgb(255, 234, 0);
|
||||
|
||||
background: var(--def);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.87);
|
||||
border-left-color: rgba(255, 255, 255, 0.7);
|
||||
border-right-color: rgba(0, 0, 0, 0.87);
|
||||
border-top-color: rgba(255, 255, 255, 0.7);
|
||||
box-sizing: border-box;
|
||||
color: rgb(255, 255, 255);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
|
|
@ -42,24 +46,71 @@
|
|||
right: 4px;
|
||||
top: 4px;
|
||||
width: 6px;
|
||||
|
||||
animation: blink 1s linear infinite both;
|
||||
}
|
||||
|
||||
&.off::after {
|
||||
background: #e63232;
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
|
||||
0 0 3px 1px rgb(230, 50, 50);
|
||||
background: var(--red);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--red);
|
||||
}
|
||||
|
||||
&.off.blink::after {
|
||||
animation: blink-off 500ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-off {
|
||||
0%, 50% {
|
||||
background: var(--def);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 0px 0px var(--def);
|
||||
}
|
||||
|
||||
50%, 100% {
|
||||
background: var(--red);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
&.on::after {
|
||||
background: rgb(100, 230, 50);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
|
||||
0 0 3px 1px rgb(100, 230, 50);
|
||||
background: var(--green);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--green);
|
||||
}
|
||||
|
||||
&.on.blink::after {
|
||||
animation: blink-on 500ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-on {
|
||||
0%, 50% {
|
||||
background: var(--def);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 0px 0px var(--def);
|
||||
}
|
||||
|
||||
50%, 100% {
|
||||
background: var(--def);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--green);
|
||||
}
|
||||
}
|
||||
|
||||
&.partial::after {
|
||||
background: rgb(230, 165, 50);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
|
||||
0 0 3px 1px rgb(230, 165, 50);
|
||||
background: var(--yellow);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--yellow);
|
||||
}
|
||||
|
||||
&.partial.blink::after {
|
||||
animation: blink-partial 500ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-partial {
|
||||
0%, 50% {
|
||||
background: var(--def);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 0px 0px var(--def);
|
||||
}
|
||||
|
||||
50%, 100% {
|
||||
background: var(--yellow);
|
||||
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
|||
|
|
@ -1,150 +1,193 @@
|
|||
<div class="rdio-status">
|
||||
<div class="led" [ngClass]="ledStyle"></div>
|
||||
<div class="branding">{{ branding }}</div>
|
||||
<div class="led" [ngClass]="ledStyle"></div>
|
||||
</div>
|
||||
<div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()">
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>{{ clock | date:'HH:mm' }}</span>
|
||||
</div>
|
||||
<div *ngIf="!linked">
|
||||
<span>NO LINK</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Q: {{ callQueue }}</span>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<div>
|
||||
<span>{{ clock | date:timeFormat }}</span>
|
||||
</div>
|
||||
<div class="row small"></div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>{{ callSystem }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ callTag }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@if (!linked) {
|
||||
<span>NO LINK</span>
|
||||
}
|
||||
@if (linked && showListenersCount) {
|
||||
<span>L: {{ listeners }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>{{ callTalkgroup }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ callProgress | date:'HH:mm' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Q: {{ callQueue }}</span>
|
||||
</div>
|
||||
<div class="row big">
|
||||
<span>{{ callTalkgroupName }}</span>
|
||||
</div>
|
||||
<div class="row small"></div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>{{ callSystem }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>F: {{ callFrequency || 0 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>TGID: {{ callTalkgroupId || 0 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ type | uppercase }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span *ngIf="callUnit">UID: {{ callUnit }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ callTag }}</span>
|
||||
</div>
|
||||
<div class="row small">
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>{{ callTalkgroup }}</span>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<span>{{ callDate | date:'MM/dd' }} </span>
|
||||
<span>{{ callProgress | date:timeFormat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row big full">
|
||||
<span>{{ callTalkgroupName }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>F: {{ callFrequency || 0 }}</span>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<span>TGID: {{ callTalkgroupId || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }} </span>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
@if (callUnit) {
|
||||
<span>UID: {{ callUnit }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row right small">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div>
|
||||
@if (tempAvoid) {
|
||||
<div>
|
||||
<span class="avoid" [ngClass]="{ avoided: avoided }">AVOID</span>
|
||||
<span class="flag" [ngClass]="{ flaged: avoided || patched }">⏲︎ {{ tempAvoid }}M</span>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="flag" [ngClass]="{ flaged: patched }">PATCH</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="flag" [ngClass]="{ flaged: delayed }">DELAYED</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
<div class="auth" [ngClass]="{ visible: auth }">
|
||||
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
|
||||
<mat-form-field>
|
||||
<input #password matInput type="password" autocomplete="off" formControlName="password"
|
||||
placeholder="Unlock code" (blur)="authFocus()">
|
||||
<mat-error *ngIf="authForm.get('password')?.hasError('expired')">
|
||||
This unlock code has expired
|
||||
</mat-error>
|
||||
<mat-error *ngIf="authForm.get('password')?.hasError('tooMany')">
|
||||
Too many connections
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</div>
|
||||
<table class="history">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span>Time</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>System</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>Talkgroup</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>Name</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let previousCall of callHistory">
|
||||
<td>
|
||||
<span>{{ previousCall?.dateTime | date:'HH:mm' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ previousCall?.systemData?.label || previousCall?.system}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ previousCall?.talkgroupData?.label || previousCall?.talkgroup }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
<div class="auth" [ngClass]="{ visible: auth }">
|
||||
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
|
||||
<mat-form-field>
|
||||
<input #password matInput type="password" autocomplete="off" formControlName="password"
|
||||
placeholder="Unlock code" (blur)="authFocus()">
|
||||
@if (authForm.get('password')?.hasError('expired')) {
|
||||
<mat-error>
|
||||
This unlock code has expired
|
||||
</mat-error>
|
||||
}
|
||||
@if (authForm.get('password')?.hasError('tooMany')) {
|
||||
<mat-error>
|
||||
Too many connections
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</div>
|
||||
<table class="history">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span>Time</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>System</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>Talkgroup</span>
|
||||
</th>
|
||||
<th>
|
||||
<span>Name</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (previousCall of callHistory; track $index) {
|
||||
<tr
|
||||
[ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} ">
|
||||
<td>
|
||||
<span>{{ previousCall?.dateTime | date:timeFormat }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ previousCall?.systemData?.label || previousCall?.system}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ previousCall?.talkgroupData?.label || previousCall?.talkgroup }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rdio-control">
|
||||
<div class="row">
|
||||
<button class="rdio-button" (click)="livefeed()"
|
||||
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
|
||||
LIVE<br>FEED
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
|
||||
HOLD<br>SYS
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
|
||||
HOLD<br>TG
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="rdio-button" (click)="replay()">
|
||||
REPLAY<br>LAST
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="skip()">
|
||||
SKIP<br>NEXT
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="avoid()">
|
||||
AVOID
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="rdio-button" (click)="showSearchPanel()">
|
||||
SEARCH<br>CALL
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
|
||||
PAUSE
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="showSelectPanel()">
|
||||
SELECT<br>TG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="rdio-button" (click)="livefeed()"
|
||||
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
|
||||
LIVE<br>FEED
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
|
||||
HOLD<br>SYS
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
|
||||
HOLD<br>TG
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="rdio-button" (click)="replay()">
|
||||
REPLAY<br>LAST
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="skip()">
|
||||
SKIP<br>NEXT
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="avoid()">
|
||||
AVOID
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="rdio-button" (click)="showSearchPanel()">
|
||||
SEARCH<br>CALL
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
|
||||
PAUSE
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rdio-button" (click)="showSelectPanel()">
|
||||
SELECT<br>TG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (email) {
|
||||
<div class="rdio-help">
|
||||
<button mat-icon-button (click)="showHelp()">
|
||||
<mat-icon>help_center</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
margin-bottom: 12px;
|
||||
|
||||
.rdio-button {
|
||||
box-sizing: content-box;
|
||||
flex: 1;
|
||||
line-height: normal;
|
||||
}
|
||||
|
|
@ -41,23 +42,12 @@
|
|||
&.idle {
|
||||
background: rgb(190, 190, 174);
|
||||
|
||||
.avoid {
|
||||
.avoid,
|
||||
.patched {
|
||||
color: rgb(190, 190, 174);
|
||||
}
|
||||
}
|
||||
|
||||
.avoid {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 3px;
|
||||
color: rgb(209, 238, 238);
|
||||
padding: 0 3px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.avoided {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
|
|
@ -91,6 +81,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.flag {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 3px;
|
||||
color: rgb(209, 238, 238);
|
||||
display: none;
|
||||
margin-left: 4px;
|
||||
opacity: 0.87;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.flaged {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -99,7 +103,20 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 20px;
|
||||
justify-content: space-between;
|
||||
|
||||
&:not(.full) > * {
|
||||
display: flex;
|
||||
flex: 33%;
|
||||
flex-direction: row;
|
||||
|
||||
&:nth-child(2) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&.big {
|
||||
font-size: 24px;
|
||||
|
|
@ -107,6 +124,10 @@
|
|||
line-height: 32px;
|
||||
}
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: 12px;
|
||||
height: 14px;
|
||||
|
|
@ -150,6 +171,10 @@
|
|||
|
||||
tr {
|
||||
height: 21px;
|
||||
|
||||
&.replaying {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
tbody > tr {
|
||||
|
|
@ -159,50 +184,74 @@
|
|||
}
|
||||
|
||||
.rdio-status {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 1.5rem;
|
||||
|
||||
.branding {
|
||||
color: rgb(64, 64, 64);
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: -1px -1px 1px rgba(0, 0, 0, 0.7), 1px 1px 1px rgba(255, 255, 255, 0.3);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.led {
|
||||
background: rgb(80, 80, 80);
|
||||
display: block;
|
||||
height: 12px;
|
||||
margin-left: auto;
|
||||
margin-left: 24px;
|
||||
width: 24px;
|
||||
|
||||
&.on {
|
||||
background: rgb(100, 230, 50);
|
||||
box-shadow: 0 0 6px 3px rgb(100, 230, 50);
|
||||
background: rgb(0, 230, 118);
|
||||
box-shadow: 0 0 6px 3px rgb(0, 230, 118);
|
||||
|
||||
&.blue {
|
||||
background: rgb(50, 50, 230);
|
||||
box-shadow: 0 0 6px 3px rgb(50, 50, 230);
|
||||
background: rgb(41, 121, 255);
|
||||
box-shadow: 0 0 6px 3px rgb(41, 121, 255);
|
||||
}
|
||||
|
||||
&.cyan {
|
||||
background: rgb(50, 230, 230);
|
||||
box-shadow: 0 0 6px 3px rgb(50, 230, 230);
|
||||
background: rgb(0, 229, 255);
|
||||
box-shadow: 0 0 6px 3px rgb(0, 229, 255);
|
||||
}
|
||||
|
||||
&.green {
|
||||
background: rgb(100, 230, 50);
|
||||
box-shadow: 0 0 6px 3px rgb(100, 230, 50);
|
||||
background: rgb(0, 230, 118);
|
||||
box-shadow: 0 0 6px 3px rgb(0, 230, 118);
|
||||
}
|
||||
|
||||
&.magenta {
|
||||
background: rgb(230, 50, 230);
|
||||
box-shadow: 0 0 6px 3px rgb(230, 50, 230);
|
||||
background: rgb(213, 0, 249);
|
||||
box-shadow: 0 0 6px 3px rgb(213, 0, 249);
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background: rgb(255, 145, 0);
|
||||
box-shadow: 0 0 6px 3px rgb(255, 145, 0);
|
||||
}
|
||||
|
||||
&.red {
|
||||
background: rgb(230, 50, 50);
|
||||
box-shadow: 0 0 6px 3px rgb(230, 50, 50);
|
||||
background: rgb(255, 23, 68);
|
||||
box-shadow: 0 0 6px 3px rgb(255, 23, 68);
|
||||
}
|
||||
|
||||
&.white {
|
||||
background: rgb(230, 230, 230);
|
||||
box-shadow: 0 0 6px 3px rgb(230, 230, 230);
|
||||
background: rgb(255, 255, 255);
|
||||
box-shadow: 0 0 6px 3px rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
&.yellow {
|
||||
background: rgb(230, 230, 50);
|
||||
box-shadow: 0 0 6px 3px rgb(230, 230, 50);
|
||||
background: rgb(255, 234, 0);
|
||||
box-shadow: 0 0 6px 3px rgb(255, 234, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,3 +272,10 @@
|
|||
.rdio-status {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.rdio-help {
|
||||
bottom: 24px;
|
||||
opacity: 0.3;
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,8 +18,9 @@
|
|||
*/
|
||||
|
||||
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Subscription, timer } from 'rxjs';
|
||||
import packageInfo from '../../../../../package.json';
|
||||
import {
|
||||
|
|
@ -32,8 +33,7 @@ import {
|
|||
RdioScannerLivefeedMode,
|
||||
} from '../rdio-scanner';
|
||||
import { RdioScannerService } from '../rdio-scanner.service';
|
||||
|
||||
const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
|
||||
import { RdioScannerSupportComponent } from './support/support.component';
|
||||
|
||||
@Component({
|
||||
selector: 'rdio-scanner-main',
|
||||
|
|
@ -42,14 +42,18 @@ const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
|
|||
'./main.component.scss',
|
||||
],
|
||||
templateUrl: './main.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
||||
auth = false;
|
||||
authForm = this.ngFormBuilder.group({ password: [] });
|
||||
authForm: FormGroup;
|
||||
|
||||
avoided = false;
|
||||
|
||||
branding = '';
|
||||
|
||||
call: RdioScannerCall | undefined;
|
||||
callDate: Date | undefined;
|
||||
callError = '0';
|
||||
callFrequency: string = this.formatFrequency(0);
|
||||
callHistory: RdioScannerCall[] = new Array<RdioScannerCall>(5);
|
||||
|
|
@ -61,14 +65,32 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
callTag = 'Tag';
|
||||
callTalkgroup = 'Talkgroup';
|
||||
callTalkgroupId = '0';
|
||||
callTalkgroupName = `Rdio Scanner ${packageInfo.name === 'rdio-scanner' ? 'v'.concat(packageInfo.version) : ''}`;
|
||||
|
||||
//
|
||||
// BEGIN OF RED TAPE:
|
||||
//
|
||||
// By modifying, deleting or disabling the following lines, you harm
|
||||
// the open source project and its author. Rdio Scanner represents a lot of
|
||||
// investment in time, support, testing and hardware.
|
||||
//
|
||||
// Be respectful, sponsor the project, use native apps when possible.
|
||||
//
|
||||
callTalkgroupName = `Rdio Scanner v${packageInfo.version}`;
|
||||
//
|
||||
// END OF RED TAPE.
|
||||
//
|
||||
|
||||
callTime = 0;
|
||||
callUnit = '0';
|
||||
|
||||
clock = new Date();
|
||||
|
||||
delayed = false;
|
||||
|
||||
dimmer = false;
|
||||
|
||||
email = '';
|
||||
|
||||
holdSys = false;
|
||||
holdTg = false;
|
||||
|
||||
|
|
@ -76,14 +98,31 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
|
||||
linked = false;
|
||||
|
||||
listeners = 0;
|
||||
|
||||
livefeedOffline = true;
|
||||
livefeedOnline = false;
|
||||
livefeedPaused = false;
|
||||
|
||||
map: RdioScannerLivefeedMap = {};
|
||||
|
||||
patched = false;
|
||||
|
||||
playbackMode = false;
|
||||
|
||||
replayOffset = 0;
|
||||
replayTimer: Subscription | undefined;
|
||||
|
||||
tempAvoid = 0;
|
||||
|
||||
timeFormat = 'HH:mm';
|
||||
|
||||
type = '';
|
||||
|
||||
get showListenersCount(): boolean {
|
||||
return this.config?.showListenersCount || false;
|
||||
}
|
||||
|
||||
@Output() openSearchPanel = new EventEmitter<void>();
|
||||
|
||||
@Output() openSelectPanel = new EventEmitter<void>();
|
||||
|
|
@ -98,18 +137,29 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
|
||||
private dimmerTimer: Subscription | undefined;
|
||||
|
||||
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
|
||||
private eventSubscription;
|
||||
|
||||
constructor(
|
||||
private rdioScannerService: RdioScannerService,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private ngChangeDetectorRef: ChangeDetectorRef,
|
||||
private ngFormBuilder: FormBuilder,
|
||||
) { }
|
||||
) {
|
||||
this.authForm = this.ngFormBuilder.group<{
|
||||
password: string | null;
|
||||
}>({
|
||||
password: null
|
||||
});
|
||||
|
||||
authenticate(password = this.authForm.value.password): void {
|
||||
this.authForm.disable();
|
||||
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
|
||||
}
|
||||
|
||||
this.rdioScannerService.authenticate(password);
|
||||
authenticate(password = this.authForm.get('password')?.value): void {
|
||||
if (password) {
|
||||
this.authForm.disable();
|
||||
|
||||
this.rdioScannerService.authenticate(password);
|
||||
}
|
||||
}
|
||||
|
||||
authFocus(): void {
|
||||
|
|
@ -119,28 +169,43 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
}
|
||||
|
||||
avoid(options?: RdioScannerAvoidOptions): void {
|
||||
const call = this.call || this.callPrevious;
|
||||
|
||||
if (this.auth) {
|
||||
this.authFocus();
|
||||
|
||||
} else {
|
||||
const call = this.call || this.callPrevious;
|
||||
|
||||
if (options || call) {
|
||||
} else if (options || call) {
|
||||
if (options) {
|
||||
this.rdioScannerService.avoid(options);
|
||||
} else if (call) {
|
||||
const avoided = this.rdioScannerService.isAvoided(call);
|
||||
const minutes = this.rdioScannerService.isAvoidedTimer(call);
|
||||
|
||||
if (call && !this.map[call.system][call.talkgroup]) {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
|
||||
|
||||
if (!avoided) {
|
||||
this.rdioScannerService.avoid({ status: false });
|
||||
} else if (!minutes) {
|
||||
this.rdioScannerService.avoid({ minutes: 30, status: false });
|
||||
} else if (minutes === 30) {
|
||||
this.rdioScannerService.avoid({ minutes: 60, status: false });
|
||||
} else if (minutes === 60) {
|
||||
this.rdioScannerService.avoid({ minutes: 120, status: false });
|
||||
} else {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
|
||||
this.rdioScannerService.avoid({ status: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (call && this.rdioScannerService.isAvoided(call)) {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
|
||||
} else {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
|
||||
}
|
||||
|
||||
this.updateDimmer();
|
||||
|
||||
} else {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
holdSystem(): void {
|
||||
|
|
@ -230,7 +295,27 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
if (!this.livefeedPaused && (this.call || this.callPrevious)) {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
|
||||
|
||||
this.rdioScannerService.replay();
|
||||
if (this.replayTimer instanceof Subscription) {
|
||||
this.replayTimer.unsubscribe();
|
||||
this.replayOffset = Math.min(this.callHistory.length, this.replayOffset + 1);
|
||||
}
|
||||
|
||||
this.replayTimer = timer(1000).subscribe(() => {
|
||||
this.replayTimer = undefined;
|
||||
this.replayOffset = 0;
|
||||
});
|
||||
|
||||
if (this.call && !this.replayOffset) {
|
||||
this.rdioScannerService.replay()
|
||||
} else if (this.callPrevious !== this.callHistory[0]) {
|
||||
if (this.replayOffset) {
|
||||
this.rdioScannerService.play(this.callHistory[this.replayOffset - 1]);
|
||||
} else {
|
||||
this.rdioScannerService.replay()
|
||||
}
|
||||
} else if (this.replayOffset < this.callHistory.length) {
|
||||
this.rdioScannerService.play(this.callHistory[this.replayOffset]);
|
||||
}
|
||||
|
||||
} else {
|
||||
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
|
||||
|
|
@ -240,6 +325,12 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
showHelp(): void {
|
||||
this.matSnackBar.openFromComponent(RdioScannerSupportComponent, {
|
||||
data: { email: this.email },
|
||||
});
|
||||
}
|
||||
|
||||
showSearchPanel(): void {
|
||||
if (!this.config) {
|
||||
return;
|
||||
|
|
@ -289,17 +380,11 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
|
||||
private eventHandler(event: RdioScannerEvent): void {
|
||||
if ('auth' in event && event.auth) {
|
||||
let password: string | null = null;
|
||||
|
||||
password = window?.localStorage?.getItem(LOCAL_STORAGE_KEY);
|
||||
const password = this.rdioScannerService.readPin();
|
||||
|
||||
if (password) {
|
||||
password = atob(password);
|
||||
this.rdioScannerService.clearPin();
|
||||
|
||||
window.localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
this.authForm.get('password')?.setValue(password);
|
||||
|
||||
this.rdioScannerService.authenticate(password);
|
||||
|
|
@ -332,10 +417,16 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
if ('config' in event) {
|
||||
this.config = event.config;
|
||||
|
||||
this.branding = this.config?.branding ?? '';
|
||||
|
||||
this.email = this.config?.email ?? '';
|
||||
|
||||
this.timeFormat = this.config?.time12hFormat ? 'h:mm a' : 'HH:mm';
|
||||
|
||||
const password = this.authForm.get('password')?.value;
|
||||
|
||||
if (password) {
|
||||
window?.localStorage?.setItem(LOCAL_STORAGE_KEY, btoa(password));
|
||||
this.rdioScannerService.savePin(password);
|
||||
|
||||
this.authForm.reset();
|
||||
}
|
||||
|
|
@ -365,12 +456,8 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
this.linked = event.linked || false;
|
||||
}
|
||||
|
||||
if ('livefeedMode' in event && event.livefeedMode) {
|
||||
this.livefeedOffline = event.livefeedMode === RdioScannerLivefeedMode.Offline;
|
||||
|
||||
this.livefeedOnline = event.livefeedMode === RdioScannerLivefeedMode.Online;
|
||||
|
||||
this.playbackMode = event.livefeedMode === RdioScannerLivefeedMode.Playback;
|
||||
if ('listeners' in event) {
|
||||
this.listeners = event.listeners || 0;
|
||||
}
|
||||
|
||||
if ('map' in event) {
|
||||
|
|
@ -395,9 +482,23 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
this.authForm.get('password')?.setErrors({ tooMany: true });
|
||||
}
|
||||
|
||||
if ('livefeedMode' in event && event.livefeedMode) {
|
||||
this.livefeedOffline = event.livefeedMode === RdioScannerLivefeedMode.Offline;
|
||||
|
||||
this.livefeedOnline = event.livefeedMode === RdioScannerLivefeedMode.Online;
|
||||
|
||||
this.playbackMode = event.livefeedMode === RdioScannerLivefeedMode.Playback;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private formatAfs(n: number): string {
|
||||
return `${(n >> 7 & 15).toString().padStart(2, '0')}-${(n >> 3 & 15).toString().padStart(2, '0')}${n & 7}`;
|
||||
}
|
||||
|
||||
private formatFrequency(frequency: number | undefined): string {
|
||||
return typeof frequency === 'number' ? frequency
|
||||
.toString()
|
||||
|
|
@ -406,6 +507,33 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
.concat(' Hz') : '';
|
||||
}
|
||||
|
||||
private getLedColor(call: RdioScannerCall | undefined): string {
|
||||
const colors = ['blue', 'cyan', 'green', 'magenta', 'orange', 'red', 'white', 'yellow'];
|
||||
|
||||
let color;
|
||||
|
||||
if (Array.isArray(call?.groupsData)) {
|
||||
const group = call?.groupsData.find((g) => g.led);
|
||||
|
||||
if (group?.led) color = group.led;
|
||||
|
||||
} else if (call?.tagData?.led) {
|
||||
color = call.tagData?.led
|
||||
|
||||
} else if (call?.systemData?.led) {
|
||||
color = call?.systemData.led;
|
||||
|
||||
} else if (call?.talkgroupData?.led) {
|
||||
color = call.talkgroupData.led;
|
||||
}
|
||||
|
||||
return color && colors.includes(color) ? color : 'green';
|
||||
}
|
||||
|
||||
private isAfsSystem(call: RdioScannerCall): boolean {
|
||||
return (call.systemData?.type === 'provoice') || (call.talkgroupData?.type === 'provoice');
|
||||
}
|
||||
|
||||
private syncClock(): void {
|
||||
this.clockTimer?.unsubscribe();
|
||||
|
||||
|
|
@ -434,14 +562,22 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
|
||||
private updateDisplay(time = this.callTime): void {
|
||||
if (this.call) {
|
||||
const isAfs = this.isAfsSystem(this.call);
|
||||
|
||||
this.callProgress = new Date(this.call.dateTime);
|
||||
this.callProgress.setSeconds(this.callProgress.getSeconds() + time);
|
||||
|
||||
if (Date.now() - this.callProgress.getTime() >= 86400000) {
|
||||
this.callDate = this.call.dateTime;
|
||||
} else {
|
||||
this.callDate = undefined;
|
||||
}
|
||||
|
||||
this.callSystem = this.call.systemData?.label || `${this.call.system}`;
|
||||
|
||||
this.callTag = this.call.talkgroupData?.tag || '';
|
||||
|
||||
this.callTalkgroup = this.call.talkgroupData?.label || `${this.call.talkgroup}`;
|
||||
this.callTalkgroup = this.call.talkgroupData?.label || `${isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup}`;
|
||||
|
||||
this.callTalkgroupName = this.call.talkgroupData?.name || this.formatFrequency(this.call?.frequency);
|
||||
|
||||
|
|
@ -459,7 +595,7 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
|
||||
this.callFrequency = typeof this.call.frequency === 'number'
|
||||
? this.formatFrequency(this.call.frequency)
|
||||
: this.call.audioName?.replace(/\..+$/, '') || '';
|
||||
: '';
|
||||
|
||||
this.callSpike = '';
|
||||
}
|
||||
|
|
@ -467,21 +603,29 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
if (Array.isArray(this.call.sources) && this.call.sources.length) {
|
||||
const source = this.call.sources.reduce((p, v) => (v.pos || 0) <= time ? v : p, {});
|
||||
|
||||
this.callTalkgroupId = `${this.call.talkgroup}`;
|
||||
this.callTalkgroupId = isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup.toString();
|
||||
|
||||
if (typeof source.src === 'number' && Array.isArray(this.call.systemData?.units)) {
|
||||
const callUnit = this.call?.systemData?.units?.find((u) => u.id === source.src);
|
||||
if (typeof source.src === 'number') {
|
||||
if (Array.isArray(this.call.systemData?.units)) {
|
||||
this.callUnit = this.call.systemData?.units?.find((u) => {
|
||||
if (typeof u.unitFrom === 'number' && typeof u.unitTo === 'number')
|
||||
if (u.unitFrom <= (source.src as number) && u.unitTo >= (source.src as number))
|
||||
return true;
|
||||
|
||||
this.callUnit = callUnit ? callUnit.label : `${source.src}`;
|
||||
return u.id === source.src;
|
||||
})?.label ?? `${source.src}`;
|
||||
|
||||
} else {
|
||||
this.callUnit = typeof this.call.source === 'number' ? `${this.call.source}` : '';
|
||||
console.log('here', this.callUnit);
|
||||
|
||||
} else {
|
||||
this.callUnit = `${source.src}`;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
this.callTalkgroupId = this.call.talkgroup.toString();
|
||||
this.callTalkgroupId = isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup.toString();
|
||||
|
||||
this.callUnit = typeof this.call.source === 'number' ? `${this.call.source}` : '';
|
||||
this.callUnit = this.call.systemData?.units?.find((u) => u.id === this.call?.source)?.label ?? `${this.call.source ?? ''}`;
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -497,22 +641,31 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
|
|||
|
||||
const call = this.call || this.callPrevious;
|
||||
|
||||
if (call && this.map[call.system]) {
|
||||
this.avoided = !this.map[call.system][call.talkgroup];
|
||||
if (call) {
|
||||
this.delayed = call.delayed;
|
||||
|
||||
} else {
|
||||
this.avoided = false;
|
||||
this.tempAvoid = this.rdioScannerService.isAvoidedTimer(call);
|
||||
|
||||
if (call.talkgroupData?.type)
|
||||
this.type = call.talkgroupData.type;
|
||||
|
||||
else if (call.systemData?.type)
|
||||
this.type = call.systemData.type;
|
||||
|
||||
if (this.rdioScannerService.isPatched(call)) {
|
||||
this.avoided = false;
|
||||
this.patched = true;
|
||||
|
||||
} else {
|
||||
this.avoided = this.rdioScannerService.isAvoided(call);
|
||||
this.patched = false;
|
||||
}
|
||||
}
|
||||
|
||||
const colors = ['blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow'];
|
||||
|
||||
this.ledStyle = this.call && this.livefeedPaused ? 'on paused' : this.call ? 'on' : 'off';
|
||||
|
||||
if (colors.includes(this.call?.talkgroupData?.led as string)) {
|
||||
this.ledStyle = `${this.ledStyle} ${this.call?.talkgroupData?.led}`;
|
||||
|
||||
} else if (colors.includes(this.call?.systemData?.led as string)) {
|
||||
this.ledStyle = `${this.ledStyle} ${this.call?.systemData?.led}`;
|
||||
if (this.call) {
|
||||
this.ledStyle = `${this.ledStyle} ${this.getLedColor(call)}`;
|
||||
}
|
||||
|
||||
this.ngChangeDetectorRef.detectChanges();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<span class="support">
|
||||
@if (email) {
|
||||
For further assistance please contact <b><a href="mailto:{{ email }}" target="_blank">{{ email }}</a></b>
|
||||
}
|
||||
@if (!email) {
|
||||
For further assistance please go to <b><a href="https://github.com/chuot/rdio-scanner/discussions"
|
||||
target="_blank">Rdio Scanner discussions</a></b>
|
||||
}
|
||||
</span>
|
||||
<span class="mat-caption countdown">{{ countdown }}</span>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
:host {
|
||||
a {
|
||||
color: currentColor;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.support {
|
||||
margin: 0 2rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
bottom: 4px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Component, Inject, Optional } from '@angular/core';
|
||||
import { MAT_SNACK_BAR_DATA, MatSnackBarRef } from '@angular/material/snack-bar';
|
||||
import { timer } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'RdioScannerSupport',
|
||||
styleUrls: ['./support.component.scss'],
|
||||
templateUrl: './support.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerSupportComponent {
|
||||
countdown = 10;
|
||||
|
||||
email: string | undefined;
|
||||
|
||||
constructor(
|
||||
@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerSupportComponent>,
|
||||
@Inject(MAT_SNACK_BAR_DATA) public data: { email: string },
|
||||
) {
|
||||
this.email = data?.email;
|
||||
|
||||
this.wait();
|
||||
}
|
||||
|
||||
private wait(): void {
|
||||
timer(1000).subscribe(() => {
|
||||
this.countdown--;
|
||||
|
||||
if (this.countdown < 1) {
|
||||
this.matSnackBarRef?.dismiss();
|
||||
|
||||
} else {
|
||||
this.wait();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -22,5 +22,6 @@ import { Component } from '@angular/core';
|
|||
@Component({
|
||||
selector: 'app-store-badge',
|
||||
templateUrl: './app-store-badge.component.svg',
|
||||
standalone: false
|
||||
})
|
||||
export class AppStoreBadgeComponent {}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -22,5 +22,6 @@ import { Component } from '@angular/core';
|
|||
@Component({
|
||||
selector: 'google-play-badge',
|
||||
templateUrl: './google-play-badge.component.svg',
|
||||
standalone: false
|
||||
})
|
||||
export class GooglePlayBadgeComponent {}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
<div>
|
||||
<div>
|
||||
<p>Do you know that Rdio Scanner works natively on {{ deviceType }} devices?</p>
|
||||
</div>
|
||||
<div>
|
||||
<ng-container *ngIf="isAndroid">
|
||||
<a href="https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner" target="_blank">
|
||||
<google-play-badge></google-play-badge>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="isApple">
|
||||
<a href="https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone" target="_blank">
|
||||
<app-store-badge></app-store-badge>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div>
|
||||
<p>Do you know that Rdio Scanner works natively on {{ deviceType }} devices?</p>
|
||||
</div>
|
||||
<div>
|
||||
@if (isAndroid) {
|
||||
<a href="https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner" target="_blank">
|
||||
<google-play-badge></google-play-badge>
|
||||
</a>
|
||||
}
|
||||
@if (isApple) {
|
||||
<a href="https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone" target="_blank">
|
||||
<app-store-badge></app-store-badge>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="countdown">{{ countdown }}</div>
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
.countdown {
|
||||
bottom: 4px;
|
||||
color: rgba(0, 0, 0, 0.67);
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -19,22 +19,23 @@
|
|||
|
||||
import { Component, Optional, OnInit } from '@angular/core';
|
||||
import { MatSnackBarRef } from '@angular/material/snack-bar';
|
||||
import { timer } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'RdioScannerNative',
|
||||
styleUrls: ['./native.scss'],
|
||||
styleUrls: ['./native.component.scss'],
|
||||
templateUrl: './native.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerNativeComponent implements OnInit {
|
||||
countdown: number = 10;
|
||||
countdown = 10;
|
||||
|
||||
deviceType: string = 'mobile';
|
||||
deviceType = 'mobile';
|
||||
|
||||
isAndroid: boolean = false;
|
||||
isApple: boolean = false;
|
||||
isAndroid = false;
|
||||
isApple = false;
|
||||
|
||||
constructor(@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerNativeComponent>) {
|
||||
}
|
||||
constructor(@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerNativeComponent>) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
const ua = navigator.userAgent;
|
||||
|
|
@ -55,7 +56,7 @@ export class RdioScannerNativeComponent implements OnInit {
|
|||
}
|
||||
|
||||
private wait(): void {
|
||||
setTimeout(() => {
|
||||
timer(1000).subscribe(() => {
|
||||
this.countdown--;
|
||||
|
||||
if (this.countdown < 1) {
|
||||
|
|
@ -64,6 +65,6 @@ export class RdioScannerNativeComponent implements OnInit {
|
|||
} else {
|
||||
this.wait();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@
|
|||
</mat-sidenav>
|
||||
<mat-sidenav-content>
|
||||
<rdio-scanner-main
|
||||
(openSearchPanel)="scrollableSearch.scrollTo(0, 0); searchPanel.open(); searchComponent.searchCalls()"
|
||||
(openSelectPanel)="scrollableSelect.scrollTo(0, 0); selectPanel.open();"
|
||||
(openSearchPanel)="scrollTop(scrollableSearch); searchPanel.open(); searchComponent.searchCalls()"
|
||||
(openSelectPanel)="scrollTop(scrollableSelect); selectPanel.open();"
|
||||
(toggleFullscreen)="toggleFullscreen()">
|
||||
</rdio-scanner-main>
|
||||
</mat-sidenav-content>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ $background-color: rgb(30, 30, 30);
|
|||
display: block;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
position: relative;
|
||||
|
||||
.mat-sidenav {
|
||||
background-color: inherit;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { MatSidenav } from '@angular/material/sidenav';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { timer } from 'rxjs';
|
||||
import { RdioScannerEvent, RdioScannerLivefeedMode } from './rdio-scanner';
|
||||
import { RdioScannerService } from './rdio-scanner.service';
|
||||
import { RdioScannerNativeComponent } from './native/native.component';
|
||||
|
|
@ -28,9 +29,10 @@ import { RdioScannerNativeComponent } from './native/native.component';
|
|||
selector: 'rdio-scanner',
|
||||
styleUrls: ['./rdio-scanner.component.scss'],
|
||||
templateUrl: './rdio-scanner.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerComponent implements OnDestroy, OnInit {
|
||||
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
|
||||
private eventSubscription;
|
||||
|
||||
private livefeedMode: RdioScannerLivefeedMode = RdioScannerLivefeedMode.Offline;
|
||||
|
||||
|
|
@ -42,7 +44,9 @@ export class RdioScannerComponent implements OnDestroy, OnInit {
|
|||
private matSnackBar: MatSnackBar,
|
||||
private ngElementRef: ElementRef,
|
||||
private rdioScannerService: RdioScannerService,
|
||||
) { }
|
||||
) {
|
||||
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', ['$event'])
|
||||
exitNotification(event: BeforeUnloadEvent): void {
|
||||
|
|
@ -53,6 +57,37 @@ export class RdioScannerComponent implements OnDestroy, OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
/*
|
||||
* BEGIN OF RED TAPE:
|
||||
*
|
||||
* By modifying, deleting or disabling the following lines, you harm
|
||||
* the open source project and its author. Rdio Scanner represents a lot of
|
||||
* investment in time, support, testing and hardware.
|
||||
*
|
||||
* Be respectful, sponsor the project, use native apps when possible.
|
||||
*
|
||||
*/
|
||||
timer(10000).subscribe(() => {
|
||||
const ua: string = navigator.userAgent;
|
||||
|
||||
if (ua.includes('Android') || ua.includes('iPad') || ua.includes('iPhone')) {
|
||||
this.matSnackBar.openFromComponent(RdioScannerNativeComponent);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* END OF RED TAPE.
|
||||
*/
|
||||
}
|
||||
|
||||
scrollTop(e: HTMLElement): void {
|
||||
setTimeout(() => e.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.rdioScannerService.startLivefeed();
|
||||
}
|
||||
|
|
@ -64,33 +99,6 @@ export class RdioScannerComponent implements OnDestroy, OnInit {
|
|||
this.selectPanel?.close();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
/*
|
||||
* BEGIN OF RED TAPE:
|
||||
*
|
||||
* By modifying, deleting or disabling the following lines, you are harming
|
||||
* the open source project and its author. Rdio Scanner represents a lot of
|
||||
* investment in time, support, testing and hardware.
|
||||
*
|
||||
* Be respectful, sponsor the project if you can, use native apps when possible.
|
||||
*
|
||||
*/
|
||||
setTimeout(() => {
|
||||
const ua: String = navigator.userAgent;
|
||||
|
||||
if (ua.includes('Android') || ua.includes('iPad') || ua.includes('iPhone')) {
|
||||
this.matSnackBar.openFromComponent(RdioScannerNativeComponent, { panelClass: 'snackbar-white' });
|
||||
}
|
||||
}, 10000);
|
||||
/**
|
||||
* END OF RED TAPE.
|
||||
*/
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
if (document.fullscreenElement) {
|
||||
const el: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -23,6 +23,7 @@ import { AppSharedModule } from '../../shared/shared.module';
|
|||
import { RdioScannerComponent } from './rdio-scanner.component';
|
||||
import { RdioScannerService } from './rdio-scanner.service';
|
||||
import { RdioScannerMainComponent } from './main/main.component';
|
||||
import { RdioScannerSupportComponent } from './main/support/support.component';
|
||||
import { RdioScannerNativeModule } from './native/native.module';
|
||||
import { RdioScannerSearchComponent } from './search/search.component';
|
||||
import { RdioScannerSelectComponent } from './select/select.component';
|
||||
|
|
@ -33,9 +34,13 @@ import { RdioScannerSelectComponent } from './select/select.component';
|
|||
RdioScannerMainComponent,
|
||||
RdioScannerSearchComponent,
|
||||
RdioScannerSelectComponent,
|
||||
RdioScannerSupportComponent,
|
||||
],
|
||||
exports: [RdioScannerComponent],
|
||||
imports: [AppSharedModule, RdioScannerNativeModule],
|
||||
imports: [
|
||||
AppSharedModule,
|
||||
RdioScannerNativeModule,
|
||||
],
|
||||
providers: [
|
||||
RdioScannerService,
|
||||
{ provide: OverlayContainer, useClass: FullscreenOverlayContainer },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,8 +17,9 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { EventEmitter, Inject, Injectable, OnDestroy } from '@angular/core';
|
||||
|
||||
import { EventEmitter, Inject, Injectable, OnDestroy, DOCUMENT } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { interval, Subscription, timer } from 'rxjs';
|
||||
import { takeWhile } from 'rxjs/operators';
|
||||
import { AppUpdateService } from '../../shared/update/update.service';
|
||||
|
|
@ -31,8 +32,10 @@ import {
|
|||
RdioScannerCategoryType,
|
||||
RdioScannerConfig,
|
||||
RdioScannerEvent,
|
||||
RdioScannerLivefeed,
|
||||
RdioScannerLivefeedMap,
|
||||
RdioScannerLivefeedMode,
|
||||
RdioScannerOscillatorData,
|
||||
RdioScannerPlaybackList,
|
||||
RdioScannerSearchOptions,
|
||||
} from './rdio-scanner';
|
||||
|
|
@ -53,14 +56,18 @@ enum WebsocketCommand {
|
|||
Config = 'CFG',
|
||||
Expired = 'XPR',
|
||||
ListCall = 'LCL',
|
||||
ListenersCount = 'LSC',
|
||||
LivefeedMap = 'LFM',
|
||||
Max = 'MAX',
|
||||
Pin = 'PIN',
|
||||
Version = 'VER',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RdioScannerService implements OnDestroy {
|
||||
static LOCAL_STORAGE_KEY = 'rdio-scanner';
|
||||
static LOCAL_STORAGE_KEY_LEGACY = 'rdio-scanner';
|
||||
static LOCAL_STORAGE_KEY_LFM = 'rdio-scanner-lfm';
|
||||
static LOCAL_STORAGE_KEY_PIN = 'rdio-scanner-pin';
|
||||
|
||||
event = new EventEmitter<RdioScannerEvent>();
|
||||
|
||||
|
|
@ -69,8 +76,6 @@ export class RdioScannerService implements OnDestroy {
|
|||
private audioSource: AudioBufferSourceNode | undefined;
|
||||
private audioSourceStartTime = NaN;
|
||||
|
||||
private beepContext: AudioContext | undefined;
|
||||
|
||||
private call: RdioScannerCall | undefined;
|
||||
private callPrevious: RdioScannerCall | undefined;
|
||||
private callQueue: RdioScannerCall[] = [];
|
||||
|
|
@ -80,20 +85,29 @@ export class RdioScannerService implements OnDestroy {
|
|||
private config: RdioScannerConfig = {
|
||||
dimmerDelay: false,
|
||||
groups: {},
|
||||
keypadBeeps: false,
|
||||
groupsData: [],
|
||||
keypadBeeps: undefined,
|
||||
playbackGoesLive: false,
|
||||
showListenersCount: false,
|
||||
systems: [],
|
||||
tags: {},
|
||||
tagsToggle: false,
|
||||
tagsData: [],
|
||||
time12hFormat: false,
|
||||
};
|
||||
|
||||
private instanceId = 'default';
|
||||
|
||||
private livefeedMap = {} as RdioScannerLivefeedMap;
|
||||
private livefeedMapPriorToHoldSystem: RdioScannerLivefeedMap | undefined;
|
||||
private livefeedMapPriorToHoldTalkgroup: RdioScannerLivefeedMap | undefined;
|
||||
private livefeedMode = RdioScannerLivefeedMode.Offline;
|
||||
private livefeedPaused = false;
|
||||
|
||||
private oscillatorContext: AudioContext | undefined;
|
||||
|
||||
private playbackList: RdioScannerPlaybackList | undefined;
|
||||
private playbackPending: number | undefined;
|
||||
private playbackRefreshing = false;
|
||||
|
||||
private skipDelay: Subscription | undefined;
|
||||
|
||||
|
|
@ -101,20 +115,56 @@ export class RdioScannerService implements OnDestroy {
|
|||
|
||||
constructor(
|
||||
appUpdateService: AppUpdateService,
|
||||
private router: Router,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
) {
|
||||
if (router.url.endsWith('/reset')) {
|
||||
window?.localStorage?.clear();
|
||||
|
||||
router.navigateByUrl(router.url.replace('/reset', ''), {
|
||||
replaceUrl: true,
|
||||
}).then(() => window?.location?.reload());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.bootstrapAudio();
|
||||
|
||||
this.restoreLivefeed();
|
||||
this.initializeInstanceId();
|
||||
|
||||
this.readLivefeedMap();
|
||||
|
||||
this.openWebsocket();
|
||||
}
|
||||
|
||||
authenticate(password: string): void {
|
||||
this.sendtoWebsocket(WebsocketCommand.Pin, btoa(password));
|
||||
this.sendtoWebsocket(WebsocketCommand.Pin, window.btoa(password));
|
||||
}
|
||||
|
||||
avoid(options: RdioScannerAvoidOptions = {}): void {
|
||||
const clearTimer = (lfm: RdioScannerLivefeed): void => {
|
||||
lfm.minutes = undefined;
|
||||
lfm.timer?.unsubscribe();
|
||||
lfm.timer = undefined;
|
||||
};
|
||||
|
||||
const setTimer = (lfm: RdioScannerLivefeed, minutes: number): void => {
|
||||
lfm.minutes = minutes;
|
||||
lfm.timer = timer(minutes * 60 * 1000).subscribe(() => {
|
||||
lfm.active = true;
|
||||
lfm.minutes = undefined;
|
||||
lfm.timer = undefined;
|
||||
|
||||
this.rebuildCategories();
|
||||
this.saveLivefeedMap();
|
||||
|
||||
this.event.emit({
|
||||
categories: this.categories,
|
||||
map: this.livefeedMap,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (this.livefeedMapPriorToHoldSystem) {
|
||||
this.livefeedMapPriorToHoldSystem = undefined;
|
||||
}
|
||||
|
|
@ -126,37 +176,39 @@ export class RdioScannerService implements OnDestroy {
|
|||
if (typeof options.all === 'boolean') {
|
||||
Object.keys(this.livefeedMap).map((sys: string) => +sys).forEach((sys: number) => {
|
||||
Object.keys(this.livefeedMap[sys]).map((tg: string) => +tg).forEach((tg: number) => {
|
||||
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !!options.all;
|
||||
const lfm = this.livefeedMap[sys][tg];
|
||||
clearTimer(lfm);
|
||||
lfm.active = typeof options.status === 'boolean' ? options.status : !!options.all;
|
||||
});
|
||||
});
|
||||
|
||||
} else if (options.call) {
|
||||
const sys = options.call.system;
|
||||
const tg = options.call.talkgroup;
|
||||
|
||||
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
|
||||
const lfm = this.livefeedMap[options.call.system][options.call.talkgroup];
|
||||
clearTimer(lfm);
|
||||
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
|
||||
if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
|
||||
|
||||
} else if (options.system && options.talkgroup) {
|
||||
const sys = options.system.id;
|
||||
const tg = options.talkgroup.id;
|
||||
|
||||
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
|
||||
const lfm = this.livefeedMap[options.system.id][options.talkgroup.id];
|
||||
clearTimer(lfm);
|
||||
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
|
||||
if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
|
||||
|
||||
} else if (options.system && !options.talkgroup) {
|
||||
const sys = options.system.id;
|
||||
|
||||
Object.keys(this.livefeedMap[sys]).map((tg: string) => +tg).forEach((tg: number) => {
|
||||
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
|
||||
const lfm = this.livefeedMap[sys][tg];
|
||||
clearTimer(lfm);
|
||||
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
|
||||
});
|
||||
|
||||
} else {
|
||||
const call = this.call || this.callPrevious;
|
||||
|
||||
if (call) {
|
||||
const sys = call.system;
|
||||
const tg = call.talkgroup;
|
||||
|
||||
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
|
||||
const lfm = this.livefeedMap[call.system][call.talkgroup];
|
||||
clearTimer(lfm);
|
||||
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
|
||||
if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +218,7 @@ export class RdioScannerService implements OnDestroy {
|
|||
|
||||
this.rebuildCategories();
|
||||
|
||||
this.storeLivefeedMap();
|
||||
this.saveLivefeedMap();
|
||||
|
||||
if (this.livefeedMode === RdioScannerLivefeedMode.Online) {
|
||||
this.startLivefeed();
|
||||
|
|
@ -181,42 +233,14 @@ export class RdioScannerService implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
beep(style = RdioScannerBeepStyle.Activate): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const context = this.beepContext;
|
||||
async beep(style = RdioScannerBeepStyle.Activate): Promise<void> {
|
||||
const seq = this.config.keypadBeeps && this.config.keypadBeeps[style];
|
||||
|
||||
const seq = this.config.keypadBeeps && this.config.keypadBeeps[style];
|
||||
if (seq) await this.playOscillatorSequence(seq);
|
||||
}
|
||||
|
||||
if (!context || !seq) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const gn = context.createGain();
|
||||
|
||||
gn.gain.value = .1;
|
||||
|
||||
gn.connect(context.destination);
|
||||
|
||||
seq.forEach((beep, index) => {
|
||||
const osc = context.createOscillator();
|
||||
|
||||
osc.connect(gn);
|
||||
|
||||
osc.frequency.value = beep.frequency;
|
||||
|
||||
osc.type = beep.type;
|
||||
|
||||
if (index === seq.length - 1) {
|
||||
osc.onended = () => resolve();
|
||||
}
|
||||
|
||||
osc.start(context.currentTime + beep.begin);
|
||||
|
||||
osc.stop(context.currentTime + beep.end);
|
||||
});
|
||||
});
|
||||
clearPin(): void {
|
||||
window?.localStorage.removeItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN);
|
||||
}
|
||||
|
||||
holdSystem(options?: { resubscribe?: boolean }): void {
|
||||
|
|
@ -236,18 +260,17 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.livefeedMapPriorToHoldSystem = this.livefeedMap;
|
||||
|
||||
this.livefeedMap = Object.keys(this.livefeedMap).map((sys) => +sys).reduce((sysMap, sys) => {
|
||||
const allOn = Object.keys(this.livefeedMap[sys]).every((tg) => !this.livefeedMap[sys][tg]);
|
||||
const allOn = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).every((tg) => !this.livefeedMap[sys][tg]);
|
||||
|
||||
sysMap[sys] = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).reduce((tgMap, tg) => {
|
||||
if (sys === call.system) {
|
||||
tgMap[tg] = allOn || this.livefeedMap[sys][tg];
|
||||
this.livefeedMap[sys][tg].timer?.unsubscribe();
|
||||
|
||||
} else {
|
||||
tgMap[tg] = false;
|
||||
}
|
||||
tgMap[tg] = {
|
||||
active: sys === call.system ? allOn || this.livefeedMap[sys][tg].active : false,
|
||||
} as RdioScannerLivefeed;
|
||||
|
||||
return tgMap;
|
||||
}, {} as { [key: number]: boolean });
|
||||
}, {} as { [key: number]: RdioScannerLivefeed });
|
||||
|
||||
return sysMap;
|
||||
}, {} as RdioScannerLivefeedMap);
|
||||
|
|
@ -291,15 +314,14 @@ export class RdioScannerService implements OnDestroy {
|
|||
|
||||
this.livefeedMap = Object.keys(this.livefeedMap).map((sys) => +sys).reduce((sysMap, sys) => {
|
||||
sysMap[sys] = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).reduce((tgMap, tg) => {
|
||||
if (sys === call.system) {
|
||||
tgMap[tg] = tg === call.talkgroup;
|
||||
this.livefeedMap[sys][tg].timer?.unsubscribe();
|
||||
|
||||
} else {
|
||||
tgMap[tg] = false;
|
||||
}
|
||||
tgMap[tg] = {
|
||||
active: sys === call.system ? tg === call.talkgroup : false,
|
||||
} as RdioScannerLivefeed;
|
||||
|
||||
return tgMap;
|
||||
}, {} as { [key: number]: boolean });
|
||||
}, {} as { [key: number]: RdioScannerLivefeed });
|
||||
|
||||
return sysMap;
|
||||
}, {} as RdioScannerLivefeedMap);
|
||||
|
|
@ -325,6 +347,23 @@ export class RdioScannerService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
isAvoided(call: RdioScannerCall): boolean {
|
||||
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][call.talkgroup]?.active !== true;
|
||||
}
|
||||
|
||||
isAvoidedTimer(call: RdioScannerCall): number {
|
||||
if (!!this.livefeedMap[call.system] && this.livefeedMap[call.system][call.talkgroup]?.minutes !== undefined) {
|
||||
return this.livefeedMap[call.system][call.talkgroup]?.minutes || 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
isPatched(call: RdioScannerCall): boolean {
|
||||
return this.isAvoided(call) && call.patches?.some((tg) => {
|
||||
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][tg]?.active || false;
|
||||
});
|
||||
}
|
||||
|
||||
livefeed(): void {
|
||||
if (this.livefeedMode === RdioScannerLivefeedMode.Offline) {
|
||||
this.startLivefeed();
|
||||
|
|
@ -434,11 +473,13 @@ export class RdioScannerService implements OnDestroy {
|
|||
arrayBufferView[i] = this.call.audio.data[i];
|
||||
}
|
||||
|
||||
this.audioContext?.decodeAudioData(arrayBuffer, (buffer) => {
|
||||
this.audioContext?.decodeAudioData(arrayBuffer, async (buffer) => {
|
||||
if (!this.audioContext || this.audioSource || !this.call) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.playAlert(this.call);
|
||||
|
||||
this.audioSource = this.audioContext.createBufferSource();
|
||||
this.audioSource.buffer = buffer;
|
||||
this.audioSource.connect(this.audioContext.destination);
|
||||
|
|
@ -491,6 +532,16 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.play(this.call || this.callPrevious);
|
||||
}
|
||||
|
||||
readPin(): string | undefined {
|
||||
const pin = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN);
|
||||
|
||||
return pin ? window.atob(pin) : undefined;
|
||||
}
|
||||
|
||||
savePin(pin: string): void {
|
||||
window?.localStorage?.setItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN, window.btoa(pin));
|
||||
}
|
||||
|
||||
searchCalls(options: RdioScannerSearchOptions): void {
|
||||
this.sendtoWebsocket(WebsocketCommand.ListCall, options);
|
||||
}
|
||||
|
|
@ -526,11 +577,19 @@ export class RdioScannerService implements OnDestroy {
|
|||
}
|
||||
|
||||
startLivefeed(): void {
|
||||
const lfm = Object.keys(this.livefeedMap).reduce((sysMap: { [key: number]: { [key: number]: boolean } }, sys) => {
|
||||
sysMap[+sys] = Object.keys(this.livefeedMap[+sys]).reduce((tgMap: { [key: number]: boolean }, tg: string) => {
|
||||
tgMap[+tg] = this.livefeedMap[+sys][+tg].active;
|
||||
return tgMap;
|
||||
}, {});
|
||||
return sysMap;
|
||||
}, {});
|
||||
|
||||
this.livefeedMode = RdioScannerLivefeedMode.Online;
|
||||
|
||||
this.event.emit({ livefeedMode: this.livefeedMode });
|
||||
|
||||
this.sendtoWebsocket(WebsocketCommand.LivefeedMap, this.livefeedMap);
|
||||
this.sendtoWebsocket(WebsocketCommand.LivefeedMap, lfm);
|
||||
}
|
||||
|
||||
stop(options?: { emit?: boolean }): void {
|
||||
|
|
@ -568,6 +627,8 @@ export class RdioScannerService implements OnDestroy {
|
|||
stopPlaybackMode(): void {
|
||||
this.livefeedMode = RdioScannerLivefeedMode.Offline;
|
||||
|
||||
this.playbackRefreshing = false;
|
||||
|
||||
this.clearQueue();
|
||||
|
||||
this.event.emit({ livefeedMode: this.livefeedMode, queue: 0 });
|
||||
|
|
@ -576,6 +637,12 @@ export class RdioScannerService implements OnDestroy {
|
|||
}
|
||||
|
||||
toggleCategory(category: RdioScannerCategory): void {
|
||||
const clearTimer = (lfm: RdioScannerLivefeed): void => {
|
||||
lfm.minutes = 0;
|
||||
lfm.timer?.unsubscribe();
|
||||
lfm.timer = undefined;
|
||||
};
|
||||
|
||||
if (category) {
|
||||
if (this.livefeedMapPriorToHoldSystem) {
|
||||
this.livefeedMapPriorToHoldSystem = undefined;
|
||||
|
|
@ -589,20 +656,23 @@ export class RdioScannerService implements OnDestroy {
|
|||
|
||||
this.config?.systems.forEach((sys) => {
|
||||
sys.talkgroups?.forEach((tg) => {
|
||||
if (category.type == RdioScannerCategoryType.Group && tg.group === category.label) {
|
||||
this.livefeedMap[sys.id][tg.id] = status;
|
||||
const lfm = this.livefeedMap[sys.id][tg.id];
|
||||
|
||||
if (category.type == RdioScannerCategoryType.Group && tg.groups.includes(category.label)) {
|
||||
clearTimer(lfm);
|
||||
lfm.active = status;
|
||||
|
||||
} else if (category.type == RdioScannerCategoryType.Tag && tg.tag === category.label) {
|
||||
this.livefeedMap[sys.id][tg.id] = status;
|
||||
clearTimer(lfm);
|
||||
lfm.active = status;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.rebuildCategories();
|
||||
|
||||
if (this.call && !this.livefeedMap[this.call.system] &&
|
||||
this.livefeedMap[this.call.system][this.call.talkgroup]) {
|
||||
|
||||
if (this.call && !this.livefeedMap[this.call.system] && this.livefeedMap[this.call.system][this.call.talkgroup]) {
|
||||
clearTimer(this.livefeedMap[this.call.system][this.call.talkgroup]);
|
||||
this.skip();
|
||||
}
|
||||
|
||||
|
|
@ -610,7 +680,7 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.startLivefeed();
|
||||
}
|
||||
|
||||
this.storeLivefeedMap();
|
||||
this.saveLivefeedMap();
|
||||
|
||||
this.cleanQueue();
|
||||
|
||||
|
|
@ -632,8 +702,8 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
|
||||
}
|
||||
|
||||
if (!this.beepContext) {
|
||||
this.beepContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
|
||||
if (!this.oscillatorContext) {
|
||||
this.oscillatorContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
|
|
@ -650,19 +720,19 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.audioContext.onstatechange = () => resume();
|
||||
}
|
||||
|
||||
if (this.beepContext) {
|
||||
if (this.oscillatorContext) {
|
||||
const resume = () => {
|
||||
if (this.beepContext?.state === 'suspended') {
|
||||
this.beepContext?.resume().then(() => resume());
|
||||
if (this.oscillatorContext?.state === 'suspended') {
|
||||
this.oscillatorContext?.resume().then(() => resume());
|
||||
}
|
||||
};
|
||||
|
||||
await this.beepContext.resume();
|
||||
await this.oscillatorContext.resume();
|
||||
|
||||
this.beepContext.onstatechange = () => resume();
|
||||
this.oscillatorContext.onstatechange = () => resume();
|
||||
}
|
||||
|
||||
if (this.audioContext && this.beepContext) {
|
||||
if (this.audioContext && this.oscillatorContext) {
|
||||
events.forEach((event) => document.body.removeEventListener(event, bootstrap));
|
||||
}
|
||||
};
|
||||
|
|
@ -671,13 +741,23 @@ export class RdioScannerService implements OnDestroy {
|
|||
}
|
||||
|
||||
private cleanQueue(): void {
|
||||
this.callQueue = this.callQueue.filter((call: RdioScannerCall) => {
|
||||
return this.livefeedMap && this.livefeedMap[call.system] && this.livefeedMap[call.system][call.talkgroup];
|
||||
});
|
||||
const isActive = (call: RdioScannerCall) => {
|
||||
const lfm = (sys: number, tg: number): boolean => this.livefeedMap && this.livefeedMap[sys] && this.livefeedMap[sys][tg]?.active;
|
||||
let active = lfm(call.system, call.talkgroup);
|
||||
if (!active && Array.isArray(call.patches)) {
|
||||
for (let i = 0; i < call.patches.length; i++) {
|
||||
active = lfm(call.system, call.patches[i]);
|
||||
if (active) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
};
|
||||
|
||||
if (this.call && !(this.livefeedMap && this.livefeedMap[this.call.system] &&
|
||||
this.livefeedMap[this.call.system][this.call.talkgroup])) {
|
||||
this.callQueue = this.callQueue.filter((call: RdioScannerCall) => isActive(call));
|
||||
|
||||
if (this.call && !isActive(this.call)) {
|
||||
this.skip();
|
||||
}
|
||||
}
|
||||
|
|
@ -704,7 +784,7 @@ export class RdioScannerService implements OnDestroy {
|
|||
const file = call.audio.data.reduce((str, val) => str += String.fromCharCode(val), '');
|
||||
const fileName = call.audioName || 'unknown.dat';
|
||||
const fileType = call.audioType || 'audio/*';
|
||||
const fileUri = `data:${fileType};base64,${btoa(file)}`;
|
||||
const fileUri = `data:${fileType};base64,${window.btoa(file)}`;
|
||||
|
||||
const el = this.document.createElement('a');
|
||||
|
||||
|
|
@ -744,6 +824,10 @@ export class RdioScannerService implements OnDestroy {
|
|||
return queueCount;
|
||||
}
|
||||
|
||||
private initializeInstanceId(): void {
|
||||
this.instanceId = this.router.parseUrl(this.router.url).queryParams['id'] || this.instanceId;
|
||||
}
|
||||
|
||||
private openWebsocket(): void {
|
||||
const websocketUrl = window.location.href.replace(/^http/, 'ws');
|
||||
|
||||
|
|
@ -764,6 +848,7 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.websocket.onmessage = (ev: MessageEvent) => this.parseWebsocketMessage(ev.data);
|
||||
}
|
||||
|
||||
this.sendtoWebsocket(WebsocketCommand.Version);
|
||||
this.sendtoWebsocket(WebsocketCommand.Config);
|
||||
};
|
||||
}
|
||||
|
|
@ -780,16 +865,19 @@ export class RdioScannerService implements OnDestroy {
|
|||
switch (message[0]) {
|
||||
case WebsocketCommand.Call:
|
||||
if (message[1] !== null) {
|
||||
if (message[2] === WebsocketCallFlag.Download) {
|
||||
const call: RdioScannerCall = message[1];
|
||||
const flag: string = message[2];
|
||||
|
||||
if (flag === WebsocketCallFlag.Download) {
|
||||
this.download(message[1]);
|
||||
|
||||
} else if (message[2] === WebsocketCallFlag.Play && message[1]?.id === this.playbackPending) {
|
||||
} else if (flag === WebsocketCallFlag.Play && call.id === this.playbackPending) {
|
||||
this.playbackPending = undefined;
|
||||
|
||||
this.queue(this.transformCall(message[1]), { priority: true });
|
||||
this.queue(this.transformCall(call), { priority: true });
|
||||
|
||||
} else {
|
||||
this.queue(this.transformCall(message[1]));
|
||||
this.queue(this.transformCall(call));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -799,12 +887,19 @@ export class RdioScannerService implements OnDestroy {
|
|||
const config = message[1];
|
||||
|
||||
this.config = {
|
||||
alerts: config.alerts,
|
||||
branding: typeof config.branding === 'string' ? config.branding : '',
|
||||
dimmerDelay: typeof config.dimmerDelay === 'number' ? config.dimmerDelay : 5000,
|
||||
email: typeof config.email === 'string' ? config.email : '',
|
||||
groups: typeof config.groups !== null && typeof config.groups === 'object' ? config.groups : {},
|
||||
groupsData: Array.isArray(config.groupsData) ? config.groupsData : [],
|
||||
keypadBeeps: config.keypadBeeps !== null && typeof config.keypadBeeps === 'object' ? config.keypadBeeps : {},
|
||||
playbackGoesLive: typeof config.playbackGoesLive === 'boolean' ? config.playbackGoesLive : false,
|
||||
showListenersCount: typeof config.showListenersCount === 'boolean' ? config.showListenersCount : false,
|
||||
systems: Array.isArray(config.systems) ? config.systems.slice() : [],
|
||||
tags: typeof config.tags !== null && typeof config.tags === 'object' ? config.tags : {},
|
||||
tagsToggle: typeof config.tagsToggle !== null && typeof config.tagsToggle === 'boolean' ? config.tagsToggle : false,
|
||||
tagsData: Array.isArray(config.tagsData) ? config.tagsData : [],
|
||||
time12hFormat: typeof config.time12hFormat === 'boolean' ? config.time12hFormat : false,
|
||||
};
|
||||
|
||||
this.rebuildLivefeedMap();
|
||||
|
|
@ -845,6 +940,11 @@ export class RdioScannerService implements OnDestroy {
|
|||
|
||||
break;
|
||||
|
||||
case WebsocketCommand.ListenersCount:
|
||||
this.event.emit({ listeners: message[1] });
|
||||
|
||||
break;
|
||||
|
||||
case WebsocketCommand.Max:
|
||||
this.event.emit({ auth: true, tooMany: true });
|
||||
|
||||
|
|
@ -854,10 +954,64 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.event.emit({ auth: true });
|
||||
|
||||
break;
|
||||
|
||||
case WebsocketCommand.Version: {
|
||||
const data = message[1];
|
||||
|
||||
if (data !== null && typeof data === 'object') {
|
||||
const branding = data['branding'];
|
||||
const email = data['email'];
|
||||
|
||||
if (typeof branding === 'string') {
|
||||
this.config.branding = branding;
|
||||
}
|
||||
|
||||
if (typeof email === 'string') {
|
||||
this.config.email = email;
|
||||
}
|
||||
|
||||
if (this.config.branding || this.config.email) {
|
||||
this.event.emit({ config: this.config });
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async playAlert(call: RdioScannerCall): Promise<void> {
|
||||
if (this.config.alerts) {
|
||||
let alert: string | undefined;
|
||||
|
||||
call?.talkgroupData?.groups.some((label) => {
|
||||
const group = this.config.groupsData?.find((group) => group.label == label);
|
||||
|
||||
if (group && group.alert) {
|
||||
alert = group.alert;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
const tag = this.config.tagsData?.find((tag) => tag.label == call.talkgroupData?.tag);
|
||||
|
||||
if (tag && tag.alert) alert = tag.alert;
|
||||
}
|
||||
|
||||
if (!alert) alert = call.systemData?.alert;
|
||||
|
||||
if (!alert) alert = call.talkgroupData?.alert;
|
||||
|
||||
if (alert) await this.playOscillatorSequence(this.config.alerts[alert]);
|
||||
console.log(alert);
|
||||
}
|
||||
}
|
||||
|
||||
private playbackNextCall(): void {
|
||||
if (this.call || this.livefeedMode !== RdioScannerLivefeedMode.Playback || !this.playbackList || this.playbackPending) {
|
||||
return;
|
||||
|
|
@ -871,7 +1025,17 @@ export class RdioScannerService implements OnDestroy {
|
|||
|
||||
} else if (index === 0) {
|
||||
if (this.playbackList.options.offset < this.playbackList.options.limit) {
|
||||
this.stopPlaybackMode();
|
||||
if (this.playbackRefreshing) {
|
||||
this.stopPlaybackMode();
|
||||
|
||||
if (this.config.playbackGoesLive) {
|
||||
this.startLivefeed();
|
||||
}
|
||||
|
||||
} else {
|
||||
this.playbackRefreshing = true;
|
||||
this.searchCalls(this.playbackList.options);
|
||||
}
|
||||
|
||||
} else {
|
||||
this.searchCalls(Object.assign({}, this.playbackList.options, {
|
||||
|
|
@ -893,8 +1057,16 @@ export class RdioScannerService implements OnDestroy {
|
|||
offset: this.playbackList.options.offset + this.playbackList.options.limit,
|
||||
}));
|
||||
|
||||
} else {
|
||||
} else if (this.playbackRefreshing) {
|
||||
this.stopPlaybackMode();
|
||||
|
||||
if (this.config.playbackGoesLive) {
|
||||
this.startLivefeed();
|
||||
}
|
||||
|
||||
} else {
|
||||
this.playbackRefreshing = true;
|
||||
this.searchCalls(this.playbackList.options);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
|
@ -903,62 +1075,116 @@ export class RdioScannerService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
private playOscillatorSequence(seq: RdioScannerOscillatorData[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const context = this.oscillatorContext;
|
||||
|
||||
if (!context || !seq) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const gn = context.createGain();
|
||||
|
||||
gn.gain.value = .1;
|
||||
|
||||
gn.connect(context.destination);
|
||||
|
||||
seq.forEach((data, index) => {
|
||||
const osc = context.createOscillator();
|
||||
|
||||
osc.connect(gn);
|
||||
|
||||
osc.frequency.value = data.frequency;
|
||||
|
||||
osc.type = data.type;
|
||||
|
||||
if (index === seq.length - 1) {
|
||||
osc.onended = () => resolve();
|
||||
}
|
||||
|
||||
osc.start(context.currentTime + data.begin);
|
||||
|
||||
osc.stop(context.currentTime + data.end);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private readLivefeedMap(): void {
|
||||
try {
|
||||
let lfm: { [key: number]: { [key: number]: boolean } } = {};
|
||||
|
||||
let store = window?.localStorage?.getItem(`${RdioScannerService.LOCAL_STORAGE_KEY_LFM}-${this.instanceId}`);
|
||||
|
||||
if (store !== null) {
|
||||
lfm = JSON.parse(store);
|
||||
|
||||
} else {
|
||||
store = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY_LEGACY);
|
||||
|
||||
if (store !== null) {
|
||||
lfm = JSON.parse(store);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(lfm ?? {}).forEach((sys: string) => {
|
||||
Object.keys(lfm[+sys]).forEach((tg) => {
|
||||
if (!this.livefeedMap[+sys]) this.livefeedMap[+sys] = {};
|
||||
if (!this.livefeedMap[+sys][+tg]) this.livefeedMap[+sys][+tg] = {} as RdioScannerLivefeed;
|
||||
this.livefeedMap[+sys][+tg].active = lfm[+sys][+tg];
|
||||
});
|
||||
});
|
||||
|
||||
} catch (_) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildCategories(): void {
|
||||
this.categories = Object.keys(this.config.groups || []).map((label) => {
|
||||
const allOff = Object.keys(this.config.groups[label]).map((sys) => +sys)
|
||||
.every((sys: number) => this.config.groups[label] && this.config.groups[label][sys]
|
||||
.every((tg) => this.livefeedMap[sys] && !this.livefeedMap[sys][tg]));
|
||||
.every((tg) => this.livefeedMap[sys] && !this.livefeedMap[sys][tg].active));
|
||||
|
||||
const allOn = Object.keys(this.config.groups[label]).map((sys) => +sys)
|
||||
.every((sys: number) => this.config.groups[label] && this.config.groups[label][sys]
|
||||
.every((tg) => this.livefeedMap[sys] && this.livefeedMap[sys][tg]));
|
||||
.every((tg) => this.livefeedMap[sys] && this.livefeedMap[sys][tg].active));
|
||||
|
||||
const status = allOff ? RdioScannerCategoryStatus.Off : allOn ? RdioScannerCategoryStatus.On : RdioScannerCategoryStatus.Partial;
|
||||
|
||||
return { label, status, type: RdioScannerCategoryType.Group };
|
||||
})
|
||||
|
||||
if (this.config.tagsToggle) {
|
||||
this.categories = this.categories.concat(Object.keys(this.config.tags || []).map((label) => {
|
||||
const allOff = Object.keys(this.config.tags[label]).map((sys) => +sys)
|
||||
.every((sys: number) => this.config.tags[label] && this.config.tags[label][sys]
|
||||
.every((tg) => this.livefeedMap[sys] && !this.livefeedMap[sys][tg]));
|
||||
|
||||
const allOn = Object.keys(this.config.tags[label]).map((sys) => +sys)
|
||||
.every((sys: number) => this.config.tags[label] && this.config.tags[label][sys]
|
||||
.every((tg) => this.livefeedMap[sys] && this.livefeedMap[sys][tg]));
|
||||
|
||||
const status = allOff ? RdioScannerCategoryStatus.Off : allOn ? RdioScannerCategoryStatus.On : RdioScannerCategoryStatus.Partial;
|
||||
|
||||
return { label, status, type: RdioScannerCategoryType.Tag };
|
||||
}))
|
||||
}
|
||||
|
||||
this.categories.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
private rebuildLivefeedMap(): void {
|
||||
const livefeedMap = this.config.systems.reduce((sysMap, sys) => {
|
||||
const lfm = this.config.systems.reduce((sysMap, sys) => {
|
||||
sysMap[sys.id] = sys.talkgroups.reduce((tgMap, tg) => {
|
||||
const state = this.livefeedMap && this.livefeedMap[sys.id] && this.livefeedMap[sys.id][tg.id];
|
||||
const group = this.categories.find((cat) => tg.groups.includes(cat.label));
|
||||
const tag = this.categories.find((cat) => cat.label === tg.tag);
|
||||
|
||||
tgMap[tg.id] = typeof state === 'boolean' ? state : true;
|
||||
tgMap[tg.id] = (this.livefeedMap[sys.id] && this.livefeedMap[sys.id][tg.id])
|
||||
? this.livefeedMap[sys.id][tg.id]
|
||||
: {
|
||||
active: !(group?.status === RdioScannerCategoryStatus.Off || tag?.status === RdioScannerCategoryStatus.Off),
|
||||
} as RdioScannerLivefeed;
|
||||
|
||||
return tgMap;
|
||||
}, sysMap[sys.id] || {});
|
||||
|
||||
}, sysMap[sys.id] || {} as { [key: number]: RdioScannerLivefeed });
|
||||
return sysMap;
|
||||
}, {} as RdioScannerLivefeedMap);
|
||||
|
||||
if (this.livefeedMapPriorToHoldSystem != null) {
|
||||
this.livefeedMapPriorToHoldSystem = livefeedMap;
|
||||
this.livefeedMapPriorToHoldSystem = lfm;
|
||||
} else if (this.livefeedMapPriorToHoldTalkgroup != null) {
|
||||
this.livefeedMapPriorToHoldTalkgroup = livefeedMap;
|
||||
this.livefeedMapPriorToHoldTalkgroup = lfm;
|
||||
} else {
|
||||
this.livefeedMap = livefeedMap;
|
||||
this.livefeedMap = lfm;
|
||||
}
|
||||
|
||||
this.storeLivefeedMap();
|
||||
this.saveLivefeedMap();
|
||||
|
||||
this.rebuildCategories();
|
||||
}
|
||||
|
|
@ -969,17 +1195,16 @@ export class RdioScannerService implements OnDestroy {
|
|||
this.openWebsocket();
|
||||
}
|
||||
|
||||
private restoreLivefeed(): void {
|
||||
const map = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY);
|
||||
private saveLivefeedMap(): void {
|
||||
const lfm = Object.keys(this.livefeedMap).reduce((sysMap: { [key: number]: { [key: number]: boolean } }, sys: string) => {
|
||||
sysMap[+sys] = Object.keys(this.livefeedMap[+sys]).reduce((tgMap: { [key: number]: boolean }, tg: string) => {
|
||||
tgMap[+tg] = this.livefeedMap[+sys][+tg].active;
|
||||
return tgMap;
|
||||
}, {});
|
||||
return sysMap;
|
||||
}, {});
|
||||
|
||||
if (map) {
|
||||
try {
|
||||
this.livefeedMap = JSON.parse(map);
|
||||
|
||||
} catch (err) {
|
||||
this.livefeedMap = {};
|
||||
}
|
||||
}
|
||||
window?.localStorage?.setItem(`${RdioScannerService.LOCAL_STORAGE_KEY_LFM}-${this.instanceId}`, JSON.stringify(lfm));
|
||||
}
|
||||
|
||||
private sendtoWebsocket(command: string, payload?: unknown, flags?: string): void {
|
||||
|
|
@ -998,9 +1223,6 @@ export class RdioScannerService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
private storeLivefeedMap(): void {
|
||||
window?.localStorage?.setItem(RdioScannerService.LOCAL_STORAGE_KEY, JSON.stringify(this.livefeedMap));
|
||||
}
|
||||
|
||||
private transformCall(call: RdioScannerCall): RdioScannerCall {
|
||||
if (call && Array.isArray(this.config?.systems)) {
|
||||
|
|
@ -1013,6 +1235,10 @@ export class RdioScannerService implements OnDestroy {
|
|||
if (call.talkgroupData?.frequency) {
|
||||
call.frequency = call.talkgroupData.frequency;
|
||||
}
|
||||
|
||||
call.groupsData = this.config.groupsData.filter((gd) => call.talkgroupData?.groups.some((l) => l === gd.label));
|
||||
|
||||
call.tagData = this.config.tagsData.find((td) => td.label === call.talkgroupData?.tag);
|
||||
}
|
||||
|
||||
return call;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -17,19 +17,19 @@
|
|||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
import { Subscription } from "rxjs";
|
||||
|
||||
export interface RdioScannerAvoidOptions {
|
||||
all?: boolean;
|
||||
call?: RdioScannerCall;
|
||||
minutes?: number;
|
||||
status?: boolean;
|
||||
system?: RdioScannerSystem;
|
||||
talkgroup?: RdioScannerTalkgroup;
|
||||
status?: boolean;
|
||||
}
|
||||
|
||||
export interface RdioScannerBeep {
|
||||
begin: number;
|
||||
end: number;
|
||||
frequency: number;
|
||||
type: OscillatorType;
|
||||
export interface RdioScannerAlerts {
|
||||
[key: string]: RdioScannerOscillatorData[];
|
||||
}
|
||||
|
||||
export enum RdioScannerBeepStyle {
|
||||
|
|
@ -46,18 +46,23 @@ export interface RdioScannerCall {
|
|||
audioName?: string;
|
||||
audioType?: string;
|
||||
dateTime: Date;
|
||||
delayed: boolean;
|
||||
frequencies?: RdioScannerCallFrequency[];
|
||||
frequency?: number;
|
||||
groupsData?: RdioScannerGroupData[];
|
||||
id: number;
|
||||
patches: number[];
|
||||
source?: number;
|
||||
sources?: RdioScannerCallSource[];
|
||||
system: number;
|
||||
tagData?: RdioScannerTagData;
|
||||
talkgroup: number;
|
||||
talkgroupData?: RdioScannerTalkgroup;
|
||||
systemData?: RdioScannerSystem;
|
||||
}
|
||||
|
||||
export interface RdioScannerCallFrequency {
|
||||
dbm?: number;
|
||||
errorCount?: number;
|
||||
freq?: number;
|
||||
len?: number;
|
||||
|
|
@ -88,12 +93,19 @@ export enum RdioScannerCategoryType {
|
|||
}
|
||||
|
||||
export interface RdioScannerConfig {
|
||||
alerts?: RdioScannerAlerts;
|
||||
branding?: string;
|
||||
dimmerDelay: number | false;
|
||||
email?: string;
|
||||
groups: { [key: string]: { [key: number]: number[] } };
|
||||
keypadBeeps: RdioScannerKeypadBeeps | false;
|
||||
groupsData: RdioScannerGroupData[];
|
||||
keypadBeeps: RdioScannerKeypadBeeps | undefined;
|
||||
playbackGoesLive: boolean;
|
||||
showListenersCount: boolean;
|
||||
systems: RdioScannerSystem[];
|
||||
tags: { [key: string]: { [key: number]: number[] } };
|
||||
tagsToggle: boolean;
|
||||
tagsData: RdioScannerTagData[];
|
||||
time12hFormat: boolean;
|
||||
}
|
||||
|
||||
export interface RdioScannerEvent {
|
||||
|
|
@ -105,6 +117,7 @@ export interface RdioScannerEvent {
|
|||
holdSys?: boolean;
|
||||
holdTg?: boolean;
|
||||
linked?: boolean;
|
||||
listeners?: number;
|
||||
livefeedMode?: RdioScannerLivefeedMode;
|
||||
map?: RdioScannerLivefeedMap;
|
||||
pause?: boolean;
|
||||
|
|
@ -115,15 +128,28 @@ export interface RdioScannerEvent {
|
|||
tooMany?: boolean;
|
||||
}
|
||||
|
||||
export interface RdioScannerGroupData {
|
||||
id: number;
|
||||
alert?: string;
|
||||
label?: string;
|
||||
led?: string;
|
||||
}
|
||||
|
||||
export interface RdioScannerKeypadBeeps {
|
||||
[RdioScannerBeepStyle.Activate]: RdioScannerBeep[];
|
||||
[RdioScannerBeepStyle.Deactivate]: RdioScannerBeep[];
|
||||
[RdioScannerBeepStyle.Denied]: RdioScannerBeep[];
|
||||
[RdioScannerBeepStyle.Activate]: RdioScannerOscillatorData[];
|
||||
[RdioScannerBeepStyle.Deactivate]: RdioScannerOscillatorData[];
|
||||
[RdioScannerBeepStyle.Denied]: RdioScannerOscillatorData[];
|
||||
}
|
||||
|
||||
export interface RdioScannerLivefeed {
|
||||
active: boolean;
|
||||
minutes: number | undefined;
|
||||
timer: Subscription | undefined;
|
||||
}
|
||||
|
||||
export interface RdioScannerLivefeedMap {
|
||||
[key: string]: {
|
||||
[key: string]: boolean;
|
||||
[key: number]: {
|
||||
[key: number]: RdioScannerLivefeed;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +159,13 @@ export enum RdioScannerLivefeedMode {
|
|||
Playback = 'playback',
|
||||
}
|
||||
|
||||
export interface RdioScannerOscillatorData {
|
||||
begin: number;
|
||||
end: number;
|
||||
frequency: number;
|
||||
type: OscillatorType;
|
||||
}
|
||||
|
||||
export interface RdioScannerPlaybackList {
|
||||
count: number;
|
||||
dateStart: Date;
|
||||
|
|
@ -150,28 +183,43 @@ export interface RdioScannerSearchOptions {
|
|||
system?: number;
|
||||
tag?: string;
|
||||
talkgroup?: number;
|
||||
unit?: number;
|
||||
}
|
||||
|
||||
export interface RdioScannerSystem {
|
||||
id: number;
|
||||
alert?: string;
|
||||
label: string;
|
||||
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'red' | 'white' | 'yellow';
|
||||
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow';
|
||||
order?: number;
|
||||
talkgroups: RdioScannerTalkgroup[];
|
||||
type?: string;
|
||||
units: RdioScannerUnit[];
|
||||
}
|
||||
|
||||
export interface RdioScannerTagData {
|
||||
id: number;
|
||||
alert?: string;
|
||||
label?: string;
|
||||
led?: string;
|
||||
}
|
||||
|
||||
export interface RdioScannerTalkgroup {
|
||||
alert?: string;
|
||||
frequency?: number;
|
||||
group: string;
|
||||
groups: string[];
|
||||
id: number;
|
||||
label: string;
|
||||
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'red' | 'white' | 'yellow';
|
||||
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow';
|
||||
name: string;
|
||||
tag: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface RdioScannerUnit {
|
||||
id: number;
|
||||
label: string;
|
||||
unitRef: number;
|
||||
unitFrom: number;
|
||||
unitTo: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,158 +1,181 @@
|
|||
<mat-card class="rdio-list">
|
||||
<mat-table [dataSource]="results">
|
||||
<ng-container matColumnDef="control">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<button *ngIf="downloadMode.checked && row" mat-icon-button (click)="download(+row.id)">
|
||||
<mat-icon>save_alt</mat-icon>
|
||||
</button>
|
||||
<button *ngIf="row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending"
|
||||
mat-icon-button (click)="play(+row.id)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button *ngIf="row && !downloadMode.checked && row?.id == callPending" mat-icon-button>
|
||||
<mat-icon class="spin">cached</mat-icon>
|
||||
</button>
|
||||
<button *ngIf="row && !downloadMode.checked && row?.id == call?.id" mat-icon-button (click)="stop()">
|
||||
<mat-icon>stop</mat-icon>
|
||||
</button>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Date</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.dateTime | date:'MM/dd' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="time">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Time</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.dateTime | date:'HH:mm' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="system">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>System</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.systemData?.label || row?.system }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="alpha">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Talkgroup</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.talkgroupData?.label || row?.talkgroup }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="name">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Name</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.talkgroupData?.name }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="['control', 'date', 'time', 'system', 'alpha', 'name']">
|
||||
</mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: ['control', 'date', 'time', 'system', 'alpha', 'name']">
|
||||
</mat-row>
|
||||
</mat-table>
|
||||
<mat-progress-bar color="primary" [mode]="resultsPending ? 'query' : 'determinate'">
|
||||
</mat-progress-bar>
|
||||
<div class="paginator">
|
||||
<mat-slide-toggle #downloadMode color="primary" labelPosition="before">
|
||||
<mat-card appearance="outlined" class="rdio-list">
|
||||
<mat-table [dataSource]="results">
|
||||
<ng-container matColumnDef="control">
|
||||
<mat-header-cell *matHeaderCellDef></mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
@if (downloadMode.checked && row) {
|
||||
<button mat-icon-button (click)="download(+row.id)">
|
||||
<mat-icon>save_alt</mat-icon>
|
||||
</mat-slide-toggle>
|
||||
<mat-paginator [disabled]="livefeedPlayback || resultsPending" [length]="playbackList?.count"
|
||||
[hidePageSize]="true" [pageSize]="results.value.length" [showFirstLastButtons]="true"
|
||||
(page)="refreshResults()">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
@if (row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending) {
|
||||
<button
|
||||
mat-icon-button (click)="play(+row.id)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (row && !downloadMode.checked && row?.id == callPending) {
|
||||
<button mat-icon-button>
|
||||
<mat-icon class="spin">cached</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (row && !downloadMode.checked && row?.id == call?.id) {
|
||||
<button mat-icon-button (click)="stop()">
|
||||
<mat-icon>stop</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Date</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.dateTime | date:'MM/dd' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="time">
|
||||
<mat-header-cell *matHeaderCellDef [ngClass]="{'time12h': time12h}">
|
||||
<span>Time</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row" [ngClass]="{'time12h': time12h}">
|
||||
<span>{{ row?.dateTime | date: time12h ? 'h:mm a' : 'HH:mm' }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="system">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>System</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.systemData?.label || row?.system }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="alpha">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Talkgroup</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.talkgroupData?.label || row?.talkgroup }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="name">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<span>Name</span>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span>{{ row?.talkgroupData?.name }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="['control', 'date', 'time', 'system', 'alpha', 'name']">
|
||||
</mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: ['control', 'date', 'time', 'system', 'alpha', 'name']">
|
||||
</mat-row>
|
||||
</mat-table>
|
||||
<mat-progress-bar color="primary" [mode]="resultsPending ? 'query' : 'determinate'">
|
||||
</mat-progress-bar>
|
||||
<div class="paginator">
|
||||
<mat-slide-toggle #downloadMode color="primary" labelPosition="before">
|
||||
<mat-icon>save_alt</mat-icon>
|
||||
</mat-slide-toggle>
|
||||
<mat-paginator [disabled]="livefeedPlayback || resultsPending" [length]="playbackList?.count"
|
||||
[hidePageSize]="true" [pageSize]="results.value.length" [showFirstLastButtons]="true"
|
||||
(page)="refreshResults()">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
</mat-card>
|
||||
<mat-card class="rdio-filters">
|
||||
<form autocomplete="off" [formGroup]="form">
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Sort order
|
||||
</mat-label>
|
||||
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="1">
|
||||
Ascending
|
||||
</mat-option>
|
||||
<mat-option [value]="-1">
|
||||
Descending
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Date
|
||||
</mat-label>
|
||||
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
|
||||
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
System
|
||||
</mat-label>
|
||||
<mat-select formControlName="system" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Systems
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let system of optionsSystem; index as i" [value]="i">
|
||||
{{ system }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Talkgroup
|
||||
</mat-label>
|
||||
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Talkgroups
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let talkgroup of optionsTalkgroup; index as i" [value]="i">
|
||||
{{ talkgroup }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Group
|
||||
</mat-label>
|
||||
<mat-select formControlName="group" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Groups
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let group of optionsGroup; index as i" [value]="i">
|
||||
{{ group }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Tag
|
||||
</mat-label>
|
||||
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Tags
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let tag of optionsTag; index as i" [value]="i">
|
||||
{{ tag }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="reset">
|
||||
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<mat-card appearance="outlined" class="rdio-filters">
|
||||
<form autocomplete="off" [formGroup]="form">
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Sort order
|
||||
</mat-label>
|
||||
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="1">
|
||||
Ascending
|
||||
</mat-option>
|
||||
<mat-option [value]="-1">
|
||||
Descending
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Date
|
||||
</mat-label>
|
||||
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
|
||||
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
System
|
||||
</mat-label>
|
||||
<mat-select formControlName="system" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Systems
|
||||
</mat-option>
|
||||
@for (system of optionsSystem; track system; let i = $index) {
|
||||
<mat-option [value]="i">
|
||||
{{ system }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Talkgroup
|
||||
</mat-label>
|
||||
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Talkgroups
|
||||
</mat-option>
|
||||
@for (talkgroup of optionsTalkgroup; track talkgroup; let i = $index) {
|
||||
<mat-option [value]="i">
|
||||
{{ talkgroup }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Group
|
||||
</mat-label>
|
||||
<mat-select formControlName="group" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Groups
|
||||
</mat-option>
|
||||
@for (group of optionsGroup; track group; let i = $index) {
|
||||
<mat-option [value]="i">
|
||||
{{ group }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Tag
|
||||
</mat-label>
|
||||
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
|
||||
<mat-option [value]="-1">
|
||||
All Tags
|
||||
</mat-option>
|
||||
@for (tag of optionsTag; track tag; let i = $index) {
|
||||
<mat-option [value]="i">
|
||||
{{ tag }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
Unit
|
||||
</mat-label>
|
||||
<input type="number" matInput>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
<div class="reset">
|
||||
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card>
|
||||
|
|
@ -5,13 +5,18 @@
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.rdio-filters,
|
||||
.rdio-list {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.rdio-filters form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.mat-form-field {
|
||||
@for $i from 1 through 6 {
|
||||
.mat-mdc-form-field {
|
||||
@for $i from 1 through 7 {
|
||||
&:nth-of-type(#{$i}) {
|
||||
order: #{$i};
|
||||
}
|
||||
|
|
@ -33,8 +38,8 @@
|
|||
overflow: hidden;
|
||||
padding: 0;
|
||||
|
||||
.mat-cell,
|
||||
.mat-header-cell {
|
||||
.mat-mdc-cell,
|
||||
.mat-mdc-header-cell {
|
||||
&:first-of-type {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
|
@ -44,12 +49,19 @@
|
|||
}
|
||||
|
||||
&:nth-child(1) {
|
||||
flex: 0 0 40px;
|
||||
flex: 0 0 48px;
|
||||
}
|
||||
|
||||
&:nth-child(2),
|
||||
&:nth-child(3) {
|
||||
flex: 0 0 50px;
|
||||
&:nth-child(2) {
|
||||
flex: 0 0 55px;
|
||||
}
|
||||
|
||||
&:nth-child(3):not(.time12h) {
|
||||
flex: 0 0 53px;
|
||||
}
|
||||
|
||||
&:nth-child(3).time12h {
|
||||
flex: 0 0 78px;
|
||||
}
|
||||
|
||||
&:nth-child(4),
|
||||
|
|
@ -64,12 +76,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.mdc-data-table__header-cell,
|
||||
.mdc-data-table__cell {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.paginator {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding-left: 8px;;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
|
|
@ -91,13 +108,19 @@
|
|||
}
|
||||
|
||||
@media (max-width: 459px) {
|
||||
.rdio-filters form .mat-form-field {
|
||||
.rdio-filters form .mat-mdc-form-field {
|
||||
flex: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 460px) {
|
||||
.rdio-list .mat-mdc-slide-toggle .mat-icon {
|
||||
padding: 0 16px 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 460px) and (max-width: 979px) {
|
||||
.rdio-filters form .mat-form-field {
|
||||
.rdio-filters form .mat-mdc-form-field {
|
||||
flex: calc(50% - 1em);
|
||||
|
||||
&:nth-of-type(2),
|
||||
|
|
@ -109,7 +132,7 @@
|
|||
}
|
||||
|
||||
@media (min-width: 980px) {
|
||||
.rdio-filters form .mat-form-field {
|
||||
.rdio-filters form .mat-mdc-form-field {
|
||||
flex: calc(100% / 3 - 2em);
|
||||
|
||||
&:nth-of-type(2),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2019-2021 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
|
|
@ -37,19 +37,13 @@ import { RdioScannerService } from '../rdio-scanner.service';
|
|||
selector: 'rdio-scanner-search',
|
||||
styleUrls: ['./search.component.scss'],
|
||||
templateUrl: './search.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class RdioScannerSearchComponent implements OnDestroy {
|
||||
call: RdioScannerCall | undefined;
|
||||
callPending: number | undefined;
|
||||
|
||||
form = this.ngFormBuilder.group({
|
||||
date: [null],
|
||||
group: [-1],
|
||||
sort: [-1],
|
||||
system: [-1],
|
||||
tag: [-1],
|
||||
talkgroup: [-1],
|
||||
});
|
||||
form: FormGroup;
|
||||
|
||||
livefeedOnline = false;
|
||||
livefeedPlayback = false;
|
||||
|
|
@ -66,9 +60,11 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
results = new BehaviorSubject(new Array<RdioScannerCall | null>(10));
|
||||
resultsPending = false;
|
||||
|
||||
time12h = false;
|
||||
|
||||
private config: RdioScannerConfig | undefined;
|
||||
|
||||
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
|
||||
private eventSubscription;
|
||||
|
||||
private limit = 200;
|
||||
|
||||
|
|
@ -80,7 +76,27 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
private rdioScannerService: RdioScannerService,
|
||||
private ngChangeDetectorRef: ChangeDetectorRef,
|
||||
private ngFormBuilder: FormBuilder,
|
||||
) { }
|
||||
) {
|
||||
this.form = this.ngFormBuilder.group<{
|
||||
date: string | null;
|
||||
group: number;
|
||||
sort: number;
|
||||
system: number;
|
||||
tag: number;
|
||||
talkgroup: number;
|
||||
unit: number;
|
||||
}>({
|
||||
date: null,
|
||||
group: -1,
|
||||
sort: -1,
|
||||
system: -1,
|
||||
tag: -1,
|
||||
talkgroup: -1,
|
||||
unit: -1,
|
||||
});
|
||||
|
||||
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
|
||||
}
|
||||
|
||||
download(id: number): void {
|
||||
this.rdioScannerService.loadAndDownload(id);
|
||||
|
|
@ -119,7 +135,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
this.optionsSystem = this.config.systems
|
||||
.filter((system) => {
|
||||
const group = selectedGroup === undefined ||
|
||||
system.talkgroups.some((talkgroup) => talkgroup.group === selectedGroup);
|
||||
system.talkgroups.some((talkgroup) => talkgroup.groups.includes(selectedGroup));
|
||||
const tag = selectedTag === undefined ||
|
||||
system.talkgroups.some((talkgroup) => talkgroup.tag === selectedTag);
|
||||
return group && tag;
|
||||
|
|
@ -131,7 +147,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
: selectedSystem.talkgroups
|
||||
.filter((talkgroup) => {
|
||||
const group = selectedGroup == undefined ||
|
||||
talkgroup.group === selectedGroup;
|
||||
talkgroup.groups.includes(selectedGroup);
|
||||
const tag = selectedTag == undefined ||
|
||||
talkgroup.tag === selectedTag;
|
||||
return group && tag;
|
||||
|
|
@ -141,14 +157,14 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
this.optionsGroup = Object.keys(this.config.groups)
|
||||
.filter((group) => {
|
||||
const system: boolean = selectedSystem === undefined ||
|
||||
selectedSystem.talkgroups.some((talkgroup) => talkgroup.group === group)
|
||||
selectedSystem.talkgroups.some((talkgroup) => talkgroup.groups.includes(group))
|
||||
const talkgroup: boolean = selectedTalkgroup === undefined ||
|
||||
selectedTalkgroup.group === group;
|
||||
selectedTalkgroup.groups.includes(group);
|
||||
const tag: boolean = selectedTag === undefined ||
|
||||
(selectedTalkgroup !== undefined && selectedTalkgroup.tag === selectedTag) ||
|
||||
(this.config !== undefined && this.config.systems
|
||||
.flatMap((system) => system.talkgroups)
|
||||
.some((talkgroup) => talkgroup.group === group && talkgroup.tag === selectedTag))
|
||||
.some((talkgroup) => talkgroup.groups.includes(group) && talkgroup.tag === selectedTag))
|
||||
return system && talkgroup && tag;
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
|
@ -160,10 +176,10 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
const talkgroup: boolean = selectedTalkgroup === undefined ||
|
||||
selectedTalkgroup.tag === tag;
|
||||
const group: boolean = selectedGroup === undefined ||
|
||||
(selectedTalkgroup !== undefined && selectedTalkgroup.group === selectedGroup) ||
|
||||
(selectedTalkgroup !== undefined && selectedTalkgroup.groups.includes(selectedGroup)) ||
|
||||
(this.config !== undefined && this.config.systems
|
||||
.flatMap((system) => system.talkgroups)
|
||||
.some((talkgroup) => talkgroup.tag === tag && talkgroup.group === selectedGroup))
|
||||
.some((talkgroup) => talkgroup.tag === tag && talkgroup.groups.includes(selectedGroup)))
|
||||
return system && talkgroup && group;
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
|
@ -207,6 +223,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
system: -1,
|
||||
tag: -1,
|
||||
talkgroup: -1,
|
||||
unit: -1,
|
||||
});
|
||||
|
||||
this.paginator?.firstPage();
|
||||
|
|
@ -228,14 +245,14 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
const options: RdioScannerSearchOptions = {
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
sort: this.form.value.sort,
|
||||
sort: this.form.get('sort')?.value ?? -1,
|
||||
};
|
||||
|
||||
if (typeof this.form.value.date === 'string') {
|
||||
options.date = new Date(Date.parse(this.form.value.date));
|
||||
}
|
||||
|
||||
if (this.form.value.group >= 0) {
|
||||
if ((this.form.get('group')?.value ?? -1) >= 0) {
|
||||
const group = this.getSelectedGroup();
|
||||
|
||||
if (group) {
|
||||
|
|
@ -243,7 +260,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.form.value.system >= 0) {
|
||||
if ((this.form.get('system')?.value ?? -1) >= 0) {
|
||||
const system = this.getSelectedSystem();
|
||||
|
||||
if (system) {
|
||||
|
|
@ -251,7 +268,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.form.value.tag >= 0) {
|
||||
if ((this.form.get('tag')?.value ?? -1) >= 0) {
|
||||
const tag = this.getSelectedTag();
|
||||
|
||||
if (tag) {
|
||||
|
|
@ -259,7 +276,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.form.value.talkgroup >= 0) {
|
||||
if ((this.form.get('talkgroup')?.value ?? -1) >= 0) {
|
||||
const talkgroup = this.getSelectedTalkgroup();
|
||||
|
||||
if (talkgroup) {
|
||||
|
|
@ -291,7 +308,7 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
const index = this.results.value.findIndex((call) => call?.id === this.callPending);
|
||||
|
||||
if (index === -1) {
|
||||
if (this.form.value.sort === -1) {
|
||||
if (this.form.get('sort')?.value === -1) {
|
||||
this.paginator?.previousPage();
|
||||
|
||||
} else {
|
||||
|
|
@ -311,7 +328,8 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
this.optionsGroup = Object.keys(this.config?.groups || []).sort((a, b) => a.localeCompare(b));
|
||||
this.optionsSystem = (this.config?.systems || []).map((system) => system.label);
|
||||
this.optionsTag = Object.keys(this.config?.tags || []).sort((a, b) => a.localeCompare(b));
|
||||
this.optionsTalkgroup = [];
|
||||
|
||||
this.time12h = this.config?.time12hFormat || false;
|
||||
}
|
||||
|
||||
if ('livefeedMode' in event) {
|
||||
|
|
@ -342,22 +360,22 @@ export class RdioScannerSearchComponent implements OnDestroy {
|
|||
}
|
||||
|
||||
private getSelectedGroup(): string | undefined {
|
||||
return this.optionsGroup[this.form.value.group];
|
||||
return this.optionsGroup[this.form.get('group')?.value ?? -1];
|
||||
}
|
||||
|
||||
private getSelectedSystem(): RdioScannerSystem | undefined {
|
||||
return this.config?.systems.find((system) => system.label === this.optionsSystem[this.form.value.system]);
|
||||
return this.config?.systems.find((system) => system.label === this.optionsSystem[this.form.get('system')?.value ?? -1]);
|
||||
}
|
||||
|
||||
private getSelectedTag(): string | undefined {
|
||||
return this.optionsTag[this.form.value.tag];
|
||||
return this.optionsTag[this.form.get('tag')?.value ?? -1];
|
||||
}
|
||||
|
||||
private getSelectedTalkgroup(): RdioScannerTalkgroup | undefined {
|
||||
const system = this.getSelectedSystem();
|
||||
|
||||
return system
|
||||
? system.talkgroups.find((talkgroup) => talkgroup.label === this.optionsTalkgroup[this.form.value.talkgroup])
|
||||
? system.talkgroups.find((talkgroup) => talkgroup.label === this.optionsTalkgroup[this.form.get('talkgroup')?.value ?? -1])
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue