Compare commits
11
Commits
1.5
...
6804bbb048
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6804bbb048 | ||
|
|
21f13c6cd5 | ||
|
|
f37beaf07e | ||
|
|
a043ec84d8 | ||
|
|
9fead922c4 | ||
|
|
2b1040d3db | ||
|
|
cf991c34af | ||
|
|
b56601bb93 | ||
|
|
72ba15891d | ||
|
|
356629a5a1 | ||
|
|
6019be6359 |
@@ -1,23 +1,36 @@
|
||||
name: build-dev
|
||||
name: build
|
||||
|
||||
on:
|
||||
# Every push to dev: build, run the offline test suite, publish to the
|
||||
# dev Attic cache.
|
||||
# Manual dispatch: always builds the `dev` branch (regardless of the ref
|
||||
# selected when dispatching) and publishes to the dev Attic cache + dev
|
||||
# live-update manifest. Version fingerprint = short commit hash.
|
||||
workflow_dispatch:
|
||||
# Any new tag: builds the tagged revision and publishes a release to the
|
||||
# release Attic cache + production live-update manifest. Version = the tag.
|
||||
push:
|
||||
branches: [dev]
|
||||
tags: ['*']
|
||||
|
||||
jobs:
|
||||
nix-build:
|
||||
runs-on: build-server
|
||||
steps:
|
||||
# actions/checkout@v4 is unreliable on host-mode runners (its files are
|
||||
# copied into the workspace instead of the repo), so clone explicitly.
|
||||
# A manual dispatch always clones `dev`; a tag push clones the tag. A full
|
||||
# clone (no --depth) lets `git describe --tags` resolve the real tag.
|
||||
- name: Checkout
|
||||
run: |
|
||||
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||
git config --global http.extraheader "Authorization: token ${{ github.token }}"
|
||||
git clone --branch "${{ gitea.ref_name }}" \
|
||||
REF="${{ gitea.ref_name }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
REF=dev
|
||||
fi
|
||||
git clone --branch "$REF" \
|
||||
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
test -f flake.nix
|
||||
git log --oneline -1
|
||||
|
||||
- name: Inspect workspace
|
||||
run: |
|
||||
pwd
|
||||
@@ -26,12 +39,18 @@ jobs:
|
||||
git remote -v 2>&1 || true
|
||||
git describe --tags --always 2>&1 || true
|
||||
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
|
||||
# Dev builds fingerprint as the short commit hash.
|
||||
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||
|
||||
# Dev builds fingerprint as the short commit hash; releases report the
|
||||
# tag. Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||
# builtins.getEnv.
|
||||
- name: Compute version
|
||||
run: |
|
||||
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
# checks is keyed per-system (checks.<system>.default); nix build's
|
||||
# #attr shorthand can't resolve it, so resolve the current system inline.
|
||||
# --impure lets the flake read APP_VERSION via builtins.getEnv
|
||||
@@ -40,12 +59,14 @@ jobs:
|
||||
run: |
|
||||
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||
--impure --out-link result-pkg
|
||||
|
||||
# The offline test suite runs in the check derivation's checkPhase.
|
||||
# This is a separate build/output from the package itself.
|
||||
- name: Build + offline test suite
|
||||
run: |
|
||||
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||
--impure --out-link result-checks
|
||||
|
||||
- name: Live portal tests
|
||||
env:
|
||||
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
|
||||
@@ -58,25 +79,44 @@ jobs:
|
||||
./build/test_api_client
|
||||
./build/test_license_service
|
||||
'
|
||||
# Dev closure (the actual package, not the check output) lands in the
|
||||
# dev cache.
|
||||
|
||||
# Manual dispatch -> dev cache; tag push -> release cache.
|
||||
- name: Push to Attic dev cache
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
|
||||
run: |
|
||||
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||
attic push radiantwave:radiantwave-dev ./result-pkg
|
||||
|
||||
- name: Push to Attic release cache
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
|
||||
run: |
|
||||
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||
attic push radiantwave:radiantwave ./result-pkg
|
||||
|
||||
# Publish the manifest kiosks poll for live app updates. `storePath`
|
||||
# must match exactly the closure pushed to the dev Attic cache above
|
||||
# must match exactly the closure pushed to the matching Attic cache above
|
||||
# (readlink resolves the result-pkg out-link symlink to its real
|
||||
# /nix/store path), so a kiosk can `nix copy --from` it without ever
|
||||
# building from source. Upload mirrors the ISO scp pattern to the
|
||||
# fileserver via the build-server's `radiantwave-infra` SSH alias.
|
||||
- name: Publish manifest
|
||||
- name: Publish dev manifest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
STOREPATH=$(readlink -f ./result-pkg)
|
||||
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||
cat manifest.json
|
||||
scp manifest.json \
|
||||
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json
|
||||
|
||||
- name: Publish release manifest
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
run: |
|
||||
STOREPATH=$(readlink -f ./result-pkg)
|
||||
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||
cat manifest.json
|
||||
scp manifest.json \
|
||||
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave/manifest.json
|
||||
@@ -1,90 +0,0 @@
|
||||
name: build-main
|
||||
|
||||
on:
|
||||
# Manual only. Dispatch against main: build, run the offline test suite,
|
||||
# publish to the release Attic cache and live-update manifest.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nix-build:
|
||||
runs-on: build-server
|
||||
steps:
|
||||
# actions/checkout@v4 is unreliable on host-mode runners (its files are
|
||||
# copied into the workspace instead of the repo), so clone explicitly.
|
||||
# A full clone (no --depth) is required so `git describe --tags` resolves
|
||||
# the real nearest tag instead of falling back to a hash.
|
||||
- name: Checkout
|
||||
run: |
|
||||
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||
git config --global http.extraheader "Authorization: token ${{ github.token }}"
|
||||
git clone --branch "${{ gitea.ref_name }}" \
|
||||
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
test -f flake.nix
|
||||
git log --oneline -1
|
||||
|
||||
- name: Inspect workspace
|
||||
run: |
|
||||
pwd
|
||||
ls -la
|
||||
git rev-parse --show-toplevel 2>&1 || true
|
||||
git remote -v 2>&1 || true
|
||||
git describe --tags --always 2>&1 || true
|
||||
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
|
||||
|
||||
# Release builds report the current git tag.
|
||||
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||
# builtins.getEnv.
|
||||
- name: Compute version
|
||||
run: |
|
||||
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
|
||||
|
||||
# checks is keyed per-system (checks.<system>.default); nix build's
|
||||
# #attr shorthand can't resolve it, so resolve the current system inline.
|
||||
# --impure lets the flake read APP_VERSION via builtins.getEnv
|
||||
# (pure evals always get the package defaults).
|
||||
- name: Build package
|
||||
run: |
|
||||
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||
--impure --out-link result-pkg
|
||||
# The offline test suite runs in the check derivation's checkPhase.
|
||||
# This is a separate build/output from the package itself.
|
||||
- name: Build + offline test suite
|
||||
run: |
|
||||
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||
--impure --out-link result-checks
|
||||
|
||||
- name: Live portal tests
|
||||
env:
|
||||
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
|
||||
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
|
||||
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
|
||||
run: |
|
||||
nix develop --command bash -c '
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build --target test_api_client test_license_service
|
||||
./build/test_api_client
|
||||
./build/test_license_service
|
||||
'
|
||||
|
||||
# Release closure (the actual package, not the check output) lands in the
|
||||
# release cache.
|
||||
- name: Push to Attic release cache
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
|
||||
run: |
|
||||
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||
attic push radiantwave:radiantwave ./result-pkg
|
||||
|
||||
# Publish the manifest production kiosks poll for live app updates.
|
||||
# `storePath` must match exactly the closure pushed to the release Attic
|
||||
# cache above (readlink resolves the result-pkg out-link symlink to its
|
||||
# real /nix/store path), so a kiosk can `nix copy --from` it without ever
|
||||
# building from source. Upload mirrors the ISO scp pattern to the
|
||||
# fileserver via the build-server's `radiantwave-infra` SSH alias.
|
||||
- name: Publish manifest
|
||||
run: |
|
||||
STOREPATH=$(readlink -f ./result-pkg)
|
||||
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||
cat manifest.json
|
||||
scp manifest.json \
|
||||
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave/manifest.json
|
||||
@@ -33,3 +33,8 @@ Thumbs.db
|
||||
|
||||
# Compiled binary
|
||||
bin/
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
.#*
|
||||
@@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
|
||||
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
||||
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
||||
from it.
|
||||
- Proclamations carry a flat `category_id` plus a tag-like `group_id`
|
||||
(`proclamation_groups`). The Options page lists every category and, under it,
|
||||
only the groups that have at least one member in that category; ungrouped
|
||||
proclamations sit directly under the category. `runMigrations()` performs a
|
||||
one-time v1→v2 migration (`PRAGMA user_version`) that converts legacy
|
||||
`proclamation_categories.parent_id` sub-categories into groups and remaps
|
||||
their proclamations, preserving selections. No network is needed for it, so
|
||||
offline kiosks migrate on startup.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,13 +225,16 @@ cached.
|
||||
- Do not modify files in `archive/` — reference only.
|
||||
- Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses
|
||||
Gitea Actions on a self-hosted host runner (`build-server`, no docker).
|
||||
The workflow lives in `.gitea/workflows/build.yml` and is **manual-only**
|
||||
(`workflow_dispatch`); pick the branch when dispatching. It runs
|
||||
The single workflow `.gitea/workflows/build.yml` has two triggers:
|
||||
**`workflow_dispatch`** always builds the `dev` branch and publishes the dev
|
||||
cache/manifest, and a **tag push** (`tags: ['*']`) builds the tag and
|
||||
publishes the release cache/manifest. The event name routes the steps
|
||||
(`github.event_name`). It runs
|
||||
`nix build -L .#checks.<system>.default` (build + the offline test suite;
|
||||
`checks` is keyed per-system, so the workflow resolves the current system
|
||||
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
|
||||
in a per-branch `APP_VERSION` — the current git tag on `main`, the current
|
||||
commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
|
||||
in an `APP_VERSION` — the short commit hash on a manual dev run, the current
|
||||
git tag on a tag push. The flake reads `APP_VERSION` from the environment via
|
||||
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the
|
||||
package defaults). The live portal tests run from `nix develop` when the
|
||||
`RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
|
||||
|
||||
@@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
|
||||
| ------- | --------- |
|
||||
| app_config | Key-value store: license, email, audio device, volume |
|
||||
| logs | Application logs |
|
||||
| proclamations | Synced proclamation text |
|
||||
| proclamation_categories | Category metadata |
|
||||
| proclamations | Synced proclamation text (category_id + group_id) |
|
||||
| proclamation_categories | Flat category metadata |
|
||||
| proclamation_groups | Tag-like groups that organize proclamations within a category |
|
||||
| solfeggio_frequencies | 9 frequency presets |
|
||||
| audio_files | Downloaded audio files |
|
||||
|
||||
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Radiant Wave Portal API (v2)
|
||||
version: 2.0.0
|
||||
description: |
|
||||
Read API used by the Radiant Wave kiosk plus the automation onboarding
|
||||
webhook.
|
||||
|
||||
## Authentication
|
||||
The v2 API is **not** API-key gated. The former bearer-token middleware in
|
||||
`src/hooks.server.ts` is present but disabled, so v2 routes are served
|
||||
without a global API key.
|
||||
|
||||
Two endpoints carry their own credential semantics:
|
||||
|
||||
- `GET /api/v2/proclamations` optionally accepts the paired
|
||||
`x-license-key` / `x-email` headers to scope results to an entitled user.
|
||||
Supplying neither returns the full catalogue; supplying only one, or a
|
||||
pair that does not match, is rejected.
|
||||
- `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256
|
||||
request signature (see the operation description).
|
||||
servers:
|
||||
- url: https://portal.radiantwavetech.com
|
||||
description: Production
|
||||
- url: http://localhost:5173
|
||||
description: Local development
|
||||
tags:
|
||||
- name: Catalogue
|
||||
description: Reference data for categories, groups, frequencies and palettes.
|
||||
- name: Proclamations
|
||||
description: Proclamation content, optionally scoped by license entitlement.
|
||||
- name: License
|
||||
description: License key validation.
|
||||
- name: Schedule
|
||||
description: A user's daily listening schedule, consumed by the kiosk.
|
||||
- name: Automations
|
||||
description: Signed machine-to-machine webhooks.
|
||||
paths:
|
||||
/api/v2/categories:
|
||||
get:
|
||||
tags: [Catalogue]
|
||||
summary: List proclamation categories
|
||||
operationId: listCategories
|
||||
responses:
|
||||
'200':
|
||||
description: All proclamation categories.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Category'
|
||||
/api/v2/proclamation-categories:
|
||||
get:
|
||||
tags: [Catalogue]
|
||||
summary: List proclamation categories (dedicated alias)
|
||||
description: Alias of `/api/v2/categories`; returns the identical payload.
|
||||
operationId: listProclamationCategories
|
||||
responses:
|
||||
'200':
|
||||
description: All proclamation categories.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Category'
|
||||
/api/v2/groups:
|
||||
get:
|
||||
tags: [Catalogue]
|
||||
summary: List proclamation groups
|
||||
description: Groups are ordered by `sortOrder` ascending.
|
||||
operationId: listGroups
|
||||
responses:
|
||||
'200':
|
||||
description: All proclamation groups.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Group'
|
||||
/api/v2/proclamation-groups:
|
||||
get:
|
||||
tags: [Catalogue]
|
||||
summary: List proclamation groups (dedicated alias)
|
||||
description: Alias of `/api/v2/groups`; returns the identical payload.
|
||||
operationId: listProclamationGroups
|
||||
responses:
|
||||
'200':
|
||||
description: All proclamation groups.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Group'
|
||||
/api/v2/frequencies:
|
||||
get:
|
||||
tags: [Catalogue]
|
||||
summary: List canonical solfeggio frequencies
|
||||
description: |
|
||||
Returns the nine canonical frequencies (ids 1-9) the kiosk expects.
|
||||
These are static values, not the portal database autoincrement ids.
|
||||
operationId: listFrequencies
|
||||
responses:
|
||||
'200':
|
||||
description: The nine canonical frequencies.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 9
|
||||
maxItems: 9
|
||||
items:
|
||||
$ref: '#/components/schemas/Frequency'
|
||||
/api/v2/color-palettes:
|
||||
get:
|
||||
tags: [Catalogue]
|
||||
summary: List color palettes per canonical frequency
|
||||
description: |
|
||||
Returns one 7-color palette for each canonical frequency id (1-9).
|
||||
Palette rows stored in the database take precedence; ids without a row
|
||||
fall back to the built-in canonical palette.
|
||||
operationId: listColorPalettes
|
||||
responses:
|
||||
'200':
|
||||
description: Nine palettes, one per canonical frequency id.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 9
|
||||
maxItems: 9
|
||||
items:
|
||||
$ref: '#/components/schemas/ColorPalette'
|
||||
/api/v2/proclamations:
|
||||
get:
|
||||
tags: [Proclamations]
|
||||
summary: List proclamations
|
||||
description: |
|
||||
Without entitlement headers, returns the full proclamation catalogue.
|
||||
|
||||
With both `x-license-key` and `x-email`, returns only the proclamations
|
||||
assigned to the matching user. Both headers must be present together and
|
||||
the key must belong to the user.
|
||||
|
||||
Proclamations whose frequency has no canonical id (for example 936 Hz)
|
||||
are omitted from the response.
|
||||
operationId: listProclamations
|
||||
parameters:
|
||||
- in: header
|
||||
name: x-license-key
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
description: License key. Must be supplied together with `x-email`.
|
||||
- in: header
|
||||
name: x-email
|
||||
schema:
|
||||
type: string
|
||||
format: email
|
||||
required: false
|
||||
description: Account email. Must be supplied together with `x-license-key`.
|
||||
responses:
|
||||
'200':
|
||||
description: Proclamation records with canonical frequency ids and embedded palettes.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Proclamation'
|
||||
'400':
|
||||
description: |
|
||||
Missing entitlement or the supplied key/email pair is invalid.
|
||||
Message is `not-authorized`, or
|
||||
`this key doesn't appear to belong to you` when the key does not
|
||||
belong to the specified user.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
/api/v2/license:
|
||||
post:
|
||||
tags: [License]
|
||||
summary: Validate a license key
|
||||
description: |
|
||||
Validates that the license key exists, is active, and belongs to the
|
||||
account matching `email_address`. A successful validation records
|
||||
`lastValidatedAt` on the key.
|
||||
operationId: validateLicense
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LicenseRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: The validated key and its owner email.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LicenseResponse'
|
||||
'400':
|
||||
description: |
|
||||
Validation failure or malformed body. Possible messages:
|
||||
`content-type must be application/json`, `invalid JSON body`,
|
||||
`request body is required`, `license_key is required`,
|
||||
`email_address is required`, `license key not found`,
|
||||
`license key is not active`, `user does not exist`,
|
||||
`key does not belong to specified user`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
'413':
|
||||
description: The request body exceeded the 64 KiB limit.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
'429':
|
||||
description: Rate limit exceeded (10 requests per minute per IP).
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: Seconds to wait before retrying.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
/api/v2/schedule:
|
||||
get:
|
||||
tags: [Schedule]
|
||||
summary: Get a user's listening schedule (query-param auth)
|
||||
description: |
|
||||
Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via
|
||||
the paired `email` + `licenseKey` **query parameters** and validates only
|
||||
that the key belongs to the account for that email. License
|
||||
active/entitlement is deliberately not checked here: an invalid license
|
||||
would already lock the kiosk out, so this endpoint only checks the
|
||||
pairing.
|
||||
|
||||
Returns every Block with its start/end times and the ordered list of
|
||||
selected Proclamation IDs. Overnight blocks (an end clock time before
|
||||
the start) are supported and flagged with `overnight: true` and a
|
||||
wrapping `durationMinutes`. Each block also carries `dynamicAudio`;
|
||||
when true, the kiosk enables dynamic audio for the block's duration,
|
||||
overriding any audio selections made on the device. Proclamation
|
||||
content is not included; the kiosk fetches it separately via
|
||||
`/api/v2/proclamations`.
|
||||
operationId: getSchedule
|
||||
parameters:
|
||||
- in: query
|
||||
name: email
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: email
|
||||
description: Account email.
|
||||
- in: query
|
||||
name: licenseKey
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||
description: License key. The legacy alias `key` is also accepted.
|
||||
responses:
|
||||
'200':
|
||||
description: The user's schedule (possibly empty).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScheduleResponse'
|
||||
example:
|
||||
email: jane@example.com
|
||||
blocks:
|
||||
- id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f
|
||||
startTime: '21:00'
|
||||
startMinutes: 1260
|
||||
endTime: '09:00'
|
||||
endMinutes: 540
|
||||
overnight: true
|
||||
durationMinutes: 720
|
||||
dynamicAudio: true
|
||||
proclamationIds:
|
||||
- b40fdcb9-703c-420e-af32-a081964c6f08
|
||||
- 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b
|
||||
'400':
|
||||
description: |
|
||||
Missing/invalid parameters, unknown key or user, or the key does not
|
||||
belong to the email's account. Possible messages: `not-authorized`,
|
||||
`this key doesn't appear to belong to you`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
'429':
|
||||
description: Rate limit exceeded (10 requests per minute per IP).
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: Seconds to wait before retrying.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
/api/v2/automations/create/account:
|
||||
post:
|
||||
tags: [Automations]
|
||||
summary: Create an account and license key (signed webhook)
|
||||
description: |
|
||||
Machine-to-machine onboarding webhook. The request must be signed with
|
||||
HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`.
|
||||
|
||||
The canonical string that is signed is the newline-joined concatenation
|
||||
of:
|
||||
|
||||
```
|
||||
POST
|
||||
<path>
|
||||
<x-timestamp>
|
||||
<x-nonce>
|
||||
<raw request body>
|
||||
```
|
||||
|
||||
where `<path>` is `/api/v2/automations/create/account`. The signature is
|
||||
the lowercase hex digest.
|
||||
|
||||
On success, creates the user if they do not already exist, generates a
|
||||
license key, emails it, and returns the user id. Invalid or missing
|
||||
signatures return `401 { "error": "invalid signature" }` (the same
|
||||
response is used for every signature failure, including replay and
|
||||
timestamp skew).
|
||||
operationId: createAutomationAccount
|
||||
parameters:
|
||||
- in: header
|
||||
name: x-timestamp
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Unix epoch seconds. Must be within 300 seconds of the server clock.
|
||||
- in: header
|
||||
name: x-nonce
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Unique value per request. Replays within the skew window are rejected.
|
||||
- in: header
|
||||
name: x-signature
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{64}$'
|
||||
description: Lowercase hex HMAC-SHA256 of the canonical request string.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OnboardingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Account and license key created.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OnboardingResponse'
|
||||
'400':
|
||||
description: |
|
||||
Malformed or invalid request. Possible messages:
|
||||
`request body is required`, `invalid JSON body`, or
|
||||
`email is required`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
'401':
|
||||
description: Signature missing, malformed, expired, replayed, or incorrect.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: invalid signature
|
||||
'413':
|
||||
description: The request body exceeded the 64 KiB limit.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
'429':
|
||||
description: |
|
||||
Rate limit exceeded. Limits are 5 requests per minute per IP and
|
||||
3 requests per 24 hours per email address.
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: Seconds to wait before retrying.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
'500':
|
||||
description: The account could not be created.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorMessage'
|
||||
components:
|
||||
schemas:
|
||||
Category:
|
||||
type: object
|
||||
required: [id, name, sortOrder]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
sortOrder:
|
||||
type: integer
|
||||
Group:
|
||||
type: object
|
||||
required: [id, name, sortOrder]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
sortOrder:
|
||||
type: integer
|
||||
Frequency:
|
||||
type: object
|
||||
required: [id, name, value]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9
|
||||
description: Canonical id used by the kiosk.
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: integer
|
||||
description: Frequency in Hz.
|
||||
ColorPalette:
|
||||
type: object
|
||||
required: [solfeggioFrequencyId, colors]
|
||||
properties:
|
||||
solfeggioFrequencyId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9
|
||||
colors:
|
||||
type: array
|
||||
description: Seven hex colors, ordered light to dark.
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
pattern: '^#[0-9A-Fa-f]{6}$'
|
||||
Proclamation:
|
||||
type: object
|
||||
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
description: HTML content.
|
||||
categoryId:
|
||||
type: integer
|
||||
groupId:
|
||||
type: integer
|
||||
solfeggioFrequencyId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9
|
||||
colorPalette:
|
||||
type: array
|
||||
description: Present only when a palette exists for the canonical frequency id.
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
pattern: '^#[0-9A-Fa-f]{6}$'
|
||||
LicenseRequest:
|
||||
type: object
|
||||
required: [license_key, email_address]
|
||||
properties:
|
||||
license_key:
|
||||
type: string
|
||||
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||
example: ABCD-EFGH-IJKL-MNOP
|
||||
email_address:
|
||||
type: string
|
||||
format: email
|
||||
maxLength: 254
|
||||
LicenseResponse:
|
||||
type: object
|
||||
required: [key, email_address]
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
example: ABCD-EFGH-IJKL-MNOP
|
||||
email_address:
|
||||
type: string
|
||||
format: email
|
||||
ScheduleBlock:
|
||||
type: object
|
||||
required:
|
||||
[
|
||||
id,
|
||||
startTime,
|
||||
startMinutes,
|
||||
endTime,
|
||||
endMinutes,
|
||||
overnight,
|
||||
durationMinutes,
|
||||
dynamicAudio,
|
||||
proclamationIds
|
||||
]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
startTime:
|
||||
type: string
|
||||
example: '06:00'
|
||||
description: Wall-clock start time as `HH:MM`.
|
||||
startMinutes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 1440
|
||||
description: Minutes from midnight.
|
||||
endTime:
|
||||
type: string
|
||||
example: '08:00'
|
||||
description: Wall-clock end time as `HH:MM`.
|
||||
endMinutes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 1440
|
||||
description: Minutes from midnight.
|
||||
overnight:
|
||||
type: boolean
|
||||
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
|
||||
durationMinutes:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
|
||||
dynamicAudio:
|
||||
type: boolean
|
||||
description: |
|
||||
When true, dynamic audio is enabled for the block's duration, overriding any
|
||||
audio selections made on the device while the block is active.
|
||||
proclamationIds:
|
||||
type: array
|
||||
description: IDs of the selected Proclamations, in play order.
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
ScheduleResponse:
|
||||
type: object
|
||||
required: [email, blocks]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
blocks:
|
||||
type: array
|
||||
maxItems: 4
|
||||
items:
|
||||
$ref: '#/components/schemas/ScheduleBlock'
|
||||
OnboardingRequest:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
maxLength: 254
|
||||
name:
|
||||
type: string
|
||||
maxLength: 120
|
||||
description: Optional display name. Defaults to the email local-part.
|
||||
OnboardingResponse:
|
||||
type: object
|
||||
required: [success, message, user_id]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
example: Account and license key created
|
||||
user_id:
|
||||
type: string
|
||||
ErrorMessage:
|
||||
type: object
|
||||
required: [message]
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
@@ -1,191 +0,0 @@
|
||||
# Portal API v2 — current schema
|
||||
|
||||
The kiosk talks to `https://portal.radiantwavetech.com/api/v2`. This document
|
||||
describes the current contract after the move to an **active/inactive license
|
||||
model**. There are no subscription levels: a license is either **valid**
|
||||
(subscribed) or not, and the kiosk treats a successful `POST /license`
|
||||
validation as the device's "subscribed" flag.
|
||||
|
||||
Base URL: `https://portal.radiantwavetech.com/api/v2`
|
||||
|
||||
Authentication: `Authorization: Bearer sk_<hex>` on every request (the API key
|
||||
is baked into the kiosk binary at build time).
|
||||
|
||||
---
|
||||
|
||||
## `POST /license`
|
||||
|
||||
Validates a license key / email pair.
|
||||
|
||||
Request:
|
||||
|
||||
```
|
||||
POST /api/v2/license
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer sk_<hex>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"license_key": "XXXX-XXXX-XXXX-XXXX",
|
||||
"email_address": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Response (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "XXXX-XXXX-XXXX-XXXX",
|
||||
"email_address": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Contract (kiosk `parseLicenseJson`):
|
||||
|
||||
- `key`: string, **required non-empty** (empty key ⇒ parse failure).
|
||||
- `email_address`: string.
|
||||
- Any other fields (e.g. the legacy `subscription_level`) are ignored. A 2xx
|
||||
response means the license is valid and the subscription is **active**; the
|
||||
kiosk stores no subscription state other than the `license_last_verified`
|
||||
stamp that drives its 7-day offline grace window.
|
||||
|
||||
Errors:
|
||||
|
||||
| Code | Body | Meaning |
|
||||
|------|------|---------|
|
||||
| 200 | success object | License valid and owned by the email |
|
||||
| 400 | `{"message": "license_key is required"}` | Missing `license_key` |
|
||||
| 400 | `{"message": "email_address is required"}` | Missing `email_address` |
|
||||
| 400 | `{"message": "license key not found"}` | No key with that value |
|
||||
| 400 | `{"message": "license key is not active"}` | Key exists but inactive |
|
||||
| 400 | `{"message": "user does not exist"}` | No user with that email |
|
||||
| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user |
|
||||
| 401 | `{"error": "..."}` | Missing/invalid API key |
|
||||
|
||||
---
|
||||
|
||||
## `GET /proclamations`
|
||||
|
||||
Lists every proclamation. There is **no `subscriptionLevelId`** — any validated
|
||||
license may access any proclamation.
|
||||
|
||||
Response (200):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||
"name": "Peace & Relaxation",
|
||||
"content": "<p>The text content, HTML formatted</p>",
|
||||
"categoryId": 5,
|
||||
"solfeggioFrequencyId": 5,
|
||||
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
|
||||
|
||||
- `id`: string UUID, **required non-empty** (empty ids are skipped).
|
||||
- `name`: string, used as the display title. Kiosk strips HTML from it.
|
||||
- `content`: string, **HTML** (the kiosk strips tags and unescapes entities
|
||||
before rendering).
|
||||
- `categoryId`: int, maps to a `/categories` id.
|
||||
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)**.
|
||||
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings. When
|
||||
absent, the kiosk falls back to `/color-palettes`, then to its own 528 Hz
|
||||
seed.
|
||||
- Legacy `subscriptionLevelId` fields in the payload are ignored.
|
||||
|
||||
Errors:
|
||||
|
||||
| Code | Body | Meaning |
|
||||
|------|------|---------|
|
||||
| 200 | array of proclamation records | Success |
|
||||
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
|
||||
| 401 | `{"error": "..."}` | Missing/invalid API key |
|
||||
|
||||
---
|
||||
|
||||
## `GET /categories`
|
||||
|
||||
Lists the proclamation categories.
|
||||
|
||||
Response (200):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Spiritual",
|
||||
"parentId": 0,
|
||||
"sortOrder": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Contract (kiosk `parseCategoriesJson`):
|
||||
|
||||
- `id`: int, **required non-zero** (zero ids are skipped).
|
||||
- `name`: string.
|
||||
- `parentId`: int, `null` maps to 0 (root).
|
||||
- `sortOrder`: int.
|
||||
|
||||
---
|
||||
|
||||
## `POST /automations/create/account`
|
||||
|
||||
Creates (or reuses) a user account and issues a license key. **No
|
||||
`subscription_level` is sent** — every created license is a standard active
|
||||
license.
|
||||
|
||||
Request:
|
||||
|
||||
```
|
||||
POST /api/v2/automations/create/account
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer sk_<hex>
|
||||
X-Timestamp: <unix seconds>
|
||||
X-Nonce: <random uuid>
|
||||
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"name": "Optional Display Name"
|
||||
}
|
||||
```
|
||||
|
||||
- `email` (required, string).
|
||||
- `name` (optional, string; defaults to the local part of `email`).
|
||||
|
||||
Response (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Account and license key created",
|
||||
"user_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
|
||||
| Code | Body | Meaning |
|
||||
|------|------|---------|
|
||||
| 200 | success object | Account (+ license key) created |
|
||||
| 400 | `{"message": "email is required"}` | Missing/empty `email` |
|
||||
| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature |
|
||||
| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email |
|
||||
| 500 | `{"message": "Failed to create account"}` | User creation failed |
|
||||
|
||||
---
|
||||
|
||||
## Readiness routes (not yet wired into the app)
|
||||
|
||||
- `GET /color-palettes` → `[{solfeggioFrequencyId, colors[7]}]`
|
||||
- `GET /frequencies` → canonical id 1–9 + `value` in Hz
|
||||
|
||||
These exist for future sync steps; the kiosk does not call them yet.
|
||||
@@ -16,6 +16,7 @@ struct ProclamationConfig {
|
||||
float scroll_speed = 1.0f; // scroll speed multiplier
|
||||
int font_size = 32; // font size in pt
|
||||
std::string current_id = ""; // currently active proclamation ID
|
||||
bool schedule_active = false; // follow the portal's listening schedule
|
||||
};
|
||||
|
||||
struct AppConfig {
|
||||
|
||||
@@ -250,6 +250,9 @@ void ConfigManager::loadAndMergeDefaults() {
|
||||
if (proc_node.hasChild("current_id")) {
|
||||
merged.proclamation.current_id = proc_node.getChild("current_id").asString(defaults.proclamation.current_id);
|
||||
}
|
||||
if (proc_node.hasChild("schedule_active")) {
|
||||
merged.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(defaults.proclamation.schedule_active);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -231,6 +231,9 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) {
|
||||
if (proc_node.hasChild("current_id")) {
|
||||
config.proclamation.current_id = proc_node.getChild("current_id").asString(config.proclamation.current_id);
|
||||
}
|
||||
if (proc_node.hasChild("schedule_active")) {
|
||||
config.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(config.proclamation.schedule_active);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -256,6 +259,7 @@ std::string serializeConfigToYaml(const AppConfig& config) {
|
||||
ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n";
|
||||
ss << " font_size: " << config.proclamation.font_size << "\n";
|
||||
ss << " current_id: \"" << config.proclamation.current_id << "\"\n";
|
||||
ss << " schedule_active: " << (config.proclamation.schedule_active ? "true" : "false") << "\n";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
+281
-18
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
|
||||
is_active INTEGER DEFAULT 1,
|
||||
selected INTEGER DEFAULT 0,
|
||||
category_id INTEGER DEFAULT 0,
|
||||
group_id INTEGER DEFAULT 0,
|
||||
solfeggio_frequency_id INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
|
||||
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proclamation_groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
@@ -175,6 +181,23 @@ bool DatabaseManager::runMigrations() {
|
||||
hex TEXT NOT NULL,
|
||||
PRIMARY KEY (solfeggio_frequency_id, color_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
start_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
end_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
overnight INTEGER NOT NULL DEFAULT 0,
|
||||
duration_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
dynamic_audio INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_block_proclamations (
|
||||
block_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
proclamation_id TEXT NOT NULL,
|
||||
PRIMARY KEY (block_id, position)
|
||||
);
|
||||
)";
|
||||
|
||||
bool ok = executeSql(schema);
|
||||
@@ -182,6 +205,7 @@ bool DatabaseManager::runMigrations() {
|
||||
// Back-fill columns for databases created before these fields existed.
|
||||
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
|
||||
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
|
||||
ensureColumn("proclamations", "group_id", "group_id INTEGER DEFAULT 0");
|
||||
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
||||
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
||||
// Clean up the pre-active/inactive-license subscription level keys.
|
||||
@@ -189,12 +213,72 @@ bool DatabaseManager::runMigrations() {
|
||||
executeSql("DELETE FROM system_kv WHERE key IN "
|
||||
"('subscription_level_id','subscription_level_name','subscription_level_description');");
|
||||
}
|
||||
if (ok) {
|
||||
ok = migrateSubCategoriesToGroups();
|
||||
}
|
||||
if (ok) {
|
||||
seedColorPalettes();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Schema migration v2: sub-categories -> tag-like groups.
|
||||
//
|
||||
// Pre-v2 databases modelled a two-level category tree via
|
||||
// `proclamation_categories.parent_id`, and proclamations pointed at either a
|
||||
// root or a sub-category. v2 flattens categories and turns each former
|
||||
// sub-category into a group, remapping the proclamations that referenced it to
|
||||
// its parent category + the new group. Selection flags are never touched, so
|
||||
// the user's picks survive the upgrade. `PRAGMA user_version` records the
|
||||
// migration so it runs exactly once; the whole thing is one transaction so a
|
||||
// failure leaves the database untouched. No network is required, so this
|
||||
// always runs at startup, including on offline kiosks.
|
||||
bool DatabaseManager::migrateSubCategoriesToGroups() {
|
||||
if (!db_) return false;
|
||||
|
||||
int userVersion = 0;
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "PRAGMA user_version;", -1, &stmt, nullptr) == SQLITE_OK) {
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
userVersion = sqlite3_column_int(stmt, 0);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
if (userVersion >= 2) {
|
||||
return true; // already migrated
|
||||
}
|
||||
|
||||
// Fresh databases (created by the v2 schema) have no legacy parent_id
|
||||
// column, so there is nothing to convert: just stamp the version.
|
||||
if (!columnExists("proclamation_categories", "parent_id")) {
|
||||
return executeSql("PRAGMA user_version = 2;");
|
||||
}
|
||||
|
||||
bool migrated = executeSql(
|
||||
"BEGIN;"
|
||||
"INSERT OR IGNORE INTO proclamation_groups (id, name, sort_order) "
|
||||
" SELECT id, name, sort_order FROM proclamation_categories WHERE parent_id != 0;"
|
||||
"UPDATE proclamations "
|
||||
" SET group_id = category_id, "
|
||||
" category_id = COALESCE("
|
||||
" (SELECT parent_id FROM proclamation_categories "
|
||||
" WHERE id = proclamations.category_id), "
|
||||
" category_id) "
|
||||
" WHERE category_id IN "
|
||||
" (SELECT id FROM proclamation_categories WHERE parent_id != 0);"
|
||||
"DELETE FROM proclamation_categories WHERE parent_id != 0;"
|
||||
"PRAGMA user_version = 2;"
|
||||
"COMMIT;");
|
||||
|
||||
if (!migrated) {
|
||||
executeSql("ROLLBACK;");
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
void DatabaseManager::seedColorPalettes() {
|
||||
static const char* kSeedSql =
|
||||
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
|
||||
@@ -337,7 +421,8 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
|
||||
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, 8, record.category_id);
|
||||
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
|
||||
sqlite3_bind_int(stmt, 9, record.group_id);
|
||||
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_id);
|
||||
}
|
||||
|
||||
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||
@@ -345,12 +430,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||
if (!db_) return false;
|
||||
|
||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||
"is_active=excluded.is_active, selected=excluded.selected, "
|
||||
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||
"category_id=excluded.category_id, group_id=excluded.group_id, "
|
||||
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -374,7 +460,8 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
|
||||
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
|
||||
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
||||
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
|
||||
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
|
||||
rec.group_id = sqlite3_column_int(stmt, firstCol + 8);
|
||||
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 9);
|
||||
return rec;
|
||||
}
|
||||
|
||||
@@ -384,7 +471,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
|
||||
if (!db_) return result;
|
||||
|
||||
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
||||
"selected, category_id, solfeggio_frequency_id "
|
||||
"selected, category_id, group_id, solfeggio_frequency_id "
|
||||
"FROM proclamations WHERE is_active = 1;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -414,8 +501,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
|
||||
"VALUES (?, ?, ?, ?);";
|
||||
const char* insSql = "INSERT INTO proclamation_categories (id, name, sort_order) "
|
||||
"VALUES (?, ?, ?);";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||
@@ -425,8 +512,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
||||
for (const auto& cat : records) {
|
||||
sqlite3_bind_int(stmt, 1, cat.id);
|
||||
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 3, cat.parent_id);
|
||||
sqlite3_bind_int(stmt, 4, cat.sort_order);
|
||||
sqlite3_bind_int(stmt, 3, cat.sort_order);
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_clear_bindings(stmt);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (ok) {
|
||||
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
|
||||
} else {
|
||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool DatabaseManager::replaceGroups(const std::vector<ProclamationGroupRecord>& records) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!db_) return false;
|
||||
|
||||
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
if (sqlite3_exec(db_, "DELETE FROM proclamation_groups;", nullptr, nullptr, nullptr) != SQLITE_OK) {
|
||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* insSql = "INSERT INTO proclamation_groups (id, name, sort_order) "
|
||||
"VALUES (?, ?, ?);";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const auto& group : records) {
|
||||
sqlite3_bind_int(stmt, 1, group.id);
|
||||
sqlite3_bind_text(stmt, 2, group.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 3, group.sort_order);
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||
ok = false;
|
||||
break;
|
||||
@@ -499,12 +628,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
|
||||
// Upsert each proclamation, preserving the stashed selection state.
|
||||
if (ok) {
|
||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||
"is_active=excluded.is_active, category_id=excluded.category_id, "
|
||||
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||
"group_id=excluded.group_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -555,7 +684,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
|
||||
if (!db_) return result;
|
||||
|
||||
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
||||
"selected, category_id, solfeggio_frequency_id FROM proclamations;";
|
||||
"selected, category_id, group_id, solfeggio_frequency_id FROM proclamations;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return result;
|
||||
@@ -574,7 +703,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
||||
std::vector<ProclamationCategoryRecord> result;
|
||||
if (!db_) return result;
|
||||
|
||||
const char* sql = "SELECT id, name, parent_id, sort_order FROM proclamation_categories "
|
||||
const char* sql = "SELECT id, name, sort_order FROM proclamation_categories "
|
||||
"ORDER BY sort_order ASC, id ASC;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -585,8 +714,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
||||
ProclamationCategoryRecord rec;
|
||||
rec.id = sqlite3_column_int(stmt, 0);
|
||||
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
rec.parent_id = sqlite3_column_int(stmt, 2);
|
||||
rec.sort_order = sqlite3_column_int(stmt, 3);
|
||||
rec.sort_order = sqlite3_column_int(stmt, 2);
|
||||
result.push_back(rec);
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<ProclamationGroupRecord> DatabaseManager::getAllGroups() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
std::vector<ProclamationGroupRecord> result;
|
||||
if (!db_) return result;
|
||||
|
||||
const char* sql = "SELECT id, name, sort_order FROM proclamation_groups "
|
||||
"ORDER BY sort_order ASC, id ASC;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ProclamationGroupRecord rec;
|
||||
rec.id = sqlite3_column_int(stmt, 0);
|
||||
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
rec.sort_order = sqlite3_column_int(stmt, 2);
|
||||
result.push_back(rec);
|
||||
}
|
||||
|
||||
@@ -948,4 +1100,115 @@ std::vector<AudioFileRecord> DatabaseManager::getAudioFiles() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Schedule Blocks Table ─────────────────────────────────────────────
|
||||
|
||||
bool DatabaseManager::replaceScheduleBlocks(const std::vector<ScheduleBlockRecord>& records) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!db_) return false;
|
||||
|
||||
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
if (sqlite3_exec(db_, "DELETE FROM schedule_block_proclamations;", nullptr, nullptr, nullptr) != SQLITE_OK ||
|
||||
sqlite3_exec(db_, "DELETE FROM schedule_blocks;", nullptr, nullptr, nullptr) != SQLITE_OK) {
|
||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* blockSql = "INSERT INTO schedule_blocks "
|
||||
"(id, start_minutes, end_minutes, overnight, duration_minutes, "
|
||||
" dynamic_audio, sort_order) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?);";
|
||||
const char* procSql = "INSERT INTO schedule_block_proclamations "
|
||||
"(block_id, position, proclamation_id) VALUES (?, ?, ?);";
|
||||
|
||||
for (size_t i = 0; i < records.size() && ok; ++i) {
|
||||
const ScheduleBlockRecord& rec = records[i];
|
||||
|
||||
sqlite3_stmt* blockStmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, blockSql, -1, &blockStmt, nullptr) != SQLITE_OK) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
sqlite3_bind_text(blockStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(blockStmt, 2, rec.start_minutes);
|
||||
sqlite3_bind_int(blockStmt, 3, rec.end_minutes);
|
||||
sqlite3_bind_int(blockStmt, 4, rec.overnight ? 1 : 0);
|
||||
sqlite3_bind_int(blockStmt, 5, rec.duration_minutes);
|
||||
sqlite3_bind_int(blockStmt, 6, rec.dynamic_audio ? 1 : 0);
|
||||
sqlite3_bind_int(blockStmt, 7, static_cast<int>(i));
|
||||
if (sqlite3_step(blockStmt) != SQLITE_DONE) ok = false;
|
||||
sqlite3_finalize(blockStmt);
|
||||
if (!ok) break;
|
||||
|
||||
for (size_t p = 0; p < rec.proclamation_ids.size(); ++p) {
|
||||
sqlite3_stmt* procStmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
sqlite3_bind_text(procStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(procStmt, 2, static_cast<int>(p));
|
||||
sqlite3_bind_text(procStmt, 3, rec.proclamation_ids[p].c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(procStmt) != SQLITE_DONE) ok = false;
|
||||
sqlite3_finalize(procStmt);
|
||||
if (!ok) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
|
||||
} else {
|
||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::vector<ScheduleBlockRecord> DatabaseManager::getScheduleBlocks() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
std::vector<ScheduleBlockRecord> result;
|
||||
if (!db_) return result;
|
||||
|
||||
const char* sql = "SELECT id, start_minutes, end_minutes, overnight, duration_minutes, "
|
||||
"dynamic_audio FROM schedule_blocks ORDER BY sort_order ASC;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ScheduleBlockRecord rec;
|
||||
rec.id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
rec.start_minutes = sqlite3_column_int(stmt, 1);
|
||||
rec.end_minutes = sqlite3_column_int(stmt, 2);
|
||||
rec.overnight = (sqlite3_column_int(stmt, 3) != 0);
|
||||
rec.duration_minutes = sqlite3_column_int(stmt, 4);
|
||||
rec.dynamic_audio = (sqlite3_column_int(stmt, 5) != 0);
|
||||
result.push_back(std::move(rec));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// Attach each block's proclamation ids in play order.
|
||||
const char* procSql = "SELECT proclamation_id FROM schedule_block_proclamations "
|
||||
"WHERE block_id = ? ORDER BY position ASC;";
|
||||
for (auto& block : result) {
|
||||
sqlite3_stmt* procStmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) {
|
||||
break;
|
||||
}
|
||||
sqlite3_bind_text(procStmt, 1, block.id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(procStmt) == SQLITE_ROW) {
|
||||
const unsigned char* text = sqlite3_column_text(procStmt, 0);
|
||||
if (text) {
|
||||
block.proclamation_ids.push_back(reinterpret_cast<const char*>(text));
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(procStmt);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace radiantwave::db
|
||||
|
||||
@@ -16,13 +16,23 @@ struct ProclamationRecord {
|
||||
bool is_active = true;
|
||||
bool selected = false;
|
||||
int category_id = 0; // maps to ProclamationCategoryRecord::id
|
||||
int group_id = 0; // maps to ProclamationGroupRecord::id (0 = ungrouped)
|
||||
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
|
||||
};
|
||||
|
||||
struct ProclamationCategoryRecord {
|
||||
int id = 0;
|
||||
std::string name;
|
||||
int parent_id = 0; // 0 = root category
|
||||
int sort_order = 0;
|
||||
};
|
||||
|
||||
// A group is a tag-like organizer: it is not owned by a category. A
|
||||
// proclamation belongs to one category and may additionally belong to one
|
||||
// group. Within the UI, a group appears under a category only when at least
|
||||
// one proclamation pairs that (category_id, group_id).
|
||||
struct ProclamationGroupRecord {
|
||||
int id = 0;
|
||||
std::string name;
|
||||
int sort_order = 0;
|
||||
};
|
||||
|
||||
@@ -44,6 +54,18 @@ struct AudioFileRecord {
|
||||
int solfeggio_frequency_id = 0; // 0 = not associated with a solfeggio frequency
|
||||
};
|
||||
|
||||
// One scheduled listening block, mirrored from GET /schedule. `proclamation_ids`
|
||||
// is the ordered play list for the block.
|
||||
struct ScheduleBlockRecord {
|
||||
std::string id;
|
||||
int start_minutes = 0;
|
||||
int end_minutes = 0;
|
||||
bool overnight = false;
|
||||
int duration_minutes = 0;
|
||||
bool dynamic_audio = true; // override the device audio for this block
|
||||
std::vector<std::string> proclamation_ids;
|
||||
};
|
||||
|
||||
// A single color in a solfeggio frequency's color palette (RGBA).
|
||||
struct PaletteColor {
|
||||
uint8_t r = 0;
|
||||
@@ -90,6 +112,9 @@ public:
|
||||
// Full sync: replaces all categories with the given set.
|
||||
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
|
||||
|
||||
// Full sync: replaces all groups with the given set.
|
||||
bool replaceGroups(const std::vector<ProclamationGroupRecord>& records);
|
||||
|
||||
// Full sync: upserts proclamations, deletes any not in `records`,
|
||||
// preserves the existing `selected` state, and forces the proclamation
|
||||
// identified by `defaultProclamationId` to remain selected.
|
||||
@@ -102,6 +127,9 @@ public:
|
||||
// Fetch all categories ordered by sort_order then id.
|
||||
std::vector<ProclamationCategoryRecord> getAllCategories();
|
||||
|
||||
// Fetch all groups ordered by sort_order then id.
|
||||
std::vector<ProclamationGroupRecord> getAllGroups();
|
||||
|
||||
// Toggle a single proclamation's selected flag.
|
||||
bool setProclamationSelected(const std::string& id, bool selected);
|
||||
|
||||
@@ -138,12 +166,23 @@ public:
|
||||
// palettes can be pushed from the portal once the API route exists.
|
||||
bool replaceColorPalettes(const std::vector<PaletteRecord>& records);
|
||||
|
||||
// ── Schedule Blocks Table ─────────────────────────────────────────
|
||||
// Full sync: replaces the stored schedule with `records` (the portal is the
|
||||
// source of truth). Pass an empty vector to clear the schedule. Block order
|
||||
// and per-block proclamation order are preserved exactly as given.
|
||||
bool replaceScheduleBlocks(const std::vector<ScheduleBlockRecord>& records);
|
||||
|
||||
// Fetch all schedule blocks, ordered as stored, with their proclamation ids
|
||||
// in play order.
|
||||
std::vector<ScheduleBlockRecord> getScheduleBlocks();
|
||||
|
||||
// ── Event Logging Table ───────────────────────────────────────────
|
||||
bool logEvent(const std::string& level, const std::string& component, const std::string& message);
|
||||
|
||||
private:
|
||||
std::string resolvePath(const std::string& raw_path) const;
|
||||
bool runMigrations();
|
||||
bool migrateSubCategoriesToGroups();
|
||||
bool executeSql(const std::string& sql);
|
||||
bool columnExists(const std::string& table, const std::string& column);
|
||||
bool ensureColumn(const std::string& table, const std::string& column,
|
||||
|
||||
@@ -221,6 +221,86 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── GET /schedule ─────────────────────────────────────────────────────
|
||||
|
||||
bool parseScheduleJson(const std::string& body, ScheduleResponse& out) {
|
||||
radiantwave::json::JsonValue root;
|
||||
if (!radiantwave::json::parseDocument(body, root) ||
|
||||
root.type != radiantwave::json::JsonValue::Type::Object) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ScheduleResponse parsed;
|
||||
parsed.email = root.stringField("email");
|
||||
|
||||
const radiantwave::json::JsonValue* blocks = root.find("blocks");
|
||||
if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& entry : blocks->array) {
|
||||
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
|
||||
|
||||
ScheduleBlock block;
|
||||
block.id = entry.stringField("id");
|
||||
if (block.id.empty()) continue;
|
||||
|
||||
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
|
||||
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
|
||||
block.overnight = entry.boolField("overnight");
|
||||
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
|
||||
|
||||
// `dynamicAudio` is optional on the wire and defaults to true.
|
||||
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
|
||||
block.dynamic_audio =
|
||||
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
|
||||
|
||||
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
|
||||
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& id : ids->array) {
|
||||
if (id.type == radiantwave::json::JsonValue::Type::String &&
|
||||
!id.str.empty()) {
|
||||
block.proclamation_ids.push_back(id.str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed.blocks.push_back(std::move(block));
|
||||
}
|
||||
}
|
||||
|
||||
out = std::move(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encodes a query-parameter value (RFC 3986 unreserved set).
|
||||
std::string urlEncode(const std::string& value) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return value;
|
||||
char* escaped = curl_easy_escape(curl, value.c_str(), static_cast<int>(value.size()));
|
||||
std::string result = escaped ? escaped : value;
|
||||
if (escaped) curl_free(escaped);
|
||||
curl_easy_cleanup(curl);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
bool fetchSchedule(const std::string& email, const std::string& license_key,
|
||||
ScheduleResponse& out) {
|
||||
if (email.empty() || license_key.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string path = std::string(PATH_SCHEDULE) +
|
||||
"?email=" + urlEncode(email) +
|
||||
"&licenseKey=" + urlEncode(license_key);
|
||||
HttpResponse response = httpGet(path);
|
||||
if (!response.transportOk || response.status < 200 || response.status >= 300) {
|
||||
return false;
|
||||
}
|
||||
return parseScheduleJson(response.body, out);
|
||||
}
|
||||
|
||||
AccountResult createAccount(const std::string& email, const std::string& name) {
|
||||
AccountResult result;
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
|
||||
// Paths relative to API_BASE_URL.
|
||||
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
||||
constexpr const char* PATH_CATEGORIES = "/categories";
|
||||
constexpr const char* PATH_GROUPS = "/groups";
|
||||
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
||||
constexpr const char* PATH_LICENSE = "/license";
|
||||
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
||||
constexpr const char* PATH_COLOR_PALETTES = "/color-palettes";
|
||||
constexpr const char* PATH_SCHEDULE = "/schedule";
|
||||
|
||||
// Result of a single HTTP request.
|
||||
struct HttpResponse {
|
||||
@@ -118,4 +120,37 @@ struct AccountResult {
|
||||
// to the user and is NOT part of the response. `name` may be empty.
|
||||
AccountResult createAccount(const std::string& email, const std::string& name = "");
|
||||
|
||||
// ─── GET /schedule ─────────────────────────────────────────────────────
|
||||
|
||||
// One scheduled listening block. Wall-clock times are carried as minutes from
|
||||
// midnight; overnight blocks wrap past midnight (end clock time <= start).
|
||||
struct ScheduleBlock {
|
||||
std::string id;
|
||||
int start_minutes = 0;
|
||||
int end_minutes = 0;
|
||||
bool overnight = false;
|
||||
int duration_minutes = 0;
|
||||
// When true the kiosk enables dynamic audio for the block's duration,
|
||||
// overriding the device's audio selection. Defaults to true: schedules are
|
||||
// dynamic unless the portal explicitly says otherwise.
|
||||
bool dynamic_audio = true;
|
||||
// Selected proclamation ids, in play order.
|
||||
std::vector<std::string> proclamation_ids;
|
||||
};
|
||||
|
||||
struct ScheduleResponse {
|
||||
std::string email;
|
||||
std::vector<ScheduleBlock> blocks;
|
||||
};
|
||||
|
||||
// Parse the JSON payload returned by the schedule endpoint. `dynamicAudio` is
|
||||
// optional and defaults to true when absent. Returns false on malformed input.
|
||||
bool parseScheduleJson(const std::string& body, ScheduleResponse& out);
|
||||
|
||||
// Fetch a user's schedule. Authenticates via the paired `email` + `license_key`
|
||||
// query parameters. Returns true only on HTTP 2xx + successful parse; failures
|
||||
// (including a missing/blank pair) leave `out` untouched.
|
||||
bool fetchSchedule(const std::string& email, const std::string& license_key,
|
||||
ScheduleResponse& out);
|
||||
|
||||
} // namespace radiantwave::api
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "network/proclamation_sync.h"
|
||||
#include "db/db_keys.h"
|
||||
#include "network/api_client.h"
|
||||
#include "network/json.h"
|
||||
|
||||
@@ -66,6 +67,7 @@ bool parseProclamationsJson(const std::string& body,
|
||||
rec.title = stripHtml(entry.stringField("name"));
|
||||
rec.content = stripHtml(entry.stringField("content"));
|
||||
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
|
||||
rec.group_id = static_cast<int>(entry.numberField("groupId"));
|
||||
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
@@ -88,7 +90,28 @@ bool parseCategoriesJson(const std::string& body,
|
||||
if (rec.id == 0) continue;
|
||||
|
||||
rec.name = entry.stringField("name");
|
||||
rec.parent_id = static_cast<int>(entry.numberField("parentId"));
|
||||
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseGroupsJson(const std::string& body,
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord>& out) {
|
||||
json::JsonValue root;
|
||||
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.clear();
|
||||
for (const auto& entry : root.array) {
|
||||
if (entry.type != json::JsonValue::Type::Object) continue;
|
||||
|
||||
radiantwave::db::ProclamationGroupRecord rec;
|
||||
rec.id = static_cast<int>(entry.numberField("id"));
|
||||
if (rec.id == 0) continue;
|
||||
|
||||
rec.name = entry.stringField("name");
|
||||
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
@@ -114,22 +137,66 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
return false;
|
||||
}
|
||||
|
||||
api::HttpResponse groupsResponse =
|
||||
api::httpGet(api::PATH_GROUPS);
|
||||
if (!groupsResponse.transportOk ||
|
||||
groupsResponse.status < 200 || groupsResponse.status >= 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<radiantwave::db::ProclamationRecord> proclamations;
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
||||
!parseCategoriesJson(categoriesResponse.body, categories)) {
|
||||
!parseCategoriesJson(categoriesResponse.body, categories) ||
|
||||
!parseGroupsJson(groupsResponse.body, groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!db.replaceCategories(categories)) {
|
||||
return false;
|
||||
}
|
||||
if (!db.replaceGroups(groups)) {
|
||||
return false;
|
||||
}
|
||||
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort: a schedule fetch/store failure must never fail the
|
||||
// proclamation sync or roll back the data just written.
|
||||
syncScheduleFromApi(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db) {
|
||||
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
|
||||
const std::string license_key = db.getKV(radiantwave::db::KV_LICENSE_KEY);
|
||||
if (email.empty() || license_key.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api::ScheduleResponse schedule;
|
||||
if (!api::fetchSchedule(email, license_key, schedule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<radiantwave::db::ScheduleBlockRecord> records;
|
||||
records.reserve(schedule.blocks.size());
|
||||
for (const auto& block : schedule.blocks) {
|
||||
radiantwave::db::ScheduleBlockRecord rec;
|
||||
rec.id = block.id;
|
||||
rec.start_minutes = block.start_minutes;
|
||||
rec.end_minutes = block.end_minutes;
|
||||
rec.overnight = block.overnight;
|
||||
rec.duration_minutes = block.duration_minutes;
|
||||
rec.dynamic_audio = block.dynamic_audio;
|
||||
rec.proclamation_ids = block.proclamation_ids;
|
||||
records.push_back(std::move(rec));
|
||||
}
|
||||
return db.replaceScheduleBlocks(records);
|
||||
}
|
||||
|
||||
// ─── Color palettes (readiness code; not wired into the app flow) ─────
|
||||
|
||||
bool parseProclamationPalettesJson(const std::string& body,
|
||||
|
||||
@@ -15,13 +15,26 @@ bool parseProclamationsJson(const std::string& body,
|
||||
bool parseCategoriesJson(const std::string& body,
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
||||
|
||||
// Fetches proclamations + categories from the API and stores them in the DB,
|
||||
// preserving any existing selection and forcing `defaultProclamationId` to
|
||||
// stay selected. Returns true only if the API sync succeeded; on failure the
|
||||
// Parse the JSON payload returned by the groups endpoint.
|
||||
bool parseGroupsJson(const std::string& body,
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord>& out);
|
||||
|
||||
// Fetches proclamations + categories + groups from the API and stores them in
|
||||
// the DB, preserving any existing selection and forcing `defaultProclamationId`
|
||||
// to stay selected. Returns true only if the API sync succeeded; on failure the
|
||||
// database is left untouched so the app can fall back to cached data.
|
||||
//
|
||||
// Also requests the user's schedule and replaces the stored schedule when the
|
||||
// device has an email + license key. Schedule sync is best-effort: a schedule
|
||||
// failure never fails (or rolls back) the proclamation sync.
|
||||
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
const std::string& defaultProclamationId);
|
||||
|
||||
// Fetch GET /schedule for the device's paired email + license key and replace
|
||||
// the stored schedule blocks. Skips (returns false) when either credential is
|
||||
// missing. Returns true only on HTTP 2xx + successful parse + successful store.
|
||||
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db);
|
||||
|
||||
// Parse color palettes embedded in the proclamations JSON payload. Each
|
||||
// proclamation object may carry a "colorPalette" array of "#RRGGBB" strings;
|
||||
// the palette is keyed by the proclamation's solfeggioFrequencyId. When
|
||||
|
||||
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
|
||||
{
|
||||
proclamations_ = state_->db.getAllProclamations();
|
||||
categories_ = state_->db.getAllCategories();
|
||||
groups_ = state_->db.getAllGroups();
|
||||
|
||||
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
||||
for (auto& p : proclamations_) {
|
||||
@@ -173,6 +174,16 @@ void ProclamationOptionsPage::rebuildItems()
|
||||
{
|
||||
items_.clear();
|
||||
|
||||
// Schedule master toggle: enables/disables following the portal schedule.
|
||||
// It is a plain on/off row (selected stores the on state) and sits above
|
||||
// the always-on proclamation so it is always the first list entry.
|
||||
items_.push_back({
|
||||
FlatItem::Type::ScheduleToggle,
|
||||
"schedule",
|
||||
"Schedule Active",
|
||||
0, false, state_->config.get().proclamation.schedule_active, false,
|
||||
});
|
||||
|
||||
// Always-on top-level proclamation, never inside a category.
|
||||
items_.push_back({
|
||||
FlatItem::Type::Proclamation,
|
||||
@@ -181,42 +192,55 @@ void ProclamationOptionsPage::rebuildItems()
|
||||
0, false, true, true,
|
||||
});
|
||||
|
||||
for (const auto& root : categories_) {
|
||||
if (root.parent_id != 0) continue;
|
||||
// Groups that actually exist, so a proclamation pointing at a group the
|
||||
// portal has since removed falls back to the ungrouped bucket.
|
||||
auto groupExists = [this](int groupId) {
|
||||
return std::any_of(groups_.begin(), groups_.end(),
|
||||
[groupId](const auto& g) { return g.id == groupId; });
|
||||
};
|
||||
|
||||
for (const auto& category : categories_) {
|
||||
items_.push_back({
|
||||
FlatItem::Type::RootCategory,
|
||||
std::to_string(root.id),
|
||||
root.name,
|
||||
FlatItem::Type::Category,
|
||||
std::to_string(category.id),
|
||||
category.name,
|
||||
0, false, false, false,
|
||||
});
|
||||
|
||||
for (const auto& sub : categories_) {
|
||||
if (sub.parent_id != root.id) continue;
|
||||
|
||||
items_.push_back({
|
||||
FlatItem::Type::SubCategory,
|
||||
std::to_string(sub.id),
|
||||
sub.name,
|
||||
1, true, false, false,
|
||||
});
|
||||
|
||||
// Groups are tag-like and global. A group is shown under a category
|
||||
// only when it has at least one proclamation in that category, so
|
||||
// impossible pairings (e.g. "Physical / Prayers") never render.
|
||||
for (const auto& group : groups_) {
|
||||
std::vector<const radiantwave::db::ProclamationRecord*> members;
|
||||
for (const auto& p : proclamations_) {
|
||||
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
||||
if (p.category_id != sub.id) continue;
|
||||
if (p.category_id != category.id || p.group_id != group.id) continue;
|
||||
members.push_back(&p);
|
||||
}
|
||||
if (members.empty()) continue;
|
||||
|
||||
items_.push_back({
|
||||
FlatItem::Type::Group,
|
||||
std::to_string(group.id),
|
||||
group.name,
|
||||
1, true, false, false,
|
||||
});
|
||||
for (const auto* p : members) {
|
||||
items_.push_back({
|
||||
FlatItem::Type::Proclamation,
|
||||
p.id,
|
||||
p.title,
|
||||
2, false, p.selected, false,
|
||||
p->id,
|
||||
p->title,
|
||||
2, false, p->selected, false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Proclamations assigned directly to the root category.
|
||||
// Proclamations with no group (or a stale group id) sit directly under
|
||||
// the category.
|
||||
for (const auto& p : proclamations_) {
|
||||
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
||||
if (p.category_id != root.id) continue;
|
||||
if (p.category_id != category.id) continue;
|
||||
if (p.group_id != 0 && groupExists(p.group_id)) continue;
|
||||
items_.push_back({
|
||||
FlatItem::Type::Proclamation,
|
||||
p.id,
|
||||
@@ -235,29 +259,34 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree walk: track the currently-open root and sub category (items_ is
|
||||
// already ordered root -> sub -> proclamation, so each category dominates
|
||||
// the items until the next category of equal or shallower depth).
|
||||
int currentRoot = -1; // items_ index of the current root category
|
||||
int currentSub = -1; // items_ index of the current sub-category
|
||||
// Tree walk: track the currently-open category and group (items_ is
|
||||
// already ordered category -> group -> proclamation, so each category
|
||||
// dominates the items until the next category).
|
||||
int currentCategory = -1; // items_ index of the current category
|
||||
int currentGroup = -1; // items_ index of the current group
|
||||
|
||||
for (size_t i = 0; i < items_.size(); ++i) {
|
||||
const FlatItem& item = items_[i];
|
||||
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
currentRoot = static_cast<int>(i);
|
||||
currentSub = -1;
|
||||
if (item.type == FlatItem::Type::ScheduleToggle) {
|
||||
visible_.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type == FlatItem::Type::SubCategory) {
|
||||
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
|
||||
if (rootOpen) {
|
||||
currentSub = static_cast<int>(i);
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
currentCategory = static_cast<int>(i);
|
||||
currentGroup = -1;
|
||||
visible_.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type == FlatItem::Type::Group) {
|
||||
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
|
||||
if (categoryOpen) {
|
||||
currentGroup = static_cast<int>(i);
|
||||
visible_.push_back(i);
|
||||
} else {
|
||||
currentSub = -1;
|
||||
currentGroup = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -266,9 +295,10 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
bool parentVisible = false;
|
||||
if (item.depth == 0) {
|
||||
parentVisible = true; // the always-on top-level proclamation
|
||||
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
|
||||
} else if (currentCategory >= 0 && items_[currentCategory].expanded) {
|
||||
parentVisible = (item.depth == 1) ||
|
||||
(item.depth == 2 && currentSub >= 0);
|
||||
(item.depth == 2 && currentGroup >= 0 &&
|
||||
items_[currentGroup].expanded);
|
||||
}
|
||||
if (parentVisible) visible_.push_back(i);
|
||||
}
|
||||
@@ -283,12 +313,24 @@ void ProclamationOptionsPage::toggleItem()
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
item.expanded = !item.expanded;
|
||||
rebuildVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type == FlatItem::Type::ScheduleToggle) {
|
||||
item.selected = !item.selected;
|
||||
const bool enabled = item.selected;
|
||||
state_->config.update([enabled](radiantwave::config::AppConfig& cfg) {
|
||||
cfg.proclamation.schedule_active = enabled;
|
||||
});
|
||||
statusMessage_ = enabled
|
||||
? "Schedule active - playback follows the portal schedule"
|
||||
: "Schedule inactive - playback uses your selections";
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isDefault) {
|
||||
statusMessage_ = "The top-level proclamation is always on";
|
||||
return;
|
||||
@@ -388,7 +430,7 @@ void ProclamationOptionsPage::collapseCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
if (item.expanded) {
|
||||
item.expanded = false;
|
||||
rebuildVisible();
|
||||
@@ -400,7 +442,7 @@ void ProclamationOptionsPage::expandCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||
if (!item.expanded) {
|
||||
item.expanded = true;
|
||||
rebuildVisible();
|
||||
@@ -417,8 +459,8 @@ void ProclamationOptionsPage::moveToParent()
|
||||
int start = static_cast<int>(visible_[highlightIndex_]);
|
||||
for (int i = start - 1; i >= 0; --i) {
|
||||
const FlatItem& candidate = items_[i];
|
||||
if ((candidate.type == FlatItem::Type::RootCategory ||
|
||||
candidate.type == FlatItem::Type::SubCategory) &&
|
||||
if ((candidate.type == FlatItem::Type::Category ||
|
||||
candidate.type == FlatItem::Type::Group) &&
|
||||
candidate.depth == item.depth - 1) {
|
||||
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
||||
if (visible_[vi] == static_cast<size_t>(i)) {
|
||||
@@ -526,7 +568,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
|
||||
if (searchActive_) return;
|
||||
if (!visible_.empty()) {
|
||||
const FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
|
||||
if ((item.type == FlatItem::Type::Category ||
|
||||
item.type == FlatItem::Type::Group) && item.expanded) {
|
||||
collapseCurrent();
|
||||
} else {
|
||||
moveToParent();
|
||||
@@ -567,6 +610,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
SDL_Color white = {255, 255, 255, 255};
|
||||
SDL_Color gray = {170, 170, 170, 255};
|
||||
SDL_Color categoryBlue = {100, 180, 255, 255};
|
||||
SDL_Color groupBlue = {160, 210, 255, 255};
|
||||
SDL_Color selectedOrange = {255, 170, 60, 255};
|
||||
|
||||
float titleY = 50.0f;
|
||||
@@ -698,14 +742,25 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
}
|
||||
} else {
|
||||
// --- Browse tree ---
|
||||
// The schedule toggle renders its on/off state inline; category
|
||||
// and group rows get an expand/collapse marker.
|
||||
auto labelFor = [](const FlatItem& item) -> std::string {
|
||||
if (item.type == FlatItem::Type::ScheduleToggle) {
|
||||
return "Schedule Active: " +
|
||||
std::string(item.selected ? "[ON]" : "[OFF]");
|
||||
}
|
||||
if (item.type == FlatItem::Type::Category ||
|
||||
item.type == FlatItem::Type::Group) {
|
||||
return (item.expanded ? "[-] " : "[+] ") + item.label;
|
||||
}
|
||||
return item.label;
|
||||
};
|
||||
|
||||
// Stable width: compute from every item so the list doesn't
|
||||
// shift left/right when groups expand or collapse.
|
||||
float maxTotalWidth = 0.0f;
|
||||
for (const auto& item : items_) {
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
std::string label = labelFor(item);
|
||||
int w = 0, h = 0;
|
||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||
float total = item.depth * indent + static_cast<float>(w);
|
||||
@@ -715,10 +770,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
std::vector<float> textWidths(visible_.size());
|
||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
std::string label = labelFor(item);
|
||||
int w = 0, h = 0;
|
||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||
textWidths[i] = static_cast<float>(w);
|
||||
@@ -733,14 +785,13 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
float y = listTop + rowIndex * rowPitch;
|
||||
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
std::string label = labelFor(item);
|
||||
|
||||
SDL_Color color = white;
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
color = categoryBlue;
|
||||
} else if (item.type == FlatItem::Type::Group) {
|
||||
color = groupBlue;
|
||||
} else if (item.selected || item.isDefault) {
|
||||
color = selectedOrange;
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ public:
|
||||
|
||||
private:
|
||||
struct FlatItem {
|
||||
enum class Type { RootCategory, SubCategory, Proclamation };
|
||||
enum class Type { ScheduleToggle, Category, Group, Proclamation };
|
||||
|
||||
Type type = Type::Proclamation;
|
||||
std::string id; // category id or proclamation id
|
||||
std::string id; // category id, group id, proclamation id, or "schedule"
|
||||
std::string label; // display text
|
||||
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
|
||||
bool expanded = false; // categories only
|
||||
bool selected = false; // proclamations only
|
||||
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
|
||||
bool expanded = false; // categories and groups only
|
||||
bool selected = false; // proclamations and the schedule toggle
|
||||
bool isDefault = false; // the always-on top-level proclamation
|
||||
};
|
||||
|
||||
@@ -67,6 +67,7 @@ private:
|
||||
AppState* state_ = nullptr;
|
||||
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
|
||||
std::vector<FlatItem> items_;
|
||||
std::vector<size_t> visible_; // indices into items_, in display order
|
||||
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
||||
|
||||
+85
-7
@@ -5,6 +5,7 @@
|
||||
#include "logical_size.h"
|
||||
#include "proclamation_options_page.h"
|
||||
#include "scramble/fibonacci_scrambler.h"
|
||||
#include "timezone_helper.h"
|
||||
#include "utf8_utils.h"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
@@ -72,6 +73,7 @@ void ScrollerPage::onPush(AppState &state) {
|
||||
createFont(state);
|
||||
createPatterns();
|
||||
|
||||
lines_.clear();
|
||||
prefilling_ = true;
|
||||
prefillLines();
|
||||
prefilling_ = false;
|
||||
@@ -79,9 +81,10 @@ void ScrollerPage::onPush(AppState &state) {
|
||||
// Start audio. In dynamic mode the current stream's solfeggio-frequency
|
||||
// track wins (when its file exists); otherwise the configured manual track
|
||||
// plays. A frequency file that is not on disk yet falls back to the manual
|
||||
// track rather than playing nothing.
|
||||
// track rather than playing nothing. A schedule block with dynamicAudio=true
|
||||
// forces dynamic audio for its duration; false leaves the device setting be.
|
||||
std::string track = state.config.get().audio.current_track;
|
||||
if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
|
||||
if (effectiveDynamicAudio() && !streams_.empty()) {
|
||||
const ProclamationStream &stream = streams_[streamIndex_];
|
||||
if (stream.solfeggio_frequency_id != 0 &&
|
||||
!stream.audioFilename.empty() &&
|
||||
@@ -137,6 +140,14 @@ void ScrollerPage::update(float dt) {
|
||||
state_->audio.update(dt);
|
||||
}
|
||||
|
||||
// Block boundaries are minute-granular, so a one-second poll is plenty and
|
||||
// avoids mutating the process timezone every frame.
|
||||
schedulePollTimer_ -= dt;
|
||||
if (schedulePollTimer_ <= 0.0f) {
|
||||
schedulePollTimer_ = 1.0f;
|
||||
pollScheduleChange();
|
||||
}
|
||||
|
||||
updateAutoVelocity(dt);
|
||||
|
||||
scrollOffset_ += velocity_ * dt;
|
||||
@@ -200,8 +211,18 @@ void ScrollerPage::render(SDL_Renderer *renderer) {
|
||||
void ScrollerPage::buildStreams(AppState &state) {
|
||||
streams_.clear();
|
||||
fallbackPalette_.clear();
|
||||
lines_.clear();
|
||||
streamIndex_ = 0;
|
||||
scheduleChangePending_ = false;
|
||||
|
||||
// Schedule state: only an active schedule with a currently-matching block
|
||||
// changes the play list. The block boundary is handled in advanceStream(),
|
||||
// so rebuilding mid-playback never truncates the current proclamation.
|
||||
const radiantwave::config::AppConfig cfg = state.config.get();
|
||||
scheduleActive_ = cfg.proclamation.schedule_active;
|
||||
timezone_ = cfg.timezone;
|
||||
scheduleBlocks_ = scheduleActive_ ? state.db.getScheduleBlocks()
|
||||
: std::vector<radiantwave::db::ScheduleBlockRecord>{};
|
||||
activeBlockIndex_ = currentScheduleBlockIndex();
|
||||
|
||||
auto proclamations = state.db.getActiveProclamations();
|
||||
if (proclamations.empty()) {
|
||||
@@ -241,9 +262,53 @@ void ScrollerPage::buildStreams(AppState &state) {
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<std::string>* scheduledIds = nullptr;
|
||||
std::vector<std::string> scheduledOrder;
|
||||
if (activeBlockIndex_ >= 0 &&
|
||||
activeBlockIndex_ < static_cast<int>(scheduleBlocks_.size())) {
|
||||
scheduledOrder = scheduleBlocks_[activeBlockIndex_].proclamation_ids;
|
||||
scheduledIds = &scheduledOrder;
|
||||
}
|
||||
|
||||
streams_ = radiantwave::scroller::buildStreams(
|
||||
proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId,
|
||||
DEFAULT_PROPLEAMATION_ID);
|
||||
DEFAULT_PROPLEAMATION_ID, scheduledIds);
|
||||
}
|
||||
|
||||
int ScrollerPage::currentScheduleBlockIndex() const {
|
||||
if (!scheduleActive_ || scheduleBlocks_.empty()) {
|
||||
return -1;
|
||||
}
|
||||
const int now = radiantwave::timezone::currentLocalMinutes(timezone_);
|
||||
for (size_t i = 0; i < scheduleBlocks_.size(); ++i) {
|
||||
const radiantwave::db::ScheduleBlockRecord &block = scheduleBlocks_[i];
|
||||
if (radiantwave::scroller::minuteInScheduleBlock(
|
||||
now, block.start_minutes, block.end_minutes, block.overnight)) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ScrollerPage::pollScheduleChange() {
|
||||
if (!scheduleActive_) {
|
||||
return;
|
||||
}
|
||||
if (currentScheduleBlockIndex() != activeBlockIndex_) {
|
||||
scheduleChangePending_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool ScrollerPage::effectiveDynamicAudio() const {
|
||||
// dynamicAudio=true on the active block forces dynamic audio, overriding
|
||||
// whatever the device selected. false means "don't override": the device's
|
||||
// own selection (dynamic or a fixed track) applies as usual.
|
||||
if (scheduleActive_ && activeBlockIndex_ >= 0 &&
|
||||
activeBlockIndex_ < static_cast<int>(scheduleBlocks_.size()) &&
|
||||
scheduleBlocks_[activeBlockIndex_].dynamic_audio) {
|
||||
return true;
|
||||
}
|
||||
return state_ ? state_->config.get().audio.dynamic_audio : false;
|
||||
}
|
||||
|
||||
void ScrollerPage::createFont(AppState &state) {
|
||||
@@ -451,7 +516,19 @@ void ScrollerPage::advanceStream() {
|
||||
if (streams_.empty()) {
|
||||
return;
|
||||
}
|
||||
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
|
||||
if (scheduleChangePending_ && state_) {
|
||||
// A schedule block changed while the previous proclamation was playing.
|
||||
// Apply it only now, at a stream boundary, so the current proclamation
|
||||
// finishes instead of being cut off.
|
||||
buildStreams(*state_);
|
||||
createPatterns();
|
||||
streamIndex_ = 0;
|
||||
} else {
|
||||
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
|
||||
}
|
||||
if (streams_.empty()) {
|
||||
return;
|
||||
}
|
||||
// Diagnostic: report the palette the incoming stream will render so the
|
||||
// console shows whether colors should change when this stream starts.
|
||||
const ProclamationStream &stream = streams_[streamIndex_];
|
||||
@@ -473,8 +550,9 @@ void ScrollerPage::switchAudioForCurrentStream() {
|
||||
}
|
||||
|
||||
// Dynamic audio only: with a fixed manual track selected the audio must
|
||||
// never switch as proclamations change.
|
||||
if (!state_->config.get().audio.dynamic_audio) {
|
||||
// never switch as proclamations change. An active schedule block's
|
||||
// dynamicAudio flag overrides the device-wide setting.
|
||||
if (!effectiveDynamicAudio()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "db/database_manager.h"
|
||||
#include "fibonacci_line_pattern.h"
|
||||
#include "page.h"
|
||||
#include "scroller_streams.h"
|
||||
@@ -82,6 +83,16 @@ private:
|
||||
bool addLineAt(float baseY);
|
||||
void advanceStream();
|
||||
void switchAudioForCurrentStream();
|
||||
// Re-reads the schedule from the DB/config and records which block (if any)
|
||||
// matches the clock right now. Returns the matching index, or -1.
|
||||
int currentScheduleBlockIndex() const;
|
||||
// If the active block changed, latch a pending switch that is applied at the
|
||||
// next proclamation boundary so the current proclamation is never cut off.
|
||||
void pollScheduleChange();
|
||||
// True when the current stream should use dynamic (frequency-following)
|
||||
// audio. A schedule block with dynamicAudio=true forces it on; otherwise the
|
||||
// configured audio.dynamic_audio setting applies.
|
||||
bool effectiveDynamicAudio() const;
|
||||
void updateAutoVelocity(float dt);
|
||||
void ensureWordTexture(const std::string& word);
|
||||
void ensureCharTexture(const std::string& glyph);
|
||||
@@ -97,6 +108,17 @@ private:
|
||||
int streamIndex_ = 0;
|
||||
bool prefilling_ = false;
|
||||
|
||||
// Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is
|
||||
// the cached schedule; `activeBlockIndex_` is the block currently playing
|
||||
// (-1 = normal mode / no matching block). A change is latched in
|
||||
// `scheduleChangePending_` and applied when the current proclamation ends.
|
||||
std::string timezone_;
|
||||
std::vector<radiantwave::db::ScheduleBlockRecord> scheduleBlocks_;
|
||||
bool scheduleActive_ = false;
|
||||
int activeBlockIndex_ = -1;
|
||||
bool scheduleChangePending_ = false;
|
||||
float schedulePollTimer_ = 0.0f;
|
||||
|
||||
TTF_Font* font_ = nullptr;
|
||||
int fontPxSize_ = 32;
|
||||
float lineHeight_ = 0;
|
||||
|
||||
@@ -53,21 +53,45 @@ std::vector<ProclamationStream> buildStreams(
|
||||
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
|
||||
const std::vector<StreamColor>& fallbackPalette,
|
||||
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId,
|
||||
const std::string& defaultProclamationId)
|
||||
const std::string& defaultProclamationId,
|
||||
const std::vector<std::string>* scheduledIds)
|
||||
{
|
||||
const db::ProclamationRecord* defaultRecord = nullptr;
|
||||
std::vector<const db::ProclamationRecord*> selected;
|
||||
selected.reserve(proclamations.size());
|
||||
|
||||
for (const auto& rec : proclamations) {
|
||||
if (rec.id == defaultProclamationId) {
|
||||
defaultRecord = &rec;
|
||||
continue;
|
||||
if (scheduledIds) {
|
||||
// A schedule supplies an explicit, ordered play list. Look each id up
|
||||
// in the proclamation pool, skipping unknown ids and the default (which
|
||||
// is interleaved as usual).
|
||||
std::unordered_map<std::string, const db::ProclamationRecord*> byId;
|
||||
byId.reserve(proclamations.size());
|
||||
for (const auto& rec : proclamations) {
|
||||
byId[rec.id] = &rec;
|
||||
}
|
||||
if (!rec.selected) {
|
||||
continue;
|
||||
defaultRecord = byId.count(defaultProclamationId) ? byId[defaultProclamationId] : nullptr;
|
||||
|
||||
selected.reserve(scheduledIds->size());
|
||||
for (const auto& id : *scheduledIds) {
|
||||
if (id == defaultProclamationId) {
|
||||
continue;
|
||||
}
|
||||
auto it = byId.find(id);
|
||||
if (it != byId.end()) {
|
||||
selected.push_back(it->second);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selected.reserve(proclamations.size());
|
||||
for (const auto& rec : proclamations) {
|
||||
if (rec.id == defaultProclamationId) {
|
||||
defaultRecord = &rec;
|
||||
continue;
|
||||
}
|
||||
if (!rec.selected) {
|
||||
continue;
|
||||
}
|
||||
selected.push_back(&rec);
|
||||
}
|
||||
selected.push_back(&rec);
|
||||
}
|
||||
|
||||
auto makeStream = [&](const db::ProclamationRecord& rec, int repeat) {
|
||||
|
||||
+18
-1
@@ -25,6 +25,16 @@ struct StreamColor {
|
||||
inline constexpr int kSelectedRepeatCount = 20;
|
||||
inline constexpr int kDefaultRepeatCount = 10;
|
||||
|
||||
// True when `minutes` (minutes since midnight) falls inside a schedule block.
|
||||
// Non-overnight blocks are [start, end); overnight blocks wrap past midnight
|
||||
// and match [start, 1440) or [0, end).
|
||||
inline bool minuteInScheduleBlock(int minutes, int start_minutes, int end_minutes,
|
||||
bool overnight)
|
||||
{
|
||||
return overnight ? (minutes >= start_minutes || minutes < end_minutes)
|
||||
: (minutes >= start_minutes && minutes < end_minutes);
|
||||
}
|
||||
|
||||
struct ProclamationStream {
|
||||
std::string name;
|
||||
std::vector<std::string> words;
|
||||
@@ -45,11 +55,18 @@ struct ProclamationStream {
|
||||
// the default plays. `palettesByFreqId` and `audioByFreqId` are
|
||||
// database-derived lookups; `fallbackPalette` is used for the default stream
|
||||
// and unknown frequencies.
|
||||
//
|
||||
// When `scheduledIds` is non-null it replaces the `selected` flag as the source
|
||||
// of the play order: the listed proclamation ids are played in the given order
|
||||
// (unknown ids and the default id are skipped) and interleaved with the default
|
||||
// exactly like a normal selection. An empty list falls back to the default
|
||||
// only. Passing nullptr keeps the normal database-selection behavior.
|
||||
std::vector<ProclamationStream> buildStreams(
|
||||
const std::vector<db::ProclamationRecord>& proclamations,
|
||||
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
|
||||
const std::vector<StreamColor>& fallbackPalette,
|
||||
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId,
|
||||
const std::string& defaultProclamationId);
|
||||
const std::string& defaultProclamationId,
|
||||
const std::vector<std::string>* scheduledIds = nullptr);
|
||||
|
||||
} // namespace radiantwave::scroller
|
||||
|
||||
@@ -133,6 +133,37 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
int currentLocalMinutes(const std::string& iana_code) {
|
||||
std::lock_guard<std::mutex> lock(g_tz_mutex);
|
||||
|
||||
const char* old_tz = std::getenv("TZ");
|
||||
std::string old_tz_str = old_tz ? old_tz : "";
|
||||
|
||||
if (!iana_code.empty()) {
|
||||
setenv("TZ", iana_code.c_str(), 1);
|
||||
} else {
|
||||
setenv("TZ", "UTC", 1);
|
||||
}
|
||||
tzset();
|
||||
|
||||
time_t now = std::time(nullptr);
|
||||
struct tm tm_buf;
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm_buf, &now);
|
||||
#else
|
||||
localtime_r(&now, &tm_buf);
|
||||
#endif
|
||||
|
||||
if (!old_tz_str.empty()) {
|
||||
setenv("TZ", old_tz_str.c_str(), 1);
|
||||
} else {
|
||||
unsetenv("TZ");
|
||||
}
|
||||
tzset();
|
||||
|
||||
return tm_buf.tm_hour * 60 + tm_buf.tm_min;
|
||||
}
|
||||
|
||||
std::string getUtcTimestampIso() {
|
||||
time_t now = std::time(nullptr);
|
||||
struct tm gmt_buf;
|
||||
|
||||
@@ -22,6 +22,11 @@ const std::vector<CountryTimeZones>& getStandardTimeZones();
|
||||
// Converts a UTC timestamp to the user's configured IANA timezone
|
||||
std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, const std::string& format = "%Y-%m-%d %H:%M:%S %Z");
|
||||
|
||||
// Wall-clock minutes since midnight (0-1439) right now in `iana_code`. Used by
|
||||
// the schedule to pick the active listening block. Falls back to UTC when the
|
||||
// timezone is empty.
|
||||
int currentLocalMinutes(const std::string& iana_code);
|
||||
|
||||
// Gets current UTC timestamp as ISO string
|
||||
std::string getUtcTimestampIso();
|
||||
|
||||
|
||||
+79
-10
@@ -113,6 +113,58 @@ TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
|
||||
EXPECT_FALSE(parseCreateAccountJson("[]", account));
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseScheduleJson) {
|
||||
const std::string body =
|
||||
R"({"email":"jane@example.com","blocks":[
|
||||
{"id":"b1","startTime":"06:00","startMinutes":360,"endTime":"08:00",
|
||||
"endMinutes":480,"overnight":false,"durationMinutes":120,
|
||||
"dynamicAudio":false,"proclamationIds":["p1","p2"]},
|
||||
{"id":"b2","startTime":"21:00","startMinutes":1260,"endTime":"09:00",
|
||||
"endMinutes":540,"overnight":true,"durationMinutes":720,
|
||||
"dynamicAudio":true,"proclamationIds":[]}]})";
|
||||
|
||||
ScheduleResponse out;
|
||||
EXPECT_TRUE(parseScheduleJson(body, out));
|
||||
EXPECT_EQ(out.email, "jane@example.com");
|
||||
ASSERT_EQ(out.blocks.size(), 2u);
|
||||
|
||||
EXPECT_EQ(out.blocks[0].id, "b1");
|
||||
EXPECT_EQ(out.blocks[0].start_minutes, 360);
|
||||
EXPECT_EQ(out.blocks[0].end_minutes, 480);
|
||||
EXPECT_FALSE(out.blocks[0].overnight);
|
||||
EXPECT_EQ(out.blocks[0].duration_minutes, 120);
|
||||
EXPECT_FALSE(out.blocks[0].dynamic_audio);
|
||||
ASSERT_EQ(out.blocks[0].proclamation_ids.size(), 2u);
|
||||
EXPECT_EQ(out.blocks[0].proclamation_ids[0], "p1");
|
||||
EXPECT_EQ(out.blocks[0].proclamation_ids[1], "p2");
|
||||
|
||||
EXPECT_EQ(out.blocks[1].id, "b2");
|
||||
EXPECT_TRUE(out.blocks[1].overnight);
|
||||
EXPECT_TRUE(out.blocks[1].dynamic_audio);
|
||||
EXPECT_TRUE(out.blocks[1].proclamation_ids.empty());
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseScheduleDefaultsDynamicAudioTrue) {
|
||||
const std::string body =
|
||||
R"({"email":"a@b.com","blocks":[
|
||||
{"id":"b1","startMinutes":0,"endMinutes":60,"overnight":false,
|
||||
"durationMinutes":60,"proclamationIds":["p1"]}]})";
|
||||
|
||||
ScheduleResponse out;
|
||||
EXPECT_TRUE(parseScheduleJson(body, out));
|
||||
ASSERT_EQ(out.blocks.size(), 1u);
|
||||
EXPECT_TRUE(out.blocks[0].dynamic_audio);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseScheduleRejectsMalformed) {
|
||||
ScheduleResponse out;
|
||||
EXPECT_FALSE(parseScheduleJson("not json", out));
|
||||
EXPECT_FALSE(parseScheduleJson("[1,2,3]", out)); // array, not object
|
||||
// Missing id blocks are skipped, not fatal.
|
||||
EXPECT_TRUE(parseScheduleJson(R"({"blocks":[{"startMinutes":0}]})", out));
|
||||
EXPECT_TRUE(out.blocks.empty());
|
||||
}
|
||||
|
||||
// ─── Offline: error message extraction ────────────────────────────────
|
||||
|
||||
TEST(ApiClientTest, ExtractErrorMessage) {
|
||||
@@ -142,16 +194,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
|
||||
EXPECT_EQ(out[1].category_id, 32);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
|
||||
const std::string body =
|
||||
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
|
||||
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
|
||||
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
|
||||
const std::string categoriesBody =
|
||||
R"([{"id":4,"name":"Spiritual","sortOrder":0},
|
||||
{"id":5,"name":"General","sortOrder":1}])";
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
|
||||
EXPECT_EQ(out[1].parent_id, 4);
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
|
||||
ASSERT_EQ(categories.size(), 2u);
|
||||
EXPECT_EQ(categories[0].id, 4);
|
||||
EXPECT_EQ(categories[1].id, 5);
|
||||
|
||||
const std::string groupsBody =
|
||||
R"([{"id":9,"name":"Prayers","sortOrder":0}])";
|
||||
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsBody, groups));
|
||||
ASSERT_EQ(groups.size(), 1u);
|
||||
EXPECT_EQ(groups[0].id, 9);
|
||||
EXPECT_EQ(groups[0].name, "Prayers");
|
||||
}
|
||||
|
||||
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
|
||||
@@ -194,7 +255,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
||||
EXPECT_GE(categories.size(), 20u);
|
||||
EXPECT_FALSE(categories.empty());
|
||||
|
||||
HttpResponse groupsResponse = httpGet(PATH_GROUPS);
|
||||
ASSERT_TRUE(groupsResponse.transportOk);
|
||||
ASSERT_EQ(groupsResponse.status, 200);
|
||||
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups));
|
||||
EXPECT_FALSE(groups.empty());
|
||||
}
|
||||
|
||||
// ─── Live: POST /license error paths (no credentials required) ─────────
|
||||
|
||||
@@ -44,6 +44,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
|
||||
original.audio.volume = 60;
|
||||
original.audio.dynamic_audio = false;
|
||||
original.proclamation.scroll_speed = 1.8f;
|
||||
original.proclamation.schedule_active = true;
|
||||
|
||||
std::string serialized = serializeConfigToYaml(original);
|
||||
AppConfig parsed = parseConfigFromYaml(serialized);
|
||||
@@ -53,6 +54,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
|
||||
EXPECT_EQ(parsed.audio.volume, original.audio.volume);
|
||||
EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio);
|
||||
EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed);
|
||||
EXPECT_TRUE(parsed.proclamation.schedule_active);
|
||||
}
|
||||
|
||||
TEST(ConfigManagerTest, DefaultGeneration) {
|
||||
@@ -68,6 +70,7 @@ TEST(ConfigManagerTest, DefaultGeneration) {
|
||||
EXPECT_EQ(cfg.audio.volume, 80);
|
||||
EXPECT_EQ(cfg.audio.dynamic_audio, true);
|
||||
EXPECT_EQ(cfg.proclamation.font_size, 32);
|
||||
EXPECT_FALSE(cfg.proclamation.schedule_active);
|
||||
|
||||
EXPECT_TRUE(std::filesystem::exists(temp_path));
|
||||
std::filesystem::remove(temp_path);
|
||||
|
||||
+148
-4
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
|
||||
DbFixture fx("test_rw_db_cats.db");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
cats.push_back({3, "Root", 0, 1});
|
||||
cats.push_back({7, "Child", 3, 2});
|
||||
cats.push_back({3, "Spiritual", 1});
|
||||
cats.push_back({7, "Physical", 2});
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
||||
|
||||
auto all = fx.db.getAllCategories();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
EXPECT_EQ(all[0].id, 3);
|
||||
EXPECT_EQ(all[0].parent_id, 0);
|
||||
EXPECT_EQ(all[0].name, "Spiritual");
|
||||
EXPECT_EQ(all[0].sort_order, 1);
|
||||
EXPECT_EQ(all[1].id, 7);
|
||||
EXPECT_EQ(all[1].parent_id, 3);
|
||||
EXPECT_EQ(all[1].name, "Physical");
|
||||
EXPECT_EQ(all[1].sort_order, 2);
|
||||
|
||||
// Replacing wipes previous categories.
|
||||
ASSERT_TRUE(fx.db.replaceCategories({}));
|
||||
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, ReplaceAndReadGroups) {
|
||||
DbFixture fx("test_rw_db_groups.db");
|
||||
|
||||
std::vector<ProclamationGroupRecord> groups;
|
||||
groups.push_back({10, "Prayers", 2});
|
||||
groups.push_back({4, "Affirmations", 1});
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceGroups(groups));
|
||||
|
||||
auto all = fx.db.getAllGroups();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
// Ordered by sort_order, not by id.
|
||||
EXPECT_EQ(all[0].id, 4);
|
||||
EXPECT_EQ(all[0].name, "Affirmations");
|
||||
EXPECT_EQ(all[1].id, 10);
|
||||
EXPECT_EQ(all[1].name, "Prayers");
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceGroups({}));
|
||||
EXPECT_EQ(fx.db.getAllGroups().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, MigratesLegacySubCategoriesToGroups) {
|
||||
const std::string path =
|
||||
(std::filesystem::temp_directory_path() / "test_rw_db_migrate_v1.db").string();
|
||||
if (std::filesystem::exists(path)) {
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
// Build a pre-v2 database by hand: a two-level category tree and a
|
||||
// proclamation selected inside a sub-category.
|
||||
{
|
||||
sqlite3* raw = nullptr;
|
||||
ASSERT_EQ(sqlite3_open(path.c_str(), &raw), SQLITE_OK);
|
||||
const char* legacy =
|
||||
"CREATE TABLE proclamations ("
|
||||
" id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL,"
|
||||
" solfeggio_freq_hz REAL DEFAULT 528.0, category TEXT DEFAULT 'general',"
|
||||
" is_active INTEGER DEFAULT 1, selected INTEGER DEFAULT 0,"
|
||||
" category_id INTEGER DEFAULT 0, solfeggio_frequency_id INTEGER DEFAULT 0,"
|
||||
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP);"
|
||||
"CREATE TABLE proclamation_categories ("
|
||||
" id INTEGER PRIMARY KEY, name TEXT NOT NULL,"
|
||||
" parent_id INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0);"
|
||||
"INSERT INTO proclamation_categories (id, name, parent_id, sort_order)"
|
||||
" VALUES (3,'Spiritual',0,1),(7,'Inner',3,2);"
|
||||
"INSERT INTO proclamations (id,title,content,category_id,solfeggio_frequency_id,selected)"
|
||||
" VALUES ('p1','Peace','x',7,2,1),('p2','Root-level','y',3,5,0);";
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(raw, legacy, nullptr, nullptr, &err);
|
||||
if (err) sqlite3_free(err);
|
||||
sqlite3_close(raw);
|
||||
ASSERT_EQ(rc, SQLITE_OK);
|
||||
}
|
||||
|
||||
DatabaseManager db;
|
||||
ASSERT_TRUE(db.initialize(path));
|
||||
|
||||
// Each former sub-category becomes a group; only the root remains a category.
|
||||
auto groups = db.getAllGroups();
|
||||
ASSERT_EQ(groups.size(), 1u);
|
||||
EXPECT_EQ(groups[0].id, 7);
|
||||
EXPECT_EQ(groups[0].name, "Inner");
|
||||
|
||||
auto cats = db.getAllCategories();
|
||||
ASSERT_EQ(cats.size(), 1u);
|
||||
EXPECT_EQ(cats[0].id, 3);
|
||||
EXPECT_EQ(cats[0].name, "Spiritual");
|
||||
|
||||
// Proclamations pointing at the old sub-category are remapped to the root
|
||||
// category + the new group; selection is preserved.
|
||||
auto procs = db.getAllProclamations();
|
||||
ASSERT_EQ(procs.size(), 2u);
|
||||
for (const auto& p : procs) {
|
||||
EXPECT_EQ(p.category_id, 3) << "proclamation " << p.id;
|
||||
if (p.id == "p1") {
|
||||
EXPECT_EQ(p.group_id, 7);
|
||||
EXPECT_TRUE(p.selected);
|
||||
} else if (p.id == "p2") {
|
||||
EXPECT_EQ(p.group_id, 0);
|
||||
EXPECT_FALSE(p.selected);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running the migration is a no-op (user_version gate + idempotent SQL).
|
||||
db.close();
|
||||
ASSERT_TRUE(db.initialize(path));
|
||||
EXPECT_EQ(db.getAllGroups().size(), 1u);
|
||||
EXPECT_EQ(db.getAllCategories().size(), 1u);
|
||||
EXPECT_EQ(db.getAllProclamations().size(), 2u);
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
||||
DbFixture fx("test_rw_db_audio.db");
|
||||
|
||||
@@ -251,6 +347,54 @@ TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) {
|
||||
EXPECT_EQ(fx.db.getAudioFiles().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, ReplaceAndReadScheduleBlocks) {
|
||||
DbFixture fx("test_rw_db_schedule.db");
|
||||
|
||||
ScheduleBlockRecord morning;
|
||||
morning.id = "b1";
|
||||
morning.start_minutes = 360;
|
||||
morning.end_minutes = 480;
|
||||
morning.overnight = false;
|
||||
morning.duration_minutes = 120;
|
||||
morning.dynamic_audio = false;
|
||||
morning.proclamation_ids = {"p2", "p1"};
|
||||
|
||||
ScheduleBlockRecord night;
|
||||
night.id = "b2";
|
||||
night.start_minutes = 1260;
|
||||
night.end_minutes = 540;
|
||||
night.overnight = true;
|
||||
night.duration_minutes = 720;
|
||||
night.dynamic_audio = true;
|
||||
night.proclamation_ids = {"p3"};
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceScheduleBlocks({morning, night}));
|
||||
|
||||
auto rows = fx.db.getScheduleBlocks();
|
||||
ASSERT_EQ(rows.size(), 2u);
|
||||
|
||||
EXPECT_EQ(rows[0].id, "b1");
|
||||
EXPECT_EQ(rows[0].start_minutes, 360);
|
||||
EXPECT_EQ(rows[0].end_minutes, 480);
|
||||
EXPECT_FALSE(rows[0].overnight);
|
||||
EXPECT_EQ(rows[0].duration_minutes, 120);
|
||||
EXPECT_FALSE(rows[0].dynamic_audio);
|
||||
// Play order is preserved, not sorted.
|
||||
ASSERT_EQ(rows[0].proclamation_ids.size(), 2u);
|
||||
EXPECT_EQ(rows[0].proclamation_ids[0], "p2");
|
||||
EXPECT_EQ(rows[0].proclamation_ids[1], "p1");
|
||||
|
||||
EXPECT_EQ(rows[1].id, "b2");
|
||||
EXPECT_TRUE(rows[1].overnight);
|
||||
EXPECT_TRUE(rows[1].dynamic_audio);
|
||||
ASSERT_EQ(rows[1].proclamation_ids.size(), 1u);
|
||||
EXPECT_EQ(rows[1].proclamation_ids[0], "p3");
|
||||
|
||||
// A re-sync replaces the whole schedule.
|
||||
ASSERT_TRUE(fx.db.replaceScheduleBlocks({}));
|
||||
EXPECT_EQ(fx.db.getScheduleBlocks().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, PalettesSeededAndRead) {
|
||||
DbFixture fx("test_rw_db_palette.db");
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
|
||||
|
||||
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||
const std::string body =
|
||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
|
||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
|
||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
|
||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
|
||||
{"notAnObject":true}])";
|
||||
|
||||
std::vector<ProclamationRecord> out;
|
||||
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||
EXPECT_EQ(out[0].title, "Peace");
|
||||
EXPECT_EQ(out[0].content, "I am peace.");
|
||||
EXPECT_EQ(out[0].category_id, 3);
|
||||
EXPECT_EQ(out[0].group_id, 7);
|
||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
|
||||
|
||||
EXPECT_EQ(out[1].id, "p2");
|
||||
EXPECT_EQ(out[1].title, "Joy & Calm");
|
||||
EXPECT_EQ(out[1].content, "text");
|
||||
EXPECT_EQ(out[1].category_id, 0);
|
||||
EXPECT_EQ(out[1].group_id, 0);
|
||||
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
|
||||
}
|
||||
|
||||
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
// Categories are flat now; a legacy parentId field is simply ignored.
|
||||
const std::string body =
|
||||
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
|
||||
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
|
||||
R"([{"id":3,"name":"Spiritual","sortOrder":1},
|
||||
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
|
||||
|
||||
std::vector<ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(parseCategoriesJson(body, out));
|
||||
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
|
||||
EXPECT_EQ(out[0].id, 3);
|
||||
EXPECT_EQ(out[0].name, "Spiritual");
|
||||
EXPECT_EQ(out[0].parent_id, 0);
|
||||
EXPECT_EQ(out[0].sort_order, 1);
|
||||
|
||||
EXPECT_EQ(out[1].id, 7);
|
||||
EXPECT_EQ(out[1].name, "Inner");
|
||||
EXPECT_EQ(out[1].parent_id, 3);
|
||||
EXPECT_EQ(out[1].name, "Physical");
|
||||
EXPECT_EQ(out[1].sort_order, 2);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseGroupsJson) {
|
||||
const std::string body =
|
||||
R"([{"id":5,"name":"Prayers","sortOrder":0},
|
||||
{"id":8,"name":"Affirmations","sortOrder":3}])";
|
||||
|
||||
std::vector<ProclamationGroupRecord> out;
|
||||
EXPECT_TRUE(parseGroupsJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
|
||||
EXPECT_EQ(out[0].id, 5);
|
||||
EXPECT_EQ(out[0].name, "Prayers");
|
||||
EXPECT_EQ(out[0].sort_order, 0);
|
||||
|
||||
EXPECT_EQ(out[1].id, 8);
|
||||
EXPECT_EQ(out[1].name, "Affirmations");
|
||||
EXPECT_EQ(out[1].sort_order, 3);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, RejectMalformedJson) {
|
||||
std::vector<ProclamationRecord> procs;
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
std::vector<ProclamationGroupRecord> groups;
|
||||
|
||||
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
||||
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
|
||||
@@ -69,6 +89,10 @@ TEST(ProclamationSyncTest, RejectMalformedJson) {
|
||||
// Non-numeric ids are tolerated (row skipped), not fatal.
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
||||
EXPECT_EQ(cats.size(), 0u);
|
||||
|
||||
EXPECT_FALSE(parseGroupsJson("garbage", groups));
|
||||
EXPECT_TRUE(parseGroupsJson(R"([{"id":"x"}])", groups));
|
||||
EXPECT_EQ(groups.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||
EXPECT_EQ(procs[0].id, "ok");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", cats));
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","sortOrder":1}])", cats));
|
||||
ASSERT_EQ(cats.size(), 1u);
|
||||
EXPECT_EQ(cats[0].id, 9);
|
||||
|
||||
std::vector<ProclamationGroupRecord> groups;
|
||||
EXPECT_TRUE(parseGroupsJson(R"([{"id":0,"name":"Bad"},{"id":4,"name":"Good","sortOrder":1}])", groups));
|
||||
ASSERT_EQ(groups.size(), 1u);
|
||||
EXPECT_EQ(groups[0].id, 4);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, StripHtmlEntities) {
|
||||
|
||||
@@ -17,6 +17,7 @@ using radiantwave::scroller::StreamColor;
|
||||
using radiantwave::scroller::buildStreams;
|
||||
using radiantwave::scroller::kDefaultRepeatCount;
|
||||
using radiantwave::scroller::kSelectedRepeatCount;
|
||||
using radiantwave::scroller::minuteInScheduleBlock;
|
||||
|
||||
const std::string kDefaultId = "default";
|
||||
|
||||
@@ -185,6 +186,77 @@ TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected)
|
||||
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount);
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, MinuteInScheduleBlockDaytime)
|
||||
{
|
||||
// 06:00 (360) to 08:00 (480), half-open.
|
||||
EXPECT_FALSE(minuteInScheduleBlock(359, 360, 480, false));
|
||||
EXPECT_TRUE(minuteInScheduleBlock(360, 360, 480, false));
|
||||
EXPECT_TRUE(minuteInScheduleBlock(479, 360, 480, false));
|
||||
EXPECT_FALSE(minuteInScheduleBlock(480, 360, 480, false));
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, MinuteInScheduleBlockOvernight)
|
||||
{
|
||||
// 21:00 (1260) to 09:00 (540) wraps past midnight.
|
||||
EXPECT_TRUE(minuteInScheduleBlock(1260, 1260, 540, true));
|
||||
EXPECT_TRUE(minuteInScheduleBlock(1439, 1260, 540, true));
|
||||
EXPECT_TRUE(minuteInScheduleBlock(0, 1260, 540, true));
|
||||
EXPECT_TRUE(minuteInScheduleBlock(539, 1260, 540, true));
|
||||
EXPECT_FALSE(minuteInScheduleBlock(540, 1260, 540, true));
|
||||
EXPECT_FALSE(minuteInScheduleBlock(1259, 1260, 540, true));
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, ScheduledIdsDriveOrderRegardlessOfSelection)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
p1.id = "one";
|
||||
p1.title = "One";
|
||||
p1.content = "x";
|
||||
p1.selected = false;
|
||||
|
||||
ProclamationRecord p2;
|
||||
p2.id = "two";
|
||||
p2.title = "Two";
|
||||
p2.content = "y";
|
||||
p2.selected = false;
|
||||
|
||||
ProclamationRecord def;
|
||||
def.id = kDefaultId;
|
||||
def.title = "Default";
|
||||
def.content = "z";
|
||||
|
||||
// Explicit order two -> one, interleaved with the default. Unknown ids are
|
||||
// skipped; the `selected` flag is ignored.
|
||||
std::vector<std::string> scheduled = {"two", "missing", "one"};
|
||||
auto streams = buildStreams({p1, p2, def}, {}, palette(), {}, kDefaultId, &scheduled);
|
||||
|
||||
ASSERT_EQ(streams.size(), 4u);
|
||||
EXPECT_EQ(streams[0].name, "Two");
|
||||
EXPECT_EQ(streams[1].name, "Default");
|
||||
EXPECT_EQ(streams[2].name, "One");
|
||||
EXPECT_EQ(streams[3].name, "Default");
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, EmptyScheduleFallsBackToDefault)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
p1.id = "one";
|
||||
p1.title = "One";
|
||||
p1.content = "x";
|
||||
p1.selected = true;
|
||||
|
||||
ProclamationRecord def;
|
||||
def.id = kDefaultId;
|
||||
def.title = "Default";
|
||||
def.content = "y";
|
||||
|
||||
std::vector<std::string> scheduled; // empty block
|
||||
auto streams = buildStreams({p1, def}, {}, palette(), {}, kDefaultId, &scheduled);
|
||||
|
||||
ASSERT_EQ(streams.size(), 1u);
|
||||
EXPECT_EQ(streams[0].name, "Default");
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
|
||||
@@ -26,6 +26,24 @@ TEST(TimeZoneTest, UtcTimestampFormat) {
|
||||
EXPECT_NE(iso.find("UTC"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(TimeZoneTest, CurrentLocalMinutesInRangeAndMatchesUtc) {
|
||||
int minutes = currentLocalMinutes("UTC");
|
||||
EXPECT_GE(minutes, 0);
|
||||
EXPECT_LT(minutes, 1440);
|
||||
|
||||
// Cross-check against the UTC wall clock. The two calls happen within the
|
||||
// same second, so a minute-boundary rollover is vanishingly unlikely.
|
||||
time_t now = std::time(nullptr);
|
||||
struct tm gmt_buf;
|
||||
gmtime_r(&now, &gmt_buf);
|
||||
EXPECT_EQ(minutes, gmt_buf.tm_hour * 60 + gmt_buf.tm_min);
|
||||
|
||||
// A timezone that is known to differ from UTC must still be in range.
|
||||
int ny = currentLocalMinutes("America/New_York");
|
||||
EXPECT_GE(ny, 0);
|
||||
EXPECT_LT(ny, 1440);
|
||||
}
|
||||
|
||||
TEST(TimeZoneTest, FormatUtcToTimezone) {
|
||||
// 2026-07-24 12:00:00 UTC
|
||||
// Fixed epoch timestamp: 1784808000
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
|
||||
Radiant Wave
|
||||
User Manual
|
||||
Version 1.0
|
||||
|
||||
Intro
|
||||
Some thank you message and introduction to Radiant Waves.
|
||||
|
||||
Table of Contents
|
||||
|
||||
Setup
|
||||
Connect to WiFi
|
||||
Connection to the internet is required for Radiant Wave to operate.
|
||||
|
||||
Wifi screen appears first
|
||||
UP/DOWN to navigate through detected WiFi networks
|
||||
Enter to select one
|
||||
Password prompt if applicable
|
||||
|
||||
I still need to test entering an incorrect WiFi password on a kiosk machine
|
||||
Check for crashes or correct error messaging
|
||||
|
||||
Email Address
|
||||
|
||||
The next screen you’ll be presented with is the email address prompt.
|
||||
|
||||
When you purchased your RadiantWave system, you should have received an email at the address you provided during your purchase containing your license key.
|
||||
|
||||
Enter the email address where you received your license key here.
|
||||
|
||||
Note that you will not be able to enter an invalid email address, but you will be able to enter the incorrect email address. You will see how to change your email address later.
|
||||
|
||||
License Key
|
||||
|
||||
Grab your license key from your email, now is the time to use it!
|
||||
|
||||
A few notes on the License Key prompt:
|
||||
You don't have to hold SHIFT or enable CAPS LOCK, because that's already done for you
|
||||
You won't have to enter the dashes "-", as those are entered for you
|
||||
You CAN enter an incorrect license key
|
||||
You CAN fix it later
|
||||
|
||||
Same steps as the email address, just type out your license key and press enter.
|
||||
|
||||
The Main Menu
|
||||
|
||||
Now you're in! This is the main menu, and it doesn't do a whole lot. It's only purpose is to display some system information for you to quickly reference when needed, such as the email address and license key you currently have saved or the current system version you have running. Other than that, the Main Menu's only purpose is to provide navigation between the system settings, and the main attraction itself.
|
||||
|
||||
TODO
|
||||
|
||||
wifi
|
||||
email
|
||||
license
|
||||
main menu
|
||||
settings
|
||||
time zone
|
||||
date
|
||||
logs
|
||||
audio options
|
||||
proclamations
|
||||
play
|
||||
updates
|
||||
Reference in New Issue
Block a user