From ab11e9f660a6d7e6da895baef594d27b6c53d406 Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 10 Oct 2026 19:04:04 -0600 Subject: [PATCH] Search and import repeaters from the RepeaterBook API (#136) The app is approved as a RepeaterBook distributed app. Each user pastes their own rbuapp_ token once; it lives in the OS credential store (Keychain / Credential Manager / Secret Service) via the keyring crate, never in SQLite, settings, logs or a backup, and is sent only in the X-RB-App-Token header over HTTPS with redirects refused. Every request goes through one path in commands/rb_api.rs that enforces what the application promised: the exact approved User-Agent, one request in flight, 2 s spacing, 20 an hour, an identical request within 10 minutes answered from memory, 429/5xx backoff honouring Retry-After else 30/60/120 s then stop, and 401/403 stopping at once. - Search one US state, Canadian province or country outside North America, narrowed by multi-select band / mode / emergency service and by county, city, call sign, on-air, open, distance from a library town and "hide ones I already have". More than 1,000 matches shows nothing. - Import at most 500 at a time; after an import the results stay up and "select all" picks the next batch not yet imported. - API records are source 'repeaterbook_api' keyed by RepeaterBook's own State ID + Repeater ID; re-import refreshes, a file-imported copy is taken over rather than duplicated, the source can't be edited. - Single-record Refresh, a 12-month stale prompt, and "Remove RepeaterBook data" deleting every API record and the token together. - API records are left out of the channel export file and refused from a channel backup; CHIRP exports carry "RepeaterBook.com" in the comment. - "Data courtesy of RepeaterBook.com" in the dialog, the import summary, the detail panel, About, and the README. docs/repeaterbook-api-commitments.md records the submitted application, with the 2,000-record library cap removed (agreed with RepeaterBook) and the api.export_row lines corrected to say what the code does. Live search verified against RepeaterBook in the dev build (Michigan). Co-Authored-By: Claude Opus 5.5 --- README.md | 43 +- docs/repeaterbook-api-commitments.md | 203 ++ package-lock.json | 14 +- package.json | 1 + src-tauri/Cargo.lock | 725 ++++++- src-tauri/Cargo.toml | 13 + src-tauri/capabilities/default.json | 6 +- src-tauri/src/commands/channel_io.rs | 94 +- src-tauri/src/commands/channels.rs | 18 +- src-tauri/src/commands/csv_map.rs | 2 +- src-tauri/src/commands/export.rs | 5 + src-tauri/src/commands/import.rs | 64 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/rb_api.rs | 1905 +++++++++++++++++ src-tauri/src/commands/talkgroups.rs | 4 +- src-tauri/src/lib.rs | 11 + src-tauri/src/models.rs | 5 + .../channels/ChannelDetailPanel.tsx | 86 +- src/components/channels/ChannelGrid.tsx | 3 +- src/components/channels/ImportDialog.tsx | 5 +- .../channels/RepeaterBookDialog.tsx | 694 ++++++ src/lib/api.ts | 24 +- src/lib/repeaterbook.tsx | 73 + src/lib/types.ts | 79 + src/pages/Channels.tsx | 78 +- src/pages/Settings.tsx | 174 +- 26 files changed, 4270 insertions(+), 60 deletions(-) create mode 100644 docs/repeaterbook-api-commitments.md create mode 100644 src-tauri/src/commands/rb_api.rs create mode 100644 src/components/channels/RepeaterBookDialog.tsx create mode 100644 src/lib/repeaterbook.tsx diff --git a/README.md b/README.md index bead514..ebd4383 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,10 @@ bench. If you own one of these and want to help measure or test it, say so on it ## Features - **Master channel database** — one library of repeaters/channels reused across every codeplug. -- **Import** from RepeaterBook, CHIRP-style CSV, or manual entry with automatic lat/lon geocoding - from a city name. +- **Search RepeaterBook** from inside the app with your own free RepeaterBook token, and import + the repeaters you pick — see [Searching RepeaterBook](#searching-repeaterbook). +- **Import** from a RepeaterBook export file, CHIRP-style CSV, or manual entry with automatic + lat/lon geocoding from a city name. - **Zones, scan lists, and talkgroups** with per-channel scan-list assignment. - **DMR contacts** — a local mirror of the [radioid.net](https://radioid.net) DMR-ID → callsign database, with prioritized export and (on the D890UV) direct caller-ID database programming. @@ -95,6 +97,43 @@ bench. If you own one of these and want to help measure or test it, say so on it - **CSV / JSON export** for radios without a direct driver. - **Whole-database backup & restore** to a single portable file. +## Searching RepeaterBook + +Repeater data courtesy of [RepeaterBook.com](https://www.repeaterbook.com). + +The **RepeaterBook** button on the Channels page searches RepeaterBook directly, replacing the +download-a-file-then-import step. It works the way RepeaterBook asks distributed apps to work: + +- **Your own token.** Sign in to RepeaterBook, open *My Dashboard → API Access → API Apps & + Tokens*, generate a token for CodePlug Magic (it starts with `rbuapp_`), and paste it into + **Settings → RepeaterBook**. It is kept in your operating system's credential store (Keychain, + Credential Manager or Secret Service), sent only to RepeaterBook over HTTPS, and never written + to the database, a log, or a backup. +- **One area per search** — a US state or territory, a Canadian province, or one country outside + North America — optionally narrowed by band, mode and the filters below. Pick as many bands, + modes or services as you like (2 m and 70 cm, DMR and D-STAR); a repeater matching any of them + is shown. A search that matches more than 1,000 + repeaters shows nothing; narrow it and search again. +- **Nothing is saved until you pick it**, at most 500 per import. Bigger searches import in + batches: after each import, "select all" picks up the next 500 not yet imported. +- **More filters** — county, city, call sign, on the air only, open only, an emergency service + (ARES, RACES, SKYWARN, CANWARN), distance from a town in your library, and hiding repeaters + you already have. They narrow the answer RepeaterBook already sent, so changing them within + 10 minutes doesn't send another request. +- **Gentle on RepeaterBook.** One request at a time, at least 2 seconds apart, at most 20 an + hour. The same search again within 10 minutes is answered from memory. If RepeaterBook says + it is busy, the app waits (30 s, then 60 s, then 120 s, or as long as RepeaterBook asks) and + then stops. +- **Kept current, not hoarded.** Re-importing a repeater refreshes it rather than duplicating it; + each repeater's detail panel can refresh that one record; repeaters not refreshed in a year + are flagged so you can refresh or delete them. +- **For your radios only.** RepeaterBook repeaters leave the app only as radio programming. The + channel export file leaves them out, and CHIRP exports mark them "RepeaterBook.com" in the + comment. **Settings → Remove RepeaterBook data** deletes every RepeaterBook repeater and your + token in one step. + +Corrections to repeater listings go to RepeaterBook itself, not to this app. + ## Download Prebuilt installers for macOS, Windows, and Linux are published on the diff --git a/docs/repeaterbook-api-commitments.md b/docs/repeaterbook-api-commitments.md new file mode 100644 index 0000000..a131063 --- /dev/null +++ b/docs/repeaterbook-api-commitments.md @@ -0,0 +1,203 @@ +# RepeaterBook API: what we told RepeaterBook + +This file holds the answers submitted to RepeaterBook's API app application +(distributed-app lane) on 2026-09-18. It is a resubmission; the first +application was sent back asking for more detail. + +**Every answer below is a promise about how CodePlug Magic behaves.** None of +the integration was built when they were written. If RepeaterBook approves the +app, the import feature has to make every one of them true. The checklist +comes first; the submitted text, verbatim, follows it. If the code and this +file ever disagree, either change the code or tell RepeaterBook before +shipping. Don't quietly edit this file to match the code. + +⚠ This is the text as it was filled into the form. Tim reviewed it before +submitting; if he changed anything there, update this file to match what was +actually sent. + +## Checklist for the implementation + +Identity and token +- [ ] Every request sends exactly `CodePlugMagic (+https://github.com/ww8l/codeplug-magic; WW8L; tim@ww8l.com)`. No version number. +- [ ] Per-user `rbuapp_` token, pasted once, stored in the OS credential store + (Keychain / Credential Manager / Secret Service), e.g. the `keyring` crate. +- [ ] Token never in SQLite, settings, logs or backup files. +- [ ] Token sent only over HTTPS, only in the `X-RB-App-Token` header, never in a URL. +- [ ] Logs record only request type, search area, HTTP status and timestamp. +- [ ] The user can remove the token in the app. +- [ ] Token setup screen links to RepeaterBook's API Apps & Tokens page. + +Requests and limits +- [ ] One search = one `api.export` request for one area (one US state or + Canadian province, or one country outside North America), with optional + band and mode filters. No automatic paging. +- [ ] More than 1,000 results → import nothing, ask the user to narrow filters. +- [ ] Nothing saved until the user selects rows; at most 500 per import. +- [ ] `api.export` (export.php) for US states and Canadian provinces; `api.export_row` + (exportROW.php) for a country outside North America. A single-record refresh is + one request, narrowed by the record's area and call sign, made only when the user + asks to refresh that record. +- [ ] One request in flight; at least 2 s apart; at most 20 per hour, enforced. +- [ ] Import button disabled while a request runs. +- [ ] An identical search within 10 minutes is served from memory (never disk). +- [ ] 429/5xx: honor Retry-After, else wait 30 s / 60 s / 120 s, stop after 3 + retries; the user cannot bypass the wait. +- [ ] 401/403: stop at once, no retry, ask the user to check the token. +- [ ] No background polling, scheduled sync, crawling, or looping over areas. + +Storage, retention and deletion +- [ ] API records carry a source distinct from file-imported RepeaterBook rows + (today those are `source = 'repeaterbook'` with a synthetic + CALLSIGN|FREQ|STATE|CITY `repeaterbook_id`). Without this, none of the + limits below can tell them apart. +- [ ] Each record stores its import date; re-import updates by RepeaterBook's + own identifier instead of duplicating. +- [ ] Records not refreshed within 12 months are flagged, and the user is + prompted to refresh or delete them. +- [ ] Single-record delete, and a "Remove RepeaterBook data" action that deletes + every API record AND the stored token in one step. + +Exports and backups +- [ ] API records are excluded from the channel export file + (`export_channels`, `src-tauri/src/commands/channel_io.rs`), and the + user is told how many were left out. +- [ ] Radio programming output carries only the channels in a codeplug for that + radio, within its channel capacity. +- [ ] "RepeaterBook.com" in the Comment field of RepeaterBook records in export + formats that have one. +- [ ] The whole-database backup (`export_database`, `src-tauri/src/commands/backup.rs`) + never contains the token. +- [ ] No feature exports RepeaterBook records as a list or dataset. + +Attribution ("Data courtesy of RepeaterBook.com", linked to https://www.repeaterbook.com) +- [ ] The search/import dialog, visible the whole time. +- [ ] The summary after every import. +- [ ] Channel library: Source column reads "RepeaterBook"; the detail view shows + the attribution line, the link and the import date. +- [ ] The About screen, the README, and the import feature's docs. + +Also attested on the form (checkboxes): display the attribution and link back +where practical; do not mirror, redistribute, bulk-export, re-serve, or build +another directory, dataset, service or API from the data without written +permission. + +## Form values as submitted + +| Field | Value | +|---|---| +| Contact Name / Call Sign | Tim Annable, WW8L | +| Contact Email | tim@ww8l.com | +| Project / Application Name | CodePlug Magic | +| Project website | https://github.com/ww8l/codeplug-magic | +| Application User-Agent | `CodePlugMagic (+https://github.com/ww8l/codeplug-magic; WW8L; tim@ww8l.com)` | +| Primary RepeaterBook use | Personal radio programming | +| Who can use it? | Public users | +| Estimated users | 25 | +| Commercial status | Non-commercial | +| Implementation status | Planned | +| Source availability | Open source | +| Project Categories | Open-Source, Not for Profit, Hobby/Personal | + +## Application review details + +CodePlug Magic is a free, open-source (GPL-3.0) desktop application for amateur radio operators: https://github.com/ww8l/codeplug-magic. It runs on the user's own computer (Windows, macOS, Linux). It keeps a local library of channels, builds codeplugs from it, and programs the user's own radios over USB, serial cable, or the radio's microSD card. Supported radios include models from AnyTone, Kenwood, Icom, Yaesu, TIDRADIO, Baofeng and Binteradio. + +Users are individual hams programming their own radios. Today they export a CSV or JSON file from the RepeaterBook website and import it into the app. The API would replace that manual export and import step. Each user creates their own free RepeaterBook account and generates their own app-bound rbuapp_ token. Requests go directly from the user's computer to RepeaterBook. There is no server of ours in between, and no data is shared between users. + +This is a resubmission. The previous review asked for more detail on attribution and link-back, maximum returned and selected records, request spacing and 429 backoff, cache/retention/deletion, how records in the local library, exports and backups stay limited to radio programming, and the final User-Agent. Each is answered in its own section below. In summary: +- Final User-Agent, sent exactly and unchanged between releases: CodePlugMagic (+https://github.com/ww8l/codeplug-magic; WW8L; tim@ww8l.com) +- At most 1,000 records displayed per search, and at most 500 selected per import. +- At least 2 seconds between requests, no more than 20 requests per hour per user, one request in flight at a time, and 429/5xx backoff honoring Retry-After. +- RepeaterBook records leave the app only as radio programming output. They are excluded from the app's general channel-export file, and there is no feature for exporting them as a list or dataset. + +The API integration is not built yet (Implementation status: Planned). The answers here are the design it will be built to. + +Developer: Tim Annable, WW8L (tim@ww8l.com). + +## API workflow and data fields + +Workflow: +1. The user opens "Import from RepeaterBook" and picks one area: one US state or Canadian province, or one country outside North America. Before sending, they can narrow by band and by mode (FM, DMR, D-STAR, System Fusion, NXDN, P25). +2. The app sends one api.export request for that area and those filters. It does not automatically page through further results. If the search matches more than 1,000 records, none are imported and the user is asked to narrow the filters. +3. The results appear in a preview table. Nothing is saved until the user selects records, and at most 500 can be selected in one import. +4. Selected records are saved to the user's local channel library, where the user assigns them to codeplugs for specific radios and programs those radios. +5. api.export serves searches in a US state or Canadian province, and api.export_row serves searches in a country outside North America. To refresh one record the user already has, and only when the user asks, the app sends one request narrowed to that record's area and call sign. + +Data fields used, and only for building a channel: +- Output frequency, input frequency / offset +- Callsign +- Uplink and downlink tone (CTCSS / DCS) +- Modes (FM analog, DMR with color code, D-STAR, System Fusion, NXDN, P25) and digital access notes +- Location (city / landmark), county, state or province, country +- Latitude and longitude, used to sort channels by distance from the user +- Use (open / closed) and operational status, so off-air or closed repeaters can be skipped +- RepeaterBook's own identifiers for the record, used only to update a record on re-import instead of duplicating it +- Notes, shown to the user in the channel's detail view + +The app does not ask for or store personal data about repeater owners or trustees beyond the repeater callsign. + +This is not a proxy, mirror, directory, map, bulk download or secondary API. The app has no server component. It makes requests only for the user who is sitting at it, only for their own radios, and never serves results to anyone else. + +## Relationship to RepeaterBook + +CodePlug Magic is a radio programming tool, not a repeater directory. It does not compete with RepeaterBook's website, mobile apps or directory. It has no browsing, mapping, repeater search across regions, trip planning or repeater detail pages. RepeaterBook data enters the app only as channels for a user to program into their own radio. + +Users still rely on RepeaterBook for discovery. The in-app dialog says "Data courtesy of RepeaterBook.com" and links to RepeaterBook, and each imported record keeps RepeaterBook as its source. Corrections are sent to RepeaterBook, not handled in the app. The app does not accept or relay repeater edits. + +The API replaces a step users already do by hand: export a CSV or JSON file from repeaterbook.com and import it. Moving that step to the API puts every request under the user's own app-bound token, the published rate limits and an identifiable User-Agent, instead of an untracked file download. The app already reads RepeaterBook's export format today. + +## Credential handling and abuse prevention + +Each user creates their own free RepeaterBook account and generates their own app-bound rbuapp_ token from the RepeaterBook dashboard. They paste it into CodePlug Magic once. No shared or app_ token exists. Nothing is embedded in the app, the installer, or the public source code. + +Storage: the token is stored in the operating system's credential store (macOS Keychain, Windows Credential Manager, or Linux Secret Service). It is never written to the app's SQLite database, its settings, its logs, or its backup files, so a database backup the user makes never contains it. Only that user's OS account can read it. + +Transmission: the token is sent only to RepeaterBook, only over HTTPS, and only in the X-RB-App-Token request header. It is never put in a URL or query string. + +Logging: the app never logs the token. Its logs record only the request type, the search area, the HTTP status and a timestamp. + +Revocation: the user can remove the token from the app at any time. "Remove RepeaterBook data" deletes the stored token together with every record that came from the API. The user can also rotate or revoke the token on RepeaterBook. A 401 or 403 response stops all requests immediately, with no retry, and asks the user to check or replace their token. + +Abuse prevention: requests are made only when the user starts a search. There is no background polling, scheduled sync, crawling or bulk download, and no feature for looping over many areas. The search and selection limits and the rate controls described in the other sections apply to every request. + +## Rate and abuse controls + +- One request in flight at a time per user, never concurrent. +- At least 2 seconds between requests. +- No more than 20 requests per hour per user, enforced by the app. +- Each search is one request for one user-chosen area. The app does not automatically page through further results. +- At most 1,000 records displayed per search. If a search matches more, nothing is imported and the user is asked to narrow the band or mode filters. +- At most 500 records selected per import. +- The Import button is disabled while a request is running. +- An identical search repeated within 10 minutes reuses the result already in memory instead of calling the API again. +- Requests happen only when the user starts a search. There is no background polling, scheduled sync or crawling. + +HTTP 429 and 5xx: the app honors Retry-After when it is present. Otherwise it waits 30 seconds, then 60, then 120. After 3 failed retries it stops and tells the user to try again later. The user cannot bypass the wait. + +HTTP 401 and 403: the app stops immediately without retrying and asks the user to check their token. + +Every request sends exactly this User-Agent: CodePlugMagic (+https://github.com/ww8l/codeplug-magic; WW8L; tim@ww8l.com) + +## Cache and retention policy + +Response cache: the only cache is the 10-minute, in-memory search result described under rate controls. It is never written to disk and is lost when the app closes. + +Retention: records the user selects are stored in their local channel library, a SQLite database on their own computer, so they can be programmed into their own radios. Nothing is uploaded, synced or shared with other users or with the developer. Each record is marked with RepeaterBook as its source and the date it was imported. Re-running an import updates existing records by their RepeaterBook identifier instead of duplicating them. Records not refreshed within 12 months are flagged, and the user is prompted to refresh or delete them. + +Deletion: the user can delete any single record at any time. "Remove RepeaterBook data" deletes every record that came from the RepeaterBook API, and the stored token, in one step. + +How RepeaterBook-derived records stay limited to the user's radio-programming workflow: +- Local library: the library exists to build codeplugs for the user's own radios. It is not exposed to any network service, API or other user. +- Exports: RepeaterBook records leave the app only as radio programming output, meaning a codeplug file for a specific radio model or a direct write to the user's radio. That output contains only the channels the user has placed in a codeplug for that radio, limited to that radio's channel capacity, and each record is attributed to RepeaterBook.com where the format has a comment field. The app's general "export channels" file, which moves a library between installs, leaves out records that came from the RepeaterBook API and tells the user how many were left out. There is no feature for exporting RepeaterBook records as a list or dataset. +- Backups: the app's whole-database backup exists only to restore the user's own installation after a disk failure or a move to a new computer. It is a single private file written where the user chooses. It never contains the token, is not uploaded anywhere, and is not offered as a way to share data. A restored backup brings back only the user's own records, still marked with their source and import date. + +## Attribution and link-back plan + +The text "Data courtesy of RepeaterBook.com", linked to https://www.repeaterbook.com, appears in these places: +1. The RepeaterBook search and import dialog, visible the whole time the user searches and previews results. +2. The summary shown after every import. +3. The channel library. Every record from RepeaterBook shows "RepeaterBook" in its Source column, and its detail view shows the attribution line, the link and the import date. +4. Radio export files: the Comment field of every RepeaterBook record reads "RepeaterBook.com" in formats that have a comment field. +5. The app's About screen, the README, and the documentation for the import feature. + +The token setup screen links to RepeaterBook's API Apps & Tokens page so users create and manage their token on RepeaterBook itself. diff --git a/package-lock.json b/package-lock.json index 5d6142a..c0e7842 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "ww8l-codeplug-magic", - "version": "26.9.6", + "version": "26.10.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ww8l-codeplug-magic", - "version": "26.9.6", + "version": "26.10.10", "license": "GPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.14.13", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.0", + "@tauri-apps/plugin-opener": "^2.7.1", "clsx": "^2.1.1", "lucide-react": "^1.48.0", "react": "^19.3.0", @@ -986,6 +987,15 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.1.tgz", + "integrity": "sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", diff --git a/package.json b/package.json index 0ccd348..f3fe06f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@tanstack/react-virtual": "^3.14.13", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.0", + "@tauri-apps/plugin-opener": "^2.7.1", "clsx": "^2.1.1", "lucide-react": "^1.48.0", "react": "^19.3.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c0e3b48..58f4cbe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -53,6 +64,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -192,6 +334,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -201,6 +352,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.0.0" @@ -326,6 +490,15 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.64" @@ -402,6 +575,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -682,6 +865,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -908,6 +1109,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -957,6 +1185,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1113,6 +1351,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1511,6 +1762,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1838,6 +2095,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -1864,6 +2131,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2020,6 +2306,22 @@ dependencies = [ "serde", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2319,6 +2621,43 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2335,6 +2674,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2361,6 +2709,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2618,6 +2977,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2630,6 +3000,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2758,6 +3138,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2830,6 +3221,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3302,7 +3707,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3330,7 +3735,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3441,6 +3846,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3667,7 +4104,7 @@ dependencies = [ "io-kit-sys 0.4.1", "libudev", "mach2 0.4.3", - "nix", + "nix 0.26.4", "scopeguard", "unescaper", "windows-sys 0.52.0", @@ -4048,6 +4485,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4420,6 +4863,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus 5.19.0", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -4520,6 +4985,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -4900,6 +5378,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unescaper" version = "0.1.8" @@ -5837,6 +6326,7 @@ dependencies = [ "chrono", "csv", "futures-util", + "keyring", "md-5 0.11.0", "nusb", "reqwest", @@ -5847,6 +6337,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-opener", "thiserror 2.0.21", "tokio", "windows-sys 0.61.2", @@ -5873,6 +6364,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5896,6 +6397,132 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -5942,6 +6569,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" @@ -5981,3 +6622,81 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.3", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c39fda0..c7cc9a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,19 @@ md-5 = "0.11" # over `reqwest`'s `bytes_stream()`. reqwest already depends on it, so naming it # here adds no new code to the build. futures-util = { version = "0.3", default-features = false } +# The RepeaterBook API token (#136) is the user's own credential and lives in +# the OS credential store, never in SQLite, settings or a backup: Keychain on +# macOS, Credential Manager on Windows, Secret Service on Linux. The Linux +# backend talks D-Bus through libdbus, which tao already links. +keyring = { version = "3", features = [ + "apple-native", + "windows-native", + "sync-secret-service", + "crypto-rust", +] } +# Opens RepeaterBook's pages (attribution link, the token page) in the user's +# browser; a link inside the webview would navigate the app itself. +tauri-plugin-opener = "2" # TYT MD-380 on Windows: TYT's CPS installs ST's STTub30 driver for the radio, # which nusb cannot open, so the DFU requests go through it by DeviceIoControl. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e895c6b..75fdc22 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,10 @@ "windows": ["main"], "permissions": [ "core:default", - "dialog:default" + "dialog:default", + { + "identifier": "opener:allow-open-url", + "allow": [{ "url": "https://www.repeaterbook.com/*" }] + } ] } diff --git a/src-tauri/src/commands/channel_io.rs b/src-tauri/src/commands/channel_io.rs index 8198a11..01f4703 100644 --- a/src-tauri/src/commands/channel_io.rs +++ b/src-tauri/src/commands/channel_io.rs @@ -112,30 +112,54 @@ pub struct ChannelBackupPreview { // Tauri commands // ============================================================ +/// What a channel export wrote, and what it deliberately left out. +#[derive(Debug, Serialize)] +pub struct ChannelExportSummary { + pub written: usize, + /// Selected channels that came from the RepeaterBook API. They are never + /// written to this file (#136): RepeaterBook data leaves the app only as + /// radio programming output, and this file moves a library between + /// installs, which is exactly the "reusable dataset" the API terms rule out. + pub excluded_repeaterbook: usize, +} + /// Write the given channels (in the supplied order) to a Codeplug Magic backup JSON -/// file at `path`. Returns how many channels were written. Channel ids that no -/// longer exist are silently skipped. +/// file at `path`. Channel ids that no longer exist are silently skipped, and +/// RepeaterBook API records are left out and counted. #[tauri::command] pub async fn export_channels( state: State<'_, AppState>, ids: Vec, path: String, -) -> Result { +) -> Result { + export_impl(&state.pool, &ids, path).await +} + +async fn export_impl( + pool: &SqlitePool, + ids: &[i64], + path: String, +) -> Result { if ids.is_empty() { return Err("No channels selected to export.".to_string()); } crate::commands::write_paths::check_write_target(&path, &["json"])?; let mut channels = Vec::with_capacity(ids.len()); - for id in &ids { + let mut excluded_repeaterbook = 0usize; + for id in ids { let Some(channel) = sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE id = ?1") .bind(id) - .fetch_optional(&state.pool) + .fetch_optional(pool) .await .estr()? else { continue; }; + if channel.source == crate::commands::rb_api::API_SOURCE { + excluded_repeaterbook += 1; + continue; + } let talkgroups = sqlx::query_as::<_, (i64, String, String, String, i64, Option)>( r#" @@ -148,7 +172,7 @@ pub async fn export_channels( "#, ) .bind(id) - .fetch_all(&state.pool) + .fetch_all(pool) .await .estr()? .into_iter() @@ -170,6 +194,14 @@ pub async fn export_channels( }); } + if channels.is_empty() && excluded_repeaterbook > 0 { + return Err(format!( + "Nothing was exported: {} selected repeater{} came from RepeaterBook, and RepeaterBook data can only leave the app as radio programming, not as a channel file.", + if excluded_repeaterbook == 1 { "the" } else { "all" }, + if excluded_repeaterbook == 1 { "" } else { "s" }, + )); + } + let backup = ChannelBackup { format: BACKUP_FORMAT.to_string(), version: BACKUP_VERSION, @@ -182,7 +214,7 @@ pub async fn export_channels( .map_err(|e| format!("Could not serialize channels: {e}"))?; std::fs::write(&path, json).map_err(|e| format!("Could not write file: {e}"))?; - Ok(backup.count) + Ok(ChannelExportSummary { written: backup.count, excluded_repeaterbook }) } /// Cheap probe used by the import dialog to route a `.json` file to the right @@ -259,10 +291,19 @@ async fn insert_backup( let mut added = 0usize; let mut skipped = 0usize; + let mut excluded_repeaterbook = 0usize; for bc in &backup.channels { let c = &bc.channel; + // This app never writes an API record to a channel file, so one here + // was put there by hand: a file is exactly how RepeaterBook data must + // NOT move between installs (#136), so it is skipped instead. + if c.source == crate::commands::rb_api::API_SOURCE { + excluded_repeaterbook += 1; + continue; + } + // Dedupe: RepeaterBook-sourced channels by their synthetic id; manual // channels by (rx_freq, name, tx_freq) so re-importing a backup into the // same library doesn't create duplicates. @@ -433,7 +474,7 @@ async fn insert_backup( } tx.commit().await.estr()?; - Ok(ImportSummary { added, skipped, updated: 0 }) + Ok(ImportSummary { added, skipped, updated: 0, excluded_repeaterbook }) } /// Project a full channel down to the importer's preview shape. @@ -651,6 +692,43 @@ mod tests { // -- test helpers (mirror the command bodies without a Tauri State) -- + /// RepeaterBook API records never reach a channel file (#136), and the + /// caller is told how many were left out. + #[tokio::test] + async fn export_leaves_out_repeaterbook_api_records() { + let dir = std::env::temp_dir().join(format!("cpm_export_rb_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("test.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + sqlx::query( + "INSERT INTO channels (id, name_long, rx_freq, mode, source, repeaterbook_id) VALUES \ + (1, 'Mine', 146.52, 'FM', 'manual', NULL), \ + (2, 'From RB', 146.94, 'FM', 'repeaterbook_api', 'rbapi:na:26:7')", + ) + .execute(&pool) + .await + .unwrap(); + let path = dir.join("out.json"); + let path = path.to_str().unwrap().to_string(); + + let s = export_impl(&pool, &[1, 2], path.clone()).await.unwrap(); + assert_eq!((s.written, s.excluded_repeaterbook), (1, 1)); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains("rbapi:"), "an API record reached the file"); + + let err = export_impl(&pool, &[2], path).await.unwrap_err(); + assert!(err.contains("Nothing was exported"), "{err}"); + + // A hand-made file holding one is refused on import, and reported as + // RepeaterBook data rather than as a duplicate. + let backup = build_backup(&pool, &[2]).await; + sqlx::query("DELETE FROM channels WHERE id = 2").execute(&pool).await.unwrap(); + let s = insert_backup(&pool, &backup).await.unwrap(); + assert_eq!((s.added, s.skipped, s.excluded_repeaterbook), (0, 0, 1)); + let _ = std::fs::remove_dir_all(&dir); + } + async fn build_backup(pool: &SqlitePool, ids: &[i64]) -> ChannelBackup { let mut channels = Vec::new(); for id in ids { diff --git a/src-tauri/src/commands/channels.rs b/src-tauri/src/commands/channels.rs index 1a1a90f..98429c1 100644 --- a/src-tauri/src/commands/channels.rs +++ b/src-tauri/src/commands/channels.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use sqlx::{Acquire, QueryBuilder}; use tauri::State; +use super::rb_api::API_SOURCE; use crate::db::AppState; use crate::error::MapErrString; use crate::models::{Channel, ChannelFilter, ChannelInput, CityCentroid}; @@ -27,6 +28,8 @@ fn normalize_freq_term(term: &str) -> String { trimmed.trim_end_matches('.').to_string() } +const API_SOURCE_LOCKED: &str = "\"repeaterbook_api\" marks a repeater imported from the RepeaterBook API. It can't be given to another channel or taken off one."; + const CHANNEL_COLUMNS: &str = "id, rb_name, name_long, name_short, callsign, rx_freq, tx_freq, offset, duplex, band, mode, tone_mode, ctcss_uplink, ctcss_downlink, dcs_code, dcs_rx_code, dcs_polarity, cross_mode, power, dmr_color_code, dmr_timeslot, dmr_talkgroup, dstar_capable, dstar_ur_call, dstar_rpt1, dstar_rpt2, ysf_capable, nxdn_capable, p25_capable, p25_nac, m17_capable, m17_can, tetra_capable, allstar_node, echolink_node, irlp_node, wires_node, use_type, operational_status, service_type, city, county, state, country, latitude, longitude, notes, source, repeaterbook_id, rb_dcs_code, rb_dcs_rx_code, rb_ctcss_uplink, rb_ctcss_downlink, rb_operational_status, rb_notes, ctcss_uplink_overridden, ctcss_downlink_overridden, operational_status_overridden, notes_overridden, has_overrides, last_rb_update, last_user_edit, created_at"; #[tauri::command] @@ -499,6 +502,11 @@ pub async fn create_channel( .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "manual".to_string()); + // ...except the RepeaterBook API's. That label is what the channel-export + // rule keys on (#136), so only the API importer may write it. + if source == API_SOURCE { + return Err(API_SOURCE_LOCKED.to_string()); + } let id = sqlx::query( r#" @@ -630,7 +638,13 @@ pub(super) async fn update_impl( .source .clone() .filter(|s| !s.trim().is_empty()) - .unwrap_or(existing.source); + .unwrap_or(existing.source.clone()); + // A RepeaterBook API record stays one, and nothing becomes one by being + // relabelled: renaming the source would carry it past the channel-export + // rule the label exists for (#136). + if (existing.source == API_SOURCE) != (source == API_SOURCE) { + return Err(API_SOURCE_LOCKED.to_string()); + } sqlx::query( r#" @@ -891,7 +905,7 @@ pub async fn delete_channels( /// Delete `ids` in one transaction, dropping any scan-list priority pointer at /// them first. Shared by the single and bulk commands so neither can forget. -async fn delete_impl(pool: &sqlx::SqlitePool, ids: &[i64]) -> Result { +pub(crate) async fn delete_impl(pool: &sqlx::SqlitePool, ids: &[i64]) -> Result { if ids.is_empty() { return Ok(0); } diff --git a/src-tauri/src/commands/csv_map.rs b/src-tauri/src/commands/csv_map.rs index 175c55f..e100948 100644 --- a/src-tauri/src/commands/csv_map.rs +++ b/src-tauri/src/commands/csv_map.rs @@ -1301,7 +1301,7 @@ async fn insert_mapped( } tx.commit().await.estr()?; - Ok(ImportSummary { added, updated: 0, skipped }) + Ok(ImportSummary { added, updated: 0, skipped, excluded_repeaterbook: 0 }) } #[cfg(test)] diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index c76578c..e4c8647 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1348,6 +1348,11 @@ fn render_chirp_csv(channels: &[&ExpandedChannel], model: &RadioModel) -> Result dmr.push_str(&format!("TG{tg_num}")); comment_parts.push(dmr); } + // Attribution promised to RepeaterBook (#136): a record that came from + // RepeaterBook says so wherever the format has a comment. + if crate::commands::rb_api::is_repeaterbook_source(&c.source) { + comment_parts.push("RepeaterBook.com".to_string()); + } let comment = comment_parts .join(" ") .split_whitespace() diff --git a/src-tauri/src/commands/import.rs b/src-tauri/src/commands/import.rs index 3cc82a0..e4d97a7 100644 --- a/src-tauri/src/commands/import.rs +++ b/src-tauri/src/commands/import.rs @@ -27,7 +27,7 @@ use crate::util::{ /// /// Only the premium JSON carries link node numbers, and only the wide CSV /// carries Operational Status, so every export is missing something here. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub(crate) struct SourceColumns { /// allstar_node, echolink_node, irlp_node, wires_node. pub(crate) link_nodes: bool, @@ -41,7 +41,10 @@ pub(crate) struct SourceColumns { /// A single RepeaterBook record (from CSV or JSON) parsed and mapped into our /// schema, ready to be inserted or previewed. Serialized directly to the import /// preview so the UI can show every field that will be imported. -#[derive(Debug, Clone, Serialize)] +/// +/// `Deserialize` is for the RepeaterBook API import (#136), where the operator +/// picks rows from a preview and the dialog hands the picked ones back. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParsedChannel { pub(crate) repeaterbook_id: String, pub(crate) rb_name: String, @@ -92,6 +95,8 @@ pub struct ParsedChannel { pub(crate) longitude: Option, pub(crate) notes: Option, /// Not part of the preview: this describes the *export*, not the channel. + /// Skipped both ways, so a row handed back by the UI arrives all-false and + /// the importer that receives it must set the truth itself. #[serde(skip)] pub(crate) covers: SourceColumns, } @@ -204,7 +209,22 @@ async fn insert_parsed( ) -> Result { let mut conn = pool.acquire().await.estr()?; let mut tx = conn.begin().await.estr()?; + let (added, updated) = insert_parsed_tx(&mut tx, parsed, "repeaterbook").await?; + tx.commit().await.estr()?; + Ok(ImportSummary { added, updated, skipped: 0, excluded_repeaterbook: 0 }) +} +/// Insert or merge `parsed` inside the caller's transaction, stamping new rows +/// with `source`. Returns `(added, updated)`. +/// +/// The file importers pass `repeaterbook`; the API importer passes +/// `repeaterbook_api` and does its own cap check in the same transaction, so +/// the count it checked is the count that commits. +pub(crate) async fn insert_parsed_tx( + tx: &mut sqlx::SqliteConnection, + parsed: &[ParsedChannel], + source: &str, +) -> Result<(usize, usize), String> { let mut added = 0usize; let mut updated = 0usize; @@ -226,7 +246,7 @@ async fn insert_parsed( .await .estr()?; if let Some(ex) = existing { - merge_existing(&mut tx, &ex, p).await?; + merge_existing(tx, &ex, p).await?; updated += 1; continue; } @@ -256,7 +276,7 @@ async fn insert_parsed( ?24, ?25, ?26, ?27, ?28, ?29, 'Amateur', ?30, ?31, ?32, ?33, ?34, ?35, ?36, - 'repeaterbook', ?37, + ?45, ?37, ?38, ?39, ?40, ?41, ?42, CURRENT_TIMESTAMP ) @@ -308,6 +328,7 @@ async fn insert_parsed( // an operator's edit from one of ours. .bind(&p.dcs_code) // ?43 .bind(&p.dcs_rx_code) // ?44 + .bind(source) // ?45 .execute(&mut *tx) .await .estr()?; @@ -315,8 +336,7 @@ async fn insert_parsed( added += 1; } - tx.commit().await.estr()?; - Ok(ImportSummary { added, updated, skipped: 0 }) + Ok((added, updated)) } /// Re-sync one already-imported channel from a fresh RepeaterBook record. @@ -540,13 +560,7 @@ pub(crate) fn finalize( // N2SKY 448.400 exists in both Fort Collins and Buena Vista, CO) and would // otherwise collapse into a single row. Kept in sync with migration 0011, // which recomputes this for already-imported rows. - let repeaterbook_id = format!( - "{}|{:.4}|{}|{}", - callsign.to_uppercase(), - rx_freq, - state.unwrap_or_default().to_uppercase(), - city_str.to_uppercase(), - ); + let repeaterbook_id = synthetic_rb_id(callsign, rx_freq, state, city); // RepeaterBook gives an uplink tone (TX, what you must transmit to key the // repeater) and a downlink tone (RX, what the repeater sends back). Map to @@ -572,6 +586,24 @@ pub(crate) fn finalize( } } +/// The file importers' synthetic dedupe id, `CALLSIGN|FREQ|STATE|CITY`. Also +/// how the API importer finds a file-imported copy of a repeater it is about to +/// import, so the two do not sit side by side in the library. +pub(crate) fn synthetic_rb_id( + callsign: &str, + rx_freq: f64, + state: Option<&str>, + city: Option<&str>, +) -> String { + format!( + "{}|{:.4}|{}|{}", + callsign.to_uppercase(), + rx_freq, + state.unwrap_or_default().to_uppercase(), + city.unwrap_or_default().to_uppercase(), + ) +} + /// Pick the primary `mode` string from the digital-capability flags. RepeaterBook /// has no explicit mode column, so a repeater with no digital flag is plain "FM" /// and a digital one reports its protocol. A machine can be mixed-mode; we surface @@ -1187,7 +1219,7 @@ struct JsonExport { /// Pull a trimmed, non-empty string value out of a JSON record (string or /// number). -fn jstr(rec: &HashMap, key: &str) -> Option { +pub(crate) fn jstr(rec: &HashMap, key: &str) -> Option { rec.get(key) .and_then(|v| match v { Value::String(s) => Some(s.trim().to_string()), @@ -1329,7 +1361,7 @@ fn s_notes_json(rec: &HashMap) -> Option { } /// Normalize a RepeaterBook country code to match manually-entered channels. -fn normalize_country(c: String) -> String { +pub(crate) fn normalize_country(c: String) -> String { match c.to_uppercase().as_str() { "US" | "USA" => "United States".to_string(), _ => c, @@ -1668,7 +1700,7 @@ pub(crate) fn parse_leading_f64(s: &str) -> Option { } /// Interpret a RepeaterBook yes/no style flag. -fn parse_bool(v: Option) -> bool { +pub(crate) fn parse_bool(v: Option) -> bool { matches!( v.map(|s| s.trim().to_lowercase()).as_deref(), Some("yes") | Some("y") | Some("true") | Some("1") | Some("x") diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7639856..4b3932c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod lists; pub mod profiles; pub mod program; pub mod radio_backups; +pub mod rb_api; pub mod rb_regions; pub mod standard_lists; pub mod talkgroups; diff --git a/src-tauri/src/commands/rb_api.rs b/src-tauri/src/commands/rb_api.rs new file mode 100644 index 0000000..ce31cc9 --- /dev/null +++ b/src-tauri/src/commands/rb_api.rs @@ -0,0 +1,1905 @@ +//! Searching and importing from the RepeaterBook API (issue #136). +//! +//! **Every limit in this file is a promise.** RepeaterBook approved CodePlug +//! Magic as a distributed app on the strength of the answers in +//! `docs/repeaterbook-api-commitments.md`: how the token is kept, how often the +//! app may ask, how many records it shows and keeps, and how those records stay +//! limited to programming the user's own radios. If a number or a behaviour +//! here has to change, RepeaterBook hears about it first; the doc is not edited +//! to match the code. +//! +//! The shape of the thing: +//! +//! * The token is the user's own `rbuapp_` token, pasted once and kept in the +//! OS credential store. It is never written to SQLite, settings, logs or a +//! backup, and it only ever travels in the `X-RB-App-Token` header of an +//! HTTPS request to RepeaterBook. +//! * Every request goes through [`fetch_export`], which owns the whole rate +//! policy: one request in flight, 2 s between requests, 20 an hour, the +//! 10-minute in-memory reuse of an identical request, and the 429/5xx +//! backoff. Nothing else in the app talks to RepeaterBook. +//! * Records land in the channel library with `source = 'repeaterbook_api'` +//! and an id built from RepeaterBook's own State ID and Repeater ID, so the +//! export and backup rules elsewhere can tell them from rows imported +//! out of a RepeaterBook file (`source = 'repeaterbook'`). + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{Acquire, SqlitePool}; +use tauri::{AppHandle, Emitter, State}; + +use super::import::{ + derive_mode, derive_tone_mode_rb, finalize, insert_parsed_tx, jstr, normalize_country, + parse_bool, parse_leading_f64, parse_rb_tone, synthetic_rb_id, ParsedChannel, RbTone, + SourceColumns, +}; +use super::rb_regions; +use crate::db::AppState; +use crate::error::MapErrString; + +// ============================================================ +// The promises, as numbers +// ============================================================ + +/// Sent exactly, on every request, and never changed between releases: the +/// API matches an approved app's User-Agent literally and answers anything else +/// with `403 ua_mismatch`. Deliberately not `http::USER_AGENT`, which carries +/// the version number. +pub(crate) const RB_USER_AGENT: &str = + "CodePlugMagic (+https://github.com/ww8l/codeplug-magic; WW8L; tim@ww8l.com)"; + +const NA_URL: &str = "https://www.repeaterbook.com/api/export.php"; +const ROW_URL: &str = "https://www.repeaterbook.com/api/exportROW.php"; + +/// The `source` stamped on every channel that came from the API. +pub(crate) const API_SOURCE: &str = "repeaterbook_api"; +/// The `source` of a channel imported from a RepeaterBook CSV/JSON file. +pub(crate) const FILE_SOURCE: &str = "repeaterbook"; + +/// Either kind of RepeaterBook record, API or file. +pub(crate) fn is_repeaterbook_source(source: &str) -> bool { + source == API_SOURCE || source == FILE_SOURCE +} + +/// Prefix of the `repeaterbook_id` of an API record. +const API_ID_PREFIX: &str = "rbapi:"; + +/// At most this many records are shown for one search. More than this and +/// nothing is shown or imported; the user narrows the filters instead. +pub(crate) const MAX_RESULTS: usize = 1000; +/// At most this many records selected for one import. +pub(crate) const MAX_SELECTED: usize = 500; + +const MIN_SPACING: Duration = Duration::from_secs(2); +const MAX_PER_HOUR: usize = 20; +const HOUR: Duration = Duration::from_secs(3600); +/// An identical request inside this window is answered from memory. +const CACHE_TTL: Duration = Duration::from_secs(600); +/// How many distinct searches the memory cache holds at once. Only a bound on +/// memory; anything older than `CACHE_TTL` is dropped regardless. +const CACHE_ENTRIES: usize = 8; +/// The wait before each retry of a 429/5xx when RepeaterBook sends no +/// Retry-After. Three entries = three retries, then stop. +const BACKOFF_SECS: [u64; 3] = [30, 60, 120]; +/// A Retry-After longer than this is honoured by refusing requests until it +/// passes, rather than by holding a search open for that long. +const MAX_INLINE_WAIT: Duration = Duration::from_secs(120); + +/// The API stops at 3,500 results. A response that size may be missing +/// records, so filtering it locally could silently leave repeaters out. +const SERVER_TRUNCATION: usize = 3500; +/// Generous for 3,500 records (~1.5 KB each), and a hard stop for anything else. +const MAX_BODY_BYTES: usize = 32 * 1024 * 1024; + +/// Which columns an API record carries: link node numbers, operational status, +/// and tones that can say DCS. See `import::SourceColumns`. +const API_COVERS: SourceColumns = + SourceColumns { link_nodes: true, operational_status: true, dcs: true }; + +/// RepeaterBook's own page for creating and managing tokens. Linked from the +/// token setup in Settings. +pub(crate) const TOKENS_PAGE: &str = "https://www.repeaterbook.com/user/api_apps.php"; + +// ============================================================ +// Areas +// ============================================================ + +/// One search covers one area: a US state or territory, a Canadian province, +/// or one country outside North America. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RbArea { + Region { state_id: String }, + Country { country: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Endpoint { + /// `export.php`, token scope `api.export`. + NorthAmerica, + /// `exportROW.php`, token scope `api.export_row`. + RestOfWorld, +} + +impl Endpoint { + fn url(self) -> &'static str { + match self { + Endpoint::NorthAmerica => NA_URL, + Endpoint::RestOfWorld => ROW_URL, + } + } + fn tag(self) -> &'static str { + match self { + Endpoint::NorthAmerica => "na", + Endpoint::RestOfWorld => "row", + } + } + fn from_tag(tag: &str) -> Option { + match tag { + "na" => Some(Endpoint::NorthAmerica), + "row" => Some(Endpoint::RestOfWorld), + _ => None, + } + } +} + +/// A searchable North American area, for the area picker. +#[derive(Debug, Clone, Serialize)] +pub struct RbAreaInfo { + /// RepeaterBook's `state_id`. Not ISO: two-digit FIPS for the US + /// ("06", not "6"), `CA##` for Canada. + pub state_id: &'static str, + pub name: &'static str, + pub country: &'static str, +} + +const US: &str = "United States"; +const CA: &str = "Canada"; + +/// RepeaterBook's `state_id` values. The US uses FIPS; Canada's numbering is +/// RepeaterBook's own and undocumented (provinces alphabetically, then the +/// territories, CA06 unused). Taken from the RepeaterBook Python client's +/// `na_states.py`, which records the same values. Mexico is not offered: the +/// application promised US states, Canadian provinces and countries outside +/// North America. +#[rustfmt::skip] +const NA_AREAS: &[(&str, &str, &str)] = &[ + ("01", "Alabama", US), ("02", "Alaska", US), ("04", "Arizona", US), + ("05", "Arkansas", US), ("06", "California", US), ("08", "Colorado", US), + ("09", "Connecticut", US), ("10", "Delaware", US), ("11", "District of Columbia", US), + ("12", "Florida", US), ("13", "Georgia", US), ("15", "Hawaii", US), + ("16", "Idaho", US), ("17", "Illinois", US), ("18", "Indiana", US), + ("19", "Iowa", US), ("20", "Kansas", US), ("21", "Kentucky", US), + ("22", "Louisiana", US), ("23", "Maine", US), ("24", "Maryland", US), + ("25", "Massachusetts", US), ("26", "Michigan", US), ("27", "Minnesota", US), + ("28", "Mississippi", US), ("29", "Missouri", US), ("30", "Montana", US), + ("31", "Nebraska", US), ("32", "Nevada", US), ("33", "New Hampshire", US), + ("34", "New Jersey", US), ("35", "New Mexico", US), ("36", "New York", US), + ("37", "North Carolina", US), ("38", "North Dakota", US), ("39", "Ohio", US), + ("40", "Oklahoma", US), ("41", "Oregon", US), ("42", "Pennsylvania", US), + ("44", "Rhode Island", US), ("45", "South Carolina", US), ("46", "South Dakota", US), + ("47", "Tennessee", US), ("48", "Texas", US), ("49", "Utah", US), + ("50", "Vermont", US), ("51", "Virginia", US), ("53", "Washington", US), + ("54", "West Virginia", US), ("55", "Wisconsin", US), ("56", "Wyoming", US), + ("60", "American Samoa", US), ("66", "Guam", US), ("69", "Northern Mariana Islands", US), + ("72", "Puerto Rico", US), ("78", "Virgin Islands", US), + ("CA01", "Alberta", CA), ("CA02", "British Columbia", CA), ("CA03", "Manitoba", CA), + ("CA04", "New Brunswick", CA), ("CA05", "Newfoundland", CA), ("CA07", "Nova Scotia", CA), + ("CA08", "Ontario", CA), ("CA09", "Prince Edward Island", CA), ("CA10", "Quebec", CA), + ("CA11", "Saskatchewan", CA), ("CA12", "Yukon Territory", CA), + ("CA13", "Northwest Territories", CA), ("CA14", "Nunavut", CA), +]; + +/// The countries served by the North American endpoint. A country search for +/// one of these goes the wrong way, so it is refused with a pointer to the +/// state/province picker instead. +const NA_COUNTRIES: &[&str] = &["united states", "usa", "us", "canada", "mexico"]; + +/// A validated area: where the request goes, its one area parameter, and the +/// label used in the log and the UI. +struct ResolvedArea { + endpoint: Endpoint, + param: (&'static str, String), + label: String, +} + +fn resolve_area(area: &RbArea) -> Result { + match area { + RbArea::Region { state_id } => { + let (id, name, country) = NA_AREAS + .iter() + .find(|(id, _, _)| *id == state_id.as_str()) + .ok_or_else(|| format!("\"{state_id}\" is not a state or province this app knows."))?; + Ok(ResolvedArea { + endpoint: Endpoint::NorthAmerica, + param: ("state_id", id.to_string()), + label: format!("{name}, {country}"), + }) + } + RbArea::Country { country } => { + let c = country.trim(); + if c.is_empty() { + return Err("Pick a country to search.".to_string()); + } + if c.chars().count() > 60 { + return Err("That country name is too long.".to_string()); + } + if NA_COUNTRIES.contains(&c.to_lowercase().as_str()) { + return Err( + "For the United States and Canada, search one state or province instead of the whole country." + .to_string(), + ); + } + Ok(ResolvedArea { + endpoint: Endpoint::RestOfWorld, + param: ("country", c.to_string()), + label: c.to_string(), + }) + } + } +} + +// ============================================================ +// Band and mode filters +// ============================================================ + +/// Amateur bands the search can be narrowed to, as (key, low MHz, high MHz). +/// The API has no band parameter, so this filter runs on the response. +const BANDS: &[(&str, f64, f64)] = &[ + ("10m", 28.0, 29.7), + ("6m", 50.0, 54.0), + ("2m", 144.0, 148.0), + ("1.25m", 219.0, 225.0), + ("70cm", 420.0, 450.0), + ("33cm", 902.0, 928.0), + ("23cm", 1240.0, 1300.0), +]; + +/// Modes the search can be narrowed to, with the API's own `mode` value where +/// it has one. The API filters only analog, DMR, NXDN, P25 and TETRA; D-STAR +/// and System Fusion are filtered on the response. +/// (our key, the API's `mode` value if it has one, the record's flag field). +type ModeRow = (&'static str, Option<&'static str>, &'static str); + +const MODES: &[ModeRow] = &[ + ("FM", Some("analog"), "FM Analog"), + ("DMR", Some("DMR"), "DMR"), + ("DSTAR", None, "D-Star"), + ("YSF", None, "System Fusion"), + ("NXDN", Some("NXDN"), "NXDN"), + ("P25", Some("P25"), "APCO P-25"), +]; + +/// The frequency ranges of the chosen bands. Empty means any band. +fn band_ranges(bands: &[String]) -> Result, String> { + bands + .iter() + .map(|b| { + BANDS + .iter() + .find(|(k, _, _)| *k == b.as_str()) + .map(|(_, lo, hi)| (*lo, *hi)) + .ok_or_else(|| format!("Unknown band \"{b}\".")) + }) + .collect() +} + +/// The table rows of the chosen modes. Empty means any mode. +fn mode_entries(modes: &[String]) -> Result, String> { + modes + .iter() + .map(|m| { + MODES + .iter() + .find(|(k, _, _)| *k == m.as_str()) + .ok_or_else(|| format!("Unknown mode \"{m}\".")) + }) + .collect() +} + +/// The `mode` value to send RepeaterBook, if any. The API takes one value +/// (a repeated parameter keeps only the last), so it is sent only when exactly +/// one mode is chosen and the API knows it; otherwise every mode is filtered +/// here on the response. +fn api_mode_param(modes: &[&'static ModeRow]) -> Option<&'static str> { + match modes { + [(_, api, _)] => *api, + _ => None, + } +} + +// ============================================================ +// Parsing one API record +// ============================================================ + +/// One raw API response, as RepeaterBook sent it. +#[derive(Debug, Deserialize)] +struct RawExport { + count: Option, + /// `null` (not `[]`) when nothing matched, on some endpoints. + results: Option>>, +} + +/// A flag field: RepeaterBook mixes "Yes"/"No" with 1/0. +fn flag(rec: &HashMap, key: &str) -> bool { + parse_bool(jstr(rec, key)) +} + +/// Status and Use in the spelling the rest of the library already uses +/// (`On Air`, `Open`), so the facets and filters do not split in two. Anything +/// unrecognised is kept as RepeaterBook wrote it. +fn normalize_status(s: String) -> String { + match s.to_lowercase().replace(['-', '_'], " ").as_str() { + "on air" => "On Air".to_string(), + "off air" => "Off Air".to_string(), + "unknown" => "Unknown".to_string(), + _ => s, + } +} +fn normalize_use(s: String) -> String { + match s.to_lowercase().as_str() { + "open" => "Open".to_string(), + "closed" => "Closed".to_string(), + "private" => "Private".to_string(), + _ => s, + } +} + +/// The library id of an API record: endpoint + RepeaterBook's State ID + +/// Repeater ID, which RepeaterBook documents as the pair that identifies a +/// repeater. The endpoint is part of it so a refresh knows where to ask. +fn api_id(endpoint: Endpoint, state_id: &str, rptr_id: &str) -> String { + format!("{API_ID_PREFIX}{}:{state_id}:{rptr_id}", endpoint.tag()) +} + +/// Split an API record id back into `(endpoint, state_id, rptr_id)`. +fn split_api_id(id: &str) -> Option<(Endpoint, &str, &str)> { + let rest = id.strip_prefix(API_ID_PREFIX)?; + let mut parts = rest.splitn(3, ':'); + let endpoint = Endpoint::from_tag(parts.next()?)?; + let state_id = parts.next().filter(|s| !s.is_empty())?; + let rptr_id = parts.next().filter(|s| !s.is_empty())?; + Some((endpoint, state_id, rptr_id)) +} + +/// Map one API record into a channel. `None` for a record that has no +/// identifier or no usable output frequency. +fn parse_api_record(rec: &HashMap, endpoint: Endpoint) -> Option { + let state_id = jstr(rec, "State ID")?; + let rptr_id = jstr(rec, "Rptr ID")?; + let rx_freq = jstr(rec, "Frequency") + .and_then(|s| parse_leading_f64(&s)) + .filter(|f| *f > 0.0)?; + let tx_freq = jstr(rec, "Input Freq") + .and_then(|s| parse_leading_f64(&s)) + .filter(|f| *f > 0.0); + let callsign = jstr(rec, "Callsign").unwrap_or_default(); + let city = jstr(rec, "Nearest City"); + + // North American records spell the state out ("Michigan"); the library + // stores the postal code, as the file importers do, so a repeater keys and + // filters the same whichever way it arrived. Only for North America: a + // rest-of-world region that happens to share a name (Georgia) is not a US + // state, and filing it as one breaks its refresh. + let raw_state = jstr(rec, "State"); + let raw_country = jstr(rec, "Country").map(normalize_country); + let na_region = match endpoint { + Endpoint::NorthAmerica => raw_state.as_deref().and_then(rb_regions::lookup), + Endpoint::RestOfWorld => None, + }; + let (state, country) = match na_region { + Some((code, country)) => (Some(code.to_string()), Some(country.to_string())), + None => (raw_state.or_else(|| jstr(rec, "Region")), raw_country), + }; + + // PL is the uplink (what you transmit), TSQ the downlink. Either can be a + // CTCSS frequency, a `Dnnn` DCS code, `CSQ` or blank. + let up = parse_rb_tone(jstr(rec, "PL")); + let dn = parse_rb_tone(jstr(rec, "TSQ")); + let (tone_mode, cross_mode, dcs_code, dcs_rx_code) = derive_tone_mode_rb(&up, &dn); + let ctcss_uplink = match up { + RbTone::Ctcss(f) => Some(f), + _ => None, + }; + let ctcss_downlink = match dn { + RbTone::Ctcss(f) => Some(f), + _ => None, + }; + + let dmr = flag(rec, "DMR"); + let dstar = flag(rec, "D-Star"); + let ysf = flag(rec, "System Fusion"); + let nxdn = flag(rec, "NXDN"); + let p25 = flag(rec, "APCO P-25"); + let m17 = flag(rec, "M17"); + let tetra = flag(rec, "Tetra"); + let dmr_color_code = if dmr { + jstr(rec, "DMR Color Code") + .and_then(|s| s.parse::().ok()) + .filter(|cc| (0..=15).contains(cc)) + } else { + None + }; + let p25_nac = if p25 { jstr(rec, "P-25 NAC") } else { None }; + + let s = finalize( + &callsign, + rx_freq, + tx_freq, + ctcss_uplink, + ctcss_downlink, + city.as_deref(), + state.as_deref(), + ); + + let mut note_parts = Vec::new(); + if let Some(lm) = jstr(rec, "Landmark") { + note_parts.push(format!("Landmark: {lm}")); + } + if let Some(n) = jstr(rec, "Notes") { + note_parts.push(n); + } + let notes = (!note_parts.is_empty()).then(|| note_parts.join(" | ")); + + Some(ParsedChannel { + repeaterbook_id: api_id(endpoint, &state_id, &rptr_id), + rb_name: s.rb_name, + name_long: s.name_long, + name_short: s.name_short, + callsign, + rx_freq, + tx_freq: s.tx_freq, + offset: s.offset, + duplex: s.duplex, + band: s.band, + mode: derive_mode(dmr, dstar, ysf, nxdn, p25, m17), + // finalize's CTCSS-only reading is replaced by the DCS-aware one. + tone_mode, + cross_mode, + ctcss_uplink, + ctcss_downlink, + dcs_code, + dcs_rx_code, + dmr_color_code, + // RepeaterBook has nothing to say about these three. + dmr_timeslot: None, + dmr_talkgroup: None, + power: None, + dstar_capable: dstar, + ysf_capable: ysf, + nxdn_capable: nxdn, + p25_capable: p25, + p25_nac, + m17_capable: m17, + tetra_capable: tetra, + allstar_node: jstr(rec, "AllStar Node"), + echolink_node: jstr(rec, "EchoLink Node"), + irlp_node: jstr(rec, "IRLP Node"), + wires_node: jstr(rec, "Wires Node"), + use_type: jstr(rec, "Use").map(normalize_use), + operational_status: jstr(rec, "Operational Status").map(normalize_status), + city, + county: jstr(rec, "County"), + state, + country, + latitude: jstr(rec, "Lat").and_then(|s| parse_leading_f64(&s)), + longitude: jstr(rec, "Long").and_then(|s| parse_leading_f64(&s)), + notes, + covers: API_COVERS, + }) +} + +/// Does a raw record carry `mode`? Uses RepeaterBook's own flag, so an FM +/// search finds every machine with FM analog, mixed-mode ones included. +fn record_has_mode(rec: &HashMap, flag_key: &str) -> bool { + flag(rec, flag_key) +} + +// ============================================================ +// The request path: rate limits, memory cache, backoff +// ============================================================ + +struct CacheEntry { + key: String, + at: Instant, + export: Arc>>, + truncated: bool, +} + +struct Limiter { + /// The least time between two requests. `MIN_SPACING` in the app; tests + /// that drive a mock server use zero so they need not sleep. + min_spacing: Duration, + in_flight: bool, + last_request: Option, + /// When each request in the last hour was sent. + history: VecDeque, + /// Set after a backoff gives up or a long Retry-After: nothing is sent + /// before this, whatever the user does. + blocked_until: Option, + /// Set by a 401/403, holding what to tell the user. Nothing is sent until + /// the token is replaced (or, for an app-identity refusal, the app is + /// restarted). + auth_failed: Option, + cache: Vec, +} + +impl Limiter { + fn new(min_spacing: Duration) -> Self { + Limiter { + min_spacing, + in_flight: false, + last_request: None, + history: VecDeque::new(), + blocked_until: None, + auth_failed: None, + cache: Vec::new(), + } + } + + fn prune(&mut self, now: Instant) { + while self.history.front().is_some_and(|t| now.duration_since(*t) >= HOUR) { + self.history.pop_front(); + } + self.cache.retain(|e| now.duration_since(e.at) < CACHE_TTL); + if self.blocked_until.is_some_and(|t| t <= now) { + self.blocked_until = None; + } + } + + /// Refuse if the hourly budget is spent, else say how long to wait for the + /// 2-second spacing. + fn next_slot(&mut self, now: Instant) -> Result { + self.prune(now); + if self.history.len() >= MAX_PER_HOUR { + let free = self.history[0] + HOUR; + let mins = free.saturating_duration_since(now).as_secs().div_ceil(60).max(1); + return Err(format!( + "This app sends RepeaterBook at most {MAX_PER_HOUR} requests an hour, and that limit is reached. Try again in about {mins} minute{}.", + if mins == 1 { "" } else { "s" } + )); + } + Ok(self + .last_request + .map(|t| (t + self.min_spacing).saturating_duration_since(now)) + .unwrap_or(Duration::ZERO)) + } + + fn record_request(&mut self, now: Instant) { + self.last_request = Some(now); + self.history.push_back(now); + } + + fn requests_last_hour(&mut self, now: Instant) -> usize { + self.prune(now); + self.history.len() + } +} + +static LIMITER: LazyLock> = LazyLock::new(|| Mutex::new(Limiter::new(MIN_SPACING))); + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, Limiter> { + // A panic while holding the lock leaves plain data behind, never a + // half-made invariant worth refusing all future searches over. + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn limiter() -> std::sync::MutexGuard<'static, Limiter> { + lock(&LIMITER) +} + +/// Clears `in_flight` however the request ends, error and early return +/// included, so one failure can never wedge every later search. +struct InFlight<'a>(&'a Mutex); +impl Drop for InFlight<'_> { + fn drop(&mut self) { + lock(self.0).in_flight = false; + } +} + +/// How long to wait before retry number `attempt` (0-based) of a 429/5xx. +fn backoff_delay(attempt: usize, retry_after: Option) -> Duration { + retry_after.unwrap_or_else(|| { + Duration::from_secs(BACKOFF_SECS[attempt.min(BACKOFF_SECS.len() - 1)]) + }) +} + +/// Retry-After as seconds or as an HTTP date. +fn parse_retry_after(value: &str) -> Option { + let v = value.trim(); + if let Ok(secs) = v.parse::() { + return Some(Duration::from_secs(secs)); + } + let when = chrono::DateTime::parse_from_rfc2822(v).ok()?; + let secs = (when.timestamp() - chrono::Utc::now().timestamp()).max(0) as u64; + Some(Duration::from_secs(secs)) +} + +/// The promised log line: request type, area, HTTP status, timestamp. Nothing +/// else — no token, no URL, no response. +fn log_request(kind: &str, area: &str, status: &str) { + eprintln!( + "[repeaterbook] {} {kind} \"{area}\" {status}", + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ") + ); +} + +/// What a 401/403 or an error body means, in words the user can act on. +fn auth_message(error_code: Option<&str>) -> String { + match error_code { + Some("auth_scope_denied") => { + "RepeaterBook says your token is not approved for this part of the world. North America and the rest of the world are approved separately — check the token on RepeaterBook's API Apps & Tokens page.".to_string() + } + Some("auth_revoked") | Some("auth_inactive") => { + "RepeaterBook says this token is no longer active. Generate a new one on RepeaterBook's API Apps & Tokens page and paste it in Settings.".to_string() + } + Some("ua_mismatch") => { + "RepeaterBook did not recognise this app's identity (ua_mismatch). That is a problem in CodePlug Magic, not your token — please report it.".to_string() + } + _ => "RepeaterBook did not accept your token. Check it on RepeaterBook's API Apps & Tokens page, then paste it again in Settings.".to_string(), + } +} + +/// The error code in a RepeaterBook error body, if it has one. +fn error_code_of(body: &[u8]) -> Option { + let v: Value = serde_json::from_slice(body).ok()?; + v.get("error_code").and_then(Value::as_str).map(str::to_string) +} + +/// One API request under every rule in the application. Returns the records, +/// whether RepeaterBook's answer hit its size limit, and whether they came +/// from the 10-minute memory cache. +async fn fetch_export( + app: &AppHandle, + token: &str, + endpoint: Endpoint, + params: &[(&'static str, String)], + kind: &str, + area_label: &str, +) -> Result<(Arc>>, bool, bool), String> { + let on_wait = |secs: u64| { + let _ = app.emit("rb-api-wait", secs); + }; + let transport = Transport { + limiter: &LIMITER, + url: endpoint.url(), + https_only: true, + on_wait: &on_wait, + }; + fetch_with(&transport, token, params, kind, area_label).await +} + +/// Where a request goes and whose limits it counts against. The app has one; +/// tests build their own around a local mock server and a fresh limiter. +struct Transport<'a> { + limiter: &'a Mutex, + url: &'a str, + https_only: bool, + /// Told how long a backoff will wait (and 0 when it ends), so the dialog + /// can count it down. + on_wait: &'a (dyn Fn(u64) + Send + Sync), +} + +/// The body of [`fetch_export`]. +/// +/// Waiting for the 2-second spacing and for a 429/5xx backoff happens here, +/// inside the one in-flight request, so a second search cannot start while the +/// first is waiting and the wait cannot be skipped by asking again. +async fn fetch_with( + t: &Transport<'_>, + token: &str, + params: &[(&'static str, String)], + kind: &str, + area_label: &str, +) -> Result<(Arc>>, bool, bool), String> { + let key = format!( + "{}?{}", + t.url, + params.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join("&") + ); + + let _guard = { + let mut l = lock(t.limiter); + let now = Instant::now(); + l.prune(now); + if let Some(hit) = l.cache.iter().find(|e| e.key == key) { + return Ok((hit.export.clone(), hit.truncated, true)); + } + if l.in_flight { + return Err("A RepeaterBook request is already running. Wait for it to finish.".to_string()); + } + if let Some(msg) = &l.auth_failed { + return Err(msg.clone()); + } + if let Some(until) = l.blocked_until { + let secs = until.saturating_duration_since(now).as_secs().max(1); + return Err(format!( + "RepeaterBook asked this app to slow down. Searching is paused for another {secs} s." + )); + } + l.in_flight = true; + InFlight(t.limiter) + }; + + let client = reqwest::Client::builder() + .user_agent(RB_USER_AGENT) + .https_only(t.https_only) + // The token rides in a custom header, which a redirect would carry to + // wherever it points. RepeaterBook has no reason to redirect an API + // call, so a redirect is treated as an error, never followed. + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(90)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + + let mut attempt = 0usize; + loop { + let wait = lock(t.limiter).next_slot(Instant::now())?; + if !wait.is_zero() { + tokio::time::sleep(wait).await; + } + lock(t.limiter).record_request(Instant::now()); + + let resp = match client + .get(t.url) + .query(params) + .header("X-RB-App-Token", token) + .send() + .await + { + Ok(r) => r, + Err(e) => { + log_request(kind, area_label, "network-error"); + // `without_url`: the URL holds only the area, but the error + // text has no need of it. + return Err(format!("Could not reach RepeaterBook: {}", e.without_url())); + } + }; + let status = resp.status(); + log_request(kind, area_label, status.as_str()); + let retry_after = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(parse_retry_after); + + if resp.content_length().is_some_and(|n| n as usize > MAX_BODY_BYTES) { + return Err("RepeaterBook's response was unexpectedly large and was not read.".to_string()); + } + let body = resp + .bytes() + .await + .map_err(|e| format!("Could not read RepeaterBook's response: {}", e.without_url()))?; + if body.len() > MAX_BODY_BYTES { + return Err("RepeaterBook's response was unexpectedly large and was not read.".to_string()); + } + let code = error_code_of(&body); + let rate_limited = code.as_deref() == Some("rate_limited"); + + if status.as_u16() == 401 + || status.as_u16() == 403 + || code.as_deref().is_some_and(|c| c.starts_with("auth_") || c == "ua_mismatch") + { + // Stop at once, no retry — whatever the reason. + if rate_limited { + // A refusal for speed, not for the token: pause, as a + // backoff that gave up would, rather than blame the token. + let delay = retry_after.unwrap_or(Duration::from_secs(BACKOFF_SECS[BACKOFF_SECS.len() - 1])); + lock(t.limiter).blocked_until = Some(Instant::now() + delay); + return Err(slow_down_message(delay)); + } + // Nothing more is sent until the token is replaced, and every + // refused search repeats the reason rather than a generic one. + let msg = auth_message(code.as_deref()); + lock(t.limiter).auth_failed = Some(msg.clone()); + return Err(msg); + } + if status.as_u16() == 429 || status.is_server_error() || rate_limited { + let delay = backoff_delay(attempt, retry_after); + if attempt >= BACKOFF_SECS.len() || delay > MAX_INLINE_WAIT { + lock(t.limiter).blocked_until = Some(Instant::now() + delay); + return Err(slow_down_message(delay)); + } + (t.on_wait)(delay.as_secs()); + tokio::time::sleep(delay).await; + (t.on_wait)(0); + attempt += 1; + continue; + } + if !status.is_success() { + return Err(format!("RepeaterBook answered HTTP {status}.")); + } + if code.is_some() { + let msg = serde_json::from_slice::(&body) + .ok() + .and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| "unknown error".to_string()); + return Err(format!("RepeaterBook returned an error: {msg}")); + } + + let raw: RawExport = serde_json::from_slice(&body) + .map_err(|e| format!("RepeaterBook's response could not be read: {e}"))?; + let results = raw.results.unwrap_or_default(); + let count = raw + .count + .as_ref() + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .unwrap_or(results.len() as u64) as usize; + let truncated = results.len() >= SERVER_TRUNCATION || count > results.len(); + let export = Arc::new(results); + + let mut l = lock(t.limiter); + l.cache.push(CacheEntry { key, at: Instant::now(), export: export.clone(), truncated }); + let overflow = l.cache.len().saturating_sub(CACHE_ENTRIES); + l.cache.drain(..overflow); + return Ok((export, truncated, false)); + } +} + +fn slow_down_message(delay: Duration) -> String { + let mins = delay.as_secs().div_ceil(60).max(1); + format!( + "RepeaterBook is busy or asked this app to slow down, so it stopped trying. Try again in about {mins} minute{}.", + if mins == 1 { "" } else { "s" } + ) +} + +// ============================================================ +// The token +// ============================================================ + +const TOKEN_USER: &str = "api-token"; + +/// Keychain service name. Taken from the app identifier, so the dev build +/// (`com.ww8l.codeplugmagic.dev`) keeps its own token, as it keeps its own +/// database. +fn token_service(app: &AppHandle) -> String { + format!("{}.repeaterbook", app.config().identifier) +} + +fn token_entry(app: &AppHandle) -> Result { + keyring::Entry::new(&token_service(app), TOKEN_USER) + .map_err(|e| format!("Could not open the system credential store: {e}")) +} + +/// The stored token, or `None` if there is none. +async fn read_token(app: &AppHandle) -> Result, String> { + let entry = token_entry(app)?; + tokio::task::spawn_blocking(move || match entry.get_password() { + Ok(t) => Ok(Some(t)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("Could not read the RepeaterBook token from the system credential store: {e}")), + }) + .await + .map_err(|e| e.to_string())? +} + +async fn delete_token(app: &AppHandle) -> Result { + let entry = token_entry(app)?; + tokio::task::spawn_blocking(move || match entry.delete_credential() { + Ok(()) => Ok(true), + Err(keyring::Error::NoEntry) => Ok(false), + Err(e) => Err(format!("Could not remove the RepeaterBook token: {e}")), + }) + .await + .map_err(|e| e.to_string())? +} + +/// Check a pasted token's shape. RepeaterBook's per-user app tokens start +/// `rbuapp_`; a shared `app_` token must never be in a distributed app, so one +/// pasted here is refused rather than stored. +fn validate_token(raw: &str) -> Result { + let t = raw.trim(); + if t.is_empty() { + return Err("Paste your RepeaterBook token first.".to_string()); + } + if t.starts_with("app_") { + return Err( + "That is a shared server token (app_…). This app needs your own per-user token, which starts with rbuapp_." + .to_string(), + ); + } + if !t.starts_with("rbuapp_") || t.len() <= "rbuapp_".len() { + return Err("That doesn't look like a RepeaterBook app token — they start with rbuapp_.".to_string()); + } + if t.len() > 512 || !t.bytes().all(|b| b.is_ascii_graphic()) { + return Err("That token has characters a RepeaterBook token cannot contain.".to_string()); + } + Ok(t.to_string()) +} + +async fn require_token(app: &AppHandle) -> Result { + read_token(app).await?.ok_or_else(|| { + "Add your RepeaterBook token in Settings first. Each user creates their own free token on RepeaterBook." + .to_string() + }) +} + +// ============================================================ +// Library queries +// ============================================================ + +async fn library_count(pool: &SqlitePool) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM channels WHERE source = ?1") + .bind(API_SOURCE) + .fetch_one(pool) + .await + .estr() +} + +/// API records not refreshed within 12 months. +const STALE_WHERE: &str = "source = 'repeaterbook_api' \ + AND (last_rb_update IS NULL OR last_rb_update < datetime('now', '-12 months'))"; + +// ============================================================ +// Commands +// ============================================================ + +#[derive(Debug, Serialize)] +pub struct RbStatus { + pub has_token: bool, + pub library_count: i64, + pub stale_count: i64, + pub max_results: usize, + pub max_selected: usize, + pub requests_last_hour: usize, + pub requests_per_hour: usize, + pub tokens_page: &'static str, +} + +#[tauri::command] +pub async fn rb_status(app: AppHandle, state: State<'_, AppState>) -> Result { + let stale_count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM channels WHERE {STALE_WHERE}")) + .fetch_one(&state.pool) + .await + .estr()?; + Ok(RbStatus { + has_token: read_token(&app).await?.is_some(), + library_count: library_count(&state.pool).await?, + stale_count, + max_results: MAX_RESULTS, + max_selected: MAX_SELECTED, + requests_last_hour: limiter().requests_last_hour(Instant::now()), + requests_per_hour: MAX_PER_HOUR, + tokens_page: TOKENS_PAGE, + }) +} + +#[tauri::command] +pub async fn rb_areas() -> Vec { + NA_AREAS + .iter() + .map(|(state_id, name, country)| RbAreaInfo { state_id, name, country }) + .collect() +} + +#[tauri::command] +pub async fn rb_set_token(app: AppHandle, token: String) -> Result<(), String> { + let token = validate_token(&token)?; + let entry = token_entry(&app)?; + tokio::task::spawn_blocking(move || entry.set_password(&token)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| format!("Could not save the token in the system credential store: {e}"))?; + // A new token is the user's answer to a 401/403. + limiter().auth_failed = None; + Ok(()) +} + +#[tauri::command] +pub async fn rb_remove_token(app: AppHandle) -> Result { + delete_token(&app).await +} + +/// One row of a search result. +#[derive(Debug, Serialize)] +pub struct RbResultRow { + #[serde(flatten)] + pub channel: ParsedChannel, + /// `"api"` when this repeater is already in the library from the API (an + /// import refreshes it), `"file"` when a RepeaterBook file import has it + /// (an import takes that row over), else `None`. + pub in_library: Option<&'static str>, +} + +#[derive(Debug, Serialize)] +pub struct RbSearchResult { + pub area: String, + /// Records matching the search after the band and mode filters. + pub matched: usize, + /// More than `MAX_RESULTS` matched: `rows` is empty and nothing can be + /// imported until the search is narrowed. + pub too_many: bool, + /// RepeaterBook's response hit its own size limit, so the band/mode filter + /// could not be trusted to see every repeater. `rows` is empty. + pub server_truncated: bool, + pub from_cache: bool, + pub rows: Vec, +} + +#[tauri::command] +pub async fn rb_search( + app: AppHandle, + state: State<'_, AppState>, + area: RbArea, + filters: RbFilters, +) -> Result { + let resolved = resolve_area(&area)?; + let bands = band_ranges(&filters.band)?; + let modes = mode_entries(&filters.mode)?; + let emcomm = emcomm_flags(&filters.emcomm)?; + if let Some(n) = &filters.near { + if !(n.miles > 0.0 && n.miles <= 1000.0) { + return Err("Pick a distance between 1 and 1,000 miles.".to_string()); + } + } + let token = require_token(&app).await?; + + let mut params = vec![resolved.param.clone()]; + if let Some(api_mode) = api_mode_param(&modes) { + params.push(("mode", api_mode.to_string())); + } + let (export, truncated, from_cache) = + fetch_export(&app, &token, resolved.endpoint, ¶ms, "search", &resolved.label).await?; + + let narrow = Narrowing { + bands, + mode_flags: modes.iter().map(|m| m.2).collect(), + emcomm_flags: emcomm, + county: lower_trimmed(filters.county.as_deref()), + city: lower_trimmed(filters.city.as_deref()), + callsign: lower_trimmed(filters.callsign.as_deref()), + on_air_only: filters.on_air_only, + open_only: filters.open_only, + near: filters.near, + }; + let rows = filter_records(&export, resolved.endpoint, &narrow); + // Marked before counting so "hide what I already have" narrows the count + // the 1,000 limit is judged on, like every other filter. + let mut rows = mark_in_library(&state.pool, rows).await?; + if filters.hide_in_library { + rows.retain(|r| r.in_library.is_none()); + } + let mut result = RbSearchResult { + area: resolved.label, + matched: rows.len(), + too_many: rows.len() > MAX_RESULTS, + server_truncated: truncated, + from_cache, + rows: Vec::new(), + }; + if result.too_many || result.server_truncated { + return Ok(result); + } + result.rows = rows; + Ok(result) +} + +/// Ways to narrow a search, all optional. Only the area and the mode reach +/// RepeaterBook (the mode only where the API has a value for it); everything +/// else runs on the response, so changing it within 10 minutes is answered +/// from memory without asking RepeaterBook again. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct RbFilters { + /// Bands, any of which matches (2 m or 70 cm). Empty means any band. + pub band: Vec, + /// Modes, any of which matches (DMR or D-STAR). Empty means any mode. + pub mode: Vec, + /// Substring of the county, any case. + pub county: Option, + /// Substring of the nearest city. + pub city: Option, + /// Substring of the call sign. + pub callsign: Option, + /// Only repeaters RepeaterBook lists as on the air. + pub on_air_only: bool, + /// Only open repeaters (not closed or private). + pub open_only: bool, + /// Any of ARES, RACES, SKYWARN, CANWARN — North America only. + pub emcomm: Vec, + /// Within this many miles of a point. Repeaters with no coordinates are + /// left out, since nothing says how far they are. + pub near: Option, + /// Leave out repeaters already in the library, from the API or a file. + pub hide_in_library: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct RbNear { + pub latitude: f64, + pub longitude: f64, + pub miles: f64, +} + +/// [`RbFilters`] after validation, ready to apply to records. +#[derive(Default)] +struct Narrowing { + bands: Vec<(f64, f64)>, + mode_flags: Vec<&'static str>, + emcomm_flags: Vec<&'static str>, + county: Option, + city: Option, + callsign: Option, + on_air_only: bool, + open_only: bool, + near: Option, +} + +const EMCOMM: &[&str] = &["ARES", "RACES", "SKYWARN", "CANWARN"]; + +fn emcomm_flags(v: &[String]) -> Result, String> { + v.iter() + .map(|e| { + EMCOMM + .iter() + .find(|k| k.eq_ignore_ascii_case(e)) + .copied() + .ok_or_else(|| format!("Unknown emergency service \"{e}\".")) + }) + .collect() +} + +fn lower_trimmed(v: Option<&str>) -> Option { + v.map(str::trim).filter(|s| !s.is_empty()).map(str::to_lowercase) +} + +fn contains_ci(field: Option<&str>, needle: &Option) -> bool { + match needle { + None => true, + Some(n) => field.is_some_and(|f| f.to_lowercase().contains(n.as_str())), + } +} + +/// Great-circle distance in statute miles. +fn haversine_miles(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const EARTH_RADIUS_MI: f64 = 3958.8; + let (p1, p2) = (lat1.to_radians(), lat2.to_radians()); + let dlat = (lat2 - lat1).to_radians(); + let dlon = (lon2 - lon1).to_radians(); + let a = (dlat / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dlon / 2.0).sin().powi(2); + EARTH_RADIUS_MI * 2.0 * a.sqrt().asin() +} + +fn filter_records( + export: &[HashMap], + endpoint: Endpoint, + n: &Narrowing, +) -> Vec { + export + .iter() + .filter(|rec| n.mode_flags.is_empty() || n.mode_flags.iter().any(|f| record_has_mode(rec, f))) + .filter(|rec| n.emcomm_flags.is_empty() || n.emcomm_flags.iter().any(|f| flag(rec, f))) + .filter_map(|rec| parse_api_record(rec, endpoint)) + .filter(|p| n.bands.is_empty() || n.bands.iter().any(|(lo, hi)| p.rx_freq >= *lo && p.rx_freq <= *hi)) + .filter(|p| contains_ci(p.county.as_deref(), &n.county)) + .filter(|p| contains_ci(p.city.as_deref(), &n.city)) + .filter(|p| contains_ci(Some(&p.callsign), &n.callsign)) + .filter(|p| !n.on_air_only || p.operational_status.as_deref() == Some("On Air")) + .filter(|p| !n.open_only || p.use_type.as_deref() == Some("Open")) + .filter(|p| { + n.near.is_none_or(|near| match (p.latitude, p.longitude) { + (Some(lat), Some(lon)) => { + haversine_miles(near.latitude, near.longitude, lat, lon) <= near.miles + } + _ => false, + }) + }) + .collect() +} + +async fn mark_in_library( + pool: &SqlitePool, + rows: Vec, +) -> Result, String> { + let existing: Vec<(String, String)> = sqlx::query_as( + "SELECT repeaterbook_id, source FROM channels \ + WHERE repeaterbook_id IS NOT NULL AND source IN ('repeaterbook', 'repeaterbook_api')", + ) + .fetch_all(pool) + .await + .estr()?; + let api: HashSet<&str> = existing + .iter() + .filter(|(_, s)| s == API_SOURCE) + .map(|(id, _)| id.as_str()) + .collect(); + let file: HashSet<&str> = existing + .iter() + .filter(|(_, s)| s == FILE_SOURCE) + .map(|(id, _)| id.as_str()) + .collect(); + Ok(rows + .into_iter() + .map(|channel| { + let in_library = if api.contains(channel.repeaterbook_id.as_str()) { + Some("api") + } else if file.contains(legacy_id(&channel).as_str()) { + Some("file") + } else { + None + }; + RbResultRow { channel, in_library } + }) + .collect()) +} + +/// The id a RepeaterBook *file* import would have given this repeater. +fn legacy_id(p: &ParsedChannel) -> String { + synthetic_rb_id(&p.callsign, p.rx_freq, p.state.as_deref(), p.city.as_deref()) +} + +#[derive(Debug, Serialize)] +pub struct RbImportSummary { + pub added: usize, + pub updated: usize, + /// Rows from a RepeaterBook file import that this import took over. They + /// are now API records, and are counted in `added` too: for the user they + /// are new RepeaterBook records. + pub took_over: usize, + pub library_count: i64, +} + +/// Save the rows the user selected from a search. +#[tauri::command] +pub async fn rb_import( + state: State<'_, AppState>, + rows: Vec, +) -> Result { + import_impl(&state.pool, rows).await +} + +async fn import_impl(pool: &SqlitePool, rows: Vec) -> Result { + if rows.is_empty() { + return Err("Select at least one repeater to import.".to_string()); + } + if rows.len() > MAX_SELECTED { + return Err(format!( + "At most {MAX_SELECTED} repeaters can be imported at once; {} are selected.", + rows.len() + )); + } + let mut seen = HashSet::new(); + let mut rows: Vec = rows + .into_iter() + .filter(|r| seen.insert(r.repeaterbook_id.clone())) + .collect(); + for r in &mut rows { + if split_api_id(&r.repeaterbook_id).is_none() { + return Err("Only repeaters from a RepeaterBook search can be imported here.".to_string()); + } + // `covers` does not survive the trip through the UI; set the truth. + r.covers = API_COVERS; + } + + let mut conn = pool.acquire().await.estr()?; + let mut tx = conn.begin().await.estr()?; + + let existing_api: HashSet = sqlx::query_scalar::<_, String>( + "SELECT repeaterbook_id FROM channels WHERE source = ?1 AND repeaterbook_id IS NOT NULL", + ) + .bind(API_SOURCE) + .fetch_all(&mut *tx) + .await + .estr()? + .into_iter() + .collect(); + // A repeater already imported from a RepeaterBook file becomes this API + // record instead of being duplicated beside it. It keeps the operator's + // names and edits; the merge below refreshes what RepeaterBook owns. + let mut took_over = 0usize; + for r in &rows { + if existing_api.contains(&r.repeaterbook_id) { + continue; + } + took_over += sqlx::query( + "UPDATE channels SET source = ?1, repeaterbook_id = ?2 \ + WHERE source = 'repeaterbook' AND repeaterbook_id = ?3", + ) + .bind(API_SOURCE) + .bind(&r.repeaterbook_id) + .bind(legacy_id(r)) + .execute(&mut *tx) + .await + .estr()? + .rows_affected() as usize; + } + + let (added, updated) = insert_parsed_tx(&mut tx, &rows, API_SOURCE).await?; + tx.commit().await.estr()?; + + Ok(RbImportSummary { + // A taken-over row went through the merge path, so it is in `updated`; + // for the user it is a new RepeaterBook record. + added: added + took_over, + updated: updated - took_over, + took_over, + library_count: library_count(pool).await?, + }) +} + +/// Refresh one API record the user asked to refresh. One request, narrowed by +/// the record's area and call sign; the reply is matched on RepeaterBook's own +/// State ID + Repeater ID. +#[tauri::command] +pub async fn rb_refresh_channel( + app: AppHandle, + state: State<'_, AppState>, + id: i64, +) -> Result<(), String> { + let (source, rb_id, callsign, country): (String, Option, Option, Option) = + sqlx::query_as("SELECT source, repeaterbook_id, callsign, country FROM channels WHERE id = ?1") + .bind(id) + .fetch_optional(&state.pool) + .await + .estr()? + .ok_or_else(|| "That channel no longer exists.".to_string())?; + if source != API_SOURCE { + return Err("Only repeaters imported from the RepeaterBook API can be refreshed.".to_string()); + } + let rb_id = rb_id.ok_or_else(|| { + "This is a copy of a RepeaterBook repeater, not the listing itself, so it can't be refreshed. Refresh the original.".to_string() + })?; + let (endpoint, state_id, rptr_id) = + split_api_id(&rb_id).ok_or_else(|| "This record's RepeaterBook id is not recognised.".to_string())?; + let callsign = callsign + .filter(|c| !c.trim().is_empty()) + .ok_or_else(|| "This repeater has no call sign to look it up by.".to_string())?; + + let (area_param, label) = match endpoint { + Endpoint::NorthAmerica => { + let name = NA_AREAS + .iter() + .find(|(sid, _, _)| *sid == state_id) + .map(|(_, n, _)| *n) + .unwrap_or(state_id); + (("state_id", state_id.to_string()), name.to_string()) + } + Endpoint::RestOfWorld => { + let c = country + .filter(|c| !c.trim().is_empty()) + .ok_or_else(|| "This repeater has no country to look it up by.".to_string())?; + (("country", c.clone()), c) + } + }; + let token = require_token(&app).await?; + let params = vec![area_param, ("callsign", callsign)]; + let (export, _, _) = fetch_export(&app, &token, endpoint, ¶ms, "refresh", &label).await?; + + let fresh = export + .iter() + .find(|rec| { + jstr(rec, "State ID").as_deref() == Some(state_id) + && jstr(rec, "Rptr ID").as_deref() == Some(rptr_id) + }) + .and_then(|rec| parse_api_record(rec, endpoint)) + .ok_or_else(|| { + "RepeaterBook no longer lists this repeater under its call sign. Keep it as it is, or delete it." + .to_string() + })?; + + let mut conn = state.pool.acquire().await.estr()?; + let mut tx = conn.begin().await.estr()?; + insert_parsed_tx(&mut tx, std::slice::from_ref(&fresh), API_SOURCE).await?; + tx.commit().await.estr()?; + Ok(()) +} + +/// Ids of API records not refreshed within 12 months, for the library's +/// "refresh or delete" prompt. +#[tauri::command] +pub async fn rb_stale_channel_ids(state: State<'_, AppState>) -> Result, String> { + sqlx::query_scalar(&format!("SELECT id FROM channels WHERE {STALE_WHERE} ORDER BY id")) + .fetch_all(&state.pool) + .await + .estr() +} + +#[derive(Debug, Serialize)] +pub struct RbRemoved { + pub channels_deleted: u64, + pub token_removed: bool, +} + +/// "Remove RepeaterBook data": every API record AND the stored token, in one +/// step. Records first — they are the larger promise — then the token, then +/// the memory cache, so nothing RepeaterBook sent survives in the app. +#[tauri::command] +pub async fn rb_remove_all_data(app: AppHandle, state: State<'_, AppState>) -> Result { + let channels_deleted = remove_api_records(&state.pool).await?; + let token_removed = delete_token(&app).await?; + { + let mut l = limiter(); + l.cache.clear(); + l.auth_failed = None; + } + Ok(RbRemoved { channels_deleted, token_removed }) +} + +async fn remove_api_records(pool: &SqlitePool) -> Result { + let ids: Vec = sqlx::query_scalar("SELECT id FROM channels WHERE source = ?1") + .bind(API_SOURCE) + .fetch_all(pool) + .await + .estr()?; + super::channels::delete_impl(pool, &ids).await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + async fn test_pool() -> SqlitePool { + use std::sync::atomic::{AtomicU32, Ordering}; + static SEQ: AtomicU32 = AtomicU32::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("cpm_rb_api_test_{}_{n}", std::process::id())); + let db_path = dir.join("test.sqlite3"); + let _ = std::fs::remove_file(&db_path); + crate::db::init_pool(&db_path).await.expect("init_pool") + } + + /// A North American record in the shape the API returns: the field names + /// approved clients (dmrfill, the RepeaterBook Python client) decode. + fn na_record(rptr: u32, call: &str, freq: &str) -> HashMap { + serde_json::from_value(json!({ + "State ID": "26", "Rptr ID": rptr, "Frequency": freq, "Input Freq": "146.34000", + "PL": "100.0", "TSQ": "", "Nearest City": "Grand Rapids", "Landmark": "Hill", + "County": "Kent", "State": "Michigan", "Country": "United States", + "Lat": "42.96", "Long": "-85.67", "Precise": 1, "Callsign": call, + "Use": "OPEN", "Operational Status": "On-air", "AllStar Node": "", + "EchoLink Node": "12345", "IRLP Node": "", "Wires Node": "", + "FM Analog": "Yes", "DMR": "No", "DMR Color Code": "", "D-Star": "No", + "NXDN": "No", "APCO P-25": "No", "P-25 NAC": "", "M17": "No", "Tetra": "No", + "System Fusion": "Yes", "Notes": "Club machine", "Last Update": "2026-01-01" + })) + .unwrap() + } + + #[test] + fn the_user_agent_is_exactly_what_was_approved() { + // RepeaterBook matches it literally; a version number here breaks every + // user's token the day it changes. + assert_eq!( + RB_USER_AGENT, + "CodePlugMagic (+https://github.com/ww8l/codeplug-magic; WW8L; tim@ww8l.com)" + ); + } + + #[test] + fn the_promised_numbers() { + assert_eq!(MAX_RESULTS, 1000); + assert_eq!(MAX_SELECTED, 500); + assert_eq!(MIN_SPACING, Duration::from_secs(2)); + assert_eq!(MAX_PER_HOUR, 20); + assert_eq!(CACHE_TTL, Duration::from_secs(600)); + assert_eq!(BACKOFF_SECS, [30, 60, 120]); + } + + #[test] + fn a_token_must_be_a_per_user_app_token() { + assert_eq!(validate_token(" rbuapp_abc123 ").unwrap(), "rbuapp_abc123"); + assert!(validate_token("").is_err()); + assert!(validate_token("app_shared").unwrap_err().contains("shared")); + assert!(validate_token("rbuapp_").is_err()); + assert!(validate_token("hello").is_err()); + assert!(validate_token("rbuapp_has space").is_err()); + } + + #[test] + fn an_api_record_becomes_a_channel() { + let p = parse_api_record(&na_record(7, "W8ABC", "146.94000"), Endpoint::NorthAmerica).unwrap(); + assert_eq!(p.repeaterbook_id, "rbapi:na:26:7"); + assert_eq!(p.rx_freq, 146.94); + assert_eq!(p.duplex, "-"); + assert_eq!(p.state.as_deref(), Some("MI"), "state stored as the postal code"); + assert_eq!(p.country.as_deref(), Some("United States")); + assert_eq!(p.tone_mode, "Tone"); + assert_eq!(p.ctcss_uplink, Some(100.0)); + assert!(p.ysf_capable); + assert_eq!(p.mode, "YSF"); + assert_eq!(p.operational_status.as_deref(), Some("On Air")); + assert_eq!(p.use_type.as_deref(), Some("Open")); + assert_eq!(p.echolink_node.as_deref(), Some("12345")); + assert_eq!(p.allstar_node, None, "a blank node is no node"); + assert_eq!(p.notes.as_deref(), Some("Landmark: Hill | Club machine")); + assert_eq!(p.latitude, Some(42.96)); + } + + #[test] + fn a_dcs_record_keeps_its_code() { + let mut r = na_record(8, "W8DCS", "443.10000"); + r.insert("PL".into(), json!("D023")); + r.insert("TSQ".into(), json!("D023")); + r.insert("Input Freq".into(), json!("448.10000")); + let p = parse_api_record(&r, Endpoint::NorthAmerica).unwrap(); + assert_eq!(p.tone_mode, "DTCS"); + assert_eq!(p.dcs_code.as_deref(), Some("023")); + } + + #[test] + fn a_rest_of_world_region_is_never_read_as_a_us_state() { + let mut r = na_record(3, "4L1AB", "145.60000"); + r.insert("State ID".into(), json!("GE01")); + r.insert("State".into(), json!("Georgia")); + r.insert("Country".into(), json!("Georgia")); + let p = parse_api_record(&r, Endpoint::RestOfWorld).unwrap(); + assert_eq!(p.state.as_deref(), Some("Georgia")); + assert_eq!(p.country.as_deref(), Some("Georgia"), "not the United States"); + // The same name from North America is the state. + let p = parse_api_record(&r, Endpoint::NorthAmerica).unwrap(); + assert_eq!(p.state.as_deref(), Some("GA")); + } + + #[test] + fn a_record_without_an_identifier_is_skipped() { + let mut r = na_record(9, "W8X", "146.94000"); + r.remove("Rptr ID"); + assert!(parse_api_record(&r, Endpoint::NorthAmerica).is_none()); + } + + #[test] + fn api_ids_round_trip() { + let id = api_id(Endpoint::RestOfWorld, "GB01", "4321"); + assert_eq!(split_api_id(&id), Some((Endpoint::RestOfWorld, "GB01", "4321"))); + assert_eq!(split_api_id("W0CPH|146.9400|CO|DENVER"), None); + } + + #[test] + fn areas_resolve_to_one_parameter() { + let a = resolve_area(&RbArea::Region { state_id: "26".into() }).unwrap(); + assert_eq!(a.endpoint, Endpoint::NorthAmerica); + assert_eq!(a.param, ("state_id", "26".to_string())); + // "6" is not "06": RepeaterBook answers it with nothing, not an error. + assert!(resolve_area(&RbArea::Region { state_id: "6".into() }).is_err()); + let c = resolve_area(&RbArea::Country { country: " Germany ".into() }).unwrap(); + assert_eq!(c.endpoint, Endpoint::RestOfWorld); + assert_eq!(c.param, ("country", "Germany".to_string())); + assert!(resolve_area(&RbArea::Country { country: "Canada".into() }).is_err()); + assert!(resolve_area(&RbArea::Country { country: "".into() }).is_err()); + } + + #[test] + fn band_and_mode_filters_run_on_the_response() { + let mut dmr = na_record(2, "W8DMR", "442.50000"); + dmr.insert("DMR".into(), json!("Yes")); + dmr.insert("Input Freq".into(), json!("447.50000")); + let recs = vec![na_record(1, "W8FM", "146.94000"), dmr]; + let all = filter_records(&recs, Endpoint::NorthAmerica, &Narrowing::default()); + assert_eq!(all.len(), 2); + let two_m = filter_records( + &recs, + Endpoint::NorthAmerica, + &Narrowing { bands: band_ranges(&["2m".into()]).unwrap(), ..Default::default() }, + ); + assert_eq!(two_m.len(), 1); + assert_eq!(two_m[0].callsign, "W8FM"); + let dmr_only = filter_records( + &recs, + Endpoint::NorthAmerica, + &Narrowing { mode_flags: vec!["DMR"], ..Default::default() }, + ); + assert_eq!(dmr_only.len(), 1); + assert_eq!(dmr_only[0].callsign, "W8DMR"); + // Two choices in one category match either. + let both_bands = filter_records( + &recs, + Endpoint::NorthAmerica, + &Narrowing { bands: band_ranges(&["2m".into(), "70cm".into()]).unwrap(), ..Default::default() }, + ); + assert_eq!(both_bands.len(), 2); + let fm_or_dmr = filter_records( + &recs, + Endpoint::NorthAmerica, + &Narrowing { mode_flags: vec!["FM Analog", "DMR"], ..Default::default() }, + ); + assert_eq!(fm_or_dmr.len(), 2); + // One API-known mode goes to RepeaterBook; D-STAR, or two modes, do not. + let m = |keys: &[&str]| mode_entries(&keys.iter().map(|k| k.to_string()).collect::>()).unwrap(); + assert_eq!(api_mode_param(&m(&["DMR"])), Some("DMR")); + assert_eq!(api_mode_param(&m(&["DSTAR"])), None); + assert_eq!(api_mode_param(&m(&["DMR", "DSTAR"])), None); + assert_eq!(api_mode_param(&m(&[])), None); + assert!(band_ranges(&["4m".into()]).is_err()); + } + + #[test] + fn the_narrower_filters() { + let mut off = na_record(2, "K8OFF", "147.00000"); + off.insert("Operational Status".into(), json!("Off-air")); + off.insert("Use".into(), json!("CLOSED")); + off.insert("County".into(), json!("Ottawa")); + off.insert("Nearest City".into(), json!("Holland")); + off.insert("Lat".into(), json!("42.79")); + off.insert("Long".into(), json!("-86.11")); + off.insert("SKYWARN".into(), json!("Yes")); + let recs = vec![na_record(1, "W8ABC", "146.94000"), off]; + let run = |n: Narrowing| -> Vec { + filter_records(&recs, Endpoint::NorthAmerica, &n).into_iter().map(|p| p.callsign).collect() + }; + assert_eq!(run(Narrowing { on_air_only: true, ..Default::default() }), ["W8ABC"]); + assert_eq!(run(Narrowing { open_only: true, ..Default::default() }), ["W8ABC"]); + assert_eq!(run(Narrowing { county: lower_trimmed(Some(" otta ")), ..Default::default() }), ["K8OFF"]); + assert_eq!(run(Narrowing { city: lower_trimmed(Some("grand")), ..Default::default() }), ["W8ABC"]); + assert_eq!(run(Narrowing { callsign: lower_trimmed(Some("k8")), ..Default::default() }), ["K8OFF"]); + assert_eq!(run(Narrowing { emcomm_flags: emcomm_flags(&["skywarn".into()]).unwrap(), ..Default::default() }), ["K8OFF"]); + // Grand Rapids to Holland is about 25 miles. + let near_gr = |miles| Narrowing { + near: Some(RbNear { latitude: 42.96, longitude: -85.67, miles }), + ..Default::default() + }; + assert_eq!(run(near_gr(10.0)), ["W8ABC"]); + assert_eq!(run(near_gr(40.0)), ["W8ABC", "K8OFF"]); + assert!(emcomm_flags(&["CERT".into()]).is_err()); + } + + #[test] + fn the_hourly_budget_and_spacing_are_enforced() { + let mut l = Limiter::new(MIN_SPACING); + let t0 = Instant::now(); + assert_eq!(l.next_slot(t0).unwrap(), Duration::ZERO); + l.record_request(t0); + // One second later the 2 s spacing still has a second to run. + let wait = l.next_slot(t0 + Duration::from_secs(1)).unwrap(); + assert_eq!(wait, Duration::from_secs(1)); + for i in 1..MAX_PER_HOUR as u64 { + l.record_request(t0 + Duration::from_secs(i * 3)); + } + let err = l.next_slot(t0 + Duration::from_secs(100)).unwrap_err(); + assert!(err.contains("20 requests an hour"), "{err}"); + // An hour after the first request, one slot frees up. + assert!(l.next_slot(t0 + HOUR + Duration::from_secs(1)).is_ok()); + } + + // ---- the request path, against a local mock server ---- + + type Script = Vec<(u16, Vec<(&'static str, &'static str)>, String)>; + + /// A one-shot HTTP server on localhost: each connection gets the next + /// scripted response, and the raw request text is kept for inspection. + async fn mock(script: Script) -> (String, Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + tokio::spawn(async move { + for (status, headers, body) in script { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = sock.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + log.lock().unwrap().push(String::from_utf8_lossy(&buf).to_string()); + let mut resp = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (k, v) in headers { + resp.push_str(&format!("{k}: {v}\r\n")); + } + resp.push_str("\r\n"); + resp.push_str(&body); + sock.write_all(resp.as_bytes()).await.unwrap(); + let _ = sock.shutdown().await; + } + }); + (url, seen) + } + + fn ok_body() -> String { + json!({ "count": 1, "results": [na_record(1, "W8ABC", "146.94000")] }).to_string() + } + + async fn fetch( + lim: &Mutex, + url: &str, + ) -> Result<(Arc>>, bool, bool), String> { + let t = Transport { limiter: lim, url, https_only: false, on_wait: &|_| {} }; + fetch_with(&t, "rbuapp_secret", &[("state_id", "26".to_string())], "search", "Michigan").await + } + + #[tokio::test] + async fn the_token_travels_only_in_its_header_and_a_repeat_is_served_from_memory() { + let (url, seen) = mock(vec![(200, vec![], ok_body())]).await; + let lim = Mutex::new(Limiter::new(Duration::ZERO)); + let (recs, truncated, cached) = fetch(&lim, &url).await.unwrap(); + assert_eq!((recs.len(), truncated, cached), (1, false, false)); + + let req = seen.lock().unwrap()[0].clone(); + let request_line = req.lines().next().unwrap().to_string(); + assert_eq!(request_line, "GET /?state_id=26 HTTP/1.1"); + let lower = req.to_lowercase(); + assert!(lower.contains("x-rb-app-token: rbuapp_secret"), "{req}"); + assert!( + lower.contains(&format!("user-agent: {}", RB_USER_AGENT.to_lowercase())), + "{req}" + ); + + let (_, _, cached) = fetch(&lim, &url).await.unwrap(); + assert!(cached, "an identical request inside 10 minutes is answered from memory"); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn a_429_is_retried_three_times_then_searching_pauses() { + let busy = |after: &'static str| (429, vec![("Retry-After", after)], "{}".to_string()); + let (url, seen) = mock(vec![busy("0"), busy("0"), busy("0"), busy("60")]).await; + let lim = Mutex::new(Limiter::new(Duration::ZERO)); + let err = fetch(&lim, &url).await.unwrap_err(); + assert!(err.contains("stopped trying"), "{err}"); + assert_eq!(seen.lock().unwrap().len(), 4, "one request and three retries"); + // The pause holds whatever the user does. + let err = fetch(&lim, &url).await.unwrap_err(); + assert!(err.contains("paused"), "{err}"); + assert_eq!(seen.lock().unwrap().len(), 4); + } + + #[tokio::test] + async fn a_server_error_then_success_recovers() { + let (url, seen) = mock(vec![ + (503, vec![("Retry-After", "0")], "{}".to_string()), + (200, vec![], ok_body()), + ]) + .await; + let lim = Mutex::new(Limiter::new(Duration::ZERO)); + assert_eq!(fetch(&lim, &url).await.unwrap().0.len(), 1); + assert_eq!(seen.lock().unwrap().len(), 2); + } + + #[tokio::test] + async fn a_401_stops_at_once_and_blocks_until_the_token_is_replaced() { + let body = json!({ "error_code": "auth_invalid", "message": "bad" }).to_string(); + let (url, seen) = mock(vec![(401, vec![], body)]).await; + let lim = Mutex::new(Limiter::new(Duration::ZERO)); + let err = fetch(&lim, &url).await.unwrap_err(); + assert!(err.contains("did not accept your token"), "{err}"); + let again = fetch(&lim, &url).await.unwrap_err(); + assert_eq!(again, err, "the refusal repeats its own reason"); + assert_eq!(seen.lock().unwrap().len(), 1, "no retry, nothing more sent"); + } + + #[tokio::test] + async fn a_403_for_rate_is_a_pause_not_a_bad_token() { + let body = json!({ "error_code": "rate_limited", "message": "slow" }).to_string(); + let (url, seen) = mock(vec![(403, vec![("Retry-After", "30")], body)]).await; + let lim = Mutex::new(Limiter::new(Duration::ZERO)); + let err = fetch(&lim, &url).await.unwrap_err(); + assert!(err.contains("slow down"), "{err}"); + assert!(lock(&lim).auth_failed.is_none(), "the token was not at fault"); + let err = fetch(&lim, &url).await.unwrap_err(); + assert!(err.contains("paused"), "{err}"); + assert_eq!(seen.lock().unwrap().len(), 1, "a 403 is never retried"); + } + + #[tokio::test] + async fn an_identity_refusal_says_so_every_time() { + let body = json!({ "error_code": "ua_mismatch", "message": "ua" }).to_string(); + let (url, _) = mock(vec![(403, vec![], body)]).await; + let lim = Mutex::new(Limiter::new(Duration::ZERO)); + fetch(&lim, &url).await.unwrap_err(); + let err = fetch(&lim, &url).await.unwrap_err(); + assert!(err.contains("not your token"), "{err}"); + } + + #[test] + fn backoff_honours_retry_after_else_30_60_120() { + assert_eq!(backoff_delay(0, None), Duration::from_secs(30)); + assert_eq!(backoff_delay(1, None), Duration::from_secs(60)); + assert_eq!(backoff_delay(2, None), Duration::from_secs(120)); + assert_eq!(backoff_delay(0, Some(Duration::from_secs(7))), Duration::from_secs(7)); + assert_eq!(parse_retry_after("45"), Some(Duration::from_secs(45))); + assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), Some(Duration::ZERO)); + assert_eq!(parse_retry_after("soon"), None); + } + + #[test] + fn error_bodies_name_the_problem() { + assert_eq!(error_code_of(br#"{"error_code":"auth_revoked","message":"x"}"#).as_deref(), Some("auth_revoked")); + assert_eq!(error_code_of(b""), None); + assert!(auth_message(Some("ua_mismatch")).contains("not your token")); + } + + fn rows(n: u32) -> Vec { + (0..n) + .map(|i| { + let freq = format!("{:.5}", 440.0 + f64::from(i) * 0.0125); + parse_api_record(&na_record(i, &format!("W8{i:04}"), &freq), Endpoint::NorthAmerica).unwrap() + }) + .collect() + } + + #[tokio::test] + async fn import_stamps_the_api_source_and_refreshes_on_reimport() { + let pool = test_pool().await; + let s = import_impl(&pool, rows(3)).await.unwrap(); + assert_eq!((s.added, s.updated, s.library_count), (3, 0, 3)); + let s = import_impl(&pool, rows(3)).await.unwrap(); + assert_eq!((s.added, s.updated, s.library_count), (0, 3, 3), "matched on RepeaterBook's id"); + let src: Vec = sqlx::query_scalar("SELECT DISTINCT source FROM channels") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(src, vec![API_SOURCE.to_string()]); + } + + #[tokio::test] + async fn at_most_500_per_import() { + let pool = test_pool().await; + let err = import_impl(&pool, rows(501)).await.unwrap_err(); + assert!(err.contains("500"), "{err}"); + assert_eq!(library_count(&pool).await.unwrap(), 0); + } + + #[tokio::test] + /// The application's 2,000-record library cap was dropped with + /// RepeaterBook's agreement (2026-10-10); only the per-search and + /// per-import limits remain. + async fn the_library_has_no_total_cap() { + let pool = test_pool().await; + let all = rows(2001); + for chunk in all.chunks(500) { + import_impl(&pool, chunk.to_vec()).await.unwrap(); + } + assert_eq!(library_count(&pool).await.unwrap(), 2001); + } + + #[tokio::test] + async fn rows_not_from_a_search_are_refused() { + let pool = test_pool().await; + let mut r = rows(1); + r[0].repeaterbook_id = "W0CPH|146.9400|CO|DENVER".into(); + assert!(import_impl(&pool, r).await.is_err()); + } + + #[tokio::test] + async fn a_file_imported_copy_is_taken_over_not_duplicated() { + let pool = test_pool().await; + let r = rows(1); + sqlx::query( + "INSERT INTO channels (name_long, callsign, rx_freq, mode, source, repeaterbook_id, state, city) \ + VALUES ('My Name', 'W80000', 440.0, 'FM', 'repeaterbook', ?1, 'MI', 'Grand Rapids')", + ) + .bind(legacy_id(&r[0])) + .execute(&pool) + .await + .unwrap(); + let s = import_impl(&pool, r).await.unwrap(); + assert_eq!((s.added, s.updated, s.took_over), (1, 0, 1)); + let (n, name, src): (i64, String, String) = + sqlx::query_as("SELECT COUNT(*), MAX(name_long), MAX(source) FROM channels") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1); + assert_eq!(name, "My Name", "the operator's name survives"); + assert_eq!(src, API_SOURCE); + } + + #[tokio::test] + async fn remove_all_deletes_only_api_records() { + let pool = test_pool().await; + import_impl(&pool, rows(4)).await.unwrap(); + sqlx::query("INSERT INTO channels (name_long, rx_freq, mode, source) VALUES ('Mine', 146.52, 'FM', 'manual')") + .execute(&pool) + .await + .unwrap(); + assert_eq!(remove_api_records(&pool).await.unwrap(), 4); + let left: Vec = sqlx::query_scalar("SELECT source FROM channels") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(left, vec!["manual".to_string()]); + } +} diff --git a/src-tauri/src/commands/talkgroups.rs b/src-tauri/src/commands/talkgroups.rs index 3c20903..9969906 100644 --- a/src-tauri/src/commands/talkgroups.rs +++ b/src-tauri/src/commands/talkgroups.rs @@ -440,7 +440,7 @@ pub async fn import_talkgroups( } tx.commit().await.estr()?; - Ok(ImportSummary { added, skipped, updated: 0 }) + Ok(ImportSummary { added, skipped, updated: 0, excluded_repeaterbook: 0 }) } /// Parse a talkgroup CSV. Header-driven and forgiving about column names: @@ -668,7 +668,7 @@ pub async fn import_talkgroup_backup( } tx.commit().await.estr()?; - Ok(ImportSummary { added, skipped, updated: 0 }) + Ok(ImportSummary { added, skipped, updated: 0, excluded_repeaterbook: 0 }) } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 466c8ee..72e804e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -81,6 +81,7 @@ fn fatal_startup_error(app: &tauri::App, message: String) { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .setup(|app| { // Database lives in the platform app-data directory. let data_dir = match app.path().app_data_dir() { @@ -127,6 +128,16 @@ pub fn run() { commands::import::import_csv, commands::import::preview_json_import, commands::import::import_json, + // RepeaterBook API (#136) + commands::rb_api::rb_status, + commands::rb_api::rb_areas, + commands::rb_api::rb_set_token, + commands::rb_api::rb_remove_token, + commands::rb_api::rb_search, + commands::rb_api::rb_import, + commands::rb_api::rb_refresh_channel, + commands::rb_api::rb_stale_channel_ids, + commands::rb_api::rb_remove_all_data, // any other CSV: map its columns to channel fields (#115) commands::csv_map::inspect_csv, commands::csv_map::preview_mapped_csv, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 5e10569..3fc9510 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -477,6 +477,11 @@ pub struct ImportSummary { /// rather than merging them, so it reports here instead of `updated`. #[serde(default)] pub skipped: usize, + /// RepeaterBook API records a channel-backup file held and the import + /// refused (#136): such data never moves between installs by file. Kept + /// apart from `skipped` so they are not reported as duplicates. + #[serde(default)] + pub excluded_repeaterbook: usize, } // ============================================================ diff --git a/src/components/channels/ChannelDetailPanel.tsx b/src/components/channels/ChannelDetailPanel.tsx index 322dd31..c77a784 100644 --- a/src/components/channels/ChannelDetailPanel.tsx +++ b/src/components/channels/ChannelDetailPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { confirm as confirmDialog } from "@tauri-apps/plugin-dialog"; -import { Check, Copy, Trash2, Plus, X } from "lucide-react"; +import { Check, Copy, Trash2, Plus, X, RefreshCw } from "lucide-react"; import { api, withToast } from "../../lib/api"; import { useOnlineGeocoding } from "../../lib/prefs"; import type { @@ -38,6 +38,14 @@ import { import { SlideOver } from "../overlays"; import { TalkgroupModal } from "../talkgroups/TalkgroupModal"; import { Button, Select, TextInput, Badge } from "../ui"; +import { + RB_API_SOURCE, + RbAttribution, + fmtImportDate, + isRbSource, + isStale, + sourceLabel, +} from "../../lib/repeaterbook"; function blankInput(): ChannelInput { return { @@ -561,9 +569,9 @@ export function ChannelDetailPanel({ return; } } - const copy = await withToast(api.duplicateChannel(channel.id), { - error: "Could not copy channel", - }); + // No fixed error text: the backend's explains a refusal (the RepeaterBook + // library cap, for a copy of a RepeaterBook record). + const copy = await withToast(api.duplicateChannel(channel.id)); setSaving(false); if (copy) { const { toast } = await import("sonner"); @@ -586,6 +594,28 @@ export function ChannelDetailPanel({ } }; + // One request for this one record, made only because the user asked (#136). + const [refreshing, setRefreshing] = useState(false); + const refreshFromRb = async () => { + if (!channel) return; + if (!sameInput(form, toInput(channel))) { + const ok = await confirmDialog( + "Refreshing reloads this repeater and discards the edits you haven't saved. Continue?", + { title: "Refresh from RepeaterBook", kind: "warning" }, + ); + if (!ok) return; + } + setRefreshing(true); + const res = await withToast(api.rbRefreshChannel(channel.id), { + success: "Refreshed from RepeaterBook", + }); + setRefreshing(false); + if (res !== undefined) { + onSaved(); + onClose(); + } + }; + const remove = async () => { if (!channel) return; const label = channel.name_long || channel.name_short || channel.callsign || "this repeater"; @@ -603,7 +633,8 @@ export function ChannelDetailPanel({ } }; - const isRb = channel?.source === "repeaterbook"; + const isRb = isRbSource(channel?.source); + const isRbApi = channel?.source === RB_API_SOURCE; // Tone-mode driven field visibility (CHIRP universal scheme). For Cross, the // two sides come from cross_mode "TX->RX"; otherwise each mode implies them. @@ -657,7 +688,7 @@ export function ChannelDetailPanel({ : undefined } > - {channel.source} + {sourceLabel(channel.source)} {channel.rb_name && ( @@ -667,6 +698,37 @@ export function ChannelDetailPanel({ )} + {isRbApi && ( +
+ + Imported {fmtImportDate(channel.last_rb_update)} + {channel.repeaterbook_id ? ( + + ) : ( + // A Duplicate of a RepeaterBook record: still RepeaterBook + // data, but not the listing itself, so it can't be refreshed. + + A copy of a RepeaterBook repeater. Refresh the original; + this copy keeps the values it was made with. + + )} + {isStale(channel.last_rb_update) && ( + + {channel.repeaterbook_id + ? "Not refreshed from RepeaterBook in over a year — refresh it, or delete it if you no longer need it." + : "This copy's RepeaterBook data is over a year old — delete it if you no longer need it."} + + )} +
+ )} )} @@ -700,11 +762,17 @@ export function ChannelDetailPanel({ value={form.source ?? ""} placeholder="manual" onChange={(e) => set("source", e.target.value)} + // A RepeaterBook API record's source is what keeps it out of + // channel files and inside the library cap; it can't be edited. + readOnly={isRbApi} + title={isRbApi ? "Imported from the RepeaterBook API — the source can't be changed" : undefined} /> - {sourceSuggestions.map((s) => ( - diff --git a/src/components/channels/ChannelGrid.tsx b/src/components/channels/ChannelGrid.tsx index a65d85b..b7f8007 100644 --- a/src/components/channels/ChannelGrid.tsx +++ b/src/components/channels/ChannelGrid.tsx @@ -4,6 +4,7 @@ import { ArrowUp, ArrowDown } from "lucide-react"; import clsx from "clsx"; import type { Channel } from "../../lib/types"; import { fmtFreq, fmtOffset } from "../../lib/constants"; +import { sourceLabel } from "../../lib/repeaterbook"; type Align = "left" | "right" | "center"; @@ -58,7 +59,7 @@ export const ALL_COLUMNS: Column[] = [ { key: "country", label: "Country", width: 110 }, { key: "latitude", label: "Lat", width: 86, align: "right", render: (c) => (c.latitude != null ? c.latitude.toFixed(4) : "") }, { key: "longitude", label: "Lon", width: 90, align: "right", render: (c) => (c.longitude != null ? c.longitude.toFixed(4) : "") }, - { key: "source", label: "Source", width: 96 }, + { key: "source", label: "Source", width: 110, render: (c) => sourceLabel(c.source) }, ]; // Default columns shown until the user customizes the view. diff --git a/src/components/channels/ImportDialog.tsx b/src/components/channels/ImportDialog.tsx index 58232cb..0b11ae9 100644 --- a/src/components/channels/ImportDialog.tsx +++ b/src/components/channels/ImportDialog.tsx @@ -199,7 +199,10 @@ export function ImportDialog({ toast.success( `Imported ${summary.added} channel${summary.added === 1 ? "" : "s"}` + (summary.updated ? ` · ${summary.updated} updated` : "") + - (summary.skipped ? ` · ${summary.skipped} skipped (duplicates)` : ""), + (summary.skipped ? ` · ${summary.skipped} skipped (duplicates)` : "") + + (summary.excluded_repeaterbook + ? ` · ${summary.excluded_repeaterbook} RepeaterBook repeater${summary.excluded_repeaterbook === 1 ? "" : "s"} left out — RepeaterBook data can't be moved by file` + : ""), ); onImported(); onClose(); diff --git a/src/components/channels/RepeaterBookDialog.tsx b/src/components/channels/RepeaterBookDialog.tsx new file mode 100644 index 0000000..9efcee6 --- /dev/null +++ b/src/components/channels/RepeaterBookDialog.tsx @@ -0,0 +1,694 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { listen } from "@tauri-apps/api/event"; +import { Search, KeyRound, SlidersHorizontal } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "../../lib/api"; +import type { + RbArea, + RbAreaInfo, + CityCentroid, + RbFilters, + RbImportSummary, + RbResultRow, + RbSearchResult, + RbStatus, +} from "../../lib/types"; +import { Modal } from "../overlays"; +import { Button, Select, Spinner, TextInput } from "../ui"; +import { fmtFreq, fmtOffset } from "../../lib/constants"; +import { RbAttribution } from "../../lib/repeaterbook"; + +/** + * Search RepeaterBook for one area and import the repeaters the user picks + * (#136). Every limit the dialog shows is enforced by the backend; the dialog + * only explains them. "Data courtesy of RepeaterBook.com" sits in the pinned + * header so it is visible the whole time, as promised. + */ + +// Keys match the backend's BANDS / MODES tables in commands/rb_api.rs. +const BANDS: [string, string][] = [ + ["10m", "10 m"], + ["6m", "6 m"], + ["2m", "2 m"], + ["1.25m", "1.25 m"], + ["70cm", "70 cm"], + ["33cm", "33 cm"], + ["23cm", "23 cm"], +]; +const MODES: [string, string][] = [ + ["FM", "FM analog"], + ["DMR", "DMR"], + ["DSTAR", "D-STAR"], + ["YSF", "System Fusion"], + ["NXDN", "NXDN"], + ["P25", "P25"], +]; + +// Suggestions for the rest-of-world search. Free text is accepted too: the +// country is sent as typed, and a misspelling simply finds nothing. +const COUNTRIES = [ + "Argentina", "Australia", "Austria", "Belgium", "Brazil", "Bulgaria", "Chile", + "China", "Colombia", "Costa Rica", "Croatia", "Czech Republic", "Denmark", + "Dominican Republic", "Ecuador", "Estonia", "Finland", "France", "Germany", + "Greece", "Guatemala", "Hong Kong", "Hungary", "Iceland", "India", "Indonesia", + "Ireland", "Israel", "Italy", "Japan", "Latvia", "Lithuania", "Luxembourg", + "Malaysia", "Malta", "Netherlands", "New Zealand", "Norway", "Panama", "Peru", + "Philippines", "Poland", "Portugal", "Romania", "Serbia", "Singapore", + "Slovakia", "Slovenia", "South Africa", "South Korea", "Spain", "Sweden", + "Switzerland", "Taiwan", "Thailand", "Turkey", "Ukraine", "United Kingdom", + "Uruguay", "Venezuela", +]; + +const EMCOMM: [string, string][] = [ + ["ARES", "ARES"], + ["RACES", "RACES"], + ["SKYWARN", "SKYWARN"], + ["CANWARN", "CANWARN"], +]; +const DISTANCES = [10, 25, 50, 75, 100, 150]; + +const cityKey = (c: CityCentroid) => `${c.city}|${c.state ?? ""}`; + +/** + * A row of on/off chips: none on means "any", several on means "any of these". + */ +function Chips({ + label, + options, + value, + onChange, + disabled, + title, +}: { + label: string; + options: [string, string][]; + value: string[]; + onChange: (v: string[]) => void; + disabled?: boolean; + title?: string; +}) { + const toggle = (k: string) => + onChange(value.includes(k) ? value.filter((v) => v !== k) : [...value, k]); + return ( +
+ {label} + {options.map(([k, text]) => { + const on = value.includes(k); + return ( + + ); + })} + {value.length === 0 && ( + any + )} +
+ ); +} + +const COUNTRY_CHOICE = "__country__"; +const LAST_AREA_KEY = "rb.lastArea"; + +const tone = (r: RbResultRow): string => { + if (r.tone_mode === "off") return ""; + const up = r.ctcss_uplink != null ? r.ctcss_uplink.toFixed(1) : r.dcs_code ? `D${r.dcs_code}` : ""; + const dn = r.ctcss_downlink != null ? r.ctcss_downlink.toFixed(1) : r.dcs_rx_code ? `D${r.dcs_rx_code}` : ""; + return dn && dn !== up ? `${up || "—"} / ${dn}` : up; +}; + +const COLUMNS: { label: string; render: (r: RbResultRow) => ReactNode; mono?: boolean }[] = [ + { label: "Call", render: (r) => r.callsign }, + { label: "RX", mono: true, render: (r) => fmtFreq(r.rx_freq) }, + { label: "Offset", mono: true, render: (r) => (r.duplex === "none" ? "" : `${r.duplex}${fmtOffset(r.offset)}`) }, + { label: "Tone", mono: true, render: tone }, + { label: "Mode", render: (r) => r.mode }, + { label: "City", render: (r) => r.city ?? "" }, + { label: "County", render: (r) => r.county ?? "" }, + { label: "Status", render: (r) => r.operational_status ?? "" }, + { label: "Use", render: (r) => r.use_type ?? "" }, + { + label: "In library", + render: (r) => + r.in_library === "api" ? ( + Yes + ) : r.in_library === "file" ? ( + + From file + + ) : ( + "" + ), + }, +]; + +function loadLastArea(): string { + try { + return localStorage.getItem(LAST_AREA_KEY) ?? ""; + } catch { + return ""; + } +} + +export function RepeaterBookDialog({ + open, + onClose, + onImported, +}: { + open: boolean; + onClose: () => void; + onImported: () => void; +}) { + const navigate = useNavigate(); + const [status, setStatus] = useState(null); + const [areas, setAreas] = useState([]); + const [areaChoice, setAreaChoice] = useState(loadLastArea); + const [country, setCountry] = useState(""); + const [band, setBand] = useState([]); + const [mode, setMode] = useState([]); + // The narrower filters, behind "More filters". All run on RepeaterBook's + // answer, so changing one inside 10 minutes costs no request. + const [showMore, setShowMore] = useState(false); + const [county, setCounty] = useState(""); + const [city, setCity] = useState(""); + const [callsign, setCallsign] = useState(""); + const [onAirOnly, setOnAirOnly] = useState(false); + const [openOnly, setOpenOnly] = useState(false); + const [emcomm, setEmcomm] = useState([]); + const [nearCity, setNearCity] = useState(""); + const [nearMiles, setNearMiles] = useState(25); + const [hideInLibrary, setHideInLibrary] = useState(false); + const [cities, setCities] = useState([]); + const [searching, setSearching] = useState(false); + const [waitSecs, setWaitSecs] = useState(0); + const [result, setResult] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [importing, setImporting] = useState(false); + const [summary, setSummary] = useState(null); + + const refreshStatus = () => api.rbStatus().then(setStatus).catch(() => setStatus(null)); + + useEffect(() => { + if (!open) return; + setResult(null); + setSelected(new Set()); + setSummary(null); + void refreshStatus(); + if (areas.length === 0) void api.rbAreas().then(setAreas); + // Towns already in the library, for "within N miles of". + void api.listCities().then(setCities).catch(() => setCities([])); + }, [open]); + + // RepeaterBook said "slow down": the backend is waiting before its retry. + // Count it down so the dialog doesn't look hung. + useEffect(() => { + const un = listen("rb-api-wait", (e) => setWaitSecs(e.payload)); + return () => { + void un.then((f) => f()); + }; + }, []); + useEffect(() => { + if (waitSecs <= 0) return; + const t = setTimeout(() => setWaitSecs((s) => Math.max(0, s - 1)), 1000); + return () => clearTimeout(t); + }, [waitSecs]); + + const area: RbArea | null = useMemo(() => { + if (areaChoice === COUNTRY_CHOICE) { + return country.trim() ? { kind: "country", country: country.trim() } : null; + } + return areaChoice ? { kind: "region", state_id: areaChoice } : null; + }, [areaChoice, country]); + + const maxSelected = status?.max_selected ?? 500; + + const isCountry = areaChoice === COUNTRY_CHOICE; + const origin = cities.find((c) => cityKey(c) === nearCity) ?? null; + const filters: RbFilters = { + band, + mode, + county: county.trim() || null, + city: city.trim() || null, + callsign: callsign.trim() || null, + on_air_only: onAirOnly, + open_only: openOnly, + // RepeaterBook only records these services for North America. + emcomm: isCountry ? [] : emcomm, + near: origin + ? { latitude: origin.latitude, longitude: origin.longitude, miles: nearMiles } + : null, + hide_in_library: hideInLibrary, + }; + const moreCount = [ + filters.county, + filters.city, + filters.callsign, + onAirOnly, + openOnly, + filters.emcomm?.length, + filters.near, + hideInLibrary, + ].filter(Boolean).length; + + const search = async () => { + if (!area || searching) return; + try { + localStorage.setItem(LAST_AREA_KEY, areaChoice); + } catch { + // Only a convenience. + } + setSearching(true); + setResult(null); + setSelected(new Set()); + setSummary(null); + try { + const r = await api.rbSearch(area, filters); + setResult(r); + // Pre-select nothing: nothing is saved until the user picks it. + } catch (e) { + toast.error(String(e)); + } finally { + setSearching(false); + setWaitSecs(0); + void refreshStatus(); + } + }; + + const rows = result?.rows ?? []; + // The limit is checked against the current selection outside the updater: + // React may run an updater twice (StrictMode does), and a toast is a side + // effect. + const toggle = (id: string) => { + if (!selected.has(id) && selected.size >= maxSelected) { + toast.error(`At most ${maxSelected} repeaters can be imported at once.`); + return; + } + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else if (next.size < maxSelected) next.add(id); + return next; + }); + }; + // "Select all" takes repeaters not yet imported from the API, up to the + // per-import limit, so a search bigger than one import is a few presses of + // the same button rather than hunting for the rows the last import missed. + const pickable = rows.filter((r) => r.in_library !== "api"); + const nextBatch = pickable.slice(0, maxSelected).map((r) => r.repeaterbook_id); + const allPicked = + nextBatch.length > 0 && + selected.size === nextBatch.length && + nextBatch.every((id) => selected.has(id)); + const toggleAll = () => setSelected(allPicked ? new Set() : new Set(nextBatch)); + + const doImport = async () => { + const picked = rows.filter((r) => selected.has(r.repeaterbook_id)); + if (picked.length === 0) return; + setImporting(true); + try { + const s = await api.rbImport(picked); + setSummary(s); + // Stay on these results: mark what was just imported, clear the + // selection, and the next "select all" picks up where this one stopped. + const done = new Set(picked.map((r) => r.repeaterbook_id)); + setResult((prev) => + prev && { + ...prev, + rows: prev.rows.map((r) => + done.has(r.repeaterbook_id) ? { ...r, in_library: "api" as const } : r, + ), + }, + ); + setSelected(new Set()); + onImported(); + } catch (e) { + toast.error(String(e)); + } finally { + setImporting(false); + void refreshStatus(); + } + }; + + const noToken = status != null && !status.has_token; + const us = areas.filter((a) => a.country === "United States"); + const ca = areas.filter((a) => a.country === "Canada"); + + let body: ReactNode; + if (noToken) { + body = ( +
+ +

+ Searching RepeaterBook needs your own RepeaterBook token. Create one + free on RepeaterBook, then paste it in Settings — it is kept in this + computer's credential store and sent only to RepeaterBook. +

+ +
+ ); + } else if (searching) { + body = ( +
+ + {waitSecs > 0 + ? `RepeaterBook asked this app to wait — trying again in ${waitSecs} s.` + : "Asking RepeaterBook…"} +
+ ); + } else if (result && (result.too_many || result.server_truncated)) { + body = ( +
+

+ {result.too_many + ? `${result.matched.toLocaleString()} repeaters in ${result.area} match — more than the ${(status?.max_results ?? 1000).toLocaleString()} one search may show.` + : `RepeaterBook sent the most it returns at once for ${result.area}, so some repeaters may be missing from the answer.`}{" "} + Nothing was kept. Narrow the search by band or mode and try again. +

+
+ ); + } else if (result) { + body = + rows.length === 0 ? ( +
+ No repeaters in {result.area} match. + {area?.kind === "country" && " Check the country is spelled the way RepeaterBook spells it."} +
+ ) : ( +
+ {summary && ( +
+

+ Imported {summary.added} repeater{summary.added === 1 ? "" : "s"} + {summary.updated ? ` · ${summary.updated} refreshed` : ""} +

+ {summary.took_over > 0 && ( +

+ {summary.took_over} of them were already in your library from a + RepeaterBook file and are now kept as RepeaterBook API records, + with your names and edits intact. +

+ )} +

+ {pickable.length > 0 + ? `${pickable.length} in this search aren't imported yet — select all picks them up next.` + : "Everything in this search is now in your library."} +

+ +
+ )} +

+ {result.matched} repeater{result.matched === 1 ? "" : "s"} in {result.area} + {result.from_cache ? " (same search a moment ago — not asked again)" : ""}. Tick + the ones to keep; nothing is saved until you import. At most {maxSelected} per import. +

+
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {rows.map((r) => ( + toggle(r.repeaterbook_id)} + className="cursor-pointer text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-700/30" + > + + {COLUMNS.map((c) => ( + + ))} + + ))} + +
+ maxSelected + ? `Select the next ${maxSelected} not yet imported` + : `Select the ${pickable.length} not yet imported` + } + /> + + {c.label} +
+ toggle(r.repeaterbook_id)} + onClick={(e) => e.stopPropagation()} + /> + + {c.render(r)} +
+
+
+ ); + } else { + body = ( +
+ Pick one state, province or country, narrow it if you like, and search. +
+ ); + } + + return ( + + + + + } + > +
+
+ + {status && ( + + {status.requests_last_hour} of {status.requests_per_hour} searches used this hour + + )} +
+ {!noToken && ( +
+ + {areaChoice === COUNTRY_CHOICE && ( + <> + setCountry(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && search()} + disabled={searching} + className="w-44" + /> + + {COUNTRIES.map((c) => ( + + + )} + + +
+ )} + {!noToken && ( +
+ + +
+ )} + {!noToken && showMore && ( +
+ setCounty(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && search()} + disabled={searching} + className="w-32" + /> + setCity(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && search()} + disabled={searching} + className="w-32" + /> + setCallsign(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && search()} + disabled={searching} + className="w-28" + /> + + + Within + + of + + + + + + + These narrow RepeaterBook's answer here — changing them within 10 + minutes doesn't ask RepeaterBook again. + +
+ )} +
+ {body} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 7b9fc30..52c2393 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -60,6 +60,15 @@ import type { DmrUsersRefreshSummary, DmrExportPreview, DmrExportSummary, + ChannelExportSummary, + RbStatus, + RbAreaInfo, + RbArea, + RbSearchResult, + RbResultRow, + RbImportSummary, + RbRemoved, + RbFilters, } from "./types"; // Tauri converts camelCase JS argument keys to the snake_case Rust parameter @@ -115,9 +124,22 @@ export const api = { importMappedCsv: (path: string, mapping: ColumnMapping) => invoke("import_mapped_csv", { path, mapping }), + // ---- RepeaterBook API (#136) ---- + rbStatus: () => invoke("rb_status"), + rbAreas: () => invoke("rb_areas"), + rbSetToken: (token: string) => invoke("rb_set_token", { token }), + rbRemoveToken: () => invoke("rb_remove_token"), + rbSearch: (area: RbArea, filters: RbFilters) => + invoke("rb_search", { area, filters }), + rbImport: (rows: RbResultRow[]) => + invoke("rb_import", { rows }), + rbRefreshChannel: (id: number) => invoke("rb_refresh_channel", { id }), + rbStaleChannelIds: () => invoke("rb_stale_channel_ids"), + rbRemoveAllData: () => invoke("rb_remove_all_data"), + // ---- native channel backup (export / re-import selected channels) ---- exportChannels: (ids: number[], path: string) => - invoke("export_channels", { ids, path }), + invoke("export_channels", { ids, path }), isChannelBackup: (path: string) => invoke("is_channel_backup", { path }), previewChannelImport: (path: string) => diff --git a/src/lib/repeaterbook.tsx b/src/lib/repeaterbook.tsx new file mode 100644 index 0000000..31bf6ff --- /dev/null +++ b/src/lib/repeaterbook.tsx @@ -0,0 +1,73 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; + +/** + * RepeaterBook API helpers shared by the search dialog, the channel library and + * Settings (#136). The attribution text and link are a promise made to + * RepeaterBook in the API application (docs/repeaterbook-api-commitments.md): + * they appear in the search dialog, the import summary, a record's detail view + * and the About screen, word for word. + */ + +/** `source` of a channel imported from the RepeaterBook API. */ +export const RB_API_SOURCE = "repeaterbook_api"; +/** `source` of a channel imported from a RepeaterBook CSV/JSON file. */ +export const RB_FILE_SOURCE = "repeaterbook"; + +export const RB_HOME = "https://www.repeaterbook.com"; + +/** Either kind of RepeaterBook record: both carry the rb_* snapshot fields. */ +export const isRbSource = (source: string | null | undefined) => + source === RB_API_SOURCE || source === RB_FILE_SOURCE; + +/** + * What the Source column shows. An API record reads "RepeaterBook", as + * promised; a file-imported one is labelled apart so the two never look like + * the same thing in a column or a filter. + */ +export function sourceLabel(source: string | null | undefined): string { + if (source === RB_API_SOURCE) return "RepeaterBook"; + if (source === RB_FILE_SOURCE) return "RepeaterBook file"; + return source ?? ""; +} + +/** `last_rb_update` is SQLite's CURRENT_TIMESTAMP: UTC, "YYYY-MM-DD HH:MM:SS". */ +function parseUtc(ts: string): Date { + return new Date(ts.replace(" ", "T") + "Z"); +} + +/** An API record not refreshed within 12 months. Matches the backend's rule. */ +export function isStale(lastRbUpdate: string | null): boolean { + if (!lastRbUpdate) return true; + const cutoff = new Date(); + cutoff.setFullYear(cutoff.getFullYear() - 1); + return parseUtc(lastRbUpdate) < cutoff; +} + +export function fmtImportDate(lastRbUpdate: string | null): string { + if (!lastRbUpdate) return "unknown"; + return parseUtc(lastRbUpdate).toLocaleDateString(); +} + +/** Open a RepeaterBook page in the user's browser. */ +export function openRb(url: string = RB_HOME) { + void openUrl(url); +} + +/** "Data courtesy of RepeaterBook.com", linked to RepeaterBook. */ +export function RbAttribution({ className = "" }: { className?: string }) { + return ( + + Data courtesy of{" "} + { + e.preventDefault(); + openRb(); + }} + className="font-medium text-sky-600 underline-offset-2 hover:underline dark:text-sky-400" + > + RepeaterBook.com + + + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 935d058..d28b1c8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -872,6 +872,85 @@ export interface ImportSummary { updated: number; // Native-backup restore: existing channels left untouched (deduped). skipped: number; + // Native-backup restore: RepeaterBook API records the file held, refused + // because RepeaterBook data never moves between installs by file (#136). + excluded_repeaterbook?: number; +} + +// ============================================================ +// RepeaterBook API (#136) +// ============================================================ + +/** What `export_channels` wrote, and how many RepeaterBook API records it left out. */ +export interface ChannelExportSummary { + written: number; + excluded_repeaterbook: number; +} + +export interface RbStatus { + has_token: boolean; + library_count: number; + /** API records not refreshed within 12 months. */ + stale_count: number; + max_results: number; + max_selected: number; + requests_last_hour: number; + requests_per_hour: number; + tokens_page: string; +} + +export interface RbAreaInfo { + state_id: string; + name: string; + country: string; +} + +export type RbArea = + | { kind: "region"; state_id: string } + | { kind: "country"; country: string }; + +export interface RbResultRow extends ImportPreviewRow { + /** "api": already imported from the API (refreshed on import); "file": a + * RepeaterBook file import has it (the import takes it over). */ + in_library: "api" | "file" | null; +} + +export interface RbSearchResult { + area: string; + matched: number; + too_many: boolean; + server_truncated: boolean; + from_cache: boolean; + rows: RbResultRow[]; +} + +export interface RbImportSummary { + added: number; + updated: number; + took_over: number; + library_count: number; +} + +/** Ways to narrow a RepeaterBook search. Mirrors `RbFilters` in rb_api.rs. */ +export interface RbFilters { + /** Any of these bands matches. Empty: any band. */ + band?: string[]; + /** Any of these modes matches. Empty: any mode. */ + mode?: string[]; + county?: string | null; + city?: string | null; + callsign?: string | null; + on_air_only?: boolean; + open_only?: boolean; + /** Any of ARES, RACES, SKYWARN, CANWARN. */ + emcomm?: string[]; + near?: { latitude: number; longitude: number; miles: number } | null; + hide_in_library?: boolean; +} + +export interface RbRemoved { + channels_deleted: number; + token_removed: boolean; } // ============================================================ diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx index 8e456df..fea9243 100644 --- a/src/pages/Channels.tsx +++ b/src/pages/Channels.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { confirm as confirmDialog } from "@tauri-apps/plugin-dialog"; import { useSearchParams } from "react-router-dom"; -import { Upload, Download, Plus, Search, X, Radio, Trash2, ListPlus, MapPin, BookMarked } from "lucide-react"; +import { Upload, Download, Plus, Search, X, Radio, Trash2, ListPlus, MapPin, BookMarked, CloudDownload, Clock } from "lucide-react"; import { api, withToast } from "../lib/api"; import { useOnlineGeocoding } from "../lib/prefs"; import type { Channel, ChannelFilter, CityCentroid } from "../lib/types"; @@ -16,6 +16,8 @@ import { import { ColumnPicker } from "../components/channels/ColumnPicker"; import { ChannelDetailPanel } from "../components/channels/ChannelDetailPanel"; import { ImportDialog } from "../components/channels/ImportDialog"; +import { RepeaterBookDialog } from "../components/channels/RepeaterBookDialog"; +import { sourceLabel } from "../lib/repeaterbook"; import { StandardListDialog } from "../components/channels/StandardListDialog"; import { AddToListModal } from "../components/channels/AddToListModal"; import { @@ -74,6 +76,10 @@ export function Channels() { const [panelMode, setPanelMode] = useState<"edit" | "create">("edit"); const [panelOpen, setPanelOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); + const [rbOpen, setRbOpen] = useState(false); + // RepeaterBook API records not refreshed within 12 months (#136). The app + // flags them and asks the user to refresh or delete them. + const [staleIds, setStaleIds] = useState([]); const [standardOpen, setStandardOpen] = useState(false); const [addToListOpen, setAddToListOpen] = useState(false); @@ -106,6 +112,7 @@ export function Channels() { // Refresh the unfiltered list that backs the filter dropdowns. const loadFacets = useCallback(async () => { setAllChannels(await api.listChannels({})); + setStaleIds(await api.rbStaleChannelIds().catch(() => [])); }, []); useEffect(() => { loadFacets(); @@ -135,6 +142,12 @@ export function Channels() { }); }, [channels, origin, maxMiles, distanceById]); + // Stale RepeaterBook records the grid is actually showing. + const staleShown = useMemo(() => { + const shown = new Set(visibleChannels.map((c) => c.id)); + return staleIds.filter((id) => shown.has(id)); + }, [staleIds, visibleChannels]); + const toggleSelect = (id: number) => setSelectedIds((prev) => { const next = new Set(prev); @@ -161,12 +174,19 @@ export function Channels() { filters: [{ name: "Codeplug Magic channel backup", extensions: ["json"] }], }); if (!path) return; - const n = await withToast(api.exportChannels(ids, path), { - error: "Export failed", - }); - if (n !== undefined) { + // No fixed error text: the backend's says why (e.g. every selected + // repeater came from RepeaterBook). + const res = await withToast(api.exportChannels(ids, path)); + if (res !== undefined) { const { toast } = await import("sonner"); - toast.success(`Exported ${n} channel${n === 1 ? "" : "s"}`); + const n = res.written; + const left = res.excluded_repeaterbook; + toast.success( + `Exported ${n} channel${n === 1 ? "" : "s"}` + + (left + ? ` · ${left} RepeaterBook repeater${left === 1 ? "" : "s"} left out — RepeaterBook data only leaves the app as radio programming` + : ""), + ); } }; @@ -318,7 +338,7 @@ export function Channels() { if (filter.state) chips.push({ key: "state", label: `State: ${filter.state}` }); if (filter.operational_status) chips.push({ key: "operational_status", label: `Status: ${filter.operational_status}` }); - if (filter.source) chips.push({ key: "source", label: `Source: ${filter.source}` }); + if (filter.source) chips.push({ key: "source", label: `Source: ${sourceLabel(filter.source)}` }); return ( <> @@ -347,6 +367,9 @@ export function Channels() { + @@ -357,6 +380,33 @@ export function Channels() { } /> + {staleIds.length > 0 && ( +
+ + + {staleIds.length} repeater{staleIds.length === 1 ? "" : "s"} from + RepeaterBook {staleIds.length === 1 ? "hasn't" : "haven't"} been + refreshed in over a year. Refresh each one from its detail panel, or + delete {staleIds.length === 1 ? "it" : "them"}. + + {/* Only the ones on screen: selecting rows the filters hide would + let the Delete button remove repeaters nobody can see. */} + {staleShown.length > 0 && ( + + )} + {staleShown.length < staleIds.length && ( + + {staleIds.length - staleShown.length} hidden by the current + filters. + + )} +
+ )} + {/* Filter bar */}
@@ -405,7 +455,9 @@ export function Channels() { > {sources.map((s) => ( - + ))} )} @@ -472,12 +524,15 @@ export function Channels() { } title="No repeaters yet" - description="Import a RepeaterBook CSV/JSON export or add a repeater manually to start building your master table." + description="Search RepeaterBook, import a RepeaterBook CSV/JSON export, or add a repeater manually to start building your master table." action={
+ @@ -529,6 +584,11 @@ export function Channels() { sourceSuggestions={sources} cities={cities} /> + setRbOpen(false)} + onImported={reload} + /> setImportOpen(false)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f2aacf5..1a62568 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -7,13 +7,16 @@ import { Download, Upload, Eraser, + KeyRound, + ExternalLink, } from "lucide-react"; import clsx from "clsx"; import { save, open, confirm as confirmDialog } from "@tauri-apps/plugin-dialog"; import { toast } from "sonner"; import { useTheme } from "../theme"; import { api } from "../lib/api"; -import type { BackupTable, RadioBackupsSummary } from "../lib/types"; +import type { BackupTable, RadioBackupsSummary, RbStatus } from "../lib/types"; +import { RbAttribution, openRb } from "../lib/repeaterbook"; import { useOnlineGeocoding } from "../lib/prefs"; import { Button, @@ -22,6 +25,7 @@ import { APP_VERSION, Spinner, Select, + TextInput, } from "../components/ui"; const THEMES = [ @@ -189,6 +193,159 @@ function RadioBackups() { ); } +/** + * The RepeaterBook API token, and the one button that takes back everything + * RepeaterBook gave this app (#136). + * + * Each user generates their own `rbuapp_` token on RepeaterBook. It is pasted + * once and kept in the OS credential store: never in the database, never in a + * backup, and never shown again — the field only says whether one is stored. + */ +function RepeaterBookCard() { + const [status, setStatus] = useState(null); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + setStatus(await api.rbStatus()); + } catch (e) { + toast.error(String(e)); + } + }, []); + useEffect(() => { + void load(); + }, [load]); + + const saveToken = async () => { + setBusy(true); + try { + await api.rbSetToken(token); + setToken(""); + toast.success("RepeaterBook token saved"); + await load(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + }; + + const removeToken = async () => { + const ok = await confirmDialog( + "Remove your RepeaterBook token from this computer? Repeaters you already imported stay in your library.", + { title: "Remove token", kind: "warning" }, + ); + if (!ok) return; + setBusy(true); + try { + await api.rbRemoveToken(); + toast.success("Token removed"); + await load(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + }; + + const removeAll = async () => { + const n = status?.library_count ?? 0; + const ok = await confirmDialog( + `Delete all ${n} repeater${n === 1 ? "" : "s"} imported from RepeaterBook, and remove your RepeaterBook token? ` + + "They are removed from every channel list and scan list that uses them. This cannot be undone.", + { title: "Remove RepeaterBook data", kind: "warning" }, + ); + if (!ok) return; + setBusy(true); + try { + const r = await api.rbRemoveAllData(); + toast.success( + `Deleted ${r.channels_deleted} repeater${r.channels_deleted === 1 ? "" : "s"}` + + (r.token_removed ? " and removed the token" : ""), + ); + await load(); + } catch (e) { + toast.error(String(e)); + } finally { + setBusy(false); + } + }; + + return ( + +

+ RepeaterBook +

+

+ Search RepeaterBook from the Channels page with your own free + RepeaterBook token. Sign in to RepeaterBook, open{" "} + { + e.preventDefault(); + if (status) openRb(status.tokens_page); + }} + className="inline-flex items-center gap-0.5 font-medium text-sky-600 underline-offset-2 hover:underline dark:text-sky-400" + > + API Apps & Tokens + + , generate a token for CodePlug Magic, and paste it here. It is kept in + this computer’s credential store, sent only to RepeaterBook, and never + written to the database or a backup. +

+
+ + {status?.has_token ? ( + <> + + Token saved + + + + ) : ( + <> + setToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && token.trim() && saveToken()} + className="w-64" + /> + + + )} +
+ {status && ( +

+ Repeaters imported from RepeaterBook leave the app only as radio + programming — never in a channel export file. + {status.stale_count > 0 + ? ` ${status.stale_count} haven’t been refreshed in over a year.` + : ""} +

+ )} +
+ + +
+
+ ); +} + export function Settings() { const { theme, setTheme } = useTheme(); const [busy, setBusy] = useState(null); @@ -359,13 +516,15 @@ export function Settings() { + +

Privacy

This app has no telemetry, no accounts and no automatic updates. - It makes exactly three kinds of network request, every one of + It makes exactly four kinds of network request, every one of them because you asked for something:

    @@ -378,6 +537,14 @@ export function Settings() { town, state and country are sent; never a call sign, a frequency, or anything identifying you. +
  • + + RepeaterBook search + {" "} + — when you search or refresh a repeater, the area you picked + (and any band or mode filter) goes to RepeaterBook with your + own RepeaterBook token. Nothing else about you is sent. +
  • DMR contact refresh @@ -422,6 +589,9 @@ export function Settings() { WW8L Codeplug Magic v{APP_VERSION} — a single master database and intelligent per-radio export for amateur radio operators.

    +

    + Repeater search: +