124 lines
5.0 KiB
Swift
124 lines
5.0 KiB
Swift
import Foundation
|
|
|
|
/// Fetches a full conversation thread, resolving all post URLs to `OrgSocialPost` objects.
|
|
///
|
|
/// Groups posts by feed URL so each unique `social.org` is downloaded only once.
|
|
public struct ThreadFetcher: Sendable {
|
|
|
|
private let threadClient: ThreadClient
|
|
private let profileFetcher: ProfileFetcher
|
|
|
|
public init(session: URLSession = .shared) {
|
|
self.threadClient = ThreadClient(session: session)
|
|
self.profileFetcher = ProfileFetcher(session: session)
|
|
}
|
|
|
|
/// Fetches the full thread for a post URL (e.g. `https://andros.dev/social.org#2025-…`).
|
|
///
|
|
/// - Parameters:
|
|
/// - postURL: The canonical post URL including `#timestamp`.
|
|
/// - relayURL: Base URL of the relay.
|
|
/// - Returns: A fully-resolved `OrgSocialThread`.
|
|
public func fetchThread(for postURL: String, from relayURL: URL) async throws -> OrgSocialThread {
|
|
let raw = try await threadClient.fetchRawThread(for: postURL, from: relayURL)
|
|
|
|
// Collect all unique feed URLs we need to download
|
|
var allPostURLs: [String] = [raw.parentURL] + raw.parentChainURLs
|
|
collectPostURLs(from: raw.replies, into: &allPostURLs)
|
|
|
|
// Group by feed URL (everything before the `#`)
|
|
let feedURLs = Set(allPostURLs.compactMap { feedURL(from: $0) })
|
|
|
|
// Fetch all feeds concurrently; skip feeds that are unavailable
|
|
let profiles = await withTaskGroup(of: (String, OrgSocialProfile)?.self) { group in
|
|
for feedURLStr in feedURLs {
|
|
group.addTask {
|
|
guard let url = URL(string: feedURLStr) else { return nil }
|
|
guard let profile = try? await self.profileFetcher.fetch(from: url) else { return nil }
|
|
return (feedURLStr, profile)
|
|
}
|
|
}
|
|
var result: [String: OrgSocialProfile] = [:]
|
|
for await item in group {
|
|
if let (key, profile) = item { result[key] = profile }
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Build a lookup: normalizedPostURL → OrgSocialPost (enriched with author metadata)
|
|
// Store both colon ("+01:00") and no-colon ("+0100") forms since the relay
|
|
// and the parser may use different timezone formats.
|
|
var lookup: [String: OrgSocialPost] = [:]
|
|
for (feedURLStr, profile) in profiles {
|
|
let feedURL = URL(string: feedURLStr)
|
|
for var post in profile.posts {
|
|
post.authorNick = profile.nick
|
|
post.authorURL = feedURL
|
|
post.authorAvatar = profile.avatar
|
|
post.feedURL = feedURL
|
|
let canonical = "\(feedURLStr)#\(post.timestamp)"
|
|
let compact = "\(feedURLStr)#\(compactTimezone(post.timestamp))"
|
|
lookup[canonical] = post
|
|
lookup[compact] = post
|
|
}
|
|
}
|
|
|
|
// Resolve focal post
|
|
guard let focalPost = lookup[raw.parentURL] else {
|
|
throw RelayError.invalidResponse
|
|
}
|
|
|
|
// Relay returns parentChain oldest-first (root at index 0)
|
|
let parentChain = raw.parentChainURLs.compactMap { lookup[$0] }
|
|
|
|
// Resolve reply tree
|
|
let replies = raw.replies.map { resolveNode($0, lookup: lookup) }
|
|
|
|
return OrgSocialThread(
|
|
focalPost: focalPost,
|
|
parentChain: Array(parentChain),
|
|
replies: replies,
|
|
moods: raw.moods
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Removes the colon from the timezone offset: `+01:00` → `+0100`.
|
|
private func compactTimezone(_ ts: String) -> String {
|
|
guard let range = ts.range(of: #"([+-]\d{2}):(\d{2})$"#, options: .regularExpression) else {
|
|
return ts
|
|
}
|
|
return ts.replacingCharacters(in: range, with: ts[range].replacingOccurrences(of: ":", with: ""))
|
|
}
|
|
|
|
private func feedURL(from postURL: String) -> String? {
|
|
postURL.components(separatedBy: "#").first
|
|
}
|
|
|
|
private func collectPostURLs(from nodes: [RawThreadNode], into urls: inout [String]) {
|
|
for node in nodes {
|
|
urls.append(node.postURL)
|
|
collectPostURLs(from: node.children, into: &urls)
|
|
}
|
|
}
|
|
|
|
private func resolveNode(_ node: RawThreadNode, lookup: [String: OrgSocialPost]) -> OrgSocialThreadNode {
|
|
let post = lookup[node.postURL] ?? fallbackPost(for: node.postURL)
|
|
let children = node.children.map { resolveNode($0, lookup: lookup) }
|
|
return OrgSocialThreadNode(post: post, children: children, moods: node.moods)
|
|
}
|
|
|
|
/// Creates a minimal post for URLs we couldn't resolve (feed unavailable).
|
|
private func fallbackPost(for postURL: String) -> OrgSocialPost {
|
|
let parts = postURL.components(separatedBy: "#")
|
|
let feedURLStr = parts.first ?? postURL
|
|
let timestamp = parts.count > 1 ? parts[1] : ""
|
|
let feedURL = URL(string: feedURLStr)
|
|
var post = OrgSocialPost(timestamp: timestamp, date: Date(), text: "")
|
|
post.authorURL = feedURL
|
|
post.feedURL = feedURL
|
|
return post
|
|
}
|
|
}
|