From dc35957e1de1da7f6f98851056096597d9f7f67c Mon Sep 17 00:00:00 2001 From: gabri Date: Wed, 10 Jun 2026 12:16:16 +0300 Subject: [PATCH 1/5] Implement WriteTextDocument --- .../workflows/WriteTextDocument_release.yml | 16 ++ .../WriteTextDocument_test_on_main.yml | 21 +++ .../WriteTextDocument_test_on_push.yml | 22 +++ Frends.Odf.WriteTextDocument/CHANGELOG.md | 7 + .../ErrorHandlerTest.cs | 61 ++++++ .../Frends.Odf.WriteTextDocument.Tests.csproj | 28 +++ .../FunctionalTests.cs | 178 ++++++++++++++++++ .../GlobalSuppressions.cs | 8 + .../Helpers/TestHelper.cs | 24 +++ .../TestBase.cs | 44 +++++ .../Frends.Odf.WriteTextDocument.sln | 40 ++++ .../Definitions/Enums.cs | 17 ++ .../Definitions/Error.cs | 21 +++ .../Definitions/Input.cs | 34 ++++ .../Definitions/Options.cs | 25 +++ .../Definitions/Result.cs | 25 +++ .../Frends.Odf.WriteTextDocument.cs | 169 +++++++++++++++++ .../Frends.Odf.WriteTextDocument.csproj | 45 +++++ .../FrendsTaskMetadata.json | 7 + .../GlobalSuppressions.cs | 10 + .../Helpers/ErrorHandler.cs | 55 ++++++ .../Helpers/ValidationHandler.cs | 34 ++++ .../Resources/template.odt | Bin 0 -> 5657 bytes .../migration.json | 12 ++ Frends.Odf.WriteTextDocument/README.md | 34 ++++ README.md | 2 + 26 files changed, 939 insertions(+) create mode 100644 .github/workflows/WriteTextDocument_release.yml create mode 100644 .github/workflows/WriteTextDocument_test_on_main.yml create mode 100644 .github/workflows/WriteTextDocument_test_on_push.yml create mode 100644 Frends.Odf.WriteTextDocument/CHANGELOG.md create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/ErrorHandlerTest.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Frends.Odf.WriteTextDocument.Tests.csproj create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/GlobalSuppressions.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/Helpers/TestHelper.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.sln create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Enums.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Error.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Options.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Result.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/FrendsTaskMetadata.json create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/GlobalSuppressions.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ErrorHandler.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Helpers/ValidationHandler.cs create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Resources/template.odt create mode 100644 Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/migration.json create mode 100644 Frends.Odf.WriteTextDocument/README.md 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..f5446f8 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs @@ -0,0 +1,178 @@ +using System; +using System.IO; +using System.Threading; +using Frends.Odf.WriteTextDocument.Definitions; +using Frends.Odf.WriteTextDocument.Tests.Helpers; +using Newtonsoft.Json.Linq; +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 = JObject.Parse(@"{ ""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 = JArray.Parse("[]"); + + 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 = JArray.Parse(@"[ + { ""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 = JArray.Parse(@"[ ""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 = JArray.Parse(@"[ + { ""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..b850f1e --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using Frends.Odf.WriteTextDocument.Definitions; +using Newtonsoft.Json.Linq; +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 = JArray.Parse(@"[ + { ""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..c128eda --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs @@ -0,0 +1,34 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json.Linq; + +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 JToken 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..e44683f --- /dev/null +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs @@ -0,0 +1,169 @@ +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 inputted 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 (input.Payload.Type != JTokenType.Array) + throw new ArgumentException("The provided JSON payload must be a valid array of objects."); + + var jsonArray = (JArray)input.Payload; + + 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; + + // Write to a temporary file first to ensure file is completely written. + var tempFilePath = normalizedPath + ".tmp"; + + try + { + using (var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write)) + { + memoryStream.CopyTo(fileStream); + } + + File.Move(tempFilePath, normalizedPath, true); + } + 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..83c6f37 --- /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 inputted 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 0000000000000000000000000000000000000000..fbd9351fb8cb35f6990cb5228cab33bca068813f GIT binary patch literal 5657 zcmZ{o1yEeumWDgH2WcFFySoKQ@W$OW(70P5XmBTZAV_d`r?CctH}0Asf#3wUFyzjg zx$|=0?5bVoZ24E!IcsfMpQ-{p0vh1I3l+e)pBsbWsqwp_JXHr92e7-hGuX`8+1|$7 z%-zPxk;Bu`g5Al?&Bl%0$rgoY<X$}rk#amQld)~lBZlJU&g7SYr3NPGP9lagbE8ELevHN!5io-VL?)PY zMdvZ$gYb|?mthrCS=ip3Y~?Ma-Z*vOfv?B5-n-zKNHOQC8QoagShz^qel1I6p%Sg| zpjIbh-OYvmQ*x1DI&=MNLf}^ztxvM$kkE}FWzbQSC||CXZ@G&OI2Eq%mm3Etf8%8U zVyJm-CC;r1y0u<#yxt-sYUV!ntZKzZ1gy1p7trF_?R9$Pj+W!}c0tx>mfY)rG$W6& zWb$ZVe+gAs2IL`Jeb5>}d47Wo06adz1ODBkW89$}i%*`kfCB*VpPow6ny=YF$}$`d zW{x(NU^n;QuB}Lb+V*l{gdGQW1fPmtdXg3?JNX11J`1U|M>60~P|s-2;gN%oBaxre z4{h9u=l7l()(L+bElr#s{|;g)P&LncUcJ6<98OrN8%Vt+zk3l?5x=?Fn+qsJ)mt$x zQKs%}Fy`QCMax{k0a`FXXTyMYx@d_l>vxVLFRB-qz+|^e?Pa1+C516b_?z>kz5Nzb zMb5#cHj0K+4Zx4}T860a2Hp3gd_h)|mzO1~ptgt~odF?^QV`L{;Nd#UEgwm>0+XY) zg&;VAjHQv<%QyE=U;EF00nTgfUw(>iRD^#m6|lS6Uy-e$5I@a{-AQvwLA|cQVDxH6 zzK#%q;RUX=+)Dc;FQ1^Nh05UNI*&`@6eb+i_E+EM<2%HVwtxfsOFRkQwb_ald{+44 zcK)i$#k9@kd%h8B`QpM0EL+d_gGZls#+d_zi4i4y_}ss89s|HKpIR9$%Pb|Gdq%AD zoa=%aRZ-W24cIoc+0=g!r#Ic2TorU&*lN}Dyx|Z$4mW)()?mnjMNpKSkOoPtr`yTr zI~vdui`#T;R|xy*EO^paXD_s)6m48%Df*z92P^(q0% zV5GE;r038;BMEygjpQz2$CjHJ5a^pL8%~B_Z9od)dCI^B<$J%GvMZ4JT7K=Xv z4|BG?EH*#&Hg9pQ2Y2)fi9YAWi5*s7_F0suy8rrN4bPyKa+&h(On!n#VI#q@mq5dc zxIhBMfcn}6UC8#qiyx&nEo%6eO`+q-HkJy0z@yO_!!_YksQ)tnRNdz#H=aVn92EdS zf0|+La5AEN2V1>5P;f`NB<1^DY;3X78gSxGB7N??|Y=CVvN{O3}W;R zUQVaU=+$QjYlP=wXQ_ExE$=zuTb8*6}UnN-ZKDZ<`)n^D4shRAo`GTGAgmlPuj=6 z7}+%R5$nR!Fjh2+usA1i0=rj{t^bvcWsTLnSBQ+>Eo-y6D@^M|@nVD=qo!jpOSiY?uPk|hOR=$DJ940eVqKDo@PxuKP0>}tr*;nXy^n9 zep$Gll7hq$CYgc(n7QMv_Sx;h)l%eyof`6z3yitRFgm)C{eZVVvdBH{(&%z+2MzM< z4>~Odwn%I^^<`Ed$hsHhhZFH^B*v{5z)@jF&oQljz`T)Z+G1(_GLH1P`YXPcgWv`l zTlUR8^tjOPyXz2tiPtIRS-}e9ZVguD7bSyiS>q6p&7*tK&yjaP`S+f0+ry>&&|jL< zNQ%OdyuAGw2h_B$5%2g~-bY!_@lKu#O_OBi1*EE3ktJd3+VV4-tb;#RB?tOpXoI)0nN(_{xK^4fmeoOJ&Z_5qLmOCbghuoExp&yADHGXy zsya!SieL65f9FaJhZIEoKQ0AwPu6Ssrz4(O0RZ@YnQ(LWwg>-p&TknyI4uj}{_^jB zK!!?-GyBIT5qS{$G&eURH~4fo5Z;}>QfR1*Wktg!XSmuw{x+hT%(grn-SMgEb#xdH zZ8*>P=m*|`<4%Z4A(WhBSju8r`7v)E)r%lfaEM@ftlGpMqgv!OlO936riJg4_gC-^ za@^;1yAEl770LjSXVkF6t9UDiyMDzwVixbpx4eg}g`*6sj-BjN z=xVHOE;HLqI#JA!hiLz1b9{Wl-j)l)uaz*38l}5Vd-$Z=)tx+tojY#K9qO1q=I~rg z8;3?WwFdsijSFdApi8DO*n$63G)%SJZekx3GC|Tnx<==F7IxnF#&vNfH^9wp^#LXC z*Ul#Lz&M>9WE9`a??>H*ZGUuAM@q#K@;y~dQUiHUTk;N)H&nq?1|qwxb}!b+3f83c zw#(sOY5Y}=&}6%#PUm&?ReHbVLXf#+CZxsgaBh3`O+1S8A~3gNRh_qiBS z_98lCWLxjz(e;GM!3_hMKd6YT@2DpaqQ8xf1P69dlzGDT)Orxdy%H$dQ$H1Gin7GL zOu#X`jl+-RRw(xI+T%dw2A%M%BXmXJkC22cY?sh~;-RyqQ#KnY5p4Q089k~;FaP4Q zIO{bhNTt zPoFTtIbFLR(I&gDKSoPH8glbKRUdeRTlU$+3el?hTMP|8WpYNXdp74?EH*SFc#(Kk zaJnJM?6GoSM9;oyXK53IcbKj>xzA#0=2dPsri%{sy|BpEK5L>~M>$FgNrI3rl8Pr* z*%oSwHK!>8Q_@HDN)}y&Xeysn;sYw_h;I5hZbKkQF^CkWrcqn6@|(IX?I`ErsGfdC z=tsSn-pv>_!5fmzU}6_ik}(2)0$4~g9O=}WG(~`>Tu@d6{b26x!$@;mBfbUBgmVLq zHxa`gQ9<$~q@}L~7}~D(9h8B@<++V_AaUs40f{rI8?)B#zZ_vGl_9Wyh7V2VZP7DsCa}?$%HJ! ze_r&yG27jm3;FpDzP?3A^dL6hzT2KM34&`RQKN3yPpWV_C98Z}7(9@LA6Z>;N|NxU z(slcqY#|0&`B!g23bV&8YKots{p4x5-V?98UYFrQpPya$hz3q6rezp6w+}Y-ew6zP zN{`09(fHE6w%mR0&aCpQScY_KEUm$mE(5d*lPb28?Du+6v)blK-8M%OB*qc*?$fM3 z*Fplb!(`sYE8ozsc@}hhk}<#p8yJE0Mt?dDEwP?y^UZjdn$<=w9kX&i$B1Hxi8vnA z!v)<6ak3#uyuY@v{@{P zVBI3IvIu*gsyHuYbH)hK^36`hT`7iVprQ}+KFY$uNW-VE|;{v?rZOzxq zs)D?QkFh6Zd3&!ywPwv2lCRh~@L}Yb4ft_L3Qpr4_yi6UE2vROJS3UIEy92UX9;=q zZGjF7Ssim-B&D(W5BomIhBfKCmOK_QJJ4YZn5}Gubm3Mlp~^zwM?SvIp@i|+VyMu` zmII&@N$O$%8GBaIJ%W2H?bXZ)XCB$jN(4zKx0IU9QY0$}QSfcPWP}Z^cEse<_~^!fU86WWygB#3+uY8(LCSbxO zI0!ZA3%mGK(&jU71NolcaeFyCUa!n3{!v9-e3Id{_y%q>v1uxgbH)2{g$?F!Wd_Wc zGyMjgU7H>nXD=^baPA_DsW|wGP-C$z3;4y3EEcy^x)nX3eDhdO7rlKqs^dx?ZxI1) zwvPC|OzUH}r06ayq2ymd6*hp$^|G027kFRXnd@DVCp&;I`YtP~s)|F|r}k7g*5t+f z_4fXZ;34J#GG)Bh=yl7R{%Q>_hVVR5M^WufodG`o3t>KGRo}cfRq-s}Z#|g5Z>csQ z2MRSS|(;<+#ipbGCkcfRFUE+ce${>4-LV@(orXvWxsaAgG&u%KD%U zcJ}+~)?}Ht%uf2VUb&X!#oYraY5Y)OM@adXP{q9PrJwQmmOsaOQTEtmyw}>u3*Ywg zZw_wI`P+_!pN(ze56~QgB&tiu(_y?^ZrladzZ(vilQ;r}^G*F|FW@4FD_Qb6=sic# zaVFF8wyx>|*E_{u(u*Kpu*QoHs>u>IO)n0}xZ@C1(f%3mQK!p` z)izaKCl8F))G8M%PrZjal;gqRkCiEU+0<9nMR1Ihf)s z!^mB=3^$@XfW~eSgJ2cjc6m<>u@Q3e@PMvCzKv8P%~c{Z=@fa_yKe`nrHs-kM6xMu z%&5(c&a!aI!tA$6!MyCUUn}|$9ank6U;crTux#}vk4?$DZ9n_ov)hOC;egYH7Edkn z`W^Qa9?cgH-!UAdd{GF))HD{Ubc_aqW*K)zByZ8H0;m;AL-nexXEC_)mFdfKJ~GjFk{;(JQ`yR5$LporD%|#B3dv_B)d{+UZK~|fA)NAhk!T&Q z*y>z)?pha<`1aq(73RJ4BOTH}mW+Fn4%ld)Rg*)gpT$on-{tPoX!72wV~j|crWbzYe8zv^*TH%388}5PXqO(B?*9zSC$@H_ zxBwb@;IOS3;#EJardbpRHZU_4!u1L~jJO<=sLr)ZXwAt3`_k24t%kibafuczxH17? zb(TVT)s#ezjFCF_bC)c=qzHv%gC|wrckDF>5E#|oL*!KBTjbAO7WRwS193hUXD#Z# zDA_G`Vw(`|ZN^*RU{RgzK*<7GYy{pd!Kv&6G2C@;&U1G(_fN0)W_`pz_sm_Im|*nJ zD&rh#rJsDc?%$QwiQkqE>JhH?B%gho_lnLoXcHOfV*F9_t^M+7kz;uRf3^Y4v$)s* zGu3Ya0@q!HX43=Qj;q;fosBc9Xj{>yLvF&St1JlncEZ*ujB!3gVLMBBqyHIAQ=fzN zm!FaR1x!X{eQCn}*;OId3t|T!Hb+zzUr1b8p;nSG*@6t8IwmrOxDd2M&;E4*T%`OR z3|@nWjwj>v@qaUpXK-wPWfxB!fPbRO-@SjA{{fe(3cpqVKa>1hq4f0ne+lKE%0JoH z-%5%n2=E_C*q%Raa7(LJc literal 0 HcmV?d00001 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..a14a731 --- /dev/null +++ b/Frends.Odf.WriteTextDocument/README.md @@ -0,0 +1,34 @@ +# Frends.Odf.WriteTextDocument + +Generate an OpenDocument Text (.odt) file by injecting user inputted 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 3fd1a5e..c564744 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Frends tasks for OpenDocument Format (ODF) related operations. # Tasks - [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 From 46cfcd56c7594dea2ed66a1b153833121de82b2c Mon Sep 17 00:00:00 2001 From: gabri Date: Thu, 11 Jun 2026 14:31:36 +0300 Subject: [PATCH 2/5] Coderabbit fixes --- .../Frends.Odf.WriteTextDocument.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs index e44683f..ac03a31 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs @@ -135,8 +135,7 @@ public static Result WriteTextDocument( memoryStream.Position = 0; - // Write to a temporary file first to ensure file is completely written. - var tempFilePath = normalizedPath + ".tmp"; + var tempFilePath = Path.Combine(directory, $"{Path.GetFileName(normalizedPath)}.{Guid.NewGuid():N}.tmp"); try { @@ -145,7 +144,16 @@ public static Result WriteTextDocument( memoryStream.CopyTo(fileStream); } - File.Move(tempFilePath, normalizedPath, true); + 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 { From 66f71b7fa12cfbf4bdd500794a628981add2223a Mon Sep 17 00:00:00 2001 From: gabri Date: Fri, 12 Jun 2026 12:15:24 +0300 Subject: [PATCH 3/5] Changes Input.Payload to string --- .../FunctionalTests.cs | 15 +++++++-------- .../TestBase.cs | 5 ++--- .../Definitions/Input.cs | 3 +-- .../Frends.Odf.WriteTextDocument.cs | 18 ++++++++++++++++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs index f5446f8..e8a2ab4 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/FunctionalTests.cs @@ -3,7 +3,6 @@ using System.Threading; using Frends.Odf.WriteTextDocument.Definitions; using Frends.Odf.WriteTextDocument.Tests.Helpers; -using Newtonsoft.Json.Linq; using NUnit.Framework; namespace Frends.Odf.WriteTextDocument.Tests; @@ -46,7 +45,7 @@ public void Should_Throw_When_Input_FilePath_Is_Incorrect() [Test] public void Should_Throw_When_Input_Content_Is_Incorrect() { - var invalidPayload = JObject.Parse(@"{ ""Name"": ""John"" }"); + var invalidPayload = @"{ ""Name"": ""John"" }"; var input = DefaultInput(); input.Payload = invalidPayload; @@ -91,7 +90,7 @@ public void Should_Overwrite_When_ActionOnExistingFile_Is_Overwrite() [Test] public void Should_Write_Empty_Document_With_Empty_Payload() { - var emptyPayload = JArray.Parse("[]"); + var emptyPayload = "[]"; var input = DefaultInput(); input.Payload = emptyPayload; @@ -110,10 +109,10 @@ public void Should_Write_Empty_Document_With_Empty_Payload() [Test] public void Should_Handle_Unicode_Content() { - var unicodePayload = JArray.Parse(@"[ + var unicodePayload = @"[ { ""Text1"": ""AäÄaOöÖo."" }, { ""Text2"": ""ÖöÄä."" } - ]"); + ]"; var input = DefaultInput(); input.Payload = unicodePayload; @@ -145,7 +144,7 @@ public void Should_Throw_When_File_Extension_Is_Not_Odt() [Test] public void Should_Throw_When_Array_Contains_Non_Objects() { - var invalidPayload = JArray.Parse(@"[ ""This is a string."" ]"); + var invalidPayload = @"[ ""This is a string."" ]"; var input = DefaultInput(); input.Payload = invalidPayload; @@ -158,9 +157,9 @@ public void Should_Throw_When_Array_Contains_Non_Objects() [Test] public void Should_Handle_Null_JSON_Values() { - var nullPayload = JArray.Parse(@"[ + var nullPayload = @"[ { ""Name"": ""John"", ""Role"": null } - ]"); + ]"; var input = DefaultInput(); input.Payload = nullPayload; diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs index b850f1e..0a0393f 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.Tests/TestBase.cs @@ -1,7 +1,6 @@ using System; using System.IO; using Frends.Odf.WriteTextDocument.Definitions; -using Newtonsoft.Json.Linq; using NUnit.Framework; namespace Frends.Odf.WriteTextDocument.Tests; @@ -29,10 +28,10 @@ public void TearDownBase() protected Input DefaultInput() { - var jsonPayload = JArray.Parse(@"[ + var jsonPayload = @"[ { ""Name"": ""John"", ""Test"": ""Test 1"" }, { ""Name"": ""Doe"", ""Test"": ""Test 2"" } - ]"); + ]"; return new Input { diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs index c128eda..c33bd6e 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using Newtonsoft.Json.Linq; namespace Frends.Odf.WriteTextDocument.Definitions; @@ -15,7 +14,7 @@ public class Input /// [ { "Name": "John" }, { "Name": "Jane" } ] [Required] [DefaultValue("")] - public JToken Payload { get; set; } + public string Payload { get; set; } /// /// Full path of the destination for the new .odt 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 index ac03a31..cd73896 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs @@ -58,10 +58,24 @@ public static Result WriteTextDocument( throw new IOException($"File already exists: {normalizedPath}"); } - if (input.Payload.Type != JTokenType.Array) + 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)input.Payload; + var jsonArray = (JArray)parsedPayload; var assembly = typeof(Odf).Assembly; From aa90fdfba0d8ce9aede2e666bf4b7552100f3aa2 Mon Sep 17 00:00:00 2001 From: MichalFrends1 Date: Mon, 15 Jun 2026 11:08:22 +0200 Subject: [PATCH 4/5] fix format --- .../Frends.Odf.WriteTextDocument/Definitions/Input.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs index c33bd6e..f4cbc9d 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Definitions/Input.cs @@ -19,7 +19,7 @@ public class Input /// /// Full path of the destination for the new .odt file. /// - /// c:\temp\foo.odt + /// C:\temp\foo.odt [Required] [DefaultValue("")] public string FilePath { get; set; } = string.Empty; From fbef27436e950495e8e3b85f74b81f88d2025737 Mon Sep 17 00:00:00 2001 From: MichalFrends1 Date: Mon, 15 Jun 2026 11:35:08 +0200 Subject: [PATCH 5/5] fix docs --- .../Frends.Odf.WriteTextDocument.cs | 2 +- .../Frends.Odf.WriteTextDocument.csproj | 2 +- Frends.Odf.WriteTextDocument/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs index cd73896..a8531e4 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.cs @@ -21,7 +21,7 @@ public static class Odf private static readonly XNamespace OfficeNamespace = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"; /// - /// Generate an OpenDocument Text (.odt) file by injecting user inputted JSON data + /// 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". diff --git a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj index 83c6f37..265fefe 100644 --- a/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj +++ b/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument/Frends.Odf.WriteTextDocument.csproj @@ -10,7 +10,7 @@ Frends MIT true - Generate an OpenDocument Text (.odt) file by injecting user inputted JSON data into a built-in template. + 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 diff --git a/Frends.Odf.WriteTextDocument/README.md b/Frends.Odf.WriteTextDocument/README.md index a14a731..479e86f 100644 --- a/Frends.Odf.WriteTextDocument/README.md +++ b/Frends.Odf.WriteTextDocument/README.md @@ -1,6 +1,6 @@ # Frends.Odf.WriteTextDocument -Generate an OpenDocument Text (.odt) file by injecting user inputted JSON data into a built-in template. +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)