Merge branch 'dev'

This commit is contained in:
NeilClack
2026-09-11 15:08:22 -05:00
15 changed files with 927 additions and 351 deletions
+8
View File
@@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires - `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read `onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
from it. 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.
--- ---
+3 -2
View File
@@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
| ------- | --------- | | ------- | --------- |
| app_config | Key-value store: license, email, audio device, volume | | app_config | Key-value store: license, email, audio device, volume |
| logs | Application logs | | logs | Application logs |
| proclamations | Synced proclamation text | | proclamations | Synced proclamation text (category_id + group_id) |
| proclamation_categories | Category metadata | | proclamation_categories | Flat category metadata |
| proclamation_groups | Tag-like groups that organize proclamations within a category |
| solfeggio_frequencies | 9 frequency presets | | solfeggio_frequencies | 9 frequency presets |
| audio_files | Downloaded audio files | | audio_files | Downloaded audio files |
+467
View File
@@ -0,0 +1,467 @@
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: 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/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
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
-191
View File
@@ -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.
+153 -18
View File
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
selected INTEGER DEFAULT 0, selected INTEGER DEFAULT 0,
category_id INTEGER DEFAULT 0, category_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0, solfeggio_frequency_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
CREATE TABLE IF NOT EXISTS proclamation_categories ( CREATE TABLE IF NOT EXISTS proclamation_categories (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, 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 sort_order INTEGER DEFAULT 0
); );
@@ -182,6 +188,7 @@ bool DatabaseManager::runMigrations() {
// Back-fill columns for databases created before these fields existed. // Back-fill columns for databases created before these fields existed.
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0"); ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
ensureColumn("proclamations", "category_id", "category_id 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("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
ensureColumn("audio_files", "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. // Clean up the pre-active/inactive-license subscription level keys.
@@ -189,12 +196,72 @@ bool DatabaseManager::runMigrations() {
executeSql("DELETE FROM system_kv WHERE key IN " executeSql("DELETE FROM system_kv WHERE key IN "
"('subscription_level_id','subscription_level_name','subscription_level_description');"); "('subscription_level_id','subscription_level_name','subscription_level_description');");
} }
if (ok) {
ok = migrateSubCategoriesToGroups();
}
if (ok) { if (ok) {
seedColorPalettes(); seedColorPalettes();
} }
return ok; 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() { void DatabaseManager::seedColorPalettes() {
static const char* kSeedSql = static const char* kSeedSql =
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES " "INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
@@ -337,7 +404,8 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0); sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0); sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
sqlite3_bind_int(stmt, 8, record.category_id); 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) { bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
@@ -345,12 +413,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
if (!db_) return false; if (!db_) return false;
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, " const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id) " "is_active, selected, category_id, group_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, " "ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, " "solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, selected=excluded.selected, " "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; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) { if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -374,7 +443,8 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0); rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0); rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
rec.category_id = sqlite3_column_int(stmt, firstCol + 7); 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; return rec;
} }
@@ -384,7 +454,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
if (!db_) return result; if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, " 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;"; "FROM proclamations WHERE is_active = 1;";
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) { if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -414,8 +484,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
return false; return false;
} }
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) " const char* insSql = "INSERT INTO proclamation_categories (id, name, sort_order) "
"VALUES (?, ?, ?, ?);"; "VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) { if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr); sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
@@ -425,8 +495,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
for (const auto& cat : records) { for (const auto& cat : records) {
sqlite3_bind_int(stmt, 1, cat.id); sqlite3_bind_int(stmt, 1, cat.id);
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, cat.parent_id); sqlite3_bind_int(stmt, 3, cat.sort_order);
sqlite3_bind_int(stmt, 4, 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) { if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false; ok = false;
break; break;
@@ -499,12 +611,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
// Upsert each proclamation, preserving the stashed selection state. // Upsert each proclamation, preserving the stashed selection state.
if (ok) { if (ok) {
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, " const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id) " "is_active, selected, category_id, group_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, " "ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, " "solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, category_id=excluded.category_id, " "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; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) { if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -555,7 +667,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
if (!db_) return result; if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, " 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; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) { if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result; return result;
@@ -574,7 +686,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
std::vector<ProclamationCategoryRecord> result; std::vector<ProclamationCategoryRecord> result;
if (!db_) return 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;"; "ORDER BY sort_order ASC, id ASC;";
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) { if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -585,8 +697,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
ProclamationCategoryRecord rec; ProclamationCategoryRecord rec;
rec.id = sqlite3_column_int(stmt, 0); rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1)); 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, 2);
rec.sort_order = sqlite3_column_int(stmt, 3); 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); result.push_back(rec);
} }
+18 -1
View File
@@ -16,13 +16,23 @@ struct ProclamationRecord {
bool is_active = true; bool is_active = true;
bool selected = false; bool selected = false;
int category_id = 0; // maps to ProclamationCategoryRecord::id 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 int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
}; };
struct ProclamationCategoryRecord { struct ProclamationCategoryRecord {
int id = 0; int id = 0;
std::string name; 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; int sort_order = 0;
}; };
@@ -90,6 +100,9 @@ public:
// Full sync: replaces all categories with the given set. // Full sync: replaces all categories with the given set.
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records); 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`, // Full sync: upserts proclamations, deletes any not in `records`,
// preserves the existing `selected` state, and forces the proclamation // preserves the existing `selected` state, and forces the proclamation
// identified by `defaultProclamationId` to remain selected. // identified by `defaultProclamationId` to remain selected.
@@ -102,6 +115,9 @@ public:
// Fetch all categories ordered by sort_order then id. // Fetch all categories ordered by sort_order then id.
std::vector<ProclamationCategoryRecord> getAllCategories(); std::vector<ProclamationCategoryRecord> getAllCategories();
// Fetch all groups ordered by sort_order then id.
std::vector<ProclamationGroupRecord> getAllGroups();
// Toggle a single proclamation's selected flag. // Toggle a single proclamation's selected flag.
bool setProclamationSelected(const std::string& id, bool selected); bool setProclamationSelected(const std::string& id, bool selected);
@@ -144,6 +160,7 @@ public:
private: private:
std::string resolvePath(const std::string& raw_path) const; std::string resolvePath(const std::string& raw_path) const;
bool runMigrations(); bool runMigrations();
bool migrateSubCategoriesToGroups();
bool executeSql(const std::string& sql); bool executeSql(const std::string& sql);
bool columnExists(const std::string& table, const std::string& column); bool columnExists(const std::string& table, const std::string& column);
bool ensureColumn(const std::string& table, const std::string& column, bool ensureColumn(const std::string& table, const std::string& column,
+1
View File
@@ -12,6 +12,7 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
// Paths relative to API_BASE_URL. // Paths relative to API_BASE_URL.
constexpr const char* PATH_PROCLAMATIONS = "/proclamations"; constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
constexpr const char* PATH_CATEGORIES = "/categories"; constexpr const char* PATH_CATEGORIES = "/categories";
constexpr const char* PATH_GROUPS = "/groups";
constexpr const char* PATH_FREQUENCIES = "/frequencies"; constexpr const char* PATH_FREQUENCIES = "/frequencies";
constexpr const char* PATH_LICENSE = "/license"; constexpr const char* PATH_LICENSE = "/license";
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account"; constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
+36 -2
View File
@@ -66,6 +66,7 @@ bool parseProclamationsJson(const std::string& body,
rec.title = stripHtml(entry.stringField("name")); rec.title = stripHtml(entry.stringField("name"));
rec.content = stripHtml(entry.stringField("content")); rec.content = stripHtml(entry.stringField("content"));
rec.category_id = static_cast<int>(entry.numberField("categoryId")); 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")); rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
out.push_back(std::move(rec)); out.push_back(std::move(rec));
} }
@@ -88,7 +89,28 @@ bool parseCategoriesJson(const std::string& body,
if (rec.id == 0) continue; if (rec.id == 0) continue;
rec.name = entry.stringField("name"); 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")); rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
out.push_back(std::move(rec)); out.push_back(std::move(rec));
} }
@@ -114,16 +136,28 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
return false; 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::ProclamationRecord> proclamations;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories; std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) || if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
!parseCategoriesJson(categoriesResponse.body, categories)) { !parseCategoriesJson(categoriesResponse.body, categories) ||
!parseGroupsJson(groupsResponse.body, groups)) {
return false; return false;
} }
if (!db.replaceCategories(categories)) { if (!db.replaceCategories(categories)) {
return false; return false;
} }
if (!db.replaceGroups(groups)) {
return false;
}
if (!db.syncProclamations(proclamations, defaultProclamationId)) { if (!db.syncProclamations(proclamations, defaultProclamationId)) {
return false; return false;
} }
+7 -3
View File
@@ -15,9 +15,13 @@ bool parseProclamationsJson(const std::string& body,
bool parseCategoriesJson(const std::string& body, bool parseCategoriesJson(const std::string& body,
std::vector<radiantwave::db::ProclamationCategoryRecord>& out); std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
// Fetches proclamations + categories from the API and stores them in the DB, // Parse the JSON payload returned by the groups endpoint.
// preserving any existing selection and forcing `defaultProclamationId` to bool parseGroupsJson(const std::string& body,
// stay selected. Returns true only if the API sync succeeded; on failure the 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. // database is left untouched so the app can fall back to cached data.
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db, bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
const std::string& defaultProclamationId); const std::string& defaultProclamationId);
+65 -46
View File
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
{ {
proclamations_ = state_->db.getAllProclamations(); proclamations_ = state_->db.getAllProclamations();
categories_ = state_->db.getAllCategories(); categories_ = state_->db.getAllCategories();
groups_ = state_->db.getAllGroups();
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID); state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
for (auto& p : proclamations_) { for (auto& p : proclamations_) {
@@ -181,42 +182,55 @@ void ProclamationOptionsPage::rebuildItems()
0, false, true, true, 0, false, true, true,
}); });
for (const auto& root : categories_) { // Groups that actually exist, so a proclamation pointing at a group the
if (root.parent_id != 0) continue; // 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({ items_.push_back({
FlatItem::Type::RootCategory, FlatItem::Type::Category,
std::to_string(root.id), std::to_string(category.id),
root.name, category.name,
0, false, false, false, 0, false, false, false,
}); });
for (const auto& sub : categories_) { // Groups are tag-like and global. A group is shown under a category
if (sub.parent_id != root.id) continue; // only when it has at least one proclamation in that category, so
// impossible pairings (e.g. "Physical / Prayers") never render.
items_.push_back({ for (const auto& group : groups_) {
FlatItem::Type::SubCategory, std::vector<const radiantwave::db::ProclamationRecord*> members;
std::to_string(sub.id),
sub.name,
1, true, false, false,
});
for (const auto& p : proclamations_) { for (const auto& p : proclamations_) {
if (p.id == DEFAULT_PROPLEAMATION_ID) continue; 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({ items_.push_back({
FlatItem::Type::Proclamation, FlatItem::Type::Proclamation,
p.id, p->id,
p.title, p->title,
2, false, p.selected, false, 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_) { for (const auto& p : proclamations_) {
if (p.id == DEFAULT_PROPLEAMATION_ID) continue; 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({ items_.push_back({
FlatItem::Type::Proclamation, FlatItem::Type::Proclamation,
p.id, p.id,
@@ -235,29 +249,29 @@ void ProclamationOptionsPage::rebuildVisible()
return; return;
} }
// Tree walk: track the currently-open root and sub category (items_ is // Tree walk: track the currently-open category and group (items_ is
// already ordered root -> sub -> proclamation, so each category dominates // already ordered category -> group -> proclamation, so each category
// the items until the next category of equal or shallower depth). // dominates the items until the next category).
int currentRoot = -1; // items_ index of the current root category int currentCategory = -1; // items_ index of the current category
int currentSub = -1; // items_ index of the current sub-category int currentGroup = -1; // items_ index of the current group
for (size_t i = 0; i < items_.size(); ++i) { for (size_t i = 0; i < items_.size(); ++i) {
const FlatItem& item = items_[i]; const FlatItem& item = items_[i];
if (item.type == FlatItem::Type::RootCategory) { if (item.type == FlatItem::Type::Category) {
currentRoot = static_cast<int>(i); currentCategory = static_cast<int>(i);
currentSub = -1; currentGroup = -1;
visible_.push_back(i); visible_.push_back(i);
continue; continue;
} }
if (item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::Group) {
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded; bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
if (rootOpen) { if (categoryOpen) {
currentSub = static_cast<int>(i); currentGroup = static_cast<int>(i);
visible_.push_back(i); visible_.push_back(i);
} else { } else {
currentSub = -1; currentGroup = -1;
} }
continue; continue;
} }
@@ -266,9 +280,10 @@ void ProclamationOptionsPage::rebuildVisible()
bool parentVisible = false; bool parentVisible = false;
if (item.depth == 0) { if (item.depth == 0) {
parentVisible = true; // the always-on top-level proclamation 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) || parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0); (item.depth == 2 && currentGroup >= 0 &&
items_[currentGroup].expanded);
} }
if (parentVisible) visible_.push_back(i); if (parentVisible) visible_.push_back(i);
} }
@@ -283,7 +298,7 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; 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; item.expanded = !item.expanded;
rebuildVisible(); rebuildVisible();
return; return;
@@ -388,7 +403,7 @@ void ProclamationOptionsPage::collapseCurrent()
{ {
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; 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) { if (item.expanded) {
item.expanded = false; item.expanded = false;
rebuildVisible(); rebuildVisible();
@@ -400,7 +415,7 @@ void ProclamationOptionsPage::expandCurrent()
{ {
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; 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) { if (!item.expanded) {
item.expanded = true; item.expanded = true;
rebuildVisible(); rebuildVisible();
@@ -417,8 +432,8 @@ void ProclamationOptionsPage::moveToParent()
int start = static_cast<int>(visible_[highlightIndex_]); int start = static_cast<int>(visible_[highlightIndex_]);
for (int i = start - 1; i >= 0; --i) { for (int i = start - 1; i >= 0; --i) {
const FlatItem& candidate = items_[i]; const FlatItem& candidate = items_[i];
if ((candidate.type == FlatItem::Type::RootCategory || if ((candidate.type == FlatItem::Type::Category ||
candidate.type == FlatItem::Type::SubCategory) && candidate.type == FlatItem::Type::Group) &&
candidate.depth == item.depth - 1) { candidate.depth == item.depth - 1) {
for (size_t vi = 0; vi < visible_.size(); ++vi) { for (size_t vi = 0; vi < visible_.size(); ++vi) {
if (visible_[vi] == static_cast<size_t>(i)) { if (visible_[vi] == static_cast<size_t>(i)) {
@@ -526,7 +541,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return; if (searchActive_) return;
if (!visible_.empty()) { if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]]; 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(); collapseCurrent();
} else { } else {
moveToParent(); moveToParent();
@@ -567,6 +583,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_Color white = {255, 255, 255, 255}; SDL_Color white = {255, 255, 255, 255};
SDL_Color gray = {170, 170, 170, 255}; SDL_Color gray = {170, 170, 170, 255};
SDL_Color categoryBlue = {100, 180, 255, 255}; SDL_Color categoryBlue = {100, 180, 255, 255};
SDL_Color groupBlue = {160, 210, 255, 255};
SDL_Color selectedOrange = {255, 170, 60, 255}; SDL_Color selectedOrange = {255, 170, 60, 255};
float titleY = 50.0f; float titleY = 50.0f;
@@ -703,7 +720,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float maxTotalWidth = 0.0f; float maxTotalWidth = 0.0f;
for (const auto& item : items_) { for (const auto& item : items_) {
std::string label = item.label; std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) { if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
label = (item.expanded ? "[-] " : "[+] ") + label; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
int w = 0, h = 0; int w = 0, h = 0;
@@ -716,7 +733,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) { for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]]; const FlatItem& item = items_[visible_[i]];
std::string label = item.label; std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) { if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
label = (item.expanded ? "[-] " : "[+] ") + label; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
int w = 0, h = 0; int w = 0, h = 0;
@@ -734,13 +751,15 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float y = listTop + rowIndex * rowPitch; float y = listTop + rowIndex * rowPitch;
std::string label = item.label; std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) { if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
label = (item.expanded ? "[-] " : "[+] ") + label; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
SDL_Color color = white; SDL_Color color = white;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::Category) {
color = categoryBlue; color = categoryBlue;
} else if (item.type == FlatItem::Type::Group) {
color = groupBlue;
} else if (item.selected || item.isDefault) { } else if (item.selected || item.isDefault) {
color = selectedOrange; color = selectedOrange;
} }
+5 -4
View File
@@ -28,13 +28,13 @@ public:
private: private:
struct FlatItem { struct FlatItem {
enum class Type { RootCategory, SubCategory, Proclamation }; enum class Type { Category, Group, Proclamation };
Type type = Type::Proclamation; Type type = Type::Proclamation;
std::string id; // category id or proclamation id std::string id; // category id, group id, or proclamation id
std::string label; // display text std::string label; // display text
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
bool expanded = false; // categories only bool expanded = false; // categories and groups only
bool selected = false; // proclamations only bool selected = false; // proclamations only
bool isDefault = false; // the always-on top-level proclamation bool isDefault = false; // the always-on top-level proclamation
}; };
@@ -67,6 +67,7 @@ private:
AppState* state_ = nullptr; AppState* state_ = nullptr;
std::vector<radiantwave::db::ProclamationRecord> proclamations_; std::vector<radiantwave::db::ProclamationRecord> proclamations_;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_; std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
std::vector<FlatItem> items_; std::vector<FlatItem> items_;
std::vector<size_t> visible_; // indices into items_, in display order std::vector<size_t> visible_; // indices into items_, in display order
std::vector<size_t> searchResults_; // indices into proclamations_, search mode std::vector<size_t> searchResults_; // indices into proclamations_, search mode
+27 -10
View File
@@ -142,16 +142,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
EXPECT_EQ(out[1].category_id, 32); EXPECT_EQ(out[1].category_id, 32);
} }
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) { TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
const std::string body = const std::string categoriesBody =
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null}, R"([{"id":4,"name":"Spiritual","sortOrder":0},
{"id":5,"name":"General","sortOrder":0,"parentId":4}])"; {"id":5,"name":"General","sortOrder":1}])";
std::vector<radiantwave::db::ProclamationCategoryRecord> out; std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out)); EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
ASSERT_EQ(out.size(), 2u); ASSERT_EQ(categories.size(), 2u);
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root) EXPECT_EQ(categories[0].id, 4);
EXPECT_EQ(out[1].parent_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) ─────────────────── // ─── Live: GET /frequencies (harmless, always runs) ───────────────────
@@ -194,7 +203,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
std::vector<radiantwave::db::ProclamationCategoryRecord> categories; std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, 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) ───────── // ─── Live: POST /license error paths (no credentials required) ─────────
+100 -4
View File
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
DbFixture fx("test_rw_db_cats.db"); DbFixture fx("test_rw_db_cats.db");
std::vector<ProclamationCategoryRecord> cats; std::vector<ProclamationCategoryRecord> cats;
cats.push_back({3, "Root", 0, 1}); cats.push_back({3, "Spiritual", 1});
cats.push_back({7, "Child", 3, 2}); cats.push_back({7, "Physical", 2});
ASSERT_TRUE(fx.db.replaceCategories(cats)); ASSERT_TRUE(fx.db.replaceCategories(cats));
auto all = fx.db.getAllCategories(); auto all = fx.db.getAllCategories();
ASSERT_EQ(all.size(), 2u); ASSERT_EQ(all.size(), 2u);
EXPECT_EQ(all[0].id, 3); 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].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. // Replacing wipes previous categories.
ASSERT_TRUE(fx.db.replaceCategories({})); ASSERT_TRUE(fx.db.replaceCategories({}));
EXPECT_EQ(fx.db.getAllCategories().size(), 0u); 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) { TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
DbFixture fx("test_rw_db_audio.db"); DbFixture fx("test_rw_db_audio.db");
+37 -8
View File
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
TEST(ProclamationSyncTest, ParseProclamationsJson) { TEST(ProclamationSyncTest, ParseProclamationsJson) {
const std::string body = const std::string body =
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2}, R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0}, {"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
{"notAnObject":true}])"; {"notAnObject":true}])";
std::vector<ProclamationRecord> out; std::vector<ProclamationRecord> out;
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
EXPECT_EQ(out[0].title, "Peace"); EXPECT_EQ(out[0].title, "Peace");
EXPECT_EQ(out[0].content, "I am peace."); EXPECT_EQ(out[0].content, "I am peace.");
EXPECT_EQ(out[0].category_id, 3); 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[0].solfeggio_frequency_id, 2);
EXPECT_EQ(out[1].id, "p2"); EXPECT_EQ(out[1].id, "p2");
EXPECT_EQ(out[1].title, "Joy & Calm"); EXPECT_EQ(out[1].title, "Joy & Calm");
EXPECT_EQ(out[1].content, "text"); EXPECT_EQ(out[1].content, "text");
EXPECT_EQ(out[1].category_id, 0); EXPECT_EQ(out[1].category_id, 0);
EXPECT_EQ(out[1].group_id, 0);
EXPECT_EQ(out[1].solfeggio_frequency_id, 0); EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
} }
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
} }
TEST(ProclamationSyncTest, ParseCategoriesJson) { TEST(ProclamationSyncTest, ParseCategoriesJson) {
// Categories are flat now; a legacy parentId field is simply ignored.
const std::string body = const std::string body =
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1}, R"([{"id":3,"name":"Spiritual","sortOrder":1},
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])"; {"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
std::vector<ProclamationCategoryRecord> out; std::vector<ProclamationCategoryRecord> out;
EXPECT_TRUE(parseCategoriesJson(body, out)); EXPECT_TRUE(parseCategoriesJson(body, out));
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
EXPECT_EQ(out[0].id, 3); EXPECT_EQ(out[0].id, 3);
EXPECT_EQ(out[0].name, "Spiritual"); EXPECT_EQ(out[0].name, "Spiritual");
EXPECT_EQ(out[0].parent_id, 0);
EXPECT_EQ(out[0].sort_order, 1); EXPECT_EQ(out[0].sort_order, 1);
EXPECT_EQ(out[1].id, 7); EXPECT_EQ(out[1].id, 7);
EXPECT_EQ(out[1].name, "Inner"); EXPECT_EQ(out[1].name, "Physical");
EXPECT_EQ(out[1].parent_id, 3);
EXPECT_EQ(out[1].sort_order, 2); 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) { TEST(ProclamationSyncTest, RejectMalformedJson) {
std::vector<ProclamationRecord> procs; std::vector<ProclamationRecord> procs;
std::vector<ProclamationCategoryRecord> cats; std::vector<ProclamationCategoryRecord> cats;
std::vector<ProclamationGroupRecord> groups;
EXPECT_FALSE(parseProclamationsJson("not json", procs)); EXPECT_FALSE(parseProclamationsJson("not json", procs));
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array 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. // Non-numeric ids are tolerated (row skipped), not fatal.
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats)); EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
EXPECT_EQ(cats.size(), 0u); 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) { TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
EXPECT_EQ(procs[0].id, "ok"); EXPECT_EQ(procs[0].id, "ok");
std::vector<ProclamationCategoryRecord> cats; 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); ASSERT_EQ(cats.size(), 1u);
EXPECT_EQ(cats[0].id, 9); 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) { TEST(ProclamationSyncTest, StripHtmlEntities) {
-62
View File
@@ -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