#include "storage/sound_store.hpp" #include "storage/database.hpp" #include "util/file_util.hpp" #include "util/id_util.hpp" #include "util/string_util.hpp" #include "util/string_util.hpp" #include #include #include #include namespace lm { namespace { constexpr const char* kSoundSelect = "SELECT id, name, description, file_name, duration_ms, enabled, volume, is_system, created_at, updated_at " "FROM sounds"; nlohmann::json rowToJson(sqlite3_stmt* stmt) { auto textOrNull = [&](int col) -> nlohmann::json { if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return nullptr; return nlohmann::json(reinterpret_cast(sqlite3_column_text(stmt, col))); }; return {{"id", textOrNull(0)}, {"name", textOrNull(1)}, {"description", textOrNull(2)}, {"file_name", textOrNull(3)}, {"duration_ms", sqlite3_column_type(stmt, 4) == SQLITE_NULL ? nlohmann::json(nullptr) : nlohmann::json(sqlite3_column_int(stmt, 4))}, {"enabled", sqlite3_column_int(stmt, 5) != 0}, {"volume", sqlite3_column_int(stmt, 6)}, {"is_system", sqlite3_column_int(stmt, 7) != 0}, {"created_at", textOrNull(8)}, {"updated_at", textOrNull(9)}}; } int clampVolume(int v) { if (v < 0) return 0; if (v > 100) return 100; return v; } bool isReservedSystemName(const std::string& name) { const std::string lower = StringUtil::toLower(StringUtil::trimCopy(name)); return lower == "beep" || lower == "horn" || lower == "chime"; } bool findNameConflict(sqlite3* db, const std::string& name, const std::string& except_id) { sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db, except_id.empty() ? "SELECT id FROM sounds WHERE lower(name) = lower(?1) LIMIT 1" : "SELECT id FROM sounds WHERE lower(name) = lower(?1) AND id != ?2 LIMIT 1", -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT); if (!except_id.empty()) sqlite3_bind_text(stmt, 2, except_id.c_str(), -1, SQLITE_TRANSIENT); const bool conflict = sqlite3_step(stmt) == SQLITE_ROW; sqlite3_finalize(stmt); return conflict; } } // namespace SoundStore::SoundStore(Database& db) : db_(db) {} nlohmann::json SoundStore::list() const { std::lock_guard lock(mu_); nlohmann::json sounds = nlohmann::json::array(); const std::string query = std::string(kSoundSelect) + " ORDER BY is_system DESC, name"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), query.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return sounds; while (sqlite3_step(stmt) == SQLITE_ROW) sounds.push_back(rowToJson(stmt)); sqlite3_finalize(stmt); return sounds; } std::optional SoundStore::find(const std::string& id) const { std::lock_guard lock(mu_); const std::string query = std::string(kSoundSelect) + " WHERE id = ?1"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), query.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return std::nullopt; sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); std::optional out; if (sqlite3_step(stmt) == SQLITE_ROW) out = rowToJson(stmt); sqlite3_finalize(stmt); return out; } std::optional SoundStore::create(const nlohmann::json& payload, std::string& err) { if (!payload.is_object()) { err = "payload must be an object"; return std::nullopt; } const std::string name = StringUtil::trimCopy(payload.value("name", "")); if (name.empty()) { err = "name is required"; return std::nullopt; } if (isReservedSystemName(name)) { err = "name reserved for built-in system sounds"; return std::nullopt; } std::lock_guard lock(mu_); if (findNameConflict(db_.handle(), name, "")) { err = "sound name already exists"; return std::nullopt; } const std::string id = payload.value("id", IdUtil::newId()); const std::string now = IdUtil::nowIso8601(); const std::string description = payload.value("description", ""); const bool enabled = payload.value("enabled", true); const int volume = clampVolume(payload.value("volume", 100)); const bool is_system = payload.value("is_system", false); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, volume, " "is_system, created_at, updated_at) " "VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6,?7,?8)", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return std::nullopt; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 4, enabled ? 1 : 0); sqlite3_bind_int(stmt, 5, volume); sqlite3_bind_int(stmt, 6, is_system ? 1 : 0); sqlite3_bind_text(stmt, 7, now.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT); if (sqlite3_step(stmt) != SQLITE_DONE) { err = sqlite3_errmsg(db_.handle()); sqlite3_finalize(stmt); return std::nullopt; } sqlite3_finalize(stmt); return nlohmann::json{{"id", id}, {"name", name}, {"description", description}, {"file_name", nullptr}, {"duration_ms", nullptr}, {"enabled", enabled}, {"volume", volume}, {"is_system", is_system}, {"created_at", now}, {"updated_at", now}}; } bool SoundStore::update(const std::string& id, const nlohmann::json& payload, std::string& err) { auto existing = find(id); if (!existing) { err = "sound not found"; return false; } const bool is_system = existing->value("is_system", false); nlohmann::json merged = *existing; for (const char* key : {"description", "enabled", "duration_ms", "volume"}) { if (payload.contains(key)) merged[key] = payload[key]; } if (!is_system && payload.contains("name")) { const std::string new_name = StringUtil::trimCopy(payload["name"].get()); if (new_name.empty()) { err = "name is required"; return false; } if (isReservedSystemName(new_name)) { err = "name reserved for built-in system sounds"; return false; } if (findNameConflict(db_.handle(), new_name, id)) { err = "sound name already exists"; return false; } merged["name"] = new_name; } if (merged.contains("volume")) merged["volume"] = clampVolume(merged["volume"].get()); const std::string now = IdUtil::nowIso8601(); std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, volume=?6, " "updated_at=?7 WHERE id=?1", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return false; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 4, merged.value("enabled", true) ? 1 : 0); if (merged["duration_ms"].is_number_integer()) sqlite3_bind_int(stmt, 5, merged["duration_ms"].get()); else sqlite3_bind_null(stmt, 5); sqlite3_bind_int(stmt, 6, clampVolume(merged.value("volume", 100))); sqlite3_bind_text(stmt, 7, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; if (!ok) err = sqlite3_errmsg(db_.handle()); sqlite3_finalize(stmt); return ok; } bool SoundStore::remove(const std::string& id, std::string& err) { auto existing = find(id); if (!existing) { err = "sound not found"; return false; } if (existing->value("is_system", false)) { err = "system sounds cannot be deleted"; return false; } std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return false; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; sqlite3_finalize(stmt); if (existing->contains("file_name") && (*existing)["file_name"].is_string()) { const auto path = db_.soundsDir() / existing->value("file_name", ""); std::error_code ec; std::filesystem::remove(path, ec); } return ok; } std::optional SoundStore::filePath(const std::string& id) const { const auto sound = find(id); if (!sound || !(*sound)["file_name"].is_string()) return std::nullopt; const auto path = db_.soundsDir() / sound->value("file_name", ""); if (!std::filesystem::exists(path)) return std::nullopt; return path; } bool SoundStore::saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err) { auto existing = find(id); if (!existing) { err = "sound not found"; return false; } if (existing->value("is_system", false)) { err = "system sounds cannot be replaced"; return false; } std::error_code ec; std::filesystem::create_directories(db_.soundsDir(), ec); const auto path = db_.soundsDir() / filename; if (!FileUtil::writeBinaryAtomic(path, bytes)) { err = "failed to write sound file"; return false; } const std::string now = IdUtil::nowIso8601(); std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "UPDATE sounds SET file_name = ?2, updated_at = ?3 WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return false; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; if (!ok) err = sqlite3_errmsg(db_.handle()); sqlite3_finalize(stmt); return ok; } bool SoundStore::ensureSystemDefaults(std::string& err) { { std::vector dup_ids; std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "SELECT id FROM sounds WHERE is_system = 0 AND lower(name) IN ('beep', 'horn', 'chime')", -1, &stmt, nullptr) == SQLITE_OK) { while (sqlite3_step(stmt) == SQLITE_ROW) { if (sqlite3_column_type(stmt, 0) != SQLITE_NULL) dup_ids.push_back(reinterpret_cast(sqlite3_column_text(stmt, 0))); } sqlite3_finalize(stmt); } for (const auto& dup_id : dup_ids) { sqlite3_stmt* del = nullptr; if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &del, nullptr) == SQLITE_OK) { sqlite3_bind_text(del, 1, dup_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_step(del); sqlite3_finalize(del); } } } struct DefaultSound { const char* id; const char* name; }; static const DefaultSound kDefaults[] = { {"sys_beep", "Beep"}, {"sys_horn", "Horn"}, {"sys_chime", "Chime"}, }; for (const auto& d : kDefaults) { if (find(d.id)) continue; nlohmann::json payload = {{"id", d.id}, {"name", d.name}, {"is_system", true}, {"volume", 100}}; if (!create(payload, err)) return false; } return true; } } // namespace lm