The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.
Database:
- Add the proclamation_groups table (id, name, sort_order) and a
proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
flat table.
- runMigrations() creates the new table/column, then runs a one-time
transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
sub-category becomes a group (its id is preserved), proclamations that
pointed at a sub-category are remapped to (parent category, new group), and
the now-stale sub-category rows are deleted. selected flags are never
touched, so user choices survive. No network is needed, so offline kiosks
migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.
API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
leaves the database untouched if any request or parse fails, then replaces
categories, replaces groups and upserts proclamations.
Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
directly under the category. Groups are collapsible and use a distinct color.
Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
group is created, proclamations are remapped, selection is preserved and a
second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
the live smoke test now also fetches /groups.
Docs: README database table and AGENTS notes updated for groups.
This commit is contained in:
@@ -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_) {
|
||||
@@ -181,42 +182,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 +249,29 @@ 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::Category) {
|
||||
currentCategory = static_cast<int>(i);
|
||||
currentGroup = -1;
|
||||
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::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 +280,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,7 +298,7 @@ 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;
|
||||
@@ -388,7 +403,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 +415,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 +432,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 +541,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 +583,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;
|
||||
@@ -703,7 +720,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float maxTotalWidth = 0.0f;
|
||||
for (const auto& item : items_) {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
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;
|
||||
}
|
||||
int w = 0, h = 0;
|
||||
@@ -734,13 +751,15 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float y = listTop + rowIndex * rowPitch;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user