Files
andros d26b9ab3b6 Add boost, poll, migration, pinned posts, edit profile, follow/unfollow, groups, visibility badge, mood, scheduled posts
- 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
2026-04-19 18:46:01 +02:00

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" }
}
}