diff --git a/.github/workflows/WriteTextDocument_release.yml b/.github/workflows/WriteTextDocument_release.yml new file mode 100644 index 0000000..eac9eee --- /dev/null +++ b/.github/workflows/WriteTextDocument_release.yml @@ -0,0 +1,16 @@ +name: WriteTextDocument_release +permissions: + contents: write + +on: + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/release.yml@main + with: + workdir: Frends.Odf.WriteTextDocument + dotnet_version: 8.0.x + strict_analyzers: true + secrets: + feed_api_key: ${{ secrets.TASKS_FEED_API_KEY }} diff --git a/.github/workflows/WriteTextDocument_test_on_main.yml b/.github/workflows/WriteTextDocument_test_on_main.yml new file mode 100644 index 0000000..4737826 --- /dev/null +++ b/.github/workflows/WriteTextDocument_test_on_main.yml @@ -0,0 +1,21 @@ +name: WriteTextDocument_test_on_main +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - 'Frends.Odf.WriteTextDocument/**' + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main + with: + workdir: Frends.Odf.WriteTextDocument + dotnet_version: 8.0.x + strict_analyzers: true + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} diff --git a/.github/workflows/WriteTextDocument_test_on_push.yml b/.github/workflows/WriteTextDocument_test_on_push.yml new file mode 100644 index 0000000..921f58c --- /dev/null +++ b/.github/workflows/WriteTextDocument_test_on_push.yml @@ -0,0 +1,22 @@ +name: WriteTextDocument_test_on_push +permissions: + contents: read + +on: + push: + branches-ignore: + - main + paths: + - 'Frends.Odf.WriteTextDocument/**' + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main + with: + workdir: Frends.Odf.WriteTextDocument + dotnet_version: 8.0.x + strict_analyzers: true + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} + test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} diff --git a/Frends.Odf.WriteTextDocument/CHANGELOG.md b/Frends.Odf.WriteTextDocument/CHANGELOG.md new file mode 100644 index 0000000..9eef3d4 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [1.0.0] - 2026-05-31 + +### Added + +- Initial implementation diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/ErrorHandlerTest.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/ErrorHandlerTest.cs new file mode 100644 index 0000000..ef0a603 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/ErrorHandlerTest.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Threading; +using NUnit.Framework; + +namespace Frends.Odf.WriteTextDocument.Tests; + +[TestFixture] +internal class ErrorHandlerTest : TestBase +{ + private const string CustomErrorMessage = "CustomErrorMessage"; + + // Fake path to trigger DirectoryNotFoundException. + private readonly string fakePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "fake_path.odt"); + + [Test] + public void Should_Throw_Error_When_ThrowErrorOnFailure_Is_True() + { + // Inject the fake path into the default input. + var input = DefaultInput(); + input.FilePath = fakePath; + + var ex = Assert.Throws(() => + Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None)); + + Assert.That(ex, Is.Not.Null); + } + + [Test] + public void Should_Return_Failed_Result_When_ThrowErrorOnFailure_Is_False() + { + // Inject the fake path into the default input. + var input = DefaultInput(); + input.FilePath = fakePath; + + var options = DefaultOptions(); + options.ThrowErrorOnFailure = false; + + var result = Odf.WriteTextDocument(input, options, CancellationToken.None); + + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Is.Not.Null); + } + + [Test] + public void Should_Use_Custom_ErrorMessageOnFailure() + { + // Inject the fake path into the default input. + var input = DefaultInput(); + input.FilePath = fakePath; + + var options = DefaultOptions(); + options.ErrorMessageOnFailure = CustomErrorMessage; + + var ex = Assert.Throws(() => + Odf.WriteTextDocument(input, options, CancellationToken.None)); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Contains.Substring(CustomErrorMessage)); + } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Frends.Odf.WriteTextDocument.Tests.csproj b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Frends.Odf.WriteTextDocument.Tests.csproj new file mode 100644 index 0000000..db1701d --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Frends.Odf.WriteTextDocument.Tests.csproj @@ -0,0 +1,28 @@ + + + net8.0 + false + disable + latest + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs new file mode 100644 index 0000000..e8a2ab4 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs @@ -0,0 +1,177 @@ +using System; +using System.IO; +using System.Threading; +using Frends.Odf.WriteTextDocument.Definitions; +using Frends.Odf.WriteTextDocument.Tests.Helpers; +using NUnit.Framework; + +namespace Frends.Odf.WriteTextDocument.Tests; + +[TestFixture] +internal class FunctionalTests : TestBase +{ + [Test] + public void Should_Write_Input_Content() + { + var input = DefaultInput(); + var options = DefaultOptions(); + + var result = Odf.WriteTextDocument(input, options, CancellationToken.None); + + Assert.That(result.Success, Is.True, "Task failed to execute successfully."); + Assert.That(File.Exists(result.FilePath), Is.True, "The output .odt file was not created."); + + var xmlString = TestHelper.ReadOdtContent(result.FilePath); + + Assert.That(xmlString, Contains.Substring("Name: John")); + Assert.That(xmlString, Contains.Substring("Test: Test 1")); + Assert.That(xmlString, Contains.Substring("Name: Doe")); + Assert.That(xmlString, Contains.Substring("Test: Test 2")); + } + + [Test] + public void Should_Throw_When_Input_FilePath_Is_Incorrect() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "fake_path.odt"); + + var input = DefaultInput(); + input.FilePath = path; + + var exception = Assert.Throws(() => Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None)); + + Assert.That(exception.Message, Contains.Substring("Destination directory not found")); + } + + [Test] + public void Should_Throw_When_Input_Content_Is_Incorrect() + { + var invalidPayload = @"{ ""Name"": ""John"" }"; + + var input = DefaultInput(); + input.Payload = invalidPayload; + + var exception = Assert.Throws(() => Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None)); + + Assert.That(exception.Message, Contains.Substring("must be a valid array of objects")); + } + + [Test] + public void Should_Throw_When_ActionOnExistingFile_Is_Throw() + { + File.WriteAllText(ValidTestFilePath, "This is an existing file."); + + var input = DefaultInput(); + input.ActionOnExistingFile = ActionOnExistingFile.Throw; + + var exception = Assert.Throws(() => Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None)); + + Assert.That(exception.Message, Contains.Substring("File already exists")); + } + + [Test] + public void Should_Overwrite_When_ActionOnExistingFile_Is_Overwrite() + { + File.WriteAllText(ValidTestFilePath, "This is an existing file."); + + var input = DefaultInput(); + input.ActionOnExistingFile = ActionOnExistingFile.Overwrite; + + var result = Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(File.Exists(result.FilePath), Is.True); + + var xmlString = TestHelper.ReadOdtContent(result.FilePath); + + Assert.That(xmlString, Contains.Substring("Name: John")); + Assert.That(xmlString, Does.Not.Contain("This is an existing file.")); + } + + [Test] + public void Should_Write_Empty_Document_With_Empty_Payload() + { + var emptyPayload = "[]"; + + var input = DefaultInput(); + input.Payload = emptyPayload; + + var result = Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(File.Exists(result.FilePath), Is.True); + + var xmlString = TestHelper.ReadOdtContent(result.FilePath); + + Assert.That(xmlString, Does.Not.Contain("Name: John")); + Assert.That(xmlString, Does.Not.Contain("text:p>")); + } + + [Test] + public void Should_Handle_Unicode_Content() + { + var unicodePayload = @"[ + { ""Text1"": ""AäÄaOöÖo."" }, + { ""Text2"": ""ÖöÄä."" } + ]"; + + var input = DefaultInput(); + input.Payload = unicodePayload; + + var result = Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(File.Exists(result.FilePath), Is.True); + + var xmlString = TestHelper.ReadOdtContent(result.FilePath); + + Assert.That(xmlString, Contains.Substring("Text1: AäÄaOöÖo.")); + Assert.That(xmlString, Contains.Substring("Text2: ÖöÄä.")); + } + + [Test] + public void Should_Throw_When_File_Extension_Is_Not_Odt() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".txt"); + + var input = DefaultInput(); + input.FilePath = path; + + var exception = Assert.Throws(() => Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None)); + + Assert.That(exception.Message, Contains.Substring("The destination file must have a .odt extension.")); + } + + [Test] + public void Should_Throw_When_Array_Contains_Non_Objects() + { + var invalidPayload = @"[ ""This is a string."" ]"; + + var input = DefaultInput(); + input.Payload = invalidPayload; + + var exception = Assert.Throws(() => Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None)); + + Assert.That(exception.Message, Contains.Substring("The JSON payload must contain valid JSON objects.")); + } + + [Test] + public void Should_Handle_Null_JSON_Values() + { + var nullPayload = @"[ + { ""Name"": ""John"", ""Role"": null } + ]"; + + var input = DefaultInput(); + input.Payload = nullPayload; + + var result = Odf.WriteTextDocument(input, DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(File.Exists(result.FilePath), Is.True); + + var xmlString = TestHelper.ReadOdtContent(result.FilePath); + + Assert.That(xmlString, Contains.Substring("Name: John")); + Assert.That(xmlString, Contains.Substring("Role: ")); + } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/GlobalSuppressions.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/GlobalSuppressions.cs new file mode 100644 index 0000000..1275afc --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.SpecialRules", "SA0001::XmlCommentAnalysisDisabled", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:PrefixLocalCallsWithThis", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:BracesMustNotBeOmitted", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Documentation checked by custom analyzers")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:FileMustHaveHeader", Justification = "Following Frends documentation guidelines")] diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Helpers/TestHelper.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Helpers/TestHelper.cs new file mode 100644 index 0000000..5797814 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Helpers/TestHelper.cs @@ -0,0 +1,24 @@ +using System; +using System.IO.Compression; +using System.Xml.Linq; + +namespace Frends.Odf.WriteTextDocument.Tests.Helpers; + +internal class TestHelper +{ + /// + /// Reads and returns content.xml from a generated .odt file as a string for testing. + /// + /// The path to the generated .odt file. + /// content.xml content as a string. + internal static string ReadOdtContent(string filePath) + { + using var archive = ZipFile.OpenRead(filePath); + var contentXmlEntry = archive.GetEntry("content.xml") ?? throw new Exception("content.xml is missing from the generated file."); + + using var stream = contentXmlEntry.Open(); + var xDocument = XDocument.Load(stream); + + return xDocument.ToString(); + } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs new file mode 100644 index 0000000..0a0393f --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using Frends.Odf.WriteTextDocument.Definitions; +using NUnit.Framework; + +namespace Frends.Odf.WriteTextDocument.Tests; + +internal abstract class TestBase +{ + protected string ValidTestFilePath { get; private set; } + + [SetUp] + public void SetupBase() + { + ValidTestFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".odt"); + } + + [TearDown] + public void TearDownBase() + { + if (File.Exists(ValidTestFilePath)) + { + File.Delete(ValidTestFilePath); + } + } + + protected static Options DefaultOptions() => new(); + + protected Input DefaultInput() + { + var jsonPayload = @"[ + { ""Name"": ""John"", ""Test"": ""Test 1"" }, + { ""Name"": ""Doe"", ""Test"": ""Test 2"" } + ]"; + + return new Input + { + FilePath = ValidTestFilePath, + Payload = jsonPayload, + ActionOnExistingFile = ActionOnExistingFile.Overwrite, + }; + } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.sln b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.sln new file mode 100644 index 0000000..46e313d --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.32112.339 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.Odf.WriteTextDocument", "Frends.Odf.WriteTextDocument\Frends.Odf.WriteTextDocument.csproj", "{35C305C0-8108-4A98-BB1D-AFE5C926239E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.Odf.WriteTextDocument.Tests", "Frends.Odf.WriteTextDocument.Tests\Frends.Odf.WriteTextDocument.Tests.csproj", "{8CA92187-8E4F-4414-803B-EC899479022E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{78F7F22E-6E20-4BCE-8362-0C558568B729}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + ..\.github\workflows\WriteTextDocument_test_on_main.yml = ..\.github\workflows\WriteTextDocument_test_on_main.yml + ..\.github\workflows\WriteTextDocument_test_on_push.yml = ..\.github\workflows\WriteTextDocument_test_on_push.yml + ..\.github\workflows\WriteTextDocument_release.yml = ..\.github\workflows\WriteTextDocument_release.yml + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.Build.0 = Release|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {55BC6629-85C9-48D8-8CA2-B0046AF1AF4B} + EndGlobalSection +EndGlobal diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Enums.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Enums.cs new file mode 100644 index 0000000..8116ebd --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Enums.cs @@ -0,0 +1,17 @@ +namespace Frends.Odf.WriteTextDocument.Definitions; + +/// +/// Action to take if the destination file already exists. +/// +public enum ActionOnExistingFile +{ + /// + /// Replaces the existing file. + /// + Overwrite, + + /// + /// Throws an error if the file already exists. + /// + Throw, +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Error.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Error.cs new file mode 100644 index 0000000..ecc77d9 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Error.cs @@ -0,0 +1,21 @@ +using System; + +namespace Frends.Odf.WriteTextDocument.Definitions; + +/// +/// Error that occurred during the task. +/// +public class Error +{ + /// + /// Summary of the error. + /// + /// File already exists: C:\temp\fake_path.odt + public string Message { get; set; } + + /// + /// Additional information about the error. + /// + /// System.IO.IOException + public Exception AdditionalInfo { get; set; } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs new file mode 100644 index 0000000..f4cbc9d --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Frends.Odf.WriteTextDocument.Definitions; + +/// +/// Essential parameters. +/// +public class Input +{ + /// + /// JSON data input to be written into the .odt document. + /// + /// [ { "Name": "John" }, { "Name": "Jane" } ] + [Required] + [DefaultValue("")] + public string Payload { get; set; } + + /// + /// Full path of the destination for the new .odt file. + /// + /// C:\temp\foo.odt + [Required] + [DefaultValue("")] + public string FilePath { get; set; } = string.Empty; + + /// + /// Defines how the file write should work if a file with the new name already exists. + /// + /// ActionOnExistingFile.Throw + [DefaultValue(ActionOnExistingFile.Throw)] + public ActionOnExistingFile ActionOnExistingFile { get; set; } = ActionOnExistingFile.Throw; +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Options.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Options.cs new file mode 100644 index 0000000..efb2b97 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Options.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Frends.Odf.WriteTextDocument.Definitions; + +/// +/// Additional parameters. +/// +public class Options +{ + /// + /// Whether to throw an error on failure. + /// + /// true + [DefaultValue(true)] + public bool ThrowErrorOnFailure { get; set; } = true; + + /// + /// Overrides the error message on failure. + /// + /// Custom error message + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + public string ErrorMessageOnFailure { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Result.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Result.cs new file mode 100644 index 0000000..684d0fd --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Result.cs @@ -0,0 +1,25 @@ +namespace Frends.Odf.WriteTextDocument.Definitions; + +/// +/// Result of the task. +/// +public class Result +{ + /// + /// Indicates if the task completed successfully. + /// + /// true + public bool Success { get; set; } + + /// + /// Full path to the newly created .odt file. + /// + /// c:\temp\foo.odt + public string FilePath { get; set; } + + /// + /// Error that occurred during task execution. + /// + /// object { string Message, Exception AdditionalInfo } + public Error Error { get; set; } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs new file mode 100644 index 0000000..a8531e4 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs @@ -0,0 +1,191 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Xml; +using System.Xml.Linq; +using Frends.Odf.WriteTextDocument.Definitions; +using Frends.Odf.WriteTextDocument.Helpers; +using Newtonsoft.Json.Linq; + +namespace Frends.Odf.WriteTextDocument; + +/// +/// Task Class for Odf operations. +/// +public static class Odf +{ + private static readonly XNamespace TextNamespace = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"; + private static readonly XNamespace OfficeNamespace = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"; + + /// + /// Generate an OpenDocument Text (.odt) file by injecting user-input JSON data + /// into a built-in template. + /// Each JSON property is written as a separate paragraph in the format "key: value". + /// For example, { "Name": "John" } produces a paragraph containing "Name: John". + /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends-Odf-WriteTextDocument) + /// + /// Essential parameters. + /// Additional parameters. + /// A cancellation token provided by Frends Platform. + /// object { bool Success, string FilePath, object Error { string Message, Exception AdditionalInfo } } + public static Result WriteTextDocument( + [PropertyTab] Input input, + [PropertyTab] Options options, + CancellationToken cancellationToken) + { + try + { + ValidationHandler.Run(input, options); + + var normalizedPath = Path.GetFullPath(input.FilePath); + var directory = Path.GetDirectoryName(normalizedPath); + + if (string.IsNullOrWhiteSpace(directory)) + throw new ArgumentException("Invalid destination path."); + + if (!Path.GetExtension(normalizedPath).Equals(".odt", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("The destination file must have a .odt extension."); + + if (!Directory.Exists(directory)) + throw new DirectoryNotFoundException($"Destination directory not found: {directory}"); + + if (File.Exists(normalizedPath)) + { + if (input.ActionOnExistingFile == ActionOnExistingFile.Throw) + throw new IOException($"File already exists: {normalizedPath}"); + } + + if (string.IsNullOrWhiteSpace(input.Payload)) + throw new ArgumentException("The provided JSON payload cannot be empty."); + + JToken parsedPayload; + + try + { + parsedPayload = JToken.Parse(input.Payload); + } + catch (Newtonsoft.Json.JsonReaderException ex) + { + throw new ArgumentException($"The provided payload is not valid JSON: {ex.Message}"); + } + + if (parsedPayload.Type != JTokenType.Array) + throw new ArgumentException("The provided JSON payload must be a valid array of objects."); + + var jsonArray = (JArray)parsedPayload; + + var assembly = typeof(Odf).Assembly; + + using var templateStream = assembly.GetManifestResourceStream("Frends.Odf.WriteTextDocument.Resources.template.odt") ?? throw new Exception("Could not find the embedded .odt template."); + + using var memoryStream = new MemoryStream(); + templateStream.CopyTo(memoryStream); + + // Open the writable stream in Update mode. leaveOpen: true prevents destroying the MemoryStream upon exit. + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Update, leaveOpen: true)) + { + var contentXml = archive.GetEntry("content.xml") ?? throw new Exception("content.xml is missing from the embedded .odt template."); + + XDocument xDocument; + + using (var stream = contentXml.Open()) + { + // Configure XmlReader to disable DTDs and external entities (XXE protection). + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }; + + using var xmlReader = XmlReader.Create(stream, settings); + xDocument = XDocument.Load(xmlReader); + } + + var textBody = xDocument.Root?.Element(OfficeNamespace + "body")?.Element(OfficeNamespace + "text") ?? throw new Exception("Invalid template structure."); + + foreach (var obj in jsonArray) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (obj is not JObject jObject) + throw new ArgumentException("The JSON payload must contain valid JSON objects."); + + foreach (var property in jObject.Properties()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var key = property.Name; + var value = string.Empty; + + if (property.Value.Type != JTokenType.Null) + value = property.Value.ToString(); + + var paragraphContent = $"{key}: {value}"; + + // Wrap in a standard XML tag and inject them into textBody, updating xDocument. + var paragraphElement = new XElement(TextNamespace + "p", paragraphContent); + textBody.Add(paragraphElement); + } + } + + // ZipArchive cannot edit an existing file. Delete previous content.xml and create a new one. + contentXml.Delete(); + var newContentXmlEntry = archive.CreateEntry("content.xml"); + + using var newStream = newContentXmlEntry.Open(); + + var newSettings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = false, + }; + + using var xmlWriter = XmlWriter.Create(newStream, newSettings); + xDocument.Save(xmlWriter); + } + + memoryStream.Position = 0; + + var tempFilePath = Path.Combine(directory, $"{Path.GetFileName(normalizedPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + using (var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write)) + { + memoryStream.CopyTo(fileStream); + } + + var overwrite = input.ActionOnExistingFile != ActionOnExistingFile.Throw; + + try + { + File.Move(tempFilePath, normalizedPath, overwrite); + } + catch (IOException ex) when (!overwrite) + { + throw new IOException($"File already exists: {normalizedPath}", ex); + } + } + catch + { + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + throw; + } + + return new Result + { + Success = true, + FilePath = normalizedPath, + Error = null, + }; + } + catch (Exception ex) + { + return ex.Handle(options); + } + } +} \ No newline at end of file diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj new file mode 100644 index 0000000..265fefe --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj @@ -0,0 +1,45 @@ + + + net8.0 + latest + 1.0.0 + Frends + Copyright (c) 2026 Frends EiPaaS + Frends + Frends + Frends + MIT + true + Generate an OpenDocument Text (.odt) file by injecting user-input JSON data into a built-in template. + https://frends.com/ + https://github.com/FrendsPlatform/Frends.Odf/tree/main/Frends.Odf.WriteTextDocument + disable + + CS1591, + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/FrendsTaskMetadata.json b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/FrendsTaskMetadata.json new file mode 100644 index 0000000..df82114 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/FrendsTaskMetadata.json @@ -0,0 +1,7 @@ +{ + "Tasks": [ + { + "TaskMethod": "Frends.Odf.WriteTextDocument.Odf.WriteTextDocument" + } + ] +} diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/GlobalSuppressions.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/GlobalSuppressions.cs new file mode 100644 index 0000000..4b4cc78 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/GlobalSuppressions.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:PrefixLocalCallsWithThis", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:BracesMustNotBeOmitted", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Documentation checked by custom analyzers")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1623:PropertySummaryDocumentationMustMatchAccessors", Justification = "Following Frends Tasks guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1629:DocumentationTextMustEndWithAPeriod", Justification = "Following Frends Tasks guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:FileMustHaveHeader", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:FileNameMustMatchTypeName", Justification = "Following Frends Tasks guidelines")] diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ErrorHandler.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ErrorHandler.cs new file mode 100644 index 0000000..34124b5 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ErrorHandler.cs @@ -0,0 +1,55 @@ +using System; +using Frends.Odf.WriteTextDocument.Definitions; + +namespace Frends.Odf.WriteTextDocument.Helpers; + +internal static class ErrorHandler +{ + /// + /// Converts an exception into a failed Result object or rethrows based on task options. + /// + /// The exception to handle. + /// Task options that control whether failures are returned as a Result object or thrown. + /// + /// When true, an OperationCanceledException is rethrown immediately. + /// When false, cancellation is handled like any other failure. + /// + /// A failed Result object when the exception is handled instead of rethrown. + internal static Result Handle(this Exception exception, Options options, bool throwCanceled = true) + { + ThrowIfCanceled(exception, throwCanceled); + if (options.ThrowErrorOnFailure) ThrowBaseException(exception, options.ErrorMessageOnFailure); + + return ReturnResult(exception, options.ErrorMessageOnFailure); + } + + private static void ThrowIfCanceled(Exception exception, bool throwCanceled = true) + { + if (throwCanceled && exception is OperationCanceledException) throw exception; + } + + private static void ThrowBaseException(Exception exception, string customMessage = null) + { + if (string.IsNullOrEmpty(customMessage)) + throw new Exception(exception.Message, exception); + + throw new Exception(customMessage, exception); + } + + private static Result ReturnResult(Exception exception, string customMessage = null) + { + var errorMessage = string.IsNullOrEmpty(customMessage) + ? exception.Message + : $"{customMessage}: {exception.Message}"; + + return new Result + { + Success = false, + Error = new Error + { + Message = errorMessage, + AdditionalInfo = exception, + }, + }; + } +} diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ValidationHandler.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ValidationHandler.cs new file mode 100644 index 0000000..e540e84 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ValidationHandler.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Frends.Odf.WriteTextDocument.Helpers; + +/// +/// Validates objects by their ValidationAttributes. +/// +internal static class ValidationHandler +{ + internal static void Run(params object[] objects) + { + if (objects == null || objects.Length == 0) + throw new ValidationException("Validation failed:\nYou must provide objects to validate"); + var validationMessage = objects.Select(obj => obj.Validate()) + .Aggregate(string.Empty, (current, message) => string.Join("\n", current, message)); + + if (validationMessage.Trim() != string.Empty) + throw new ValidationException($"Validation failed:\n{validationMessage}"); + } + + private static string Validate(this T objectToValidate) + { + if (objectToValidate == null) return "Validated object can't be null!\n"; + var ctx = new ValidationContext(objectToValidate); + List validateResults = []; + Validator.TryValidateObject(objectToValidate, ctx, validateResults, true); + + return validateResults.Aggregate( + string.Empty, + (current, error) => string.Join("\n", current, $"{error.ErrorMessage}")); + } +} diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Resources/template.odt b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Resources/template.odt new file mode 100644 index 0000000..fbd9351 Binary files /dev/null and b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Resources/template.odt differ diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/migration.json b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/migration.json new file mode 100644 index 0000000..e541cfc --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/migration.json @@ -0,0 +1,12 @@ +[ + { + "Task": "Frends.Odf.WriteTextDocument", + "Migrations": [ + { + "Version": "1.0.0", + "Description": "", + "Migration": [] + } + ] + } +] diff --git a/Frends.Odf.WriteTextDocument/README.md b/Frends.Odf.WriteTextDocument/README.md new file mode 100644 index 0000000..479e86f --- /dev/null +++ b/Frends.Odf.WriteTextDocument/README.md @@ -0,0 +1,34 @@ +# Frends.Odf.WriteTextDocument + +Generate an OpenDocument Text (.odt) file by injecting user-input JSON data into a built-in template. + +[![WriteTextDocument_build](https://github.com/FrendsPlatform/Frends.Odf/actions/workflows/WriteTextDocument_test_on_main.yml/badge.svg)](https://github.com/FrendsPlatform/Frends.Odf/actions/workflows/WriteTextDocument_test_on_main.yml) +![Coverage](https://app-github-custom-badges.azurewebsites.net/Badge?key=FrendsPlatform/Frends.Odf/Frends.Odf.WriteTextDocument|main) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) + +## Installing + +You can install the Task via Frends UI Task View. + +## Building + +### Clone a copy of the repository + +`git clone https://github.com/FrendsPlatform/Frends.Odf.git` + +### Build the project + +`dotnet build` + +### Run tests + +Run the tests + +`dotnet test` + +### Create a NuGet package + +`dotnet pack --configuration Release` + +### StyleCop.Analyzers Version +This project uses StyleCop.Analyzers 1.2.0-beta.556, as recommended by the author, to get the latest fixes and improvements not available in the last stable release. diff --git a/README.md b/README.md index 6a7b00e..833a397 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Frends tasks for OpenDocument Format (ODF) related operations. - [Frends.Odf.ReadTextDocument](Frends.Odf.ReadTextDocument/README.md) - [Frends.Odf.ReadSpreadsheet](Frends.Odf.ReadSpreadsheet/README.md) +- [Frends.Odf.WriteTextDocument](Frends.Odf.WriteTextDocument/README.md) # Contributing