diff --git a/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs b/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs index 8562ca2..3956b06 100644 --- a/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs +++ b/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs @@ -27,10 +27,12 @@ internal sealed class ScriptedDialogService : IDialogService return result ?? false; } - public Task PickSaveFilePathAsync(string suggestedFileName) + public Task>?> PickSaveFileAsync(string suggestedFileName) { SuggestedFileNames.Add(suggestedFileName); - return Task.FromResult(SaveFilePath); + string? path = SaveFilePath; + return Task.FromResult>?>( + path is null ? null : () => Task.FromResult(File.Create(path))); } /// Answers every confirmation dialog with the given choice. diff --git a/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs b/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs index ca638bb..dbebe41 100644 --- a/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs +++ b/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs @@ -414,5 +414,22 @@ public class TagsAndSearchViewModelTests Assert.IsTrue(message.HasDetails); } + [TestMethod] + public async Task ExportReadFailureIsReportedAndLeavesTheTargetUntouched() + { + using var fixture = new Fixture(); + WorkspaceViewModel workspace = fixture.CreateWorkspace(); + string path = Path.Combine(fixture.Directory.FullPath, "out.md"); + File.WriteAllText(path, "previous"); + fixture.Dialogs.SaveFilePath = path; + + fixture.Repository.Dispose(); + await workspace.ExportCommand.ExecuteAsync(null); + + var message = (MessageDialogViewModel)fixture.Dialogs.Shown.Single(); + Assert.AreEqual(Resources.Export_Failed_Title, message.Title); + Assert.AreEqual("previous", File.ReadAllText(path)); + } + #endregion } diff --git a/YKanBan/ViewModels/Dialogs/IDialogService.cs b/YKanBan/ViewModels/Dialogs/IDialogService.cs index 0be1ec6..8e1a156 100644 --- a/YKanBan/ViewModels/Dialogs/IDialogService.cs +++ b/YKanBan/ViewModels/Dialogs/IDialogService.cs @@ -20,8 +20,12 @@ public interface IDialogService /// Shows the system save-file dialog for a Markdown file. /// /// The proposed file name. - /// The chosen local path, or when the user cancelled. - Task PickSaveFilePathAsync(string suggestedFileName); + /// + /// A function opening the chosen file for writing, emptied first, or + /// when the user cancelled. Nothing is created or + /// truncated until the function is called. + /// + Task>?> PickSaveFileAsync(string suggestedFileName); } /// diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs index 8343658..3a594bf 100644 --- a/YKanBan/ViewModels/WorkspaceViewModel.cs +++ b/YKanBan/ViewModels/WorkspaceViewModel.cs @@ -96,18 +96,22 @@ public sealed partial class WorkspaceViewModel : ViewModelBase private async Task ExportAsync() { DateTimeOffset exportedAt = DateTimeOffset.Now; - string? path = await _dialogs.PickSaveFilePathAsync(MarkdownExporter.BuildFileName(WorkspaceName, exportedAt)); - if (path is null) - { - return; - } - try { + Func>? openTarget = + await _dialogs.PickSaveFileAsync(MarkdownExporter.BuildFileName(WorkspaceName, exportedAt)); + if (openTarget is null) + { + return; + } + + // Read everything before opening the target, so a failed read leaves an existing file untouched. WorkspaceRepository repository = Session.Repository; string markdown = MarkdownExporter.Export( WorkspaceName, repository.GetColumns(), repository.GetCards(), repository.GetTagsWithUsage(), exportedAt); - await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + await using Stream stream = await openTarget(); + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + await writer.WriteAsync(markdown); } catch (Exception exception) { diff --git a/YKanBan/Views/DialogService.cs b/YKanBan/Views/DialogService.cs index ec54cba..e88d5cb 100644 --- a/YKanBan/Views/DialogService.cs +++ b/YKanBan/Views/DialogService.cs @@ -52,7 +52,7 @@ public sealed class DialogService : IDialogService } /// - public async Task PickSaveFilePathAsync(string suggestedFileName) + public async Task>?> PickSaveFileAsync(string suggestedFileName) { if (!_owners.TryPeek(out Window? owner)) { @@ -69,7 +69,28 @@ public sealed class DialogService : IDialogService new FilePickerFileType(YKanBan.Resources.Export_FileTypeMarkdown) { Patterns = ["*.md"] }, ], }); - return file?.TryGetLocalPath(); + if (file is null) + { + return null; + } + + // A local file is replaced through the file system; a target without a + // local path (a sandboxed or virtual location) is written through the + // storage provider, emptied first because it may already have content. + string? localPath = file.TryGetLocalPath(); + if (localPath is not null) + { + return () => Task.FromResult(new FileStream(localPath, FileMode.Create, FileAccess.Write)); + } + return async () => + { + Stream stream = await file.OpenWriteAsync(); + if (stream.CanSeek) + { + stream.SetLength(0); + } + return stream; + }; } ///