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
|
|
|
}
|