/* * @Author: DI JUNKUN * @Date: 2026-10-12 * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ #ifndef _GALLERY_WORKSPACE_H_ #define _GALLERY_WORKSPACE_H_ #include #include #include #include #include #include #include #include #include "gallery_storage.h" #include "gallery_stream_settings.h" namespace crossdesk::gallery { struct Member { std::string id; std::string group; }; // Layout contains no credentials and never reconnects devices when loaded. struct Workspace { // Group assignments are shared by history and the wall. Wall enrollment is // independent, so assigning a group alone must never add a device to it. std::vector members; std::vector groups; std::set selected; int page = 0; int capacity = 6; StreamSettings stream_settings; static std::string NormalizeId(const std::string &input) { std::string result; for (size_t i = 0; i < input.size();) { const auto ch = static_cast(input[i]); if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { ++i; continue; } if (i + 2 < input.size() && ch == 0xEF && static_cast(input[i + 1]) == 0xBC && static_cast(input[i + 2]) >= 0x90 && static_cast(input[i + 2]) <= 0x99) { result += char('0' + static_cast(input[i + 2]) - 0x90); i += 3; } else if (input.compare(i, 3, "\xe3\x80\x80") == 0) i += 3; else result += input[i++]; } return result; } static bool ValidId(const std::string &id) { return id.size() == 9 && std::all_of(id.begin(), id.end(), [](unsigned char c) { return c >= '0' && c <= '9'; }); } bool Add(const std::string &id, const std::string &group = {}) { if (!ValidId(id)) return false; const bool joined = wall_ids_.insert(id).second; // A device grouped earlier in history joins at the end when explicitly // added, while retaining its existing group assignments. if (joined) std::stable_partition(members.begin(), members.end(), [&](const auto &m) { return m.id != id; }); if (group.empty() && HasAssignments(id)) return joined; return AddAssignment(id, group) || joined; } std::vector Ids(const std::string &group = {}) const { std::vector ids; std::set seen; for (const auto &m : members) if (wall_ids_.count(m.id) && (group.empty() || m.group == group) && seen.insert(m.id).second) ids.push_back(m.id); return OrderIds(ids); } // Order is independent of membership, group assignments and live sessions. std::vector OrderIds(const std::vector &ids) const { if (device_order_.empty()) return ids; std::set remaining(ids.begin(), ids.end()); std::vector ordered; ordered.reserve(ids.size()); for (const auto &id : device_order_) if (remaining.erase(id)) ordered.push_back(id); for (const auto &id : ids) if (remaining.erase(id)) ordered.push_back(id); return ordered; } bool SwapPositions(const std::string &source, const std::string &target, const std::vector &visible_ids) { if (source == target || !ValidId(source) || !ValidId(target) || std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() || std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end()) return false; auto ordered = device_order_; std::set known(ordered.begin(), ordered.end()); // Include hidden groups before applying a swap in a filtered view. for (const auto &id : Ids()) if (known.insert(id).second) ordered.push_back(id); // Active-only devices can be arranged without enrolling them in the wall. for (const auto &id : visible_ids) if (ValidId(id) && known.insert(id).second) ordered.push_back(id); std::iter_swap(std::find(ordered.begin(), ordered.end(), source), std::find(ordered.begin(), ordered.end(), target)); device_order_ = std::move(ordered); return true; } // History filtering uses group assignments, independently of wall enrollment. // nullopt means all devices; an empty name means no named group. bool MatchesGroup(const std::string &id, const std::optional &group) const { if (!group) return true; if (group->empty()) return std::none_of(members.begin(), members.end(), [&](const auto &m) { return m.id == id && !m.group.empty(); }); return std::any_of(members.begin(), members.end(), [&](const auto &m) { return m.id == id && m.group == *group; }); } size_t AddMany(const std::vector &ids, const std::string &group = {}) { size_t added = 0; for (const auto &id : ids) { // Without a target group, an existing membership already puts the device // on the wall. An explicit group may reuse that same device/session. if (group.empty() && wall_ids_.count(id)) continue; if (Add(id, group)) ++added; } return added; } // Both editors replace the same group assignments without changing wall // enrollment. Keep the position of devices already on the wall untouched. bool SetGroups(const std::string &id, const std::vector &names) { if (!ValidId(id)) return false; std::vector replacements; std::set seen; for (const auto &name : names) if (!name.empty() && seen.insert(name).second) replacements.push_back({id, name}); if (replacements.empty()) replacements.push_back({id, ""}); std::vector updated; bool inserted = false; for (const auto &member : members) { if (member.id != id) { updated.push_back(member); } else if (!inserted) { updated.insert(updated.end(), replacements.begin(), replacements.end()); inserted = true; } } if (!inserted) updated.insert(updated.end(), replacements.begin(), replacements.end()); members = std::move(updated); for (const auto &member : replacements) if (!member.group.empty() && std::find(groups.begin(), groups.end(), member.group) == groups.end()) groups.push_back(member.group); PruneGroups(); return true; } void Remove(const std::string &id, const std::string &group = {}) { if (group.empty()) { // Removing from the wall leaves history's group assignments intact. wall_ids_.erase(id); std::erase(device_order_, id); } else if (MatchesGroup(id, group)) { // Removing the last named group leaves an ungrouped wall member. // Reuse the editor path so enrollment and card order stay unchanged. std::vector remaining; for (const auto &member : members) if (member.id == id && member.group != group) remaining.push_back(member.group); SetGroups(id, remaining); } selected.erase(id); } void ResizePage(int new_capacity, int count) { const int first = page * capacity; capacity = std::max(1, new_capacity); page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity)); } // The caller supplies every ID in the current filter, across all pages. void ToggleSelectAll(const std::vector &ids) { std::set targets(ids.begin(), ids.end()); if (selected == targets) selected.clear(); else selected = std::move(targets); } bool Load(const std::filesystem::path &path) { try { if (!std::filesystem::exists(path)) return true; std::ifstream file(path); const auto doc = nlohmann::json::parse(file); const int version = doc.at("version").get(); if ((version != 1 && version != 2) || !doc.at("members").is_array()) return false; if (version == 2 && !doc.at("wall_ids").is_array()) return false; Workspace loaded; if (doc.contains("stream_settings")) loaded.stream_settings = StreamSettings::FromJson(doc["stream_settings"]); for (const auto &member : doc.at("members")) { const auto id = member.at("id").get(); loaded.AddAssignment(id, member.value("group", "")); // Every saved member in the old format was already on the wall. if (version == 1 && ValidId(id)) loaded.wall_ids_.insert(id); } if (version == 2) for (const auto &value : doc.at("wall_ids")) { const auto id = value.get(); if (ValidId(id) && loaded.HasAssignments(id)) loaded.wall_ids_.insert(id); } if (doc.contains("device_order") && doc["device_order"].is_array()) { std::set seen; for (const auto &value : doc["device_order"]) { if (!value.is_string()) continue; const auto id = value.get(); if (ValidId(id) && seen.insert(id).second) loaded.device_order_.push_back(id); } } members = std::move(loaded.members); groups = std::move(loaded.groups); wall_ids_ = std::move(loaded.wall_ids_); device_order_ = std::move(loaded.device_order_); stream_settings = loaded.stream_settings; return true; } catch (...) { return false; } } bool Save(const std::filesystem::path &path) const { try { nlohmann::json doc{{"version", 2}, {"members", nlohmann::json::array()}, {"wall_ids", wall_ids_}, {"device_order", device_order_}, {"stream_settings", stream_settings.ToJson()}}; for (const auto &m : members) doc["members"].push_back({{"id", m.id}, {"group", m.group}}); return WriteAtomically(path, doc.dump(2)); } catch (...) { return false; } } private: std::set wall_ids_; std::vector device_order_; bool HasAssignments(const std::string &id) const { return std::any_of(members.begin(), members.end(), [&](const auto &m) { return m.id == id; }); } bool AddAssignment(const std::string &id, const std::string &group) { if (!ValidId(id)) return false; if (std::any_of(members.begin(), members.end(), [&](const auto &m) { return m.id == id && m.group == group; })) return false; if (!group.empty() && std::find(groups.begin(), groups.end(), group) == groups.end()) groups.push_back(group); members.push_back({id, group}); return true; } void PruneGroups() { groups.erase(std::remove_if(groups.begin(), groups.end(), [&](const auto &name) { return std::none_of(members.begin(), members.end(), [&](const auto &m) { return m.group == name; }); }), groups.end()); } }; } // namespace crossdesk::gallery #endif