import Foundation /// Errors from post creation or vhost upload. public enum PostWriterError: Error, Sendable, Equatable { /// The feed content has no `* Posts` section to append to. case missingPostsSection /// No post with the given timestamp was found in the feed. case postNotFound /// The vhost upload endpoint returned a non-2xx status. case uploadFailed(statusCode: Int) /// A network-level error occurred during upload. case networkError(underlying: String) } extension PostWriterError: LocalizedError { public var errorDescription: String? { switch self { case .missingPostsSection: return "The feed content has no '* Posts' section." case .postNotFound: return "No post with that timestamp was found." case .uploadFailed(let code): return "Upload to vhost failed with HTTP \(code)." case .networkError(let msg): return "Network error during upload: \(msg)" } } } /// Creates new posts and uploads the modified feed to the vhost. /// /// ```swift /// let writer = PostWriter() /// /// let (updatedContent, postURL) = try writer.appendPost( /// to: feedContent, /// feedURL: URL(string: "https://host.example.com/social.org")!, /// options: NewPostOptions(text: "Hello!", lang: "en") /// ) /// /// try await writer.upload(content: updatedContent, to: feedURL) /// ``` public struct PostWriter: Sendable { private let session: URLSession public init(session: URLSession = .shared) { self.session = session } // MARK: - Public API /// Appends a new post to `content` and returns the updated feed string /// together with the new post's canonical URL (`feedURL#timestamp`). /// /// - Parameters: /// - content: Current raw text of the `social.org` file. /// - feedURL: Public URL of the feed (used to build the post URL). /// - options: Post parameters. /// - Returns: `(updatedContent, postURL)` /// - Throws: `PostWriterError.missingPostsSection` if the feed has no `* Posts` section. /// Appends a new post to `content`. /// /// - Parameters: /// - date: Post timestamp. Defaults to `Date()` (now). Pass a future date to /// create a scheduled post (it will be hidden in the timeline until that time). public func appendPost( to content: String, feedURL: URL, options: NewPostOptions, date: Date = Date() ) throws -> (updatedContent: String, postURL: String) { guard content.contains("\n* Posts") || content.hasPrefix("* Posts") else { throw PostWriterError.missingPostsSection } let timestamp = generateTimestamp(for: date) let block = buildPostBlock(timestamp: timestamp, options: options) let updated = appendBlock(block, to: content) let postURL = "\(feedURL.absoluteString)#\(timestamp)" return (updated, postURL) } /// Removes the post with `timestamp` from `content` and returns the updated feed string. /// /// - Parameters: /// - timestamp: RFC 3339 timestamp of the post (as stored in `OrgSocialPost.timestamp`). /// - content: Current raw text of the `social.org` file. /// - Returns: Updated feed content without the deleted post. /// - Throws: `PostWriterError.postNotFound` if no post matches `timestamp`. public func deletePost(timestamp: String, from content: String) throws -> String { var lines = content.components(separatedBy: "\n") guard let range = findBlockRange(timestamp: timestamp, in: lines) else { throw PostWriterError.postNotFound } // Extend range backwards to absorb the blank separator line(s) before the block. var removeFrom = range.lowerBound while removeFrom > 0 && lines[removeFrom - 1].trimmingCharacters(in: .whitespaces).isEmpty { removeFrom -= 1 } lines.removeSubrange(removeFrom.. String { // Body-only edit: keep the properties block intact and replace the text after :END:. var lines = content.components(separatedBy: "\n") guard let range = findBlockRange(timestamp: timestamp, in: lines) else { throw PostWriterError.postNotFound } guard let endIdx = lines[range].firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == ":END:" }) else { throw PostWriterError.postNotFound } let bodyStart = endIdx + 1 let bodyEnd = range.upperBound let trimmed = newText.trimmingCharacters(in: .whitespacesAndNewlines) let newBody: [String] = trimmed.isEmpty ? [""] : ["", trimmed] lines.replaceSubrange(bodyStart.. String { var lines = content.components(separatedBy: "\n") guard let range = findBlockRange(timestamp: timestamp, in: lines) else { throw PostWriterError.postNotFound } // Locate :PROPERTIES: and :END: inside the block var propsStart: Int? = nil var propsEnd: Int? = nil for i in range { let t = lines[i].trimmingCharacters(in: .whitespaces) if t == ":PROPERTIES:" { propsStart = i } if t == ":END:" { propsEnd = i; break } } // Preserve existing properties EXCEPT the ones we control explicitly let overriddenKeys: Set = ["LANG", "TAGS", "MOOD", "VISIBILITY", "ID"] var preservedProps: [String] = [] if let ps = propsStart, let pe = propsEnd { for i in (ps + 1).. String { Self.compactFormatter.string(from: date) } /// Parses any RFC 3339 timestamp the spec allows: compact (`+0200`), /// colon (`+02:00`), or `Z`. public static func parseTimestamp(_ s: String) -> Date? { let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime, .withColonSeparatorInTimeZone] return iso.date(from: normalizeTimezone(s)) } /// Converts a trailing compact timezone (`+0200`) or `Z` to the colon /// form ISO8601DateFormatter requires when `.withColonSeparatorInTimeZone` /// is set. Used by both the parser and the App-side timestamp round-trip. public static func normalizeTimezone(_ s: String) -> String { if s.hasSuffix("Z") { return String(s.dropLast()) + "+00:00" } guard let range = s.range(of: #"([+-])(\d{2})(\d{2})$"#, options: .regularExpression) else { return s } let tz = String(s[range]) let sign = String(tz.prefix(1)) let h = String(tz.dropFirst(1).prefix(2)) let m = String(tz.suffix(2)) return s.replacingCharacters(in: range, with: "\(sign)\(h):\(m)") } private static let compactFormatter: DateFormatter = { let df = DateFormatter() df.dateFormat = "yyyy-MM-dd'T'HH:mm:ssxx" df.timeZone = TimeZone.current df.locale = Locale(identifier: "en_US_POSIX") return df }() // MARK: - Org Mode block builder /// Builds the Org Mode text for a single post block. func buildPostBlock(timestamp: String, options: NewPostOptions) -> String { var lines: [String] = [] lines.append("** \(timestamp)") lines.append(":PROPERTIES:") if let lang = options.lang, !lang.isEmpty { lines.append(":LANG: \(lang)") } if let tags = options.tags, !tags.isEmpty { lines.append(":TAGS: \(tags)") } lines.append(":CLIENT: \(options.client)") if let replyTo = options.replyTo, !replyTo.isEmpty { lines.append(":REPLY_TO: \(replyTo)") } if let include = options.include, !include.isEmpty { lines.append(":INCLUDE: \(include)") } if let group = options.group, !group.isEmpty { lines.append(":GROUP: \(group)") } if let mood = options.mood, !mood.isEmpty { lines.append(":MOOD: \(mood)") } if options.visibility == .mention { lines.append(":VISIBILITY: mention") } if let pollEnd = options.pollEnd { lines.append(":POLL_END: \(generateTimestamp(for: pollEnd))") } if let pollOption = options.pollOption, !pollOption.isEmpty { lines.append(":POLL_OPTION: \(pollOption)") } if let migration = options.migration, !migration.isEmpty { lines.append(":MIGRATION: \(migration)") } lines.append(":END:") lines.append("") if !options.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { lines.append(options.text.trimmingCharacters(in: .whitespacesAndNewlines)) } return lines.joined(separator: "\n") } /// Appends `block` to the `* Posts` section of `content`. /// Ensures exactly one blank line between consecutive posts. private func appendBlock(_ block: String, to content: String) -> String { var result = content // Ensure the file ends with a newline before appending if !result.hasSuffix("\n") { result += "\n" } // Add a blank separator line between existing posts and the new one // (the Elisp client always adds a blank line between posts) if !result.hasSuffix("\n\n") { result += "\n" } result += block // Ensure file ends with a trailing newline if !result.hasSuffix("\n") { result += "\n" } return result } // MARK: - Multipart body private func buildMultipartBody(boundary: String, feedURL: String, content: String) -> Data { var body = Data() let crlf = "\r\n" func append(_ string: String) { if let data = string.data(using: .utf8) { body.append(data) } } // Part 1: vfile field (the feed's public URL) append("--\(boundary)\(crlf)") append("Content-Disposition: form-data; name=\"vfile\"\(crlf)\(crlf)") append(feedURL) append(crlf) // Part 2: file field (the updated social.org content) append("--\(boundary)\(crlf)") append("Content-Disposition: form-data; name=\"file\"; filename=\"social.org\"\(crlf)") append("Content-Type: text/plain; charset=utf-8\(crlf)\(crlf)") if let contentData = content.data(using: .utf8) { body.append(contentData) } append(crlf) // Closing boundary append("--\(boundary)--\(crlf)") return body } // MARK: - Block search helpers /// Returns the line range `[start, end)` of the post block matching `timestamp`, /// or `nil` if not found. `start` is the `**` heading line; `end` is exclusive. private func findBlockRange(timestamp: String, in lines: [String]) -> Range? { var i = 0 while i < lines.count { let trimmed = lines[i].trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("**") else { i += 1; continue } let blockStart = i let blockEnd = blockEndIndex(after: blockStart, in: lines) if blockMatchesTimestamp(timestamp, lines: lines, start: blockStart, end: blockEnd) { return blockStart.. Int { for i in (start + 1).. Bool { let heading = lines[start].trimmingCharacters(in: .whitespaces) if heading.hasPrefix("** ") { let rest = String(heading.dropFirst(3)).trimmingCharacters(in: .whitespaces) if rest == timestamp { return true } } for i in (start + 1).. String { var result = content while result.contains("\n\n\n") { result = result.replacingOccurrences(of: "\n\n\n", with: "\n\n") } return result } // MARK: - Upload helpers private func hostURL(from url: URL) -> String? { guard let scheme = url.scheme, let host = url.host else { return nil } if let port = url.port { return "\(scheme)://\(host):\(port)" } return "\(scheme)://\(host)" } }