gitbay/Discovery/ProfileViewModel.swift
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}