Repository: TootSDK/TootSDK (swift)

Issue:
Post.application init param doesn't match the property
 The Post application property is optional so the corresponding init param should be changed from

```swift
application: TootApplication,
```
to
```swift
application: TootApplication?,
```

Pull request description:
Access to the translation functions added in Mastodon 4.x via TootClient.getPostTranslation and TootClient.getTranslationLanguages. Includes fix #245 to facilitate Post copy (I place the translation results in a copy of the original post, the Post copy init is in a TootSDK extension package at https://github.com/technicat/fedicat)

Patch:
diff --git a/Package.swift b/Package.swift
index f0a7b636..9fb80b46 100644
--- a/Package.swift
+++ b/Package.swift
@@ -77,6 +77,8 @@ let package = Package(
                 .copy("Resources/scheduled_post_multiple_attachments.json"),
                 .copy("Resources/scheduled_post_reply.json"),
                 .copy("Resources/tag.json"),
+                .copy("Resources/translation_attachment.json"),
+                .copy("Resources/translation_poll.json"),
                 .copy("Resources/encrypted_push_notification.base64"),
             ]),
     ]
diff --git a/Sources/TootSDK/Models/Post.swift b/Sources/TootSDK/Models/Post.swift
index 6095641c..7aceef24 100644
--- a/Sources/TootSDK/Models/Post.swift
+++ b/Sources/TootSDK/Models/Post.swift
@@ -15,7 +15,7 @@ public class Post: Codable, Identifiable, @unchecked Sendable {
         sensitive: Bool,
         spoilerText: String,
         mediaAttachments: [MediaAttachment],
-        application: TootApplication,
+        application: TootApplication?,
         mentions: [Mention],
         tags: [Tag],
         emojis: [Emoji],
diff --git a/Sources/TootSDK/Models/PostTranslationParams.swift b/Sources/TootSDK/Models/PostTranslationParams.swift
new file mode 100644
index 00000000..adf1d345
--- /dev/null
+++ b/Sources/TootSDK/Models/PostTranslationParams.swift
@@ -0,0 +1,20 @@
+//
+//  TranslatePostParams.swift
+//
+//
+//  Created by Philip Chu on 1/30/24.
+//
+
+import Foundation
+
+/// Params to translate a post
+public struct PostTranslationParams: Codable {
+    /// (ISO 639 language code).
+    /// The post content will be translated into this language.
+    /// Defaults to the user’s current locale.
+    public var lang: String
+
+    public init(lang: String) {
+        self.lang = lang
+    }
+}
diff --git a/Sources/TootSDK/Models/Translation.swift b/Sources/TootSDK/Models/Translation.swift
new file mode 100644
index 00000000..1a6c4378
--- /dev/null
+++ b/Sources/TootSDK/Models/Translation.swift
@@ -0,0 +1,70 @@
+//
+//  Translation.swift
+//
+//
+//  Created by Philip Chu on 1/30/24.
+//
+
+import Foundation
+
+public struct Translation: Codable {
+
+    /// HTML-encoded translated content of the status.
+    public var content: String
+    /// The translated spoiler warning of the status.
+    /// The Mastodon spec incorrectly lists this as spoiler_warning.
+    public var spoilerText: String
+    /// The translated poll options of the status.
+    /// The Mastodon spec incorrectly lists this as an array.
+    public var poll: Poll?
+    /// The translated media descriptions of the status
+    public var mediaAttachments: [MediaAttachment]
+    /// The language of the source text, as auto-detected by the machine translation provider.
+    /// ISO 639 language code
+    public var detectedSourceLanguage: String
+    /// The service that provided the machine translation.
+    public var provider: String
+    /// The target language
+    /// Not in the Mastodon spec, but in the Mastodon code
+    public var language: String
+
+    public struct MediaAttachment: Codable, Hashable, Identifiable, Sendable {
+
+        public init(
+            id: String,
+            description: String? = nil
+        ) {
+            self.id = id
+            self.description = description
+        }
+
+        /// The ID of the attachment in the database.
+        public var id: String
+        /// Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.
+        public var description: String?
+    }
+
+    public struct Poll: Codable, Hashable, Identifiable, Sendable {
+        public init(
+            id: String,
+            options: [Poll.Option]
+        ) {
+            self.id = id
+            self.options = options
+        }
+
+        /// The ID of the poll in the database.
+        public var id: String
+        /// Possible answers for the poll.
+        public var options: [Option]
+
+        public struct Option: Codable, Hashable, Sendable {
+            public init(title: String) {
+                self.title = title
+            }
+
+            /// The text value of the poll option. String.
+            public var title: String
+        }
+    }
+}
diff --git a/Sources/TootSDK/TootClient/TootClient+Instance.swift b/Sources/TootSDK/TootClient/TootClient+Instance.swift
index a11a70d7..d28b5022 100644
--- a/Sources/TootSDK/TootClient/TootClient+Instance.swift
+++ b/Sources/TootSDK/TootClient/TootClient+Instance.swift
@@ -25,6 +25,7 @@ extension TootClient {
         return try await fetch([InstanceRule].self, req)
     }
 
+    /// Obtain an extended description of this server
     public func getExtendedDescription() async throws -> ExtendedDescription {
         try requireFeature(.extendedDescription)
         let req = HTTPRequestBuilder {
@@ -33,6 +34,16 @@ extension TootClient {
         }
         return try await fetch(ExtendedDescription.self, req)
     }
+
+    /// Translation language pairs supported by the translation engine used by the server.
+    public func getTranslationLanguages() async throws -> [String: [String]] {
+        try requireFeature(.translatePost)
+        let req = HTTPRequestBuilder {
+            $0.url = getURL(["api", "v1", "instance", "translation_languages"])
+            $0.method = .get
+        }
+        return try await fetch([String: [String]].self, req)
+    }
 }
 
 extension TootFeature {
diff --git a/Sources/TootSDK/TootClient/TootClient+Post.swift b/Sources/TootSDK/TootClient/TootClient+Post.swift
index e52126de..8a05cca4 100644
--- a/Sources/TootSDK/TootClient/TootClient+Post.swift
+++ b/Sources/TootSDK/TootClient/TootClient+Post.swift
@@ -242,6 +242,20 @@ extension TootClient {
 
 }
 
+extension TootClient {
+
+    /// Translate the post content into some language.
+    public func getPostTranslation(id: String, params: PostTranslationParams? = nil) async throws -> Translation {
+        try requireFeature(.translatePost)
+        let req = try HTTPRequestBuilder {
+            $0.url = getURL(["api", "v1", "statuses", id, "translate"])
+            $0.method = .post
+            $0.body = try .json(params, encoder: self.encoder)
+        }
+        return try await fetch(Translation.self, req)
+    }
+}
+
 extension TootFeature {
 
     /// Ability to bookmark posts
@@ -255,3 +269,10 @@ extension TootFeature {
     ///
     public static let mutePost = TootFeature(supportedFlavours: [.mastodon, .akkoma, .pleroma, .pixelfed, .friendica, .firefish])
 }
+
+extension TootFeature {
+
+    /// Ability to translate a post
+    ///
+    public static let translatePost = TootFeature(supportedFlavours: [.mastodon])
+}


Test patch:
diff --git a/Tests/TootSDKTests/PostTranslationTests.swift b/Tests/TootSDKTests/PostTranslationTests.swift
new file mode 100644
index 00000000..a36d571c
--- /dev/null
+++ b/Tests/TootSDKTests/PostTranslationTests.swift
@@ -0,0 +1,47 @@
+//
+//  PostTranslationTests.swift
+//  
+//
+//  Created by Philip Chu on 2/10/24.
+//
+
+import XCTest
+@testable import TootSDK
+
+final class PostTranslationTests: XCTestCase {
+    
+    func testTranslatedPostPoll() throws {
+         // arrange
+         let json = localContent("translation_poll")
+         let decoder = TootDecoder()
+
+         // act
+         let result = try decoder.decode(Translation.self, from: json)
+
+         // assert
+         XCTAssertNotNil(result)
+         XCTAssertEqual(result.detectedSourceLanguage, "es")
+         XCTAssertEqual(result.provider, "LibreTranslate")
+         XCTAssertNotNil(result.poll)
+         XCTAssertEqual(result.poll!.id, "255331")
+         XCTAssertEqual(result.poll!.options[0].title, "One")
+         XCTAssertEqual(result.poll!.options[1].title, "Two.")
+         XCTAssertEqual(result.poll!.options[2].title, "Three.")
+     }
+    
+     func testTranslatedPostAttachment() throws {
+         // arrange
+         let json = localContent("translation_attachment")
+         let decoder = TootDecoder()
+
+         // act
+         let result = try decoder.decode(Translation.self, from: json)
+
+         // assert
+         XCTAssertNotNil(result)
+         XCTAssertEqual(result.detectedSourceLanguage, "de")
+         XCTAssertEqual(result.provider, "DeepL.com")
+         XCTAssertNil(result.poll)
+         XCTAssertEqual(result.mediaAttachments[0].description, "three cats")
+     }
+}
diff --git a/Tests/TootSDKTests/Resources/translation_attachment.json b/Tests/TootSDKTests/Resources/translation_attachment.json
new file mode 100644
index 00000000..3d56f504
--- /dev/null
+++ b/Tests/TootSDKTests/Resources/translation_attachment.json
@@ -0,0 +1,14 @@
+{
+  "detected_source_language" : "de",
+  "provider" : "DeepL.com",
+  "content" : "<p>Test cats</p>",
+  "poll" : null,
+  "media_attachments" : [
+    {
+      "id" : "111897950658086793",
+      "description" : "three cats"
+    }
+  ],
+  "language" : "en",
+  "spoiler_text" : ""
+}
diff --git a/Tests/TootSDKTests/Resources/translation_poll.json b/Tests/TootSDKTests/Resources/translation_poll.json
new file mode 100644
index 00000000..58cf578e
--- /dev/null
+++ b/Tests/TootSDKTests/Resources/translation_poll.json
@@ -0,0 +1,24 @@
+{
+  "detected_source_language" : "es",
+  "provider" : "LibreTranslate",
+  "content" : "<p>a test</p>",
+  "poll" : {
+    "id" : "255331",
+    "options" : [
+      {
+        "title" : "One"
+      },
+      {
+        "title" : "Two."
+      },
+      {
+        "title" : "Three."
+      }
+    ]
+  },
+  "media_attachments" : [
+
+  ],
+  "language" : "en",
+  "spoiler_text" : ""
+}