9e126297ac
- Groups tab: Follow/Following button per row, driven by #+GROUP: entries in the user's social.org. Orphaned groups (followed but not on current relay) appear at bottom with a distinct icon and "Not on current relay" caption. - ProfileWriter: addGroup and removeGroup pure string helpers for managing #+GROUP: header lines. - GroupsViewModel: load relay groups + own feed in parallel, merge into GroupDisplayItem list with isFollowed state; toggleFollow uses UploaderFactory so authentication is handled by the correct uploader. - GroupsView: Follow button only shown when canFollow (publicFeedURL set); uses Button + navigationDestination(item:) instead of NavigationLink to avoid the iOS list auto-fire bug. - Info.plist: CFBundleShortVersionString and CFBundleVersion now use build setting variables instead of hardcoded values, fixing App Store upload version mismatch. - Bump MARKETING_VERSION to 1.2, CURRENT_PROJECT_VERSION to 10, and app category to Social Networking.
190 lines
7.7 KiB
Swift
190 lines
7.7 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: - Group management
|
|
|
|
/// Adds a `#+GROUP: name relayURL` entry to the header.
|
|
///
|
|
/// Inserts after the last existing `#+GROUP:` line, or just before `* Posts`.
|
|
/// Idempotent: does nothing if an identical line already exists.
|
|
@discardableResult
|
|
public func addGroup(name: String, relayURL: URL, to content: String) -> String {
|
|
let relayBase = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let newLine = "#+GROUP: \(name) \(relayBase)"
|
|
var lines = content.components(separatedBy: "\n")
|
|
|
|
if lines.contains(where: { $0.trimmingCharacters(in: .whitespaces) == newLine }) {
|
|
return content
|
|
}
|
|
|
|
if let lastGroupIdx = lines.lastIndex(where: {
|
|
$0.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+GROUP:")
|
|
}) {
|
|
lines.insert(newLine, at: lastGroupIdx + 1)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
// No existing #+GROUP: — insert before * Posts (skip blank lines before it)
|
|
var postsIdx = postsHeadingIndex(in: lines) ?? lines.count
|
|
while postsIdx > 0 && lines[postsIdx - 1].trimmingCharacters(in: .whitespaces).isEmpty {
|
|
postsIdx -= 1
|
|
}
|
|
lines.insert(newLine, at: postsIdx)
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
/// Removes all `#+GROUP:` lines matching `name` and `relayURL`.
|
|
@discardableResult
|
|
public func removeGroup(name: String, relayURL: URL, from content: String) -> String {
|
|
let relayBase = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
return content.components(separatedBy: "\n")
|
|
.filter { line in
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
guard t.uppercased().hasPrefix("#+GROUP:") else { return true }
|
|
let value = String(t.dropFirst("#+GROUP:".count)).trimmingCharacters(in: .whitespaces)
|
|
guard let lastSpace = value.lastIndex(of: " ") else { return true }
|
|
let storedName = String(value[..<lastSpace])
|
|
let storedURL = String(value[value.index(after: lastSpace)...])
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
return !(storedName == name && storedURL == relayBase)
|
|
}
|
|
.joined(separator: "\n")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func postsHeadingIndex(in lines: [String]) -> Int? {
|
|
lines.firstIndex { $0.trimmingCharacters(in: .whitespaces) == "* Posts" }
|
|
}
|
|
}
|