diff --git a/InterlinedList/Models/ListFolder.cs b/InterlinedList/Models/ListFolder.cs new file mode 100644 index 0000000..0892ead --- /dev/null +++ b/InterlinedList/Models/ListFolder.cs @@ -0,0 +1,28 @@ +namespace InterlinedList.Models; + +/// +/// A list folder, from GET /api/folders. +/// +/// +/// +/// A separate resource from document folders (/api/documents/folders) +/// despite the similar shape — different endpoints, different ids, no overlap. +/// +/// +/// Live shape verified 2026-09-16 by creating a parent and a child on a +/// throwaway (both since deleted): the payload is exactly +/// {id, name, parentId}, flat, with nesting expressed only through +/// parentId. There is no createdAt/updatedAt/userId +/// and no server-side nesting. +/// +/// +public sealed class ListFolder +{ + public required string Id { get; init; } + public required string Name { get; init; } + + /// Parent folder, or null at root. The only nesting signal. + public string? ParentId { get; init; } + + public bool IsRoot => ParentId is null or ""; +} diff --git a/InterlinedList/Services/InterlinedApiClient.ListFolders.cs b/InterlinedList/Services/InterlinedApiClient.ListFolders.cs new file mode 100644 index 0000000..8892cca --- /dev/null +++ b/InterlinedList/Services/InterlinedApiClient.ListFolders.cs @@ -0,0 +1,141 @@ +using System.Net.Http; +using System.Text.Json; +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// List folders — /api/folders. +/// +/// +/// +/// Distinct from document folders (/api/documents/folders), which the app +/// already implements: different endpoints, different ids, no overlap. This +/// domain was entirely missing. +/// +/// +/// All four operations verified live 2026-09-16 against throwaway folders +/// (since deleted, account confirmed restored). Envelopes: +/// +/// +/// GET /api/folders -> 200 {"folders":[{id,name,parentId}, …]} // flat +/// POST /api/folders -> 201 {"message":"Folder created successfully","folder":{id,name,parentId}} +/// PUT /api/folders/{id} -> 200 {"message":"Folder updated successfully","folder":{…}} +/// DELETE /api/folders/{id} -> 200 +/// +/// +/// ⚠️ The server does NOT guard against cycles. Moving a folder into its +/// own descendant returns 200 and creates a loop — verified by moving a +/// parent into its own child. A tree renderer walking parentId would +/// then recurse forever. is therefore not +/// defensive politeness; it is the only thing preventing it. +/// +/// +public sealed partial class InterlinedApiClient +{ + public async Task> GetListFoldersAsync(CancellationToken ct = default) + { + var json = await GetElementAsync("api/folders", ct); + return json.TryGetProperty("folders", out var arr) && arr.ValueKind == JsonValueKind.Array + ? arr.Deserialize>(JsonOptions) ?? [] + : []; + } + + /// Create a folder. Subscribers only — a free account gets 402/403. + public async Task CreateListFolderAsync( + string name, string? parentId = null, CancellationToken ct = default) + { + var body = new Dictionary { ["name"] = name }; + // Only send parentId when there is one. An explicit null is a 500 on + // some writers on this API (see #171), so don't risk it. + if (parentId is { Length: > 0 }) body["parentId"] = parentId; + + var json = await SendJsonAsync(HttpMethod.Post, "api/folders", body, ct); + return ReadFolder(json); + } + + /// + /// Rename and/or move a folder. Pass only what is changing. + /// + /// + /// New parent. Pass to move to the top level — + /// an explicit JSON null is what the server wants there, and it was + /// verified to work. + /// + public async Task UpdateListFolderAsync( + string id, string? name = null, string? parentId = null, + bool moveToRoot = false, CancellationToken ct = default) + { + var body = new Dictionary(); + if (name is { Length: > 0 }) body["name"] = name; + if (moveToRoot) body["parentId"] = null; + else if (parentId is { Length: > 0 }) body["parentId"] = parentId; + + var json = await SendJsonAsync(HttpMethod.Put, $"api/folders/{id}", body, ct); + return ReadFolder(json); + } + + /// Sentinel for UpdateListFolderAsync(moveToRoot: true) readability. + public const bool MoveToRoot = true; + + /// + /// Soft-delete a folder and its subfolders. Confirm before calling — + /// the cascade is not obvious from the name. + /// + public Task DeleteListFolderAsync(string id, CancellationToken ct = default) + => SendVoidAsync(HttpMethod.Delete, $"api/folders/{id}", null, ct); + + /// + /// Assign a list to a folder, or clear it with + /// null. + /// + /// + /// folderId is not among the eleven fields + /// POST /api/lists accepts, so a new list cannot be created straight + /// into a folder — it has to be moved afterward. Verified live: + /// PUT /api/lists/{id} with {"folderId": …} returns + /// 200 and the list's folderId reflects it, and + /// {"folderId": null} clears it. See #179. + /// + public Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default) + => SendVoidAsync(HttpMethod.Put, $"api/lists/{listId}", + new Dictionary { ["folderId"] = folderId }, ct); + + private static ListFolder? ReadFolder(JsonElement json) + { + if (json.ValueKind != JsonValueKind.Object) return null; + // Create/update wrap the object under "folder"; tolerate a bare object. + var node = json.TryGetProperty("folder", out var wrapped) ? wrapped : json; + return node.ValueKind == JsonValueKind.Object + ? node.Deserialize(JsonOptions) + : null; + } + + /// + /// True when moving under + /// would create a loop. + /// + /// + /// The server permits this — verified live, moving a parent into its + /// own child returned 200 and produced a genuine cycle. Nothing + /// server-side prevents it, so every move must be checked here first or a + /// tree walk will hang. + /// + public static bool WouldCreateCycle( + IReadOnlyCollection folders, string folderId, string? newParentId) + { + if (newParentId is null or "") return false; // moving to root is always safe + if (newParentId == folderId) return true; // its own parent + + var byId = folders.ToDictionary(f => f.Id, StringComparer.Ordinal); + var cursor = newParentId; + // Bounded by the folder count, so a pre-existing cycle in the data + // can't hang this check either. + for (var hops = 0; hops <= folders.Count && cursor is { Length: > 0 }; hops++) + { + if (cursor == folderId) return true; + cursor = byId.TryGetValue(cursor, out var parent) ? parent.ParentId : null; + } + return false; + } +} diff --git a/InterlinedList/ViewModels/ListFolderPanelViewModel.cs b/InterlinedList/ViewModels/ListFolderPanelViewModel.cs new file mode 100644 index 0000000..d3c90e0 --- /dev/null +++ b/InterlinedList/ViewModels/ListFolderPanelViewModel.cs @@ -0,0 +1,255 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// The list-folder tree: browse, create, rename, move, delete. +/// +/// +/// The API returns a flat array and expresses nesting only through +/// parentId, so the tree is built here. Critically, the server does +/// not reject a cycle — moving a folder into its own descendant returns +/// 200 — so every move is checked with +/// first, and the tree build +/// is itself cycle-tolerant in case the data already contains one. +/// +public partial class ListFolderPanelViewModel : ObservableObject +{ + private readonly SessionService _session; + private List _flat = []; + + public ObservableCollection Roots { get; } = new(); + + /// Every folder, flat — for a "move to…" picker. + public ObservableCollection AllFolders { get; } = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CreateFolderCommand))] + private string newFolderName = ""; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CreateFolderCommand))] + private bool isBusy; + + [ObservableProperty] + private string? errorMessage; + + [ObservableProperty] + private string? statusMessage; + + /// Set when the data itself contains a loop, so the UI can say so. + [ObservableProperty] + private bool hasOrphanedFolders; + + public ListFolderPanelViewModel(SessionService session) => _session = session; + + [RelayCommand] + private async Task LoadAsync() + { + IsBusy = true; + ErrorMessage = null; + try + { + _flat = await _session.Api.GetListFoldersAsync(); + Rebuild(); + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsBusy = false; + } + } + + private void Rebuild() + { + Roots.Clear(); + AllFolders.Clear(); + foreach (var f in _flat.OrderBy(f => f.Name, StringComparer.CurrentCultureIgnoreCase)) + AllFolders.Add(f); + + var byParent = _flat + .GroupBy(f => f.ParentId ?? "") + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + // Track what we've placed. Because the server allows cycles, a naive + // recursive build can loop forever — so nodes are only ever visited + // once and anything left over is reported rather than rendered wrongly. + var placed = new HashSet(StringComparer.Ordinal); + + foreach (var root in Children(byParent, "", placed)) + Roots.Add(root); + + HasOrphanedFolders = placed.Count < _flat.Count; + } + + private List Children( + Dictionary> byParent, string parentKey, HashSet placed) + { + if (!byParent.TryGetValue(parentKey, out var kids)) return []; + + var result = new List(); + foreach (var kid in kids.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)) + { + // Cycle guard: never place the same folder twice. + if (!placed.Add(kid.Id)) continue; + result.Add(new ListFolderNodeViewModel(kid, Children(byParent, kid.Id, placed), this)); + } + return result; + } + + private bool CanCreate() => !IsBusy && !string.IsNullOrWhiteSpace(NewFolderName); + + [RelayCommand(CanExecute = nameof(CanCreate))] + private async Task CreateFolderAsync() + { + IsBusy = true; + ErrorMessage = null; + StatusMessage = null; + try + { + await _session.Api.CreateListFolderAsync(NewFolderName.Trim()); + NewFolderName = ""; + await LoadAsync(); + } + catch (InterlinedApiException ex) + { + // Creating a folder is subscriber-gated; say that rather than + // showing a bare status code. + ErrorMessage = ex.StatusCode is 402 or 403 + ? "Creating list folders is a Subscriber feature." + : ex.Message; + } + finally + { + IsBusy = false; + } + } + + internal async Task RenameAsync(ListFolderNodeViewModel node, string newName) + { + if (string.IsNullOrWhiteSpace(newName)) return; + try + { + await _session.Api.UpdateListFolderAsync(node.Id, name: newName.Trim()); + await LoadAsync(); + } + catch (InterlinedApiException ex) { ErrorMessage = ex.Message; } + } + + internal async Task MoveAsync(ListFolderNodeViewModel node, string? newParentId) + { + // THE check. The server returns 200 for a cycle, so this is the only + // thing standing between a move and an unrenderable tree. + if (InterlinedApiClient.WouldCreateCycle(_flat, node.Id, newParentId)) + { + ErrorMessage = $"Can't move “{node.Name}” inside itself or one of its own subfolders."; + return; + } + + try + { + if (newParentId is null or "") + await _session.Api.UpdateListFolderAsync(node.Id, moveToRoot: true); + else + await _session.Api.UpdateListFolderAsync(node.Id, parentId: newParentId); + await LoadAsync(); + } + catch (InterlinedApiException ex) { ErrorMessage = ex.Message; } + } + + internal async Task DeleteAsync(ListFolderNodeViewModel node) + { + try + { + await _session.Api.DeleteListFolderAsync(node.Id); + StatusMessage = $"Deleted “{node.Name}” and any subfolders."; + await LoadAsync(); + } + catch (InterlinedApiException ex) { ErrorMessage = ex.Message; } + } +} + +/// One folder in the tree, with its children. +public partial class ListFolderNodeViewModel : ObservableObject +{ + private readonly ListFolderPanelViewModel _owner; + + public string Id { get; } + public string Name { get; } + public string? ParentId { get; } + public ObservableCollection Children { get; } + public bool HasChildren => Children.Count > 0; + + [ObservableProperty] + private bool isExpanded = true; + + [ObservableProperty] + private bool isRenaming; + + [ObservableProperty] + private string renameText = ""; + + /// Armed by a first press — delete cascades to subfolders. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DeletePrompt))] + private bool isDeleteArmed; + + public string DeletePrompt => IsDeleteArmed + ? (HasChildren ? "Delete this and its subfolders?" : "Delete?") + : "Delete"; + + public ListFolderNodeViewModel( + ListFolder folder, List children, ListFolderPanelViewModel owner) + { + Id = folder.Id; + Name = folder.Name; + ParentId = folder.ParentId; + Children = new ObservableCollection(children); + _owner = owner; + renameText = folder.Name; + } + + [RelayCommand] + private void BeginRename() + { + RenameText = Name; + IsRenaming = true; + } + + [RelayCommand] + private void CancelRename() => IsRenaming = false; + + [RelayCommand] + private async Task CommitRename() + { + IsRenaming = false; + await _owner.RenameAsync(this, RenameText); + } + + [RelayCommand] + private async Task Delete() + { + // Two-step: the cascade to subfolders isn't obvious, and a native + // confirm dialog would block the dispatcher. + if (!IsDeleteArmed) + { + IsDeleteArmed = true; + return; + } + IsDeleteArmed = false; + await _owner.DeleteAsync(this); + } + + [RelayCommand] + private Task MoveToRoot() => _owner.MoveAsync(this, null); + + [RelayCommand] + private Task MoveTo(ListFolder target) => _owner.MoveAsync(this, target?.Id); +} diff --git a/InterlinedList/Views/ListFolderPanel.xaml b/InterlinedList/Views/ListFolderPanel.xaml new file mode 100644 index 0000000..7893ac8 --- /dev/null +++ b/InterlinedList/Views/ListFolderPanel.xaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +