Files

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