Files
SNote/Client/SNote/ViewModels/MainViewModel.cs
T

876 lines
28 KiB
C#
Raw Normal View History

2026-06-01 05:09:20 +10:00
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using SNote.Data;
using SNote.Models;
using SNote.Services;
2026-05-28 02:54:17 +10:00
namespace SNote.ViewModels;
2026-06-01 05:09:20 +10:00
public class NotebookItem
{
public Node Node { get; set; }
public string Uuid => Node.Uuid;
public string Name => Node.Name;
public string Type => Node.Type;
public bool IsFolder => Type == "Folder";
public bool IsNote => Type == "Note";
public ObservableCollection<NotebookItem> Children { get; } = new();
public NotebookItem(Node node)
{
Node = node;
}
}
2026-05-28 02:54:17 +10:00
public partial class MainViewModel : ViewModelBase
{
2026-06-01 05:09:20 +10:00
private readonly CryptoService _cryptoService;
private readonly SyncService _syncService;
private LocalConfig? _config;
// View layout responsive state
[ObservableProperty] private bool _isOnline = false;
[ObservableProperty] private bool _isLoggedIn = false;
[ObservableProperty] private string _username = "Offline User";
[ObservableProperty] private string _serverUrl = "http://localhost:5000";
[ObservableProperty] private string _loginPassword = "";
[ObservableProperty] private string _syncStatus = "Offline Mode";
// Narrow vs Full mode states
[ObservableProperty] private bool _isNarrowMode = false;
[ObservableProperty] private bool _isSidebarVisible = true;
[ObservableProperty] private bool _isContentVisible = false;
// Editor / Selection state
[ObservableProperty] private Node? _selectedNode;
[ObservableProperty] private string _noteName = "";
[ObservableProperty] private string _noteContent = "";
// Dialog state controllers (shown via VM binding or simple toggles)
[ObservableProperty] private bool _isLinkDialogVisible = false;
[ObservableProperty] private bool _isShareDialogVisible = false;
[ObservableProperty] private bool _isAcceptDialogVisible = false;
[ObservableProperty] private bool _isCreateFolderDialogVisible = false;
[ObservableProperty] private string _newFolderName = "";
// New properties for Rename Dialog and Note Migration Dialog
[ObservableProperty] private bool _isRenameDialogVisible = false;
[ObservableProperty] private string _renameTargetName = "";
[ObservableProperty] private bool _isMigrateDialogVisible = false;
[ObservableProperty] private string? _selectedLocalAccount;
// Share results info
[ObservableProperty] private string _shareGuid = "";
[ObservableProperty] private string _sharePasskey = "";
// Accept Share entry
[ObservableProperty] private string _acceptGuid = "";
[ObservableProperty] private string _acceptPasskey = "";
// Tree selection
[ObservableProperty] private NotebookItem? _selectedNotebookItem;
partial void OnSelectedNotebookItemChanged(NotebookItem? value)
{
if (value != null)
{
SelectNode(value.Node);
}
}
public ObservableCollection<NotebookItem> NotebookTree { get; } = new();
public ObservableCollection<Node> CurrentNotesList { get; } = new();
public ObservableCollection<Node> AllFoldersList { get; } = new(); // Used in Link to... dialog
public ObservableCollection<string> LocalAccounts { get; } = new();
partial void OnSelectedLocalAccountChanged(string? value)
{
if (!string.IsNullOrEmpty(value))
{
Username = value;
}
}
public void ScanLocalAccounts()
{
LocalAccounts.Clear();
try
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
var dbPath = Path.Combine(path, "SNote");
if (Directory.Exists(dbPath))
{
using var globalDb = new ClientDbContext();
globalDb.Database.EnsureCreated();
var entries = globalDb.UserDirectory.ToList();
foreach (var entry in entries)
{
var file = Path.Combine(dbPath, $"snote_{entry.UserGuid}.db");
if (File.Exists(file) && !LocalAccounts.Contains(entry.Username))
{
LocalAccounts.Add(entry.Username);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error scanning local accounts: {ex.Message}");
}
}
public MainViewModel()
{
_cryptoService = new CryptoService();
_syncService = new SyncService(_cryptoService);
// Bootstrap local SQLite DB
using var db = new ClientDbContext();
db.Database.EnsureCreated();
// Load configuration if exists
_config = db.Configs.OrderByDescending(c => c.Id).FirstOrDefault();
if (_config != null)
{
Username = _config.Username;
ServerUrl = _config.ServerUrl;
IsOnline = _config.IsOnlineMode;
}
RefreshNotebookTree();
ScanLocalAccounts();
}
// Handles window resizing and adaptive column toggles
public void UpdateWidthMode(double width)
{
IsNarrowMode = width < 700;
if (IsNarrowMode)
{
// In narrow mode, if editing, hide sidebar. Otherwise, show sidebar.
IsSidebarVisible = SelectedNode == null;
IsContentVisible = SelectedNode != null;
}
else
{
IsSidebarVisible = true;
IsContentVisible = true;
}
}
[RelayCommand]
private void GoBack()
{
SelectedNode = null;
if (IsNarrowMode)
{
IsSidebarVisible = true;
IsContentVisible = false;
}
}
// Initialize local database with some default folders if empty
public void RefreshNotebookTree()
{
NotebookTree.Clear();
CurrentNotesList.Clear();
AllFoldersList.Clear();
using var db = new ClientDbContext();
var allNodes = db.Nodes.ToList();
var activeNodes = allNodes.Where(n => !n.IsDeleted).ToList();
// Populate folders for dialog select
var folders = activeNodes.Where(n => n.Type == "Folder").ToList();
foreach (var folder in folders)
{
AllFoldersList.Add(folder);
}
// Map Node Uuid -> NotebookItem wrapper
var itemMap = activeNodes.ToDictionary(n => n.Uuid, n => new NotebookItem(n));
var childUuids = new System.Collections.Generic.HashSet<string>();
// Build tree links
foreach (var folder in folders)
{
if (itemMap.TryGetValue(folder.Uuid, out var folderItem))
{
try
{
var childrenList = JsonSerializer.Deserialize<List<string>>(folder.Content) ?? new();
foreach (var childUuid in childrenList)
{
if (itemMap.TryGetValue(childUuid, out var childItem))
{
folderItem.Children.Add(childItem);
childUuids.Add(childUuid);
}
}
}
catch { }
}
}
// Roots are all items that are not children of any folder
var roots = itemMap.Values.Where(item => !childUuids.Contains(item.Uuid)).ToList();
foreach (var root in roots)
{
NotebookTree.Add(root);
}
// If a node was selected, refresh it
if (SelectedNode != null)
{
var refreshed = allNodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid && !n.IsDeleted);
if (refreshed != null)
{
SelectNode(refreshed);
}
else
{
SelectedNode = null;
}
}
}
[RelayCommand]
public void SelectNode(Node node)
{
SelectedNode = node;
NoteName = node.Name;
if (node.Type == "Folder")
{
CurrentNotesList.Clear();
using var db = new ClientDbContext();
// Fetch children
try
{
var childrenUuids = JsonSerializer.Deserialize<List<string>>(node.Content) ?? new();
var children = db.Nodes.Where(n => childrenUuids.Contains(n.Uuid) && !n.IsDeleted).ToList();
foreach (var child in children)
{
CurrentNotesList.Add(child);
}
}
catch { }
}
else
{
// Note node: bind content
NoteContent = node.Content;
}
if (IsNarrowMode)
{
IsSidebarVisible = false;
IsContentVisible = true;
}
}
[RelayCommand]
private void OpenCreateFolderDialog()
{
NewFolderName = "New Folder";
IsCreateFolderDialogVisible = true;
}
[RelayCommand]
private void ConfirmCreateFolder()
{
if (string.IsNullOrWhiteSpace(NewFolderName)) return;
CreateFolder(NewFolderName);
IsCreateFolderDialogVisible = false;
}
[RelayCommand]
private void CreateFolder(string name)
{
if (string.IsNullOrWhiteSpace(name)) name = "New Folder";
using var db = new ClientDbContext();
string uuid;
do
{
uuid = Guid.NewGuid().ToString();
} while (db.Nodes.Any(n => n.Uuid == uuid));
var folder = new Node
{
Uuid = uuid,
Name = name,
Type = "Folder",
Content = "[]",
OwnerUsername = Username,
UpdatedAt = DateTime.UtcNow
};
db.Nodes.Add(folder);
// If we have an active folder selected, link this new folder inside it
if (SelectedNode != null && SelectedNode.Type == "Folder")
{
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (parent != null)
{
try
{
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
childList.Add(folder.Uuid);
parent.Content = JsonSerializer.Serialize(childList);
parent.UpdatedAt = DateTime.UtcNow;
}
catch { }
}
}
db.SaveChanges();
RefreshNotebookTree();
}
[RelayCommand]
private void CreateNote(string name)
{
if (string.IsNullOrWhiteSpace(name)) name = "New Note";
using var db = new ClientDbContext();
string uuid;
do
{
uuid = Guid.NewGuid().ToString();
} while (db.Nodes.Any(n => n.Uuid == uuid));
var note = new Node
{
Uuid = uuid,
Name = name,
Type = "Note",
Content = "Write something here...",
OwnerUsername = Username,
UpdatedAt = DateTime.UtcNow
};
db.Nodes.Add(note);
// Must link inside active folder
if (SelectedNode != null && SelectedNode.Type == "Folder")
{
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (parent != null)
{
try
{
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
childList.Add(note.Uuid);
parent.Content = JsonSerializer.Serialize(childList);
parent.UpdatedAt = DateTime.UtcNow;
}
catch { }
}
}
db.SaveChanges();
RefreshNotebookTree();
SelectNode(note);
}
[RelayCommand]
private void SaveActiveNote()
{
if (SelectedNode == null || SelectedNode.Type == "Folder") return;
using var db = new ClientDbContext();
var note = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (note != null)
{
note.Name = NoteName;
note.Content = NoteContent;
note.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
SyncStatus = "Saved locally.";
RefreshNotebookTree();
}
}
[RelayCommand]
private void RenameNode(string newName)
{
if (SelectedNode == null || string.IsNullOrWhiteSpace(newName)) return;
using var db = new ClientDbContext();
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (node != null)
{
node.Name = newName;
node.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
RefreshNotebookTree();
}
}
[RelayCommand]
private void DeleteNode()
{
if (SelectedNode == null) return;
using var db = new ClientDbContext();
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (node != null)
{
node.IsDeleted = true;
node.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
SelectedNode = null;
RefreshNotebookTree();
GoBack();
}
}
[RelayCommand]
private void OpenLinkDialog()
{
if (SelectedNode == null) return;
IsLinkDialogVisible = true;
}
[RelayCommand]
private void CloseLinkDialog()
{
IsLinkDialogVisible = false;
}
// Symbolic linking of folders/notes
[RelayCommand]
public void LinkNodeToFolder(Node targetFolder)
{
if (SelectedNode == null || targetFolder == null || targetFolder.Uuid == SelectedNode.Uuid) return;
using var db = new ClientDbContext();
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == targetFolder.Uuid);
if (parent != null)
{
try
{
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
if (!childList.Contains(SelectedNode.Uuid))
{
childList.Add(SelectedNode.Uuid);
parent.Content = JsonSerializer.Serialize(childList);
parent.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
}
}
catch { }
}
IsLinkDialogVisible = false;
RefreshNotebookTree();
SyncStatus = $"Linked successfully to {targetFolder.Name}.";
}
// Start sharing flow: prompts the dialog
[RelayCommand]
private void OpenShareDialog()
{
if (SelectedNode == null) return;
ShareGuid = SelectedNode.Uuid;
SharePasskey = "share-" + new Random().Next(1000, 9999);
IsShareDialogVisible = true;
}
// Initiate sharing on the server
[RelayCommand]
private async Task ConfirmShareNode(string recipient)
{
if (SelectedNode == null || _config == null || string.IsNullOrEmpty(recipient)) return;
SyncStatus = "Sharing with server...";
var (success, error, guid) = await _syncService.ShareNodeAsync(SelectedNode.Uuid, recipient, SharePasskey, _config);
if (success)
{
SyncStatus = "Node shared! Share GUID and passkey shown.";
ShareGuid = guid; // Display GUID for user copy
}
else
{
SyncStatus = $"Share failed: {error}";
IsShareDialogVisible = false;
}
}
[RelayCommand]
private void OpenAcceptDialog()
{
IsAcceptDialogVisible = true;
}
// Accept share flow
[RelayCommand]
private async Task ConfirmAcceptShare()
{
if (_config == null || string.IsNullOrWhiteSpace(AcceptGuid) || string.IsNullOrWhiteSpace(AcceptPasskey)) return;
SyncStatus = "Accepting share from server...";
var (success, error) = await _syncService.AcceptShareAsync(AcceptGuid, AcceptPasskey, _config);
if (success)
{
SyncStatus = "Share accepted. Synchronizing to download...";
// Sync now to pull it
await TriggerSync();
IsAcceptDialogVisible = false;
AcceptGuid = "";
AcceptPasskey = "";
}
else
{
SyncStatus = $"Failed to accept share: {error}";
}
}
// Rename Dialog Commands
[RelayCommand]
private void OpenRenameDialog(NotebookItem item)
{
if (item == null) return;
SelectedNotebookItem = item;
RenameTargetName = item.Node.Name;
IsRenameDialogVisible = true;
}
[RelayCommand]
private void ConfirmRename()
{
if (SelectedNode == null || string.IsNullOrWhiteSpace(RenameTargetName))
{
IsRenameDialogVisible = false;
return;
}
using var db = new ClientDbContext();
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (node != null)
{
node.Name = RenameTargetName;
node.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
RefreshNotebookTree();
}
IsRenameDialogVisible = false;
}
[RelayCommand]
private void CancelRename()
{
IsRenameDialogVisible = false;
}
// Migration Commands
[RelayCommand]
private async Task ConfirmMigration()
{
IsMigrateDialogVisible = false;
SyncStatus = "Migrating offline notes to encrypted database...";
try
{
var userGuid = "";
using (var globalDb = new ClientDbContext())
{
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
if (entry != null)
{
userGuid = entry.UserGuid;
}
}
if (string.IsNullOrEmpty(userGuid))
{
SyncStatus = "Migration failed: User directory mapping not found.";
return;
}
// Load all nodes from the local offline database
List<Node> localNodes;
using (var globalDb = new ClientDbContext())
{
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
localNodes = globalDb.Nodes.Where(n => !n.IsDeleted).ToList();
}
// Switch back to the secure DB
var encryptionKey = _cryptoService.DeriveKeyFromPassword(LoginPassword, Username);
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
ClientDbContext.ActiveDbPassword = encryptionKey;
using (var userDb = new ClientDbContext())
{
userDb.Database.EnsureCreated();
var userMasterKey = _config?.MasterKey;
if (string.IsNullOrEmpty(userMasterKey))
{
SyncStatus = "Migration failed: Master key not loaded.";
return;
}
foreach (var node in localNodes)
{
// Encrypt node content with a newly generated node-specific key k_0
using var aes = System.Security.Cryptography.Aes.Create();
aes.GenerateKey();
var k0 = Convert.ToBase64String(aes.Key);
var secureNode = new Node
{
Uuid = node.Uuid,
Name = node.Name,
Type = node.Type,
OwnerUsername = Username,
UpdatedAt = DateTime.UtcNow,
IsDeleted = false
};
// Encrypt node content using raw key k0
secureNode.Content = _cryptoService.Encrypt(node.Content, k0);
// Create the encrypted node key mapping k0 encrypted by k_master
var secureKey = new NodeKey
{
NodeUuid = node.Uuid,
Username = Username,
EncryptedNodeKey = _cryptoService.Encrypt(k0, userMasterKey)
};
// Add permission
var acl = new AclEntry
{
NodeUuid = node.Uuid,
UserOrRole = Username,
CanView = true,
CanEdit = true,
CanDelete = true,
CanRename = true
};
userDb.Nodes.Add(secureNode);
userDb.Database.ExecuteSqlRaw(
"INSERT OR IGNORE INTO NodeKeys (NodeUuid, Username, EncryptedNodeKey) VALUES ({0}, {1}, {2})",
secureKey.NodeUuid, secureKey.Username, secureKey.EncryptedNodeKey);
}
await userDb.SaveChangesAsync();
}
SyncStatus = "Local notes migrated successfully! Triggering sync upload...";
await TriggerSync();
}
catch (Exception ex)
{
SyncStatus = $"Migration error: {ex.Message}";
Console.WriteLine($"Migration error: {ex.Message}");
}
}
[RelayCommand]
private async Task CancelMigration()
{
IsMigrateDialogVisible = false;
SyncStatus = "Migration skipped. Syncing empty account...";
await TriggerSync();
}
// Account registration
[RelayCommand]
private async Task TriggerRegister(string password)
{
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(password)) return;
SyncStatus = "Registering on server...";
var (success, error) = await _syncService.RegisterAsync(Username, password, ServerUrl);
if (success)
{
SyncStatus = "Registered! You can now log in.";
// Also pre-create their UserDirectoryEntry so they have a GUID pre-generated!
using (var globalDb = new ClientDbContext())
{
globalDb.Database.EnsureCreated();
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
if (entry == null)
{
var userGuid = Guid.NewGuid().ToString();
entry = new UserDirectoryEntry
{
Username = Username,
UserGuid = userGuid,
RemoteServerUrl = ServerUrl
};
globalDb.UserDirectory.Add(entry);
globalDb.SaveChanges();
}
}
ScanLocalAccounts();
}
else
{
SyncStatus = $"Registration failed: {error}";
}
}
// Login and online activation
[RelayCommand]
private async Task TriggerLogin()
{
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(LoginPassword)) return;
SyncStatus = "Logging in to server...";
var (success, error, config) = await _syncService.LoginAsync(Username, LoginPassword, ServerUrl);
if (success && config != null)
{
_config = config;
// Switch database to the secure account database!
string userGuid;
using (var globalDb = new ClientDbContext())
{
globalDb.Database.EnsureCreated();
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
if (entry == null)
{
userGuid = Guid.NewGuid().ToString();
entry = new UserDirectoryEntry
{
Username = Username,
UserGuid = userGuid,
RemoteServerUrl = ServerUrl
};
globalDb.UserDirectory.Add(entry);
globalDb.SaveChanges();
}
else
{
userGuid = entry.UserGuid;
}
}
// Derive key from password for SQLCipher
var encryptionKey = _cryptoService.DeriveKeyFromPassword(LoginPassword, Username);
// Configure ClientDbContext to open the encrypted database
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
ClientDbContext.ActiveDbPassword = encryptionKey;
// Check if this database file is newly created / has 0 nodes
bool databaseExistsAndEmpty = false;
using (var userDb = new ClientDbContext())
{
userDb.Database.EnsureCreated();
databaseExistsAndEmpty = !userDb.Nodes.Any();
}
// Check if the global local database has nodes to migrate
bool hasLocalNotes = false;
using (var globalDb = new ClientDbContext())
{
// Temporarily switch back to read from global DB
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
hasLocalNotes = globalDb.Nodes.Any(n => !n.IsDeleted);
}
// Restore active DB configuration to user secure database
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
ClientDbContext.ActiveDbPassword = encryptionKey;
IsLoggedIn = true;
IsOnline = true;
SyncStatus = "Logged in successfully.";
// Save config to user secure database so it works online
using (var userDb = new ClientDbContext())
{
userDb.Database.EnsureCreated();
userDb.Configs.Add(_config);
userDb.SaveChanges();
}
// First-time login migration check
if (databaseExistsAndEmpty && hasLocalNotes)
{
IsMigrateDialogVisible = true;
}
else
{
// Run Sync directly
await TriggerSync();
}
ScanLocalAccounts();
}
else
{
SyncStatus = $"Login failed: {error}";
}
}
[RelayCommand]
private async Task TriggerSync()
{
if (_config == null)
{
SyncStatus = "Please log in to sync.";
return;
}
SyncStatus = "Synchronizing notes...";
var (success, error) = await _syncService.SyncNodesAsync(_config);
if (success)
{
SyncStatus = $"Synchronized at {DateTime.Now:HH:mm:ss}";
RefreshNotebookTree();
}
else
{
SyncStatus = $"Sync failed: {error}";
}
}
[RelayCommand]
private void DisconnectOnline()
{
IsLoggedIn = false;
IsOnline = false;
LoginPassword = "";
_config = null;
// Switch back to unencrypted default local DB snote_client.db
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
SyncStatus = "Switched to local offline mode.";
RefreshNotebookTree();
}
2026-05-28 02:54:17 +10:00
}