diff --git a/LanMountainDesktop.Tests/WhiteboardNotePersistenceServiceTests.cs b/LanMountainDesktop.Tests/WhiteboardNotePersistenceServiceTests.cs new file mode 100644 index 0000000..3274591 --- /dev/null +++ b/LanMountainDesktop.Tests/WhiteboardNotePersistenceServiceTests.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class WhiteboardNotePersistenceServiceTests +{ + [Fact] + public void SaveNote_ThenLoadNote_RoundTripsSnapshot() + { + using var sandbox = new WhiteboardNotePersistenceSandbox(); + var service = sandbox.CreateService(); + var snapshot = CreateSampleSnapshot(); + + service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15); + + var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15); + + Assert.Single(loaded.Strokes); + Assert.Equal(2, loaded.Strokes[0].Points.Count); + Assert.Equal("#FF112233", loaded.Strokes[0].Color); + Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue); + } + + [Fact] + public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded() + { + using var sandbox = new WhiteboardNotePersistenceSandbox(); + var service = sandbox.CreateService(); + + service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7); + sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7); + + var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7); + + Assert.Empty(loaded.Strokes); + Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board")); + } + + [Fact] + public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows() + { + using var sandbox = new WhiteboardNotePersistenceSandbox(); + var service = sandbox.CreateService(); + + service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7); + service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7); + service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15); + + sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7); + sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7); + sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15); + + var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10); + + Assert.Equal(2, deletedCount); + Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a")); + Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b")); + Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c")); + } + + private static WhiteboardNoteSnapshot CreateSampleSnapshot() + { + return new WhiteboardNoteSnapshot + { + Strokes = + [ + new WhiteboardStrokeSnapshot + { + Color = "#FF112233", + InkThickness = 3.5d, + IgnorePressure = true, + Points = + [ + new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 }, + new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 } + ] + } + ] + }; + } + + private sealed class WhiteboardNotePersistenceSandbox : IDisposable + { + private readonly string _directoryPath = Path.Combine( + Path.GetTempPath(), + "LanMountainDesktop.WhiteboardNoteTests", + Guid.NewGuid().ToString("N")); + + private readonly string _databasePath; + + public WhiteboardNotePersistenceSandbox() + { + Directory.CreateDirectory(_directoryPath); + _databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db"); + } + + public WhiteboardNotePersistenceService CreateService() + { + return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath)); + } + + public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays) + { + var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays)); + using var connection = new AppDatabaseService(_databasePath).OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE whiteboard_notes + SET saved_at_utc_ms = $savedAtUtcMs, + expires_at_utc_ms = $expiresAtUtcMs, + updated_at_utc_ms = $updatedAtUtcMs + WHERE component_id = $componentId + AND placement_id = $placementId; + """; + command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$componentId", componentId); + command.Parameters.AddWithValue("$placementId", placementId); + command.ExecuteNonQuery(); + } + + public bool Exists(string componentId, string placementId) + { + using var connection = new AppDatabaseService(_databasePath).OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(1) + FROM whiteboard_notes + WHERE component_id = $componentId + AND placement_id = $placementId; + """; + command.Parameters.AddWithValue("$componentId", componentId); + command.Parameters.AddWithValue("$placementId", placementId); + return Convert.ToInt32(command.ExecuteScalar()) > 0; + } + + public void Dispose() + { + try + { + if (Directory.Exists(_directoryPath)) + { + Directory.Delete(_directoryPath, true); + } + } + catch + { + // Temporary test directories are best-effort cleanup. + } + } + } +} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index f2f63cd..e87ef9c 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -410,6 +410,7 @@ "common.monet": "Monet", "desktop.page_index_format": "Desktop {0}", "launcher.title": "App Launcher", + "launcher.folder": "Folder", "launcher.subtitle": "Apps and folders from Windows Start Menu", "launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries", "launcher.empty": "No Start Menu entries found.", @@ -595,6 +596,18 @@ "component.blackboard_landscape": "Blackboard (Landscape)", "component.browser": "Browser", "component.office_recent_documents": "Recent Documents", + "whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.", + "whiteboard.settings.retention.title": "Note retention", + "whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.", + "whiteboard.settings.retention.option": "{0} days", + "whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.", + "office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.", + "office_recent_documents.settings.sources_title": "Recent document sources", + "office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.", + "office_recent_documents.settings.source.registry": "Office registry MRU", + "office_recent_documents.settings.source.recent_folders": "Windows Recent folders", + "office_recent_documents.settings.source.jump_lists": "Windows Jump Lists", + "office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.", "component.removable_storage": "Removable Storage", "component.holiday_calendar": "Holiday Calendar", "component.study_environment": "Environment", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 46e761d..82bacad 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -408,6 +408,7 @@ "common.monet": "莫奈", "desktop.page_index_format": "桌面 {0}", "launcher.title": "应用启动台", + "launcher.folder": "文件夹", "launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹", "launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用", "launcher.empty": "未找到开始菜单条目。", @@ -593,6 +594,18 @@ "component.blackboard_landscape": "横向小黑板", "component.browser": "浏览器", "component.office_recent_documents": "最近文档", + "whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。", + "whiteboard.settings.retention.title": "笔记保留时间", + "whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。", + "whiteboard.settings.retention.option": "{0} 天", + "whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。", + "office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。", + "office_recent_documents.settings.sources_title": "最近文档来源", + "office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。", + "office_recent_documents.settings.source.registry": "Office 注册表 MRU", + "office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹", + "office_recent_documents.settings.source.jump_lists": "Windows 跳转列表", + "office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。", "component.holiday_calendar": "节假日日历", "component.study_environment": "环境", "component.study_session_control": "自习时段控制", diff --git a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs index 96c8e5c..eb25147 100644 --- a/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/ComponentSettingsSnapshot.cs @@ -58,12 +58,15 @@ public sealed class ComponentSettingsSnapshot public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12; + public int WhiteboardNoteRetentionDays { get; set; } = 15; + public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true; public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20; public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated; + public List? OfficeRecentDocumentsEnabledSources { get; set; } public ComponentSettingsSnapshot Clone() { diff --git a/LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs b/LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs new file mode 100644 index 0000000..5e2e140 --- /dev/null +++ b/LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs @@ -0,0 +1,23 @@ +namespace LanMountainDesktop.Models; + +public static class WhiteboardNoteRetentionPolicy +{ + public const int MinimumDays = 7; + public const int MaximumDays = 15; + public const int DefaultDays = MaximumDays; + + public static int NormalizeDays(int days) + { + if (days < MinimumDays) + { + return MinimumDays; + } + + if (days > MaximumDays) + { + return MaximumDays; + } + + return days; + } +} diff --git a/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs b/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs new file mode 100644 index 0000000..7aee12f --- /dev/null +++ b/LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace LanMountainDesktop.Models; + +public sealed class WhiteboardNoteSnapshot +{ + public int Version { get; set; } = 1; + + public DateTimeOffset SavedUtc { get; set; } + + public List Strokes { get; set; } = []; + + public WhiteboardNoteSnapshot Clone() + { + var clone = (WhiteboardNoteSnapshot)MemberwiseClone(); + clone.Strokes = Strokes is { Count: > 0 } + ? new List(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot())) + : []; + return clone; + } +} + +public sealed class WhiteboardStrokeSnapshot +{ + public string Color { get; set; } = "#FF000000"; + + public double InkThickness { get; set; } = 2.5d; + + public bool IgnorePressure { get; set; } = true; + + public List Points { get; set; } = []; + + public WhiteboardStrokeSnapshot Clone() + { + var clone = (WhiteboardStrokeSnapshot)MemberwiseClone(); + clone.Points = Points is { Count: > 0 } + ? new List(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot())) + : []; + return clone; + } +} + +public sealed class WhiteboardStylusPointSnapshot +{ + public double X { get; set; } + + public double Y { get; set; } + + public double Pressure { get; set; } = 0.5d; + + public double Width { get; set; } + + public double Height { get; set; } + + public WhiteboardStylusPointSnapshot Clone() + { + return (WhiteboardStylusPointSnapshot)MemberwiseClone(); + } +} diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 350927d..a8eb726 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -43,6 +43,7 @@ sealed class Program var diagnostics = StartupDiagnosticsService.Run(args); StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); + ScheduleWhiteboardNoteStartupCleanup(); try { @@ -88,6 +89,25 @@ sealed class Program return builder; } + private static void ScheduleWhiteboardNoteStartupCleanup() + { + _ = Task.Run(() => + { + try + { + var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512); + if (deletedCount > 0) + { + AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance."); + } + } + catch (Exception ex) + { + AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex); + } + }); + } + private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId) { var singleInstance = SingleInstanceService.CreateDefault(); diff --git a/LanMountainDesktop/Services/AppDatabaseService.cs b/LanMountainDesktop/Services/AppDatabaseService.cs index b98d26e..d68ebb2 100644 --- a/LanMountainDesktop/Services/AppDatabaseService.cs +++ b/LanMountainDesktop/Services/AppDatabaseService.cs @@ -29,6 +29,16 @@ public sealed class AppDatabaseService _databasePath = Path.Combine(dataDirectory, "app.db"); } + public AppDatabaseService(string databasePath) + { + if (string.IsNullOrWhiteSpace(databasePath)) + { + throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath)); + } + + _databasePath = databasePath; + } + public SqliteConnection OpenConnection() { var directory = Path.GetDirectoryName(_databasePath); diff --git a/LanMountainDesktop/Services/ComponentSettingsService.cs b/LanMountainDesktop/Services/ComponentSettingsService.cs index 4455f08..92b1107 100644 --- a/LanMountainDesktop/Services/ComponentSettingsService.cs +++ b/LanMountainDesktop/Services/ComponentSettingsService.cs @@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore public void DeleteForComponent(string componentId, string? placementId) { + _ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId); + if (_settingsService is not null) { _settingsService.SaveSnapshot( diff --git a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs index 4e35315..0d1e76e 100644 --- a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs +++ b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs @@ -75,6 +75,12 @@ public static class DesktopComponentEditorRegistryFactory [BuiltInComponentIds.DesktopRemovableStorage] = new( BuiltInComponentIds.DesktopRemovableStorage, context => new RemovableStorageComponentEditor(context)), + [BuiltInComponentIds.DesktopWhiteboard] = new( + BuiltInComponentIds.DesktopWhiteboard, + context => new WhiteboardComponentEditor(context)), + [BuiltInComponentIds.DesktopBlackboardLandscape] = new( + BuiltInComponentIds.DesktopBlackboardLandscape, + context => new WhiteboardComponentEditor(context)), [BuiltInComponentIds.DesktopOfficeRecentDocuments] = new( BuiltInComponentIds.DesktopOfficeRecentDocuments, context => new OfficeRecentDocumentsComponentEditor(context)), diff --git a/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs b/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs new file mode 100644 index 0000000..44dfe8d --- /dev/null +++ b/LanMountainDesktop/Services/IWhiteboardNotePersistenceService.cs @@ -0,0 +1,19 @@ +using System; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +public interface IWhiteboardNotePersistenceService +{ + WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays); + + void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays); + + bool DeleteNote(string componentId, string? placementId); + + bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays); + + bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null); + + DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays); +} diff --git a/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs b/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs new file mode 100644 index 0000000..efdafdc --- /dev/null +++ b/LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs @@ -0,0 +1,338 @@ +using System; +using System.Text.Json; +using LanMountainDesktop.Models; +using Microsoft.Data.Sqlite; + +namespace LanMountainDesktop.Services; + +public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService +{ + private const int DefaultCleanupBatchSize = 256; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly object _schemaSyncRoot = new(); + private readonly AppDatabaseService _databaseService; + private bool _schemaInitialized; + + public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null) + { + _databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault(); + } + + public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays) + { + if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId)) + { + return new WhiteboardNoteSnapshot(); + } + + try + { + using var connection = OpenConnection(); + DeleteExpiredInternal( + connection, + normalizedComponentId, + normalizedPlacementId, + WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays), + DateTimeOffset.UtcNow); + + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT note_json, saved_at_utc_ms + FROM whiteboard_notes + WHERE component_id = $componentId + AND placement_id = $placementId + LIMIT 1; + """; + command.Parameters.AddWithValue("$componentId", normalizedComponentId); + command.Parameters.AddWithValue("$placementId", normalizedPlacementId); + + using var reader = command.ExecuteReader(); + if (!reader.Read() || reader.IsDBNull(0)) + { + return new WhiteboardNoteSnapshot(); + } + + var json = reader.GetString(0); + if (string.IsNullOrWhiteSpace(json)) + { + return new WhiteboardNoteSnapshot(); + } + + var snapshot = JsonSerializer.Deserialize(json, JsonOptions) ?? new WhiteboardNoteSnapshot(); + if (!reader.IsDBNull(1)) + { + snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1)); + } + + if (IsExpired(snapshot, retentionDays)) + { + DeleteNote(normalizedComponentId, normalizedPlacementId); + return new WhiteboardNoteSnapshot(); + } + + return snapshot.Clone(); + } + catch + { + return new WhiteboardNoteSnapshot(); + } + } + + public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays) + { + if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId)) + { + return; + } + + try + { + var nowUtc = DateTimeOffset.UtcNow; + var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot(); + persistedSnapshot.SavedUtc = nowUtc; + var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays); + var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions); + + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO whiteboard_notes( + component_id, + placement_id, + note_json, + saved_at_utc_ms, + expires_at_utc_ms, + updated_at_utc_ms) + VALUES( + $componentId, + $placementId, + $noteJson, + $savedAtUtcMs, + $expiresAtUtcMs, + $updatedAtUtcMs) + ON CONFLICT(component_id, placement_id) DO UPDATE SET + note_json = excluded.note_json, + saved_at_utc_ms = excluded.saved_at_utc_ms, + expires_at_utc_ms = excluded.expires_at_utc_ms, + updated_at_utc_ms = excluded.updated_at_utc_ms; + """; + command.Parameters.AddWithValue("$componentId", normalizedComponentId); + command.Parameters.AddWithValue("$placementId", normalizedPlacementId); + command.Parameters.AddWithValue("$noteJson", json); + command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds()); + command.ExecuteNonQuery(); + } + catch + { + // Keep whiteboard usable even when persistence is unavailable. + } + } + + public bool DeleteNote(string componentId, string? placementId) + { + if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId)) + { + return false; + } + + try + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + DELETE FROM whiteboard_notes + WHERE component_id = $componentId + AND placement_id = $placementId; + """; + command.Parameters.AddWithValue("$componentId", normalizedComponentId); + command.Parameters.AddWithValue("$placementId", normalizedPlacementId); + return command.ExecuteNonQuery() > 0; + } + catch + { + return false; + } + } + + public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays) + { + if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId)) + { + return false; + } + + try + { + using var connection = OpenConnection(); + return DeleteExpiredInternal( + connection, + normalizedComponentId, + normalizedPlacementId, + WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays), + DateTimeOffset.UtcNow); + } + catch + { + return false; + } + } + + public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null) + { + try + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + DELETE FROM whiteboard_notes + WHERE rowid IN ( + SELECT rowid + FROM whiteboard_notes + WHERE expires_at_utc_ms <= $nowUtcMs + ORDER BY expires_at_utc_ms ASC + LIMIT $batchSize + ); + """; + command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds()); + command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize)); + return command.ExecuteNonQuery(); + } + catch + { + return 0; + } + } + + public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null) + { + if (snapshot is null) + { + return false; + } + + var expirationUtc = GetExpirationUtc(snapshot, retentionDays); + if (!expirationUtc.HasValue) + { + return false; + } + + return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow); + } + + public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays) + { + if (snapshot is null || snapshot.SavedUtc == default) + { + return null; + } + + return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays)); + } + + private SqliteConnection OpenConnection() + { + var connection = _databaseService.OpenConnection(); + EnsureSchema(connection); + return connection; + } + + private void EnsureSchema(SqliteConnection connection) + { + if (_schemaInitialized) + { + return; + } + + lock (_schemaSyncRoot) + { + if (_schemaInitialized) + { + return; + } + + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE IF NOT EXISTS whiteboard_notes ( + component_id TEXT NOT NULL, + placement_id TEXT NOT NULL, + note_json TEXT NOT NULL, + saved_at_utc_ms INTEGER NOT NULL, + expires_at_utc_ms INTEGER NOT NULL, + updated_at_utc_ms INTEGER NOT NULL, + PRIMARY KEY (component_id, placement_id) + ); + + CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at + ON whiteboard_notes(expires_at_utc_ms); + """; + command.ExecuteNonQuery(); + _schemaInitialized = true; + } + } + + private static bool DeleteExpiredInternal( + SqliteConnection connection, + string componentId, + string placementId, + int retentionDays, + DateTimeOffset nowUtc) + { + using var selectCommand = connection.CreateCommand(); + selectCommand.CommandText = """ + SELECT saved_at_utc_ms + FROM whiteboard_notes + WHERE component_id = $componentId + AND placement_id = $placementId + LIMIT 1; + """; + selectCommand.Parameters.AddWithValue("$componentId", componentId); + selectCommand.Parameters.AddWithValue("$placementId", placementId); + + var scalar = selectCommand.ExecuteScalar(); + if (scalar is not long savedAtUtcMs) + { + return false; + } + + var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs); + var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays)); + if (expiresUtc > nowUtc) + { + return false; + } + + using var deleteCommand = connection.CreateCommand(); + deleteCommand.CommandText = """ + DELETE FROM whiteboard_notes + WHERE component_id = $componentId + AND placement_id = $placementId; + """; + deleteCommand.Parameters.AddWithValue("$componentId", componentId); + deleteCommand.Parameters.AddWithValue("$placementId", placementId); + return deleteCommand.ExecuteNonQuery() > 0; + } + + private static bool TryNormalizeKeys( + string componentId, + string? placementId, + out string normalizedComponentId, + out string normalizedPlacementId) + { + normalizedComponentId = componentId?.Trim() ?? string.Empty; + normalizedPlacementId = placementId?.Trim() ?? string.Empty; + return !string.IsNullOrWhiteSpace(normalizedComponentId); + } + + private static int NormalizeBatchSize(int batchSize) + { + return batchSize <= 0 + ? DefaultCleanupBatchSize + : Math.Clamp(batchSize, 1, 4096); + } +} diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml index 10b1142..df605f2 100644 --- a/LanMountainDesktop/Styles/GlassModule.axaml +++ b/LanMountainDesktop/Styles/GlassModule.axaml @@ -165,7 +165,13 @@ - - - - - + +