Initial commit of version 0.2.0

This commit is contained in:
Elijah 2026-07-19 18:37:14 -07:00
parent 86fc06b877
commit dfb04462f3
108 changed files with 16167 additions and 80 deletions

View file

@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using Decky.Core.Api;
using Decky.Core.Api.Models;
using Decky.Core.Api.Requests;
@ -26,23 +27,43 @@ try
var boards = await client.GetBoardsAsync();
Console.WriteLine($"Connected through {client.ApiBaseUri}");
Console.WriteLine($"Found {boards.Count} accessible board(s).\n");
Console.WriteLine($"Found {boards.Count} board entry/entries.\n");
var readableBoards = new List<BoardDto>();
foreach (var board in boards.OrderBy(board => board.Title, StringComparer.CurrentCultureIgnoreCase))
{
var stacks = await client.GetStacksAsync(board.Id);
var cardCount = stacks.Sum(stack => stack.Cards.Count);
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)");
if (board.Archived || board.DeletedAt != 0)
{
Console.WriteLine($"[{board.Id}] {board.Title}: skipped ({(board.Archived ? "archived" : "deleted")})");
continue;
}
try
{
var stacks = await client.GetStacksAsync(board.Id);
var cardCount = stacks.Sum(stack => stack.Cards.Count);
readableBoards.Add(board);
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)");
}
catch (DeckApiException exception) when (exception.StatusCode == HttpStatusCode.Forbidden)
{
Console.WriteLine($"[{board.Id}] {board.Title}: unavailable (403 Permission denied)");
}
}
Console.WriteLine("\nPASS Authentication, board retrieval, stack retrieval, and card-list retrieval");
if (readableBoards.Count == 0)
{
throw new InvalidOperationException("Nextcloud did not return any boards whose stacks can be read.");
}
Console.WriteLine($"\nPASS Authentication and read access to {readableBoards.Count} board(s)");
Console.WriteLine("\nOptional write tests create and delete one temporary card, but can reorder that card");
Console.WriteLine("inside the selected board. Use a disposable board with at least two stacks.");
Console.Write("Type WRITE to run mutation compatibility checks, or press Enter to finish: ");
if (string.Equals(Console.ReadLine(), "WRITE", StringComparison.Ordinal))
{
var writeTestsPassed = await RunWriteProbeAsync(client, boards);
var writeTestsPassed = await RunWriteProbeAsync(client, readableBoards);
Environment.ExitCode = writeTestsPassed ? 0 : 2;
}
else
@ -50,7 +71,8 @@ try
Console.WriteLine("\nRead-only API compatibility checks passed.");
}
}
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException)
catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or ArgumentException or JsonException or InvalidOperationException)
{
Console.Error.WriteLine($"\nCompatibility check failed: {exception.Message}");
Environment.ExitCode = 1;
@ -161,7 +183,7 @@ static async Task<bool> RunWriteProbeAsync(DeckApiClient client, IReadOnlyList<B
return true;
}
catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException)
exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException or JsonException)
{
Console.Error.WriteLine($"FAIL Mutation compatibility check: {exception.Message}");
return false;

View file

@ -1,4 +1,5 @@
using Microsoft.UI.Xaml;
using Decky.App.Services;
namespace Decky.App;
@ -8,13 +9,35 @@ public partial class App : Application
public App()
{
UnhandledException += (_, args) => WriteStartupError(args.Exception);
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_window = new MainWindow();
_window.Activate();
try
{
_window = new MainWindow();
_window.Activate();
}
catch (Exception exception)
{
WriteStartupError(exception);
throw;
}
}
private static void WriteStartupError(Exception exception)
{
try
{
var logPath = Path.Combine(Path.GetDirectoryName(AppDataPaths.SettingsFile)!, "startup-error.log");
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
File.WriteAllText(logPath, $"{DateTimeOffset.Now:O}{Environment.NewLine}{exception}");
}
catch
{
// Startup logging must never replace the original exception.
}
}
}

View file

@ -11,6 +11,8 @@
<UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<SelfContained>true</SelfContained>
<EnableMsixTooling>true</EnableMsixTooling>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />

View file

@ -82,7 +82,18 @@
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
VerticalAlignment="Top">
<StackPanel Spacing="10">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1" Click="NewCardButton_OnClick"
ToolTipService.ToolTip="Create card">
<FontIcon Glyph="&#xE710;" FontSize="14" />
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Cards}">
<ItemsControl.ItemTemplate>
<DataTemplate>
@ -93,6 +104,16 @@
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
<TextBlock Text="{Binding DueDate}" FontSize="11" Opacity="0.75"
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="4">
<Button Click="EditCardButton_OnClick"
ToolTipService.ToolTip="Edit card">
<FontIcon Glyph="&#xE70F;" FontSize="12" />
</Button>
<Button Click="DeleteCardButton_OnClick"
ToolTipService.ToolTip="Delete card">
<FontIcon Glyph="&#xE74D;" FontSize="12" />
</Button>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>

View file

@ -1,6 +1,8 @@
using System.ComponentModel;
using Decky.App.Services;
using Decky.App.ViewModels;
using Decky.App.Views;
using Decky.Core.Api.Models;
using Decky.Core.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@ -32,6 +34,65 @@ public sealed partial class MainWindow : Window
_viewModel.AppPassword = ((PasswordBox)sender).Password;
}
private async void NewCardButton_OnClick(object sender, RoutedEventArgs e)
{
if (((Button)sender).DataContext is not StackDto stack)
{
return;
}
var editor = new CardEditorDialog();
var result = await editor.ShowForResultAsync(RootGrid.XamlRoot);
if (result is not null)
{
await _viewModel.CreateCardAsync(stack.Id, result);
}
}
private async void EditCardButton_OnClick(object sender, RoutedEventArgs e)
{
if (((Button)sender).DataContext is not CardDto card)
{
return;
}
var fullCard = await _viewModel.GetCardDetailsAsync(card);
if (fullCard is null)
{
return;
}
var editor = new CardEditorDialog(fullCard);
var result = await editor.ShowForResultAsync(RootGrid.XamlRoot);
if (result is not null)
{
await _viewModel.UpdateCardAsync(fullCard, result);
}
}
private async void DeleteCardButton_OnClick(object sender, RoutedEventArgs e)
{
if (((Button)sender).DataContext is not CardDto card)
{
return;
}
var confirmation = new ContentDialog
{
XamlRoot = RootGrid.XamlRoot,
Title = "Delete card?",
Content = $"“{card.Title}” will be deleted from Nextcloud.",
PrimaryButtonText = "Delete",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Close,
};
if (await confirmation.ShowAsync() == ContentDialogResult.Primary)
{
await _viewModel.DeleteCardAsync(card);
}
}
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.AppPassword) &&

View file

@ -0,0 +1,9 @@
namespace Decky.App.ViewModels;
public sealed record CardEditorResult(
string Title,
string Description,
DateTimeOffset? DueDate,
string Color,
bool IsCompleted);

View file

@ -1,9 +1,12 @@
using System.Collections.ObjectModel;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Decky.Core.Api;
using Decky.Core.Api.Models;
using Decky.Core.Api.Requests;
using Decky.Core.Configuration;
using Decky.Core.Models;
using Decky.Core.Services;
@ -15,6 +18,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly ISettingsService _settingsService;
private readonly ICredentialService _credentialService;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _mutationLocks = new();
private DeckApiClient? _apiClient;
private CancellationTokenSource? _loadCancellation;
private AppSettings _settings = new();
@ -160,7 +164,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
var boards = await _apiClient.GetBoardsAsync();
Boards.Clear();
foreach (var board in boards.Where(board => !board.Archived).OrderBy(board => board.Title))
foreach (var board in boards
.Where(board => !board.Archived && board.DeletedAt == 0)
.OrderBy(board => board.Title))
{
Boards.Add(board);
}
@ -184,7 +190,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
await SaveSelectedBoardAsync(boardToOpen.Id);
}
}
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException)
catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or ArgumentException or JsonException)
{
IsConnected = false;
StatusMessage = exception.Message;
@ -261,6 +268,126 @@ public partial class MainViewModel : ObservableObject, IDisposable
private bool CanRefresh() => !IsBusy && _apiClient is not null && SelectedBoard is not null;
public async Task<CardDto?> GetCardDetailsAsync(CardDto card)
{
if (_apiClient is null || SelectedBoard is null)
{
return null;
}
IsBusy = true;
StatusMessage = $"Loading {card.Title}…";
try
{
return await _apiClient.GetCardAsync(SelectedBoard.Id, card.StackId, card.Id);
}
catch (Exception exception) when (IsApiException(exception))
{
StatusMessage = $"Card details could not be loaded: {exception.Message}";
return null;
}
finally
{
IsBusy = false;
}
}
public Task<bool> CreateCardAsync(int stackId, CardEditorResult editor) =>
RunBoardMutationAsync(
async (client, boardId) =>
{
var stack = Stacks.FirstOrDefault(item => item.Id == stackId)
?? throw new InvalidOperationException("The destination stack is no longer loaded.");
var nextOrder = stack.Cards.Count == 0 ? 0 : stack.Cards.Max(card => card.Order) + 1;
var created = await client.CreateCardAsync(
boardId,
stackId,
new CreateCardRequest
{
Title = editor.Title,
Description = editor.Description,
DueDate = editor.DueDate,
Color = editor.Color,
Order = nextOrder,
});
if (editor.IsCompleted)
{
var fullCard = await client.GetCardAsync(boardId, stackId, created.Id);
await client.UpdateCardAsync(
boardId,
stackId,
created.Id,
CardUpdateRequest.FromCard(
fullCard,
done: DateTimeOffset.UtcNow,
replaceDone: true));
}
},
"Card created");
public Task<bool> UpdateCardAsync(CardDto card, CardEditorResult editor) =>
RunBoardMutationAsync(
async (client, boardId) =>
{
DateTimeOffset? done = editor.IsCompleted ? card.Done ?? DateTimeOffset.UtcNow : null;
var request = CardUpdateRequest.FromCard(
card,
title: editor.Title,
description: editor.Description,
replaceDescription: true,
dueDate: editor.DueDate,
replaceDueDate: true,
done: done,
replaceDone: true,
color: editor.Color);
await client.UpdateCardAsync(boardId, card.StackId, card.Id, request);
},
"Card updated");
public Task<bool> DeleteCardAsync(CardDto card) =>
RunBoardMutationAsync(
(client, boardId) => client.DeleteCardAsync(boardId, card.StackId, card.Id),
"Card deleted");
private async Task<bool> RunBoardMutationAsync(
Func<DeckApiClient, int, Task> mutation,
string successMessage)
{
var client = _apiClient;
var board = SelectedBoard;
if (client is null || board is null)
{
StatusMessage = "Connect and select a board before editing cards.";
return false;
}
var mutationLock = _mutationLocks.GetOrAdd(board.Id, _ => new SemaphoreSlim(1, 1));
await mutationLock.WaitAsync();
IsBusy = true;
StatusMessage = "Saving card…";
try
{
await mutation(client, board.Id);
await LoadBoardAsync(board);
StatusMessage = successMessage;
return true;
}
catch (Exception exception) when (IsApiException(exception) || exception is InvalidOperationException)
{
StatusMessage = $"The card change failed: {exception.Message}";
return false;
}
finally
{
IsBusy = false;
mutationLock.Release();
}
}
private static bool IsApiException(Exception exception) =>
exception is DeckApiException or HttpRequestException or JsonException or ArgumentException;
private async Task LoadBoardAsync(BoardDto board)
{
if (_apiClient is null)
@ -296,7 +423,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception) when (exception is DeckApiException or HttpRequestException)
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or JsonException)
{
StatusMessage = $"Offline · {exception.Message}";
}
@ -319,6 +446,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
disposableSettingsService.Dispose();
}
foreach (var mutationLock in _mutationLocks.Values)
{
mutationLock.Dispose();
}
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,179 @@
using Decky.App.ViewModels;
using Decky.Core.Api.Models;
using Decky.Core.Utilities;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Decky.App.Views;
public sealed class CardEditorDialog : ContentDialog
{
private readonly TextBox _titleBox;
private readonly TextBox _descriptionBox;
private readonly CalendarDatePicker _dueDatePicker;
private readonly ColorPicker _colorPicker;
private readonly TextBox _hexColorBox;
private readonly CheckBox _completedBox;
private readonly TextBlock _errorText;
private bool _synchronizingColor;
public CardEditorDialog(CardDto? card = null)
{
Title = card is null ? "Create card" : "Edit card";
PrimaryButtonText = card is null ? "Create" : "Save";
CloseButtonText = "Cancel";
DefaultButton = ContentDialogButton.Primary;
_titleBox = new TextBox
{
Header = "Title",
Text = card?.Title ?? string.Empty,
MaxLength = 255,
};
_descriptionBox = new TextBox
{
Header = "Description",
Text = card?.Description ?? string.Empty,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
Height = 110,
};
_dueDatePicker = new CalendarDatePicker
{
Header = "Due date",
Date = card?.DueDate,
IsGroupLabelVisible = true,
};
var initialColor = DeckColor.Normalize(card?.Color) ?? "E5E7EB";
_colorPicker = new ColorPicker
{
IsAlphaEnabled = false,
IsAlphaSliderVisible = false,
IsAlphaTextInputVisible = false,
Color = ParseColor(initialColor),
};
_hexColorBox = new TextBox
{
Header = "Hex color",
Text = $"#{initialColor}",
MaxLength = 7,
};
_completedBox = new CheckBox
{
Content = "Completed",
IsChecked = card?.Done is not null,
};
_errorText = new TextBlock
{
Foreground = new SolidColorBrush(Colors.IndianRed),
TextWrapping = TextWrapping.Wrap,
};
_colorPicker.ColorChanged += (_, args) =>
{
if (_synchronizingColor)
{
return;
}
_synchronizingColor = true;
_hexColorBox.Text = $"#{args.NewColor.R:X2}{args.NewColor.G:X2}{args.NewColor.B:X2}";
_synchronizingColor = false;
};
_hexColorBox.TextChanged += (_, _) =>
{
if (_synchronizingColor)
{
return;
}
try
{
var normalized = DeckColor.Normalize(_hexColorBox.Text);
if (normalized is null)
{
return;
}
_synchronizingColor = true;
_colorPicker.Color = ParseColor(normalized);
_synchronizingColor = false;
_errorText.Text = string.Empty;
}
catch (ArgumentException)
{
// Validation is shown when Save is pressed so typing is not interrupted.
}
};
Content = new ScrollViewer
{
MaxHeight = 620,
Content = new StackPanel
{
Spacing = 12,
MinWidth = 390,
Children =
{
_titleBox,
_descriptionBox,
_dueDatePicker,
new TextBlock { Text = "Color" },
_colorPicker,
_hexColorBox,
_completedBox,
_errorText,
},
},
};
PrimaryButtonClick += ValidateAndCaptureResult;
}
public CardEditorResult? Result { get; private set; }
public async Task<CardEditorResult?> ShowForResultAsync(XamlRoot xamlRoot)
{
XamlRoot = xamlRoot;
var dialogResult = await ShowAsync();
return dialogResult == ContentDialogResult.Primary ? Result : null;
}
private void ValidateAndCaptureResult(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
var title = _titleBox.Text.Trim();
if (string.IsNullOrWhiteSpace(title))
{
args.Cancel = true;
_errorText.Text = "A card title is required.";
return;
}
try
{
var color = DeckColor.Normalize(_hexColorBox.Text)!;
Result = new CardEditorResult(
title,
_descriptionBox.Text,
_dueDatePicker.Date,
color,
_completedBox.IsChecked == true);
_errorText.Text = string.Empty;
}
catch (ArgumentException exception)
{
args.Cancel = true;
_errorText.Text = exception.Message;
}
}
private static Color ParseColor(string color) => Color.FromArgb(
255,
Convert.ToByte(color[..2], 16),
Convert.ToByte(color[2..4], 16),
Convert.ToByte(color[4..], 16));
}

View file

@ -118,17 +118,51 @@ public sealed class DeckApiClient
request,
cancellationToken);
public Task<CardDto> ReorderCardAsync(
public async Task<CardDto> ReorderCardAsync(
int boardId,
int sourceStackId,
int cardId,
ReorderCardRequest request,
CancellationToken cancellationToken = default) =>
SendJsonAsync<CardDto>(
HttpMethod.Put,
$"boards/{boardId}/stacks/{sourceStackId}/cards/{cardId}/reorder",
request,
cancellationToken);
CancellationToken cancellationToken = default)
{
await SendReorderAsync(sourceStackId).ConfigureAwait(false);
var card = await ResolveReorderedCardAsync().ConfigureAwait(false);
if (request.StackId != sourceStackId && card.StackId == sourceStackId)
{
// Deck 1.16.x binds the route stackId over the JSON body stackId. In
// that version the route value is passed straight to CardService as
// the destination, so retrying with the requested destination in the
// route safely works around the server bug.
await SendReorderAsync(request.StackId).ConfigureAwait(false);
card = await ResolveReorderedCardAsync().ConfigureAwait(false);
}
return card;
async Task SendReorderAsync(int routeStackId)
{
using var response = await SendAsync(
HttpMethod.Put,
$"boards/{boardId}/stacks/{routeStackId}/cards/{cardId}/reorder",
request,
cancellationToken).ConfigureAwait(false);
}
async Task<CardDto> ResolveReorderedCardAsync()
{
try
{
return await GetCardAsync(boardId, request.StackId, cardId, cancellationToken).ConfigureAwait(false);
}
catch (DeckApiException exception) when (
exception.StatusCode == System.Net.HttpStatusCode.NotFound &&
request.StackId != sourceStackId)
{
return await GetCardAsync(boardId, sourceStackId, cardId, cancellationToken).ConfigureAwait(false);
}
}
}
public async Task DeleteCardAsync(
int boardId,

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Decky.Core.Api.Serialization;
namespace Decky.Core.Api.Models;
@ -44,6 +45,8 @@ public sealed record BoardDto
public long LastModified { get; init; }
public long DeletedAt { get; init; }
public BoardPermissionsDto? Permissions { get; init; }
[JsonExtensionData]
@ -62,6 +65,8 @@ public sealed record StackDto
public long LastModified { get; init; }
public long DeletedAt { get; init; }
public IReadOnlyList<CardDto> Cards { get; init; } = [];
[JsonExtensionData]
@ -80,6 +85,7 @@ public sealed record CardDto
public string Type { get; init; } = "plain";
[JsonConverter(typeof(CardOwnerJsonConverter))]
public string Owner { get; init; } = string.Empty;
public int Order { get; init; }
@ -101,4 +107,3 @@ public sealed record CardDto
[JsonExtensionData]
public Dictionary<string, JsonElement>? AdditionalData { get; init; }
}

View file

@ -48,6 +48,7 @@ public sealed record CardUpdateRequest
CardDto card,
string? title = null,
string? description = null,
bool replaceDescription = false,
DateTimeOffset? dueDate = null,
bool replaceDueDate = false,
DateTimeOffset? done = null,
@ -61,7 +62,7 @@ public sealed record CardUpdateRequest
Title = title ?? card.Title,
Type = card.Type,
Owner = card.Owner,
Description = description ?? card.Description,
Description = replaceDescription ? description : description ?? card.Description,
Order = card.Order,
DueDate = replaceDueDate ? dueDate : card.DueDate,
StartDate = card.StartDate,
@ -78,4 +79,3 @@ public sealed record ReorderCardRequest
public required int StackId { get; init; }
}

View file

@ -0,0 +1,55 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Decky.Core.Api.Serialization;
public sealed class CardOwnerJsonConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
return reader.GetString() ?? string.Empty;
}
if (reader.TokenType == JsonTokenType.Null)
{
return string.Empty;
}
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException($"A card owner must be a string or user object, not {reader.TokenType}.");
}
using var document = JsonDocument.ParseValue(ref reader);
var owner = document.RootElement;
if (TryGetString(owner, "uid", out var userId) ||
TryGetString(owner, "primaryKey", out userId))
{
return userId;
}
throw new JsonException("A card owner object did not contain a string 'uid' or 'primaryKey'.");
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
private static bool TryGetString(JsonElement element, string propertyName, out string value)
{
if (element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(property.GetString()))
{
value = property.GetString()!;
return true;
}
value = string.Empty;
return false;
}
}