e4fdde15aa
- EditPostView now edits Schedule / Visibility / Language / Mood / Tags; form state lives in an @Observable model to prevent init-time @State resets that were silently dropping selections. Visibility/Mood rows switched away from Menu/confirmationDialog (which refused to propagate selection inside the sheet) to an inline segmented Picker and a horizontal emoji strip + free text field. Shared across Compose and Edit via PostOptionRows. - Polls now accept lang/tags/mood/visibility (spec-compliant); ComposeView no longer hides those rows when Poll is on. NewPostOptions.poll signature extended with the new optional params. - Compose warns before publishing a mention-only post that has no [[org-social:URL][nick]] links in the body (would otherwise be invisible). - Reactions can be removed: ReactionViewModel gains loadExistingReaction (scans own feed on row appear so the toggled state survives relaunches) and unreact (deletes the reaction post). Tapping the React button when already reacted now removes the reaction. - PostRowView strips raw `- [ ] Option` checkbox lines from poll body render so the poll card is the sole UI for options. - PostWriter.editPost learns MOOD; OrgSocialPost.mood promoted to var so timeline/profile applyEdit can update it.
468 lines
19 KiB
Swift
468 lines
19 KiB
Swift
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..<range.upperBound)
|
|
return cleanExcessBlankLines(lines.joined(separator: "\n"))
|
|
}
|
|
|
|
/// Replaces the body text of the post with `timestamp` in `content`.
|
|
///
|
|
/// All post properties (`:LANG:`, `:TAGS:`, `:CLIENT:`, etc.) are preserved;
|
|
/// only the text after `:END:` is replaced.
|
|
///
|
|
/// - Parameters:
|
|
/// - timestamp: RFC 3339 timestamp of the post to edit.
|
|
/// - newText: Replacement body text. Surrounding whitespace is trimmed.
|
|
/// - content: Current raw text of the `social.org` file.
|
|
/// - Returns: Updated feed content.
|
|
/// - Throws: `PostWriterError.postNotFound` if no post matches `timestamp`.
|
|
public func editPost(timestamp: String, newText: String, in content: String) throws -> 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..<bodyEnd, with: newBody)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
/// Fully edits a post's body and editable properties, preserving everything else.
|
|
///
|
|
/// `LANG`, `TAGS`, `MOOD`, and `VISIBILITY` are overwritten: passing a non-empty value
|
|
/// sets them, passing `nil` or empty removes them. All other properties (`REPLY_TO`,
|
|
/// `INCLUDE`, `GROUP`, `POLL_END`, `POLL_OPTION`, `MIGRATION`, `CLIENT`, etc.) are kept.
|
|
///
|
|
/// - Parameters:
|
|
/// - timestamp: RFC 3339 timestamp of the post to edit (original).
|
|
/// - content: Current raw text of the `social.org` file.
|
|
/// - newText: Replacement body text. Surrounding whitespace is trimmed.
|
|
/// - newTimestamp: If non-nil, replace the heading timestamp (the post's ID).
|
|
/// - lang: New `LANG` value; `nil` or empty removes the property.
|
|
/// - tags: New `TAGS` value; `nil` or empty removes the property.
|
|
/// - mood: New `MOOD` value; `nil` or empty removes the property.
|
|
/// - visibility: New `VISIBILITY`. `.public` removes the property (public is the default).
|
|
public func editPost(
|
|
timestamp: String,
|
|
in content: String,
|
|
newText: String,
|
|
newTimestamp: String? = nil,
|
|
lang: String? = nil,
|
|
tags: String? = nil,
|
|
mood: String? = nil,
|
|
visibility: NewPostOptions.Visibility = .public
|
|
) throws -> 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<String> = ["LANG", "TAGS", "MOOD", "VISIBILITY", "ID"]
|
|
var preservedProps: [String] = []
|
|
if let ps = propsStart, let pe = propsEnd {
|
|
for i in (ps + 1)..<pe {
|
|
let line = lines[i]
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
if t.hasPrefix(":"), let endKey = t.dropFirst().firstIndex(of: ":") {
|
|
let key = String(t.dropFirst()[..<endKey]).uppercased()
|
|
if overriddenKeys.contains(key) { continue }
|
|
}
|
|
preservedProps.append(line)
|
|
}
|
|
}
|
|
|
|
// Build the new block
|
|
let effectiveTimestamp = newTimestamp ?? timestamp
|
|
var block: [String] = []
|
|
block.append("** \(effectiveTimestamp)")
|
|
block.append(":PROPERTIES:")
|
|
block.append(contentsOf: preservedProps)
|
|
if let lang = lang?.trimmingCharacters(in: .whitespaces), !lang.isEmpty {
|
|
block.append(":LANG: \(lang)")
|
|
}
|
|
if let tags = tags?.trimmingCharacters(in: .whitespaces), !tags.isEmpty {
|
|
block.append(":TAGS: \(tags)")
|
|
}
|
|
if let mood = mood?.trimmingCharacters(in: .whitespaces), !mood.isEmpty {
|
|
block.append(":MOOD: \(mood)")
|
|
}
|
|
if visibility == .mention {
|
|
block.append(":VISIBILITY: mention")
|
|
}
|
|
block.append(":END:")
|
|
let trimmedText = newText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmedText.isEmpty {
|
|
block.append("")
|
|
block.append(trimmedText)
|
|
}
|
|
|
|
lines.replaceSubrange(range, with: block)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
/// Uploads `content` to the vhost at `feedURL`.
|
|
///
|
|
/// The vhost host is derived from `feedURL` (scheme + host). The upload
|
|
/// endpoint is `POST {host}/upload` with `multipart/form-data` containing:
|
|
/// - `vfile`: the full feed URL (so the host knows which file to replace)
|
|
/// - `file`: the new file content
|
|
///
|
|
/// - Parameters:
|
|
/// - content: Updated feed content to upload.
|
|
/// - feedURL: Public URL of the feed (e.g. `https://host.example.com/social.org`).
|
|
/// - Throws: `PostWriterError` on network or HTTP failure.
|
|
public func upload(content: String, to feedURL: URL) async throws {
|
|
guard let host = hostURL(from: feedURL) else {
|
|
throw PostWriterError.networkError(underlying: "Could not derive host URL from \(feedURL)")
|
|
}
|
|
guard let uploadURL = URL(string: "\(host)/upload") else {
|
|
throw PostWriterError.networkError(underlying: "Invalid upload URL")
|
|
}
|
|
|
|
let boundary = "----OrgSocialBoundary\(UInt32.random(in: 0..<UInt32.max))"
|
|
let body = buildMultipartBody(
|
|
boundary: boundary,
|
|
feedURL: feedURL.absoluteString,
|
|
content: content
|
|
)
|
|
|
|
var request = URLRequest(url: uploadURL)
|
|
request.httpMethod = "POST"
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = body
|
|
|
|
let response: URLResponse
|
|
do {
|
|
(_, response) = try await session.data(for: request)
|
|
} catch {
|
|
throw PostWriterError.networkError(underlying: error.localizedDescription)
|
|
}
|
|
|
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
throw PostWriterError.uploadFailed(statusCode: http.statusCode)
|
|
}
|
|
}
|
|
|
|
// MARK: - Timestamp
|
|
|
|
/// Generates an RFC 3339 timestamp in the form `YYYY-MM-DDTHH:MM:SS±HHMM`,
|
|
/// using the device's local timezone. Matches the Emacs reference
|
|
/// client's `format-time-string "%Y-%m-%dT%H:%M:%S%z"`, e.g.
|
|
/// `2025-12-30T18:30:15-0200` (the compact form shown in the Org Social spec).
|
|
public func generateTimestamp(for date: Date = Date()) -> 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<Int>? {
|
|
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..<blockEnd
|
|
}
|
|
i = blockEnd
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Returns the exclusive end index of the block starting at `start`
|
|
/// (the index of the next `**` heading or top-level heading, or EOF).
|
|
private func blockEndIndex(after start: Int, in lines: [String]) -> Int {
|
|
for i in (start + 1)..<lines.count {
|
|
let t = lines[i].trimmingCharacters(in: .whitespaces)
|
|
if t.hasPrefix("**") { return i }
|
|
if t.hasPrefix("* ") && !t.hasPrefix("** ") { return i }
|
|
}
|
|
return lines.count
|
|
}
|
|
|
|
/// Returns `true` if the block `[start, end)` belongs to the post with `timestamp`.
|
|
/// Checks both the `** TIMESTAMP` heading and `:ID: TIMESTAMP` in properties.
|
|
private func blockMatchesTimestamp(_ timestamp: String, lines: [String], start: Int, end: Int) -> 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)..<end {
|
|
let t = lines[i].trimmingCharacters(in: .whitespaces)
|
|
if t == ":END:" { break }
|
|
if t.hasPrefix(":ID:") {
|
|
let val = String(t.dropFirst(4)).trimmingCharacters(in: .whitespaces)
|
|
if val == timestamp { return true }
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Collapses runs of 3+ consecutive newlines down to 2.
|
|
private func cleanExcessBlankLines(_ content: String) -> 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)"
|
|
}
|
|
}
|