From 2765d3eef88d9727f65f67376f4fab11efed83aa Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 01:17:26 +0100 Subject: [PATCH 1/5] Fix URL-safe encoding of uploaded filenames Replace the fragment marker used for unsupported filename characters with an underscore and cover upload metadata and static-file downloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FileSystem/AbstractFileSystemHelpers.cs | 3 +- .../Yavsc.Blogs.Tests/FileSystemApiTests.cs | 17 +++-- .../Yavsc.Blogs.Tests/FilterFileNameTests.cs | 76 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs diff --git a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs index 7665df8ab..46eedb571 100644 --- a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs +++ b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs @@ -49,6 +49,7 @@ namespace Yavsc.Server.Helpers // Ensure this path is canonical, // No "dirto/./this", neither "dirt/to/that/" // no .. and each char must be listed as valid in constants + // Encode unsupported chars with '_' rather than the URL fragment marker '#'. public static string FilterFileName(string fileName) { if (fileName==null) return null; @@ -57,7 +58,7 @@ namespace Yavsc.Server.Helpers { if (ValidFileNameChars.Contains(c)) sb.Append(c); - else sb.Append("#" + ((int)c).ToString("D3")); + else sb.Append("_" + ((int)c).ToString("D3")); } return sb.ToString(); } diff --git a/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs b/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs index 0e36a6018..569e4af3d 100644 --- a/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs +++ b/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs @@ -91,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture return file.Id; } - [Fact] - public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller( + string originalName, string storedName) { _fixture.ResetDatabase(); // DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense @@ -105,7 +109,7 @@ public sealed class FileSystemApiTests : IClassFixture using var form = new MultipartFormDataContent(); var fileContent = new ByteArrayContent(payload); fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - form.Add(fileContent, "file", "note.txt"); + form.Add(fileContent, "file", originalName); using var http = NewClient("alice"); var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken); @@ -117,18 +121,17 @@ public sealed class FileSystemApiTests : IClassFixture var item = Assert.Single(doc.RootElement.EnumerateArray()); var fileId = item.GetProperty("fileId").GetInt64(); Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}"); - Assert.Equal("note.txt", item.GetProperty("fileName").GetString()); + Assert.Equal(storedName, item.GetProperty("fileName").GetString()); Assert.False(item.GetProperty("quotaOffense").GetBoolean()); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice")); - Assert.Equal("note.txt", row.Path); + Assert.Equal(storedName, row.Path); Assert.Equal("text/plain", row.ContentType); Assert.Equal(payload.Length, row.Length); - // The file physically landed under {BlogFilesRoot}/alice/note.txt. - Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")), + Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)), "Expected the uploaded file on disk under the owner's personal root"); } diff --git a/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs b/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs new file mode 100644 index 000000000..d445c29e4 --- /dev/null +++ b/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.FileProviders; +using Yavsc.Models; +using Yavsc.Server.Helpers; + +namespace Yavsc.Blogs.Tests; + +public sealed class FilterFileNameTests +{ + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("ABC-123=_~.PDF", "ABC-123=_~.PDF")] + [InlineData("Troisi\u00e8me.PDF", "Troisi_232me.PDF")] + [InlineData("\u00e9\u00e0\u00e7.pdf", "_233_224_231.pdf")] + [InlineData("folder/file\\name.pdf", "folder_047file_092name.pdf")] + public void Filter_encodes_unsupported_characters_without_URL_fragments( + string originalName, string expectedName) + { + var filtered = AbstractFileSystemHelpers.FilterFileName(originalName); + + Assert.Equal(expectedName, filtered); + Assert.DoesNotContain("#", filtered); + Assert.True(filtered.IsValidYavscPath()); + Assert.Equal(filtered, AbstractFileSystemHelpers.FilterFileName(filtered)); + } + + [Fact] + public async Task Uploaded_accented_filename_is_accessible_through_static_files() + { + var root = Path.Combine(Path.GetTempPath(), $"yavsc-filename-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var payload = System.Text.Encoding.UTF8.GetBytes("accented filename regression"); + using var input = new MemoryStream(payload); + var file = new FormFile(input, 0, payload.Length, "file", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF") + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + var user = new ApplicationUser { DiskQuota = 10_000_000 }; + var received = user.ReceiveUserFile(root, file); + Assert.False(received.QuotaOffense); + Assert.Equal("INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF", + received.FileName); + + using var provider = new PhysicalFileProvider(root); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + await using var app = builder.Build(); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = provider, + RequestPath = "/files" + }); + await app.StartAsync(TestContext.Current.CancellationToken); + using var http = app.GetTestClient(); + var uri = new Uri(http.BaseAddress!, $"/files/{received.FileName}"); + Assert.Equal(string.Empty, uri.Fragment); + using var response = await http.GetAsync(uri, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(payload, + await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} From 79ba934bceee4b6062b4a8abadcbf2eb976e3ca3 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 01:18:10 +0100 Subject: [PATCH 2/5] UseStaticFiles was called twice --- src/Yavsc.Org/Extensions/HostingExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 79aaad6fb..ff4a657dd 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -1046,7 +1046,6 @@ public static class HostingExtensions app.UseSwagger(); app.UseSwaggerUI(); - app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseIdentityServer(); From 96ff4af440fc0f9f9ead588812a54b34fdb4812a Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 01:21:02 +0100 Subject: [PATCH 3/5] Document changelog updates in project instructions Require changelog updates by default for each commit and record the uploaded filename encoding fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 +++ CHANGELOG.md | 8 ++++++++ 2 files changed, 11 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..52d500e18 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# Conventions du projet + +- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer. diff --git a/CHANGELOG.md b/CHANGELOG.md index af5479790..87d34909d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,16 @@ ### Changed +* [Projet] Instructions Copilot persistantes : mise à jour du changelog + par défaut à chaque commit, avec justification explicite des exceptions. + ### Fixed +* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés + dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour + éviter les fragments d'URL et permettre le téléchargement via les + fichiers statiques, notamment pour les noms accentués. + * [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont désormais enregistrés côté serveur pour les requêtes authentifiées : le middleware de télémétrie est maintenant branché après From 63b44b5a92bf0ee0f4840aecc978797950c6ab04 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 01:38:25 +0100 Subject: [PATCH 4/5] Fix PostIt attachment links to match uploaded filenames Apply the shared filename filter before URL encoding, preserve escaped URLs and original link labels, and add regression coverage for accented and spaced filenames. Update the changelog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ .../PostIt.Tests/BlogAttachmentLinkTests.cs | 21 ++++++++++++------- .../PostIt/ViewModels/Blogs/BlogsViewModel.cs | 6 ++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d34909d..9c62779c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ ### Fixed +* [PostIt] Les liens Markdown des pièces jointes utilisent désormais + le même filtre de nom que l'upload serveur, avant l'encodage URL. + Le libellé conserve le nom original, notamment ses accents. + * [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour éviter les fragments d'URL et permettre le téléchargement via les diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 946d3d28e..6c88e9412 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,8 +20,13 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [Fact] - public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + [InlineData("rapport final.pdf", "rapport%20final.pdf")] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority( + string originalName, string urlName) { const string authority = "https://idp.example.test"; @@ -53,7 +58,7 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - "note.txt", + originalName, System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); @@ -68,12 +73,14 @@ public class BlogAttachmentLinkTests var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; - var sentStringContent = multipartContent.First() as StringContent ; + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + var sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + using var document = System.Text.Json.JsonDocument.Parse(sentString); + var article = document.RootElement.GetProperty("article").GetString(); + Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", sentString); + Assert.DoesNotContain("](/files/", article); } } diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index 882b5d19e..cecd5cb2c 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Abstract.Files; +using Yavsc.Server.Helpers; using AvaloniaEdit.Document; namespace PostIt.ViewModels; @@ -611,7 +612,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel foreach (var attachment in attachments) { - var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}"; + var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName); + var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(storedName)}"; var fileUrl = ResolveUserFileUrl(relativePath); var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; @@ -636,7 +638,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel if (!string.IsNullOrWhiteSpace(authority) && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) { - return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString(); + return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri; } return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; From 4ec169ed7179c49dd3f0a9b8de8bd1d6715155d3 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 02:03:48 +0100 Subject: [PATCH 5/5] Improve PostIt draft attachment and save workflow Insert attachment links on file addition, resolve provisional links on first save, preserve the editor selection, and highlight unsaved draft changes. Include regression tests and changelog updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + src/PostIt/PostIt.Tests/BlogApiTestFakes.cs | 8 + .../PostIt.Tests/BlogAttachmentLinkTests.cs | 42 ++++- .../PostIt.Tests/PostItViewModelTests.cs | 85 +++++++++ .../PostIt/ViewModels/Blogs/BlogsViewModel.cs | 177 +++++++++++++----- src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 13 +- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 1 - 7 files changed, 271 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c62779c3..946ee328f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ ### Changed +* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; + les liens provisoires des nouveaux billets sont résolus au premier + enregistrement. Save conserve l'édition ouverte et est mis en valeur + lorsque le titre, le corps ou les pièces jointes sont modifiés. + * [Projet] Instructions Copilot persistantes : mise à jour du changelog par défaut à chaque commit, avec justification explicite des exceptions. diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 112c11752..741b3863c 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 6c88e9412..c9d7d3aae 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -62,18 +62,18 @@ public class BlogAttachmentLinkTests System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); + Assert.Empty(recorder.Calls); + var draftBeforeSave = vm.DraftArticle; + await vm.SaveAsync(); - // The update branch issues two PUTs: the multipart one - // carrying the file, then — because TryAppendAttachmentLinks - // appended a link — a JSON one carrying the final article. - var linkPut = recorder.Calls.LastOrDefault( - c => c.method == HttpMethod.Put); + var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; var sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); @@ -82,5 +82,35 @@ public class BlogAttachmentLinkTests Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. Assert.DoesNotContain("](/files/", article); + Assert.Equal(draftBeforeSave, vm.DraftArticle); + Assert.NotNull(vm.SelectedPost); + Assert.False(vm.IsDraftModified); + } + + [Fact] + public async Task New_post_resolves_only_the_provisional_link_at_first_save() + { + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); + vm.DraftTitle = "Nouveau billet"; + vm.DraftArticle = "Texte conservé."; + vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); + + Assert.Contains("postit-attachment:", vm.DraftArticle); + Assert.Empty(recorder.Calls); + await vm.SaveAsync(); + + Assert.Equal("Nouveau billet", vm.DraftTitle); + Assert.Contains("Texte conservé.", vm.DraftArticle); + Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", + vm.DraftArticle); + Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); + Assert.Equal(42, vm.SelectedPost?.Id); + Assert.Empty(vm.DraftAttachments); + Assert.False(vm.IsDraftModified); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); } } diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index bf7023997..9a1247657 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -12,6 +12,91 @@ namespace PostIt.Tests; public class PostItViewModelTests { + [Fact] + public void Draft_modified_tracks_title_body_and_attachments_not_publication() + { + var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/")); + vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + Assert.False(vm.IsDraftModified); + vm.DraftIsPublished = true; + Assert.False(vm.IsDraftModified); + vm.DraftTitle = "Autre"; + Assert.True(vm.IsDraftModified); + vm.DraftTitle = "Titre"; + Assert.False(vm.IsDraftModified); + vm.DraftArticleDocument!.Insert(0, "Edit "); + Assert.True(vm.IsDraftModified); + vm.DraftArticle = "Corps"; + Assert.False(vm.IsDraftModified); + vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 })); + Assert.True(vm.IsDraftModified); + } + + [AvaloniaFact] + public async Task Save_keeps_selection_and_editor_open_and_resets_highlight() + { + var recorder = new CallRecorder(); + var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder), + "https://blogs.example.test/api/v1/")); + var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + vm.Posts.Add(selected); + vm.SearchText = ""; + vm.SelectedPost = selected; + var page = new BlogsPage { DataContext = vm }; + var navigation = new NavigationPage(); + await navigation.PushAsync(page); + var window = new Window { Content = navigation }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + selected = Assert.Single(vm.FilteredPosts); + selected.AuthorId = "tester"; + selected.Title = "Titre"; + selected.Article = "Corps"; + page.PostsListBox.SelectedItem = selected; + Dispatcher.UIThread.RunJobs(); + Assert.Same(selected, page.PostsListBox.SelectedItem); + Assert.Same(selected, vm.SelectedPost); + Assert.DoesNotContain("modified", page.SaveButton.Classes); + vm.DraftTitle = "Titre modifié"; + Dispatcher.UIThread.RunJobs(); + Assert.Contains("modified", page.SaveButton.Classes); + Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight); + Assert.Same(vm.SaveCommand, page.SaveButton.Command); + await vm.SaveAsync(); + Dispatcher.UIThread.RunJobs(); + Assert.Same(page, navigation.NavigationStack.Last()); + Assert.Same(selected, vm.SelectedPost); + Assert.Same(selected, page.PostsListBox.SelectedItem); + Assert.Equal("Titre modifié", vm.DraftTitle); + Assert.Equal("Corps", vm.DraftArticle); + Assert.DoesNotContain("modified", page.SaveButton.Classes); + } + finally + { + window.Close(); + } + } + + [Fact] + public async Task Failed_save_preserves_draft_attachments_and_modified_state() + { + var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/")); + var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + vm.SelectedPost = post; + vm.DraftTitle = "Titre modifié"; + vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 })); + var article = vm.DraftArticle; + await vm.SaveAsync(); + Assert.Same(post, vm.SelectedPost); + Assert.Equal("Titre modifié", vm.DraftTitle); + Assert.Equal(article, vm.DraftArticle); + Assert.Single(vm.DraftAttachments); + Assert.True(vm.IsDraftModified); + Assert.False(vm.IsBusy); + } + [AvaloniaFact] public void Published_badge_tracks_post_publication_state() { diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index cecd5cb2c..2e3c34a0f 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -89,12 +90,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } OnPropertyChanged(nameof(DraftArticleDocument)); + UpdateDraftModified(); } } private void DraftArticleDocument_TextChanged(object? sender, EventArgs e) { OnPropertyChanged(nameof(DraftArticle)); + UpdateDraftModified(); } /// Editor buffer for the post's publication state. @@ -108,6 +111,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel /// mutable field. Toggling is its own action. [ObservableProperty] public partial bool DraftIsPublished { get; set; } + + [ObservableProperty] + public partial bool IsDraftModified { get; private set; } + + private bool _preserveDraft; + private string _savedTitle = string.Empty; + private string _savedArticle = string.Empty; + private readonly Dictionary _pendingAttachmentLinks = new(); public bool IsLoaded { get; private set; } public Settings SettingsModel { get; } @@ -157,7 +168,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel }); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanSave))] internal async Task SaveAsync() { // The button is already disabled when the title is empty @@ -175,16 +186,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel { var attachments = DraftAttachments.ToArray(); - // Build a fresh BlogPostDto from the editor buffer on - // every Save — we no longer mutate SelectedPost in - // place. The previous behavior copied the buffer - // (which was a no-op when SelectedPost was null) - // back onto the model and relied on a - // [Required] violation to surface the missing - // input; the new shape keeps the editor buffer as - // the single source of truth for outgoing payloads - // and the selected post as a read-only hint for - // the update path. if (SelectedPost is null || SelectedPost.Id == 0) { var draft = new BlogPostDto @@ -195,12 +196,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, IsPublished = DraftIsPublished }; + var savedTitle = draft.Title; + var savedArticle = draft.Article; var created = await BlogClient!.CreatePostAsync(draft, attachments); if (created is not null) { - SelectedPost = created; + _preserveDraft = true; + try + { + SelectedPost = created; + } + finally + { + _preserveDraft = false; + } - if (TryAppendAttachmentLinks(created, attachments)) + if (ResolvePendingAttachmentLinks(created)) { var linkUpdate = new BlogPostDto { @@ -212,16 +223,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateCreated = created.DateCreated, DateModified = DateTime.UtcNow, }; + savedTitle = linkUpdate.Title; + savedArticle = linkUpdate.Article; await BlogClient.UpdatePostAsync(created.Id, linkUpdate); } + created.Title = savedTitle; + created.Article = savedArticle; this.SetInfoStatus($"Billet {created.Id} créé."); DraftAttachments.Clear(); + MarkDraftSaved(savedTitle, savedArticle); } + else + throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé."); } else { - TryAppendAttachmentLinks(SelectedPost, attachments); var update = new BlogPostDto { Id = SelectedPost.Id, @@ -233,8 +250,11 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, }; await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); + SelectedPost.Title = update.Title; + SelectedPost.Article = update.Article; this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); DraftAttachments.Clear(); + MarkDraftSaved(update.Title, update.Article); } await RefreshPostsAsync(); @@ -430,6 +450,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); DraftAttachments = new ObservableCollection(); + DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged; SelectedPost = null; IsBusy = false; this.SetInfoStatus("Prêt."); @@ -441,6 +462,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DraftArticle = string.Empty; DraftIsPublished = false; + MarkDraftSaved(); IsLoaded = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor @@ -501,6 +523,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel partial void OnSelectedPostChanged(BlogPostDto? value) { + if (_preserveDraft) return; // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content // when the user clicks a post in the list. When the @@ -514,6 +537,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // null selection so a fresh draft starts unpublished. DraftIsPublished = value?.IsPublished ?? false; DraftAttachments.Clear(); + _pendingAttachmentLinks.Clear(); + MarkDraftSaved(); UpdateCommandStates(); } @@ -522,7 +547,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // Save's CanExecute depends on the buffer: the button must // enable as soon as the user has typed a non-whitespace // title, regardless of whether a post is selected. - partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); + partial void OnDraftTitleChanged(string value) + { + SaveCommand.NotifyCanExecuteChanged(); + UpdateDraftModified(); + } + + private void UpdateDraftModified() + { + IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal) + || !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal) + || (DraftAttachments?.Count ?? 0) > 0; + } + + private void MarkDraftSaved() + => MarkDraftSaved(DraftTitle, DraftArticle); + + private void MarkDraftSaved(string? title, string? article) + { + _savedTitle = title ?? string.Empty; + _savedArticle = article ?? string.Empty; + UpdateDraftModified(); + } private async Task RefreshPostsAsync() @@ -535,10 +581,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } ApplyFilter(); - if (SelectedPost is not null) - { - SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost; - } } private void ApplyFilter() @@ -553,10 +595,29 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) .OrderByDescending(p => p.DateModified); - FilteredPosts.Clear(); - foreach (var post in filtered) + var selection = SelectedPost; + var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray(); + _preserveDraft = true; + try { - FilteredPosts.Add(post); + for (var i = FilteredPosts.Count - 1; i >= 0; i--) + { + if (!visiblePosts.Contains(FilteredPosts[i])) + FilteredPosts.RemoveAt(i); + } + for (var i = 0; i < visiblePosts.Length; i++) + { + var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]); + if (previousIndex < 0) + FilteredPosts.Insert(i, visiblePosts[i]); + else if (previousIndex != i) + FilteredPosts.Move(previousIndex, i); + } + SelectedPost = selection; + } + finally + { + _preserveDraft = false; } } @@ -595,41 +656,53 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } } - private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection attachments) + private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - if (attachments.Count == 0) - return false; - - var ownerSegment = post.Author?.UserName; - if (string.IsNullOrWhiteSpace(ownerSegment)) - ownerSegment = post.AuthorId; - - if (string.IsNullOrWhiteSpace(ownerSegment)) - return false; - - var article = DraftArticle ?? string.Empty; - var links = new List(); - - foreach (var attachment in attachments) + if (e.NewItems is not null) { - var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName); - var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(storedName)}"; - var fileUrl = ResolveUserFileUrl(relativePath); - var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; - - if (!article.Contains(markdownLine, StringComparison.Ordinal)) - links.Add(markdownLine); + foreach (BlogUploadFile attachment in e.NewItems) + { + var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName); + string fileUrl; + if (SelectedPost is { Id: > 0 } post) + fileUrl = AttachmentUrl(post, storedName); + else + { + fileUrl = $"postit-attachment:{Guid.NewGuid():N}"; + _pendingAttachmentLinks.Add(fileUrl, storedName); + } + var article = DraftArticle ?? string.Empty; + var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; + if (!article.Contains(markdownLine, StringComparison.Ordinal)) + { + var prefix = article.Length == 0 ? "" + : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); + DraftArticle = article + prefix + markdownLine; + } + } } + UpdateDraftModified(); + } - if (links.Count == 0) - return false; + private string AttachmentUrl(BlogPostDto post, string storedName) + { + var owner = post.Author?.UserName ?? post.AuthorId; + if (string.IsNullOrWhiteSpace(owner)) + throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier."); + return ResolveUserFileUrl( + $"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}"); + } - var prefix = article.Length == 0 - ? "" - : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); - - DraftArticle = article + prefix + string.Join("\n", links); - return true; + private bool ResolvePendingAttachmentLinks(BlogPostDto post) + { + var article = DraftArticle ?? string.Empty; + foreach (var link in _pendingAttachmentLinks) + article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})", + StringComparison.Ordinal); + var changed = article != DraftArticle; + if (changed) DraftArticle = article; + _pendingAttachmentLinks.Clear(); + return changed; } private string ResolveUserFileUrl(string relativePath) diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 7da9a4ce8..4d0858060 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -17,6 +17,13 @@ + + + @@ -60,6 +69,7 @@ @@ -118,6 +128,7 @@ BorderThickness="1"> diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index b40021276..5fab966ca 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -50,7 +50,6 @@ public partial class BlogsPage : ContentPage uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name))); } - vm.DraftAttachments.Clear(); foreach (var upload in uploads) vm.DraftAttachments.Add(upload); }