gitbay/Discovery/ProfileViewModel.swift

v1.10.0
gitbay-ios/gitbay/Discovery/ProfileViewModel.swift history · blame · raw

204 lines · 7706 bytes

  1import Foundation
  2import Observation
  3
  4/// `profile show <name>` — the whole profile in one read: description,
  5/// links, org membership (or an org's members), the repositories the
  6/// account may see, and a year of activity.
  7@Observable
  8@MainActor
  9final class ProfileViewModel {
 10
 11    nonisolated struct Profile: Decodable, Sendable, Hashable {
 12        let name: String
 13        let kind: String // user | org
 14        let description: String?
 15        let website: String?
 16        /// Long-form profile text, rendered between the header and the
 17        /// activity graph the way the web renders it.
 18        let about: String?
 19        /// The file in `<name>/.gitbay` the text was read from, e.g.
 20        /// `profile/README.org`. Its extension picks the renderer.
 21        let aboutPath: String?
 22        let links: [Link]?
 23        let orgs: [Member]?
 24        let members: [Member]?
 25        let repos: [Repo]
 26        let activity: [Day]?
 27        let activityTotal: Int
 28
 29        enum CodingKeys: String, CodingKey {
 30            case name, kind, description, website, about, links, orgs, members, repos, activity
 31            case aboutPath = "about_path"
 32            case activityTotal = "activity_total"
 33        }
 34
 35        var isOrg: Bool { kind == "org" }
 36
 37        /// Who may write this profile: a user edits their own, and an org
 38        /// is edited by its admins — the rule `org profile` enforces.
 39        func canEdit(by username: String?) -> Bool {
 40            guard let username else { return false }
 41            if isOrg {
 42                return (members ?? []).contains { $0.name == username && $0.role == "admin" }
 43            }
 44            return name == username
 45        }
 46
 47        /// One of the free-form links a profile carries beside its
 48        /// website. The server caps the list at five.
 49        nonisolated struct Link: Decodable, Sendable, Hashable {
 50            let label: String?
 51            let url: String
 52
 53            /// Labels are optional; a link without one shows its URL.
 54            var title: String {
 55                if let label, !label.isEmpty { return label }
 56                return url
 57            }
 58        }
 59
 60        nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
 61            let name: String
 62            let role: String?
 63            var id: String { name }
 64        }
 65
 66        nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
 67            let path: String
 68            let visibility: String
 69            let description: String?
 70            let archived: Bool?
 71            let topics: [String]?
 72            let license: String?
 73            let defaultBranch: String?
 74            let updated: String?
 75
 76            enum CodingKeys: String, CodingKey {
 77                case path, visibility, description, archived, topics, license, updated
 78                case defaultBranch = "default_branch"
 79            }
 80
 81            var id: String { path }
 82            var isPrivate: Bool { visibility == "private" }
 83            var isArchived: Bool { archived ?? false }
 84            var name: String { String(path.split(separator: "/").last ?? "") }
 85
 86            /// "main · MIT · updated 2026-08-30", the line the web's repo
 87            /// rows carry. Parts the server did not report are left out.
 88            var meta: String {
 89                var parts: [String] = []
 90                if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) }
 91                if let license, !license.isEmpty { parts.append(license) }
 92                if let updated, !updated.isEmpty { parts.append("updated \(updated)") }
 93                return parts.joined(separator: " · ")
 94            }
 95        }
 96
 97        /// One day's contributions. Days with none are omitted, so the
 98        /// calendar is filled in by the view.
 99        nonisolated struct Day: Decodable, Sendable, Hashable {
100            let date: String
101            let count: Int
102        }
103    }
104
105    private(set) var state: LoadState<Profile> = .loading
106    private(set) var actionError: String?
107    private(set) var working = false
108
109    private let client: GitbayClient
110    let name: String
111
112    init(client: GitbayClient, name: String) {
113        self.client = client
114        self.name = name
115    }
116
117    func load() async {
118        do {
119            state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
120        } catch {
121            state = .from(error)
122        }
123    }
124
125    /// `org create <name>`.
126    func createOrg(_ name: String) async -> Bool {
127        let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
128        guard !name.isEmpty else { return false }
129        await perform(["org", "create", name])
130        return actionError == nil
131    }
132
133    /// `profile set` for the caller's own profile, `org profile <org>` for
134    /// an organization; both take the same flags.
135    func saveProfile(_ edit: ProfileEdit) async -> Bool {
136        guard edit.validationError == nil else { return false }
137        let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"]
138        await perform(command + edit.flags())
139        return actionError == nil
140    }
141
142    /// The repository that holds this profile's about text.
143    var aboutRepo: String { "\(name)/.gitbay" }
144
145    /// Creates `<name>/.gitbay` when it is missing and commits a starter
146    /// `profile/README.md`, the same starter the web's settings page
147    /// writes, so the file editor has something to open. The repository
148    /// may already exist without the file, and creating it again would
149    /// fail, so it is looked up first — and when it does exist, the
150    /// commit targets its default branch: the about file is only read
151    /// from there, and an existing repository's default is not
152    /// necessarily `main` (the first branch ever pushed becomes the
153    /// default). A repository this call creates is fresh, so its default
154    /// is `main`.
155    func createAboutFile() async -> Bool {
156        working = true
157        actionError = nil
158        defer { working = false }
159        do {
160            var ref = "main"
161            do {
162                let existing = try await client.read(["repo", "show", aboutRepo], as: Exists.self)
163                ref = existing.defaultBranch ?? "main"
164            } catch let error as GitbayError where error.isEmptyState {
165                try await client.run(["repo", "create", aboutRepo])
166            }
167            try await client.run(
168                ["repo", "commit-file", aboutRepo, "profile/README.md", "--ref", ref,
169                 "--message", "add profile about", "--file", "-"],
170                stdin: "# \(name)\n\nThis is the about text on your profile.\n")
171            await load()
172            return true
173        } catch let error as GitbayError {
174            actionError = error.userFacingMessage
175        } catch {
176            actionError = GitbayError.transport(error).userFacingMessage
177        }
178        return false
179    }
180
181    /// `repo show` is read only to learn whether the repository exists,
182    /// and which branch is its default.
183    nonisolated private struct Exists: Decodable, Sendable {
184        let defaultBranch: String?
185
186        enum CodingKeys: String, CodingKey {
187            case defaultBranch = "default_branch"
188        }
189    }
190
191    private func perform(_ argv: [String]) async {
192        working = true
193        actionError = nil
194        defer { working = false }
195        do {
196            try await client.run(argv)
197            await load()
198        } catch let error as GitbayError {
199            actionError = error.userFacingMessage
200        } catch {
201            actionError = GitbayError.transport(error).userFacingMessage
202        }
203    }
204}