Files
andros 9e126297ac Groups follow/unfollow: bump to 1.2 and fix App Store version vars
- 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.
2026-05-16 11:31:30 +02:00

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