fix(export): write through the storage provider when the target has no local path
The save picker used to return only TryGetLocalPath(), so a target without a local path looked like a cancel and nothing was written. The dialog service now hands back an opener for the chosen file. The picker call is inside the error handler, and the workspace is read before the target is opened, so a failed read leaves an existing file untouched.
This commit is contained in:
1 parent
f9f0e71453
commit
c9523d0953
5 files changed
+61
-13
No files matched your search
@@ -27,10 +27,12 @@ internal sealed class ScriptedDialogService : IDialogService
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
public Task<string?> PickSaveFilePathAsync(string suggestedFileName)
|
||||
public Task<Func<Task<Stream>>?> PickSaveFileAsync(string suggestedFileName)
|
||||
{
|
||||
SuggestedFileNames.Add(suggestedFileName);
|
||||
return Task.FromResult(SaveFilePath);
|
||||
string? path = SaveFilePath;
|
||||
return Task.FromResult<Func<Task<Stream>>?>(
|
||||
path is null ? null : () => Task.FromResult<Stream>(File.Create(path)));
|
||||
}
|
||||
|
||||
/// <summary>Answers every confirmation dialog with the given choice.</summary>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -20,8 +20,12 @@ public interface IDialogService
|
||||
/// Shows the system save-file dialog for a Markdown file.
|
||||
/// </summary>
|
||||
/// <param name="suggestedFileName">The proposed file name.</param>
|
||||
/// <returns>The chosen local path, or <see langword="null"/> when the user cancelled.</returns>
|
||||
Task<string?> PickSaveFilePathAsync(string suggestedFileName);
|
||||
/// <returns>
|
||||
/// A function opening the chosen file for writing, emptied first, or
|
||||
/// <see langword="null"/> when the user cancelled. Nothing is created or
|
||||
/// truncated until the function is called.
|
||||
/// </returns>
|
||||
Task<Func<Task<Stream>>?> PickSaveFileAsync(string suggestedFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<Task<Stream>>? 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)
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class DialogService : IDialogService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> PickSaveFilePathAsync(string suggestedFileName)
|
||||
public async Task<Func<Task<Stream>>?> 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<Stream>(new FileStream(localPath, FileMode.Create, FileAccess.Write));
|
||||
}
|
||||
return async () =>
|
||||
{
|
||||
Stream stream = await file.OpenWriteAsync();
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.SetLength(0);
|
||||
}
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in new issue
Block a user