import Foundation struct Person: Decodable, Hashable, Identifiable { let did: String let handle: String var avatar: String? var id: String { did } } /// A page of accounts: who follows someone, or whom they follow. struct ActorPage: Decodable, Sendable { let actors: [Person] var cursor: String? } /// A repository as the feed and notifications name it. struct RepoRef: Decodable, Hashable { let did: String let handle: String let name: String var uri: String { "at://\(did)/dev.knotless.repo/\(name)" } var label: String { "\(handle)/\(name)" } } struct Repo: Decodable, Identifiable, Hashable { let uri: String let owner: Person let name: String var description: String? var starCount: Int? var forkCount: Int? var `private`: Bool? var language: Language? var id: String { uri } var ref: RepoRef { RepoRef(did: owner.did, handle: owner.handle, name: name) } } struct Language: Decodable, Hashable { let name: String var color: String? } /// A link a repository or an account shows, by its title or else its host. struct WebLink: Decodable, Hashable { let url: String var title: String? var label: String { title ?? URL(string: url)?.host() ?? url } } struct RepoList: Decodable { var repos: [Repo] = [] } struct RepoDetail: Decodable { let uri: String let owner: Person let name: String var description: String? let defaultBranch: String let openPulls: Int let openIssues: Int var starCount: Int? var `private`: Bool? var links: [WebLink]? var forkOf: Basic? var releases: Releases? var viewer: Viewing? struct Viewing: Decodable { /// The asker's star of it, if they starred it. var star: String? /// What they chose to hear of it: `all`, `releases` or `ignore`. var subscription: String? } struct Basic: Decodable, Hashable { let did: String let handle: String let name: String var ref: RepoRef { RepoRef(did: did, handle: handle, name: name) } } struct Releases: Decodable { let count: Int var release: Latest? struct Latest: Decodable { let package: String let version: String var latest: Bool? var createdAt: String? } } } struct RepoMade: Decodable, Sendable { let uri: String let name: String } struct Branch: Decodable, Identifiable, Hashable { let name: String var id: String { name } } struct BranchList: Decodable { var branches: [Branch] = [] } struct CommitList: Decodable { var commits: [Commit] = [] var cursor: String? } struct Release: Decodable, Identifiable, Hashable { let uri: String let package: String let version: String var latest: Bool? var notes: String? var html: String? var files: [File]? var createdAt: String? var id: String { uri } struct File: Decodable, Hashable { let name: String var size: Int? } } struct ReleaseList: Decodable { var releases: [Release] = [] var cursor: String? } struct Run: Decodable, Identifiable, Hashable { let check: Check let ref: String var pull: Pull? var batch: Int? var subject: String? var id: String { check.id } struct Pull: Decodable, Hashable { var title: String? } } struct RunList: Decodable { var runs: [Run] = [] var cursor: String? var workflows: [Workflow] = [] } /// A workflow on the default branch. struct Workflow: Decodable, Hashable { let name: String /// It runs only when asked for by name. var asked: Bool? /// The first sentence of the comment its file opens with. var about: String? } /// One run, as its runner filed it: each step, and the end of what they printed. struct RunDetail: Decodable, Hashable { let check: Check let ref: String var pull: String? let runner: Person var steps: [Step] = [] /// Each step's after a line `==> `. var log: String? } struct Step: Decodable, Hashable { let name: String /// `success`, `failure`, `skipped` or `cancelled`; `running` only while it runs. let status: String /// Absent when it was killed. var exitCode: Int? var durationMs: Int? } /// The workflows at a ref, and what runs each there. struct WorkflowList: Decodable, Hashable { /// In full. let ref: String var workflows: [Entry] = [] struct Entry: Decodable, Hashable { let name: String let push: Bool let pull: Bool /// Its owner may ask for it at the ref by name. let asked: Bool } } struct CheckImage: Decodable, Hashable { let alt: String /// On this server, from its root. let url: String } struct ItemLabel: Decodable, Hashable { let name: String var color: String? } struct Item: Decodable, Identifiable, Hashable { let uri: String let kind: String let title: String let state: String let author: Person let createdAt: String let comments: Int var draft: Bool? var labels: [ItemLabel]? var id: String { uri } } struct ItemList: Decodable { var items: [Item] = [] var openCount: Int? var closedCount: Int? var cursor: String? } struct DiffRow: Decodable, Hashable { let kind: String let line: Bool var old: Int? var new: Int? let text: String } struct DiffFile: Decodable, Identifiable, Hashable { let old: String let new: String let status: String let added: Int let removed: Int var note: String? var ranges: String? var rows: [DiffRow] = [] var rowCount: Int? var id: String { old + "\u{0}" + new } var path: String { new.isEmpty ? old : new } enum CodingKeys: String, CodingKey { case old, new, status, added, removed, note, ranges, rows, rowCount } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) old = try c.decode(String.self, forKey: .old) new = try c.decode(String.self, forKey: .new) status = try c.decode(String.self, forKey: .status) added = try c.decode(Int.self, forKey: .added) removed = try c.decode(Int.self, forKey: .removed) note = try c.decodeIfPresent(String.self, forKey: .note) ranges = try c.decodeIfPresent(String.self, forKey: .ranges) rows = try c.decodeIfPresent([DiffRow].self, forKey: .rows) ?? [] rowCount = try c.decodeIfPresent(Int.self, forKey: .rowCount) } } struct CommitAuthor: Decodable, Hashable { var name: String? var actor: Person? /// The coding agent whose address it is. var agent: String? var label: String { actor?.handle ?? name ?? "" } /// The agent's own face, where the app has it. var face: String? { agent == "claude" ? "agent-claude" : nil } } struct Commit: Decodable, Identifiable, Hashable { let sha: String let message: String var author: CommitAuthor? var coauthors: [CommitAuthor]? var committer: CommitAuthor? var parents: [Parent]? var date: String? var committedAt: String? var pull: Pull? var id: String { sha } var subject: String { message.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? message } /// What its message says after its subject. var details: String { message.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false).dropFirst().first .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" } var when: String? { committedAt ?? date } var authors: [CommitAuthor] { (author.map { [$0] } ?? []) + (coauthors ?? []) } /// Who committed it, where that's someone other than those who wrote it. var otherCommitter: CommitAuthor? { guard let committer, !authors.contains(where: { $0.label == committer.label }) else { return nil } return committer } struct Parent: Decodable, Hashable { let sha: String } struct Pull: Decodable, Hashable { let uri: String var title: String? } } /// What was searched for, on this phone, newest first, as one string to keep. enum RecentSearches { static let key = "recentSearches" private static let most = 10 static func decode(_ text: String) -> [String] { text.split(separator: "\n").map(String.init) } static func adding(_ query: String, to text: String) -> String { let clean = query.replacing("\n", with: " ").trimmingCharacters(in: .whitespaces) guard !clean.isEmpty else { return text } return ([clean] + decode(text).filter { $0 != clean }).prefix(most).joined(separator: "\n") } } /// `listActorItems`: pulls or issues, each with the repository it's on. struct ActorItems: Decodable, Sendable { let items: [ActorItem] var cursor: String? } struct ActorItem: Decodable, Identifiable, Hashable, Sendable { let item: Item let repo: RepoRef var id: String { item.uri } } /// `searchCode`. struct CodeSearch: Decodable, Sendable { var files: [File]? var cursor: String? struct File: Decodable, Identifiable, Hashable, Sendable { let repo: RepoHit let path: String var language: String? var chunks: [Chunk]? var id: String { repo.did + "/" + repo.name + "/" + path } } struct Chunk: Decodable, Hashable, Sendable { let startLine: Int let content: String var ranges: [Match]? var highlights: String? } struct Match: Decodable, Hashable, Sendable { let line: Int let byteStart: Int let byteEnd: Int } } /// A repository in `listRepos` or `searchCode`, whose owner may not be named. struct RepoHit: Decodable, Hashable, Sendable { let did: String let name: String var description: String? var owner: Person? var `private`: Bool? var starCount: Int? var language: Language? var ref: RepoRef { RepoRef(did: did, handle: owner?.handle ?? did, name: name) } var repo: Repo { Repo( uri: "at://\(did)/dev.knotless.repo/\(name)", owner: owner ?? Person(did: did, handle: did), name: name, description: description, starCount: starCount, private: `private`, language: language) } } struct RepoSearch: Decodable, Sendable { let repos: [RepoHit] var cursor: String? } struct OwnerSearch: Decodable, Sendable { let owners: [Found] struct Found: Decodable, Hashable, Sendable { let owner: Person let repoCount: Int } } struct CommitAnswer: Decodable { let commit: Commit var files: [DiffFile] = [] } struct Changes: Decodable { /// Where its commits can be read, which for an open pull from a fork is the fork. var repo: RepoDetail.Basic? var commits: [Commit]? var files: [DiffFile]? } struct Comment: Decodable, Identifiable, Hashable { var uri: String? let author: Person let createdAt: String var body: String? var html: String? var verdict: String? /// The lines of a pull's diff it's on, if it's on any. var line: Lines? /// The comment starting the thread on those lines that this answers. var reply: String? var resolved: Bool? var reactions: [ReactionCount]? var id: String { uri ?? (author.did + createdAt) } struct Lines: Decodable, Hashable { let path: String var side: String? let line: Int var startLine: Int? let outdated: Bool /// As they were when it was written; absent when that can't be read. var lines: [Text]? struct Text: Decodable, Hashable { let number: Int let text: String } } /// Each thread on lines together: answers straight after what they answer, /// wherever they fell in time, as the pages show them. static func threaded(_ comments: [Comment]) -> [Comment] { let starts = Set(comments.compactMap(\.uri)) var answers: [String: [Comment]] = [:] for comment in comments { if let root = comment.reply, starts.contains(root) { answers[root, default: []].append(comment) } } return comments.flatMap { comment -> [Comment] in if let root = comment.reply, starts.contains(root) { return [] } return [comment] + (comment.uri.flatMap { answers[$0] } ?? []) } } } struct Check: Decodable, Identifiable, Hashable { let uri: String let workflow: String let status: String /// Why it was skipped, when it was. var skipped: String? var startedAt: String? var finishedAt: String? var commit: String? /// Why it couldn't run. var error: String? var images: [CheckImage]? var id: String { uri } } struct Review: Decodable, Hashable { let reviewer: Person var verdict: String? var outdated: Bool? } struct PullHead: Decodable, Hashable { var branch: String? } struct PullThread: Decodable { let item: Item var body: String? var html: String? var base: String? var head: PullHead? var merged: String? var comments: [Comment]? var reviews: [Review]? var checks: [Check]? var changes: Changes? var reactions: [ReactionCount]? var viewer: ThreadViewer? var queue: QueueInfo? } struct ReactionCount: Decodable, Hashable { let content: String var count: Int /// The asker's own reaction of this content, if they reacted so. var reaction: String? } struct ThreadViewer: Decodable { var setState: Bool? } /// Where a pull stands in the merge queue, if it was queued or ejected. struct QueueInfo: Decodable, Hashable { let state: String /// Where it stands, in the server's words. let said: String var reason: String? } /// Whether a pull can merge, and what the asker may do about it. struct MergeStatus: Decodable { let state: String var checks: String? var queueOnly: Bool? var queueBlocked: String? var reviews: String? var reviewsMissing: [Note]? var may: May? struct Note: Decodable { let text: String } struct May: Decodable { var merge: Bool? var queue: Bool? } } struct DiffAnswer: Decodable { var files: [DiffFile] = [] } struct TreeEntry: Decodable, Identifiable, Hashable { let name: String let kind: String var size: Int? var id: String { name } } struct Tree: Decodable { var ref: String? var path: String? var lastCommit: Commit? var entries: [TreeEntry]? var readme: Readme? var checks: [Check]? struct Readme: Decodable { var name: String? } } struct FileAnswer: Decodable { var ref: String? var path: String? var kind: String? var size: Int? var lines: Int? var text: String? var ranges: String? /// A Markdown file, made into HTML. var html: String? } struct Org: Decodable, Hashable { let actor: Person } struct Profile: Decodable { let did: String let handle: String var displayName: String? var description: String? var pronouns: String? var avatar: String? var followersCount: Int? var followsCount: Int? var links: [WebLink]? var viewer: Viewing? var orgs: [Org]? struct Viewing: Decodable { /// The asker's follow of them, if they follow them. var following: String? /// The asker's block of them from all its repositories, if it has one. var blocking: String? } } /// What knotless data the asking account holds, before it deletes it. struct AccountData: Decodable, Sendable { let did: String let collections: [Count] let repos: [Held] let spaces: Int struct Count: Decodable, Hashable, Sendable { let collection: String let count: Int } struct Held: Decodable, Hashable, Sendable { let name: String let `private`: Bool let collaborators: Int var members: Int? /// How many other accounts use it. var sharing: Int { max(collaborators, members ?? 0) } } } /// One part of deleting an account's data. struct DeleteStep: Decodable, Sendable { let deleted: Int let done: Bool } /// The accounts the asker blocks, the latest first. struct BlockList: Decodable, Sendable { let blocks: [Block] struct Block: Decodable, Identifiable, Sendable { let uri: String let subject: Person /// The one repository it's blocked from, or from all of them with none. var repo: From? let createdAt: String var id: String { uri } struct From: Decodable, Sendable { let did: String let name: String var uri: String { "at://\(did)/dev.knotless.repo/\(name)" } } } } struct InboxItem: Decodable, Identifiable, Hashable { let uri: String let author: Person let reason: String var reasonSubject: String? var repo: RepoRef? var title: String? var verdict: String? var text: String? var subjectState: String? let isRead: Bool let createdAt: String var id: String { uri + reason } } struct NotificationList: Decodable { var notifications: [InboxItem] = [] var cursor: String? } struct FeedEvent: Decodable, Identifiable, Hashable { let createdAt: String let actor: Person let repo: RepoRef let action: Action /// The pulls and issues among `events`, each once, by what last happened. static func recent(_ events: [FeedEvent], most: Int = 8) -> [FeedEvent] { var seen = Set() return Array(events.filter { event in guard let subject = event.action.subject, subject.contains("/dev.knotless.pull/") || subject.contains("/dev.knotless.issue/") else { return false } return seen.insert(subject).inserted }.prefix(most)) } var id: String { createdAt + actor.did + repo.label + action.summary } /// An open union: a kind this version doesn't know still shows as an event. struct Action: Decodable, Hashable { let type: String var subject: String? var title: String? var state: String? var subjectState: String? var text: String? var verdict: String? var ref: String? var count: Int? var forkOf: RepoRef? enum CodingKeys: String, CodingKey { case type = "$type", subject, title, state, subjectState, text, verdict, ref, count, forkOf } var kind: String { type.split(separator: "#").last.map(String.init) ?? type } /// Where the subject stands now, or as this event left it on an older server. var standing: String? { subjectState ?? state } /// What the account whose feed this is did, in a line of its own. var yours: String { switch kind { // A queue's runner merges what you opened, so no event of yours says so. case "opened": switch subjectState { case "merged": "Merged" case "closed": "Closed" default: "You opened" } case "changed": "You \(state == "open" ? "reopened" : state ?? "changed")" case "commented": switch verdict { case "approve": "You approved" case "changes": "You asked for changes" default: "You commented" } default: "You updated" } } var branch: String? { ref?.replacingOccurrences(of: "refs/heads/", with: "") } var summary: String { switch kind { case "created": forkOf.map { "forked \($0.label)" } ?? "created this repository" case "pushed": if let tag = ref?.replacing("refs/tags/", with: ""), ref?.hasPrefix("refs/tags/") == true { "pushed tag \(tag)" } else if (count ?? 0) == 0 { "pushed to \(branch ?? "a branch")" } else { count == 1 ? "pushed 1 commit to \(branch ?? "a branch")" : "pushed \(count ?? 0) commits to \(branch ?? "a branch")" } case "deleted": "deleted \(branch ?? "a branch")" case "opened": "opened" case "commented": switch verdict { case "approve": "approved" case "changes": "asked for changes on" default: "commented on" } case "changed": switch state { case "merged": "merged" case "closed": "closed" case "open": "reopened" default: "changed" } case "starred": "starred this repository" default: "did something new" } } } } struct Viewer: Decodable { var actor: Person? var reauth: Bool? } /// What the server asks of clients, of which the app reads its own part. struct ServerDescription: Decodable { struct IOS: Decodable { var minimumBuild: Int var reason: String? } var ios: IOS? } struct Feed: Decodable { var events: [FeedEvent] = [] var cursor: String? } /// A `dev.knotless.theme#palette` as the account keeps it. struct PaletteRecord: Codable, Equatable, Sendable { struct Syntax: Codable, Equatable, Sendable { var comments, strings, keywords, numbers, functions, types, tags: String? } var colorScheme: String? var background, panel, soft, border, text, textMuted, textFaint: String? var primary, onPrimary, accent, success, danger, done, attention, neutral: String? var syntax: Syntax? } /// A theme the app is drawn in, as `appearancePref` and its `night` name one: /// one the pages offer by name, or `custom` with its palette. struct ThemeHalf: Codable, Equatable, Sendable { var theme: String? var palette: PaletteRecord? } /// How the app is drawn: in one theme, or in a day one while the system is /// light and a night one while it is dark. struct ThemeChoice: Codable, Equatable, Sendable { var day: ThemeHalf var night: ThemeHalf? init(day: ThemeHalf, night: ThemeHalf? = nil) { self.day = day self.night = night } } /// A theme of the account's own: one it published, or one it added, as Appearance /// on the pages lists them. struct OwnTheme: Codable, Equatable, Identifiable, Sendable { let uri: String let name: String let palette: PaletteRecord /// Who made it, when that's someone else. var maker: String? /// When it was made or added, which orders them, the latest first. var at: String? var id: String { uri } } /// What `getPreferences` answers, of the kinds the app acts on. struct Preferences: Decodable, Sendable { let notifications: NotificationChoices? /// The theme the pages are drawn in, which the app is drawn in too. let appearance: ThemeChoice? /// The themes people made that the account added to its own, `themesPref`. let themes: [OwnTheme] private struct Added: Decodable { let uri: String let name: String? let palette: PaletteRecord let addedAt: String? } private struct Kind: Decodable { let choices: NotificationChoices? let appearance: ThemeChoice? let themes: [OwnTheme]? private enum Keys: String, CodingKey { case type = "$type", theme, palette, night, themes } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: Keys.self) let type = try c.decodeIfPresent(String.self, forKey: .type) choices = try type == NotificationChoices.type ? NotificationChoices(from: decoder) : nil if type == "dev.knotless.getPreferences#appearancePref" { let day = ThemeHalf( theme: try? c.decodeIfPresent(String.self, forKey: .theme), palette: try? c.decodeIfPresent(PaletteRecord.self, forKey: .palette) ) let night = try? c.decodeIfPresent(ThemeHalf.self, forKey: .night) appearance = ThemeChoice(day: day, night: night) } else { appearance = nil } if type == "dev.knotless.getPreferences#themesPref" { let added = (try? c.decode([Added].self, forKey: .themes)) ?? [] themes = added.map { OwnTheme(uri: $0.uri, name: $0.name ?? "Untitled", palette: $0.palette, at: $0.addedAt) } } else { themes = nil } } } private enum Keys: String, CodingKey { case preferences } init(from decoder: Decoder) throws { let kinds = try decoder.container(keyedBy: Keys.self).decode([Kind].self, forKey: .preferences) notifications = kinds.lazy.compactMap(\.choices).first appearance = kinds.lazy.compactMap(\.appearance).first themes = kinds.lazy.compactMap(\.themes).first ?? [] } } /// Which notifications the account hears of: each reason the server gives, on or /// off, and whether only of what accounts it follows did. struct NotificationChoices: Decodable, Equatable, Sendable { static let type = "dev.knotless.getPreferences#notificationPref" struct Reason: Decodable, Equatable, Sendable, Identifiable { let reason: String var enabled: Bool var id: String { reason } } var reasons: [Reason] var followedOnly: Bool? /// As `putPreferences` is given it. var written: [String: Any] { [ "$type": Self.type, "reasons": reasons.map { ["reason": $0.reason, "enabled": $0.enabled] as [String: Any] }, "followedOnly": followedOnly ?? false, ] } }