d26b9ab3b6
- Library: NewPostOptions factories for boost/poll/vote/migration/reaction; PostWriter emits new properties; ProfileWriter for header manipulation; OrgSocialPollVote model; RelayClient.fetchPollVotes/fetchGroupList/fetchGroupPostURLs - Tests: ProfileWriterTests (14), SpecialPostTests (17) — 117 total, all passing - App: ComposeView/ViewModel support poll options, poll end date, scheduled posts; PostRowView shows boost confirm, poll with vote bars, reaction/migration/visibility badges, mood; BoostViewModel, EditProfileViewModel with async load, GroupsViewModel; EditProfileView, GroupsView with group post list; ProfileView toolbar with follow/unfollow and edit button, pinned post section; full uploader support across all views; Groups tab in RootView
140 lines
5.4 KiB
Swift
140 lines
5.4 KiB
Swift
import Foundation
|
|
|
|
/// Modifies the header section of a `social.org` file.
|
|
///
|
|
/// All methods are pure string transformations — they return an updated copy
|
|
/// of the feed content without performing any network operations.
|
|
///
|
|
/// ```swift
|
|
/// var content = try await FeedFetcher().fetch(from: myFeedURL)
|
|
/// content = ProfileWriter().addFollow(url: friendURL, nick: "friend", to: content)
|
|
/// try await VFileUploader(tokenURL: tokenURL).upload(content: content)
|
|
/// ```
|
|
public struct ProfileWriter: Sendable {
|
|
|
|
public init() {}
|
|
|
|
// MARK: - Single-value keyword
|
|
|
|
/// Sets or replaces a single-value header keyword (e.g. `NICK`, `DESCRIPTION`, `AVATAR`).
|
|
///
|
|
/// - If the keyword already exists, its line is replaced in-place.
|
|
/// - If it does not exist, a new line is inserted before `* Posts`.
|
|
public func setKeyword(_ keyword: String, value: String, in content: String) -> String {
|
|
var lines = content.components(separatedBy: "\n")
|
|
let kwUpper = keyword.uppercased()
|
|
let prefix = "#+\(kwUpper):"
|
|
|
|
// Replace existing line
|
|
var found = false
|
|
for i in lines.indices {
|
|
if lines[i].trimmingCharacters(in: .whitespaces).uppercased().hasPrefix(prefix) {
|
|
lines[i] = "#+\(kwUpper): \(value)"
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found { return lines.joined(separator: "\n") }
|
|
|
|
// Insert before * Posts
|
|
if let postsIdx = postsHeadingIndex(in: lines) {
|
|
lines.insert("#+\(kwUpper): \(value)", at: postsIdx)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
return content + "\n#+\(kwUpper): \(value)"
|
|
}
|
|
|
|
/// Removes a single-value header keyword line if present.
|
|
public func removeKeyword(_ keyword: String, from content: String) -> String {
|
|
let kwUpper = keyword.uppercased()
|
|
let prefix = "#+\(kwUpper):"
|
|
return content.components(separatedBy: "\n")
|
|
.filter { !$0.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix(prefix) }
|
|
.joined(separator: "\n")
|
|
}
|
|
|
|
// MARK: - Multi-value keywords (LINK, CONTACT)
|
|
|
|
/// Replaces all occurrences of a repeatable keyword with a new set of values.
|
|
/// Pass an empty array to remove all lines for that keyword.
|
|
public func setMultiKeyword(_ keyword: String, values: [String], in content: String) -> String {
|
|
let kwUpper = keyword.uppercased()
|
|
let prefix = "#+\(kwUpper):"
|
|
var lines = content.components(separatedBy: "\n")
|
|
|
|
// Remove all existing lines for this keyword
|
|
let firstIdx = lines.firstIndex { $0.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix(prefix) }
|
|
lines.removeAll { $0.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix(prefix) }
|
|
|
|
let newLines = values.map { "#+\(kwUpper): \($0)" }
|
|
if newLines.isEmpty { return lines.joined(separator: "\n") }
|
|
|
|
let insertIdx = firstIdx ?? postsHeadingIndex(in: lines) ?? lines.count
|
|
lines.insert(contentsOf: newLines, at: min(insertIdx, lines.count))
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
// MARK: - Follow management
|
|
|
|
/// Adds a `#+FOLLOW:` entry for `url` with an optional nick.
|
|
///
|
|
/// The new entry is inserted after the last existing `#+FOLLOW:` line,
|
|
/// or before `* Posts` if none exist. Does nothing if already following.
|
|
@discardableResult
|
|
public func addFollow(url: URL, nick: String? = nil, to content: String) -> String {
|
|
guard !isFollowing(url: url, in: content) else { return content }
|
|
|
|
let entry: String
|
|
if let nick, !nick.isEmpty {
|
|
entry = "#+FOLLOW: \(nick) \(url.absoluteString)"
|
|
} else {
|
|
entry = "#+FOLLOW: \(url.absoluteString)"
|
|
}
|
|
|
|
var lines = content.components(separatedBy: "\n")
|
|
|
|
if let lastFollowIdx = lines.lastIndex(where: {
|
|
$0.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+FOLLOW:")
|
|
}) {
|
|
lines.insert(entry, at: lastFollowIdx + 1)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
if let postsIdx = postsHeadingIndex(in: lines) {
|
|
lines.insert(entry, at: postsIdx)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
return content + "\n" + entry
|
|
}
|
|
|
|
/// Removes all `#+FOLLOW:` lines containing `url`.
|
|
@discardableResult
|
|
public func removeFollow(url: URL, from content: String) -> String {
|
|
let urlStr = url.absoluteString
|
|
return content.components(separatedBy: "\n")
|
|
.filter { line in
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
guard t.uppercased().hasPrefix("#+FOLLOW:") else { return true }
|
|
return !t.contains(urlStr)
|
|
}
|
|
.joined(separator: "\n")
|
|
}
|
|
|
|
/// Returns `true` if the content has a `#+FOLLOW:` line containing `url`.
|
|
public func isFollowing(url: URL, in content: String) -> Bool {
|
|
let urlStr = url.absoluteString
|
|
return content.components(separatedBy: "\n").contains { line in
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
return t.uppercased().hasPrefix("#+FOLLOW:") && t.contains(urlStr)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func postsHeadingIndex(in lines: [String]) -> Int? {
|
|
lines.firstIndex { $0.trimmingCharacters(in: .whitespaces) == "* Posts" }
|
|
}
|
|
}
|