using System.Text.Json; namespace RemSound.Core; /// /// File-backed store for instances. One profile = one JSON file /// under <exe>\profiles\<machine name>\<title>.json. /// /// Profile names are user-supplied "plain English" strings; the store sanitises them /// for the filesystem (replaces invalid chars with underscores) but keeps the original /// string as the in-file Title. Two profiles whose sanitised filenames collide will /// overwrite each other — fine in practice; very rare. /// /// Per-machine subfolder: profiles\<machine>\ — keeps each machine's profiles /// separate by default. To share a profile between machines, copy the .json file from /// one machine's folder into the other machine's folder. The profile content is fully /// portable; device IDs that don't exist on the loading machine are silently dropped /// at apply time. /// public sealed class ProfileStore { private readonly string baseDir; public ProfileStore() { var machineFolder = SanitiseFsName(Environment.MachineName); baseDir = Path.Combine(AppContext.BaseDirectory, "profiles", machineFolder); try { Directory.CreateDirectory(baseDir); } catch { /* permissions; List/Save will surface this when actually used */ } } /// Construct a profile store pointing at an explicit directory. Used when the /// user has picked a custom profiles folder via the "Browse for profile folder" button /// — typically a Dropbox / OneDrive / shared-drive path, or a per-project folder. /// No per-machine subfolder is appended; the supplied path IS the profiles folder, so /// the same path on multiple machines shares profiles. Throws if the path is null or /// empty (caller should validate before constructing). public ProfileStore(string customDirectory) { if (string.IsNullOrWhiteSpace(customDirectory)) throw new ArgumentException("Custom profile directory cannot be null or empty", nameof(customDirectory)); baseDir = customDirectory; try { Directory.CreateDirectory(baseDir); } catch { /* permissions; List/Save will surface this when actually used */ } } /// Folder this store reads from and writes into. public string BaseDirectory => baseDir; /// Returns the user-facing titles of every profile in the folder, sorted /// alphabetically (case-insensitive). Excludes the synthetic blank-template; the /// caller decides whether to surface that. public IReadOnlyList ListProfileTitles() { if (!Directory.Exists(baseDir)) return []; try { return Directory.GetFiles(baseDir, "*.json") .Select(p => TryReadTitle(p) ?? Path.GetFileNameWithoutExtension(p)) .Where(static t => !string.IsNullOrWhiteSpace(t)) .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) .ToList(); } catch { return []; } } /// Loads a profile by title. Returns null if the file is missing or /// unreadable. Malformed JSON is treated as "not found" rather than throwing — /// the caller can surface a diagnostic and fall back to a blank template. public Profile? Load(string title) { if (string.IsNullOrWhiteSpace(title)) return null; var path = PathFor(title); if (!File.Exists(path)) return null; try { var json = File.ReadAllText(path); var profile = JsonSerializer.Deserialize(json); // Force the in-file Title to whatever was on disk; defends against the user // renaming the .json filename without editing the JSON. if (profile is not null && string.IsNullOrWhiteSpace(profile.Title)) { profile.Title = title; } return profile; } catch { return null; } } /// Writes a profile to disk. Title must be non-empty; caller is responsible /// for prompting the user or generating one. Throws if filesystem write fails. public void Save(Profile profile) { if (profile is null) throw new ArgumentNullException(nameof(profile)); if (string.IsNullOrWhiteSpace(profile.Title)) throw new ArgumentException("Profile title cannot be empty", nameof(profile)); Directory.CreateDirectory(baseDir); var path = PathFor(profile.Title); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, json); } /// Deletes the profile by title. Returns true if a file was removed, /// false if it didn't exist or couldn't be deleted. public bool Delete(string title) { if (string.IsNullOrWhiteSpace(title)) return false; var path = PathFor(title); if (!File.Exists(path)) return false; try { File.Delete(path); return true; } catch { return false; } } /// True if a profile with the given title (sanitised filename) already exists. public bool Exists(string title) => !string.IsNullOrWhiteSpace(title) && File.Exists(PathFor(title)); /// Rename a profile on disk. Loads the JSON, updates the in-file Title field, /// writes it under the new sanitised filename, then deletes the old file. Returns true /// on success. Fails (returns false, no changes made) if the source doesn't exist, the /// new title is empty/identical, or a file already exists at the destination filename. /// 2026-05-06. public bool Rename(string oldTitle, string newTitle) { if (string.IsNullOrWhiteSpace(oldTitle) || string.IsNullOrWhiteSpace(newTitle)) return false; if (string.Equals(oldTitle, newTitle, StringComparison.Ordinal)) return false; var oldPath = PathFor(oldTitle); var newPath = PathFor(newTitle); if (!File.Exists(oldPath)) return false; if (File.Exists(newPath) && !string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) return false; try { var profile = Load(oldTitle); if (profile is null) return false; profile.Title = newTitle; var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(newPath, json); if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) { File.Delete(oldPath); } return true; } catch { return false; } } /// The on-disk path for a profile of the given title in this store's base /// directory. Sanitises filesystem-invalid characters in the title before joining. /// Public so callers (e.g. MainForm at startup) can record where a loaded profile /// lives, which matters once Save As lets the user write outside . public string PathFor(string title) => Path.Combine(baseDir, SanitiseFsName(title) + ".json"); /// Read just the Title field of a profile JSON to surface the user-supplied /// name even if it differs from the sanitised filename. Cheap; the file is small. private static string? TryReadTitle(string path) { try { using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (doc.RootElement.TryGetProperty(nameof(Profile.Title), out var titleProp) && titleProp.ValueKind == JsonValueKind.String) { return titleProp.GetString(); } } catch { /* fall through */ } return null; } private static string SanitiseFsName(string name) { if (string.IsNullOrWhiteSpace(name)) return "untitled"; foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); return name.Trim(); } }