krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.1: Hutch/Models/Project.swift · raw
1import Foundation
2
3struct Project: Identifiable, Hashable, Sendable {
4 struct MailingList: Identifiable, Hashable, Sendable {
5 let id: String
6 let name: String
7 let description: String?
8 let visibility: Visibility
9 let owner: Entity
10
11 var ownerUsername: String {
12 owner.canonicalName.srhtUsername
13 }
14
15 var inboxReference: InboxMailingListReference {
16 InboxMailingListReference(
17 id: 0,
18 rid: id,
19 name: name,
20 owner: owner
21 )
22 }
23 }
24
25 struct SourceRepo: Identifiable, Hashable, Sendable {
26 enum RepoType: String, Decodable, Sendable {
27 case git = "GIT"
28 case hg = "HG"
29
30 var service: SRHTService {
31 switch self {
32 case .git: .git
33 case .hg: .hg
34 }
35 }
36 }
37
38 let id: String
39 let name: String
40 let description: String?
41 let visibility: Visibility
42 let owner: Entity
43 let repoType: RepoType
44
45 var ownerUsername: String {
46 owner.canonicalName.srhtUsername
47 }
48 }
49
50 struct Tracker: Identifiable, Hashable, Sendable {
51 let id: String
52 let name: String
53 let description: String?
54 let visibility: Visibility
55 let owner: Entity
56
57 var ownerUsername: String {
58 owner.canonicalName.srhtUsername
59 }
60 }
61
62 let id: String
63 let name: String
64 let description: String?
65 let website: String?
66 let visibility: Visibility
67 let tags: [String]
68 let updated: Date
69 let mailingLists: [MailingList]
70 let sources: [SourceRepo]
71 let trackers: [Tracker]
72 let isFullyLoaded: Bool
73
74 /// Identity and display fields for a project.
75 struct Metadata: Sendable, Hashable {
76 let id: String
77 let name: String
78 let description: String?
79 let website: String?
80 let visibility: Visibility
81 let tags: [String]
82 let updated: Date
83 }
84
85 struct Resources: Sendable, Hashable {
86 let mailingLists: [MailingList]
87 let sources: [SourceRepo]
88 let trackers: [Tracker]
89 let isFullyLoaded: Bool
90 }
91
92 init(metadata: Metadata, resources: Resources) {
93 id = metadata.id
94 name = metadata.name
95 description = metadata.description
96 website = metadata.website
97 visibility = metadata.visibility
98 tags = metadata.tags
99 updated = metadata.updated
100 mailingLists = resources.mailingLists
101 sources = resources.sources
102 trackers = resources.trackers
103 isFullyLoaded = resources.isFullyLoaded
104 }
105
106 var resourceSummary: String? {
107 let parts = [
108 Self.resourceCountText(count: sources.count, singular: "repo"),
109 Self.resourceCountText(count: trackers.count, singular: "tracker"),
110 Self.resourceCountText(count: mailingLists.count, singular: "list")
111 ].compactMap { $0 }
112
113 if !parts.isEmpty {
114 return parts.joined(separator: " • ")
115 }
116
117 if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
118 return "Website linked"
119 }
120
121 return nil
122 }
123
124 var displayName: String {
125 Self.normalizedText(name) ?? "Untitled Project"
126 }
127
128 var displayDescription: String? {
129 Self.normalizedText(description)
130 }
131
132 var displayTags: [String] {
133 var seen = Set<String>()
134 return tags.compactMap { tag in
135 guard let normalized = Self.normalizedText(tag) else { return nil }
136 let key = normalized.lowercased()
137 guard seen.insert(key).inserted else { return nil }
138 return normalized
139 }
140 }
141
142 var websiteURL: URL? {
143 guard let website = Self.normalizedText(website) else { return nil }
144 return URL(string: website)
145 }
146
147 var hasLinkedResources: Bool {
148 !sources.isEmpty || !trackers.isEmpty || !mailingLists.isEmpty
149 }
150
151 var metadataLine: String {
152 var parts = [visibility.displayName, updated.relativeDescription]
153 if let summary = resourceSummary {
154 parts.append(summary)
155 }
156 return parts.joined(separator: " • ")
157 }
158
159 private static func resourceCountText(count: Int, singular: String) -> String? {
160 guard count > 0 else { return nil }
161 let label = count == 1 ? singular : "\(singular)s"
162 return "\(count) \(label)"
163 }
164
165 fileprivate static func normalizedText(_ value: String?) -> String? {
166 guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
167 !trimmed.isEmpty else {
168 return nil
169 }
170 return trimmed
171 }
172}
173
174extension Project.MailingList {
175 var displayName: String {
176 Project.normalizedText(name) ?? "Untitled Mailing List"
177 }
178
179 var displayDescription: String? {
180 Project.normalizedText(description)
181 }
182
183 var ownerDisplayName: String {
184 Project.normalizedText(owner.canonicalName) ?? "~unknown"
185 }
186}
187
188extension Project.SourceRepo {
189 var displayName: String {
190 Project.normalizedText(name) ?? "Untitled Repository"
191 }
192
193 var displayDescription: String? {
194 Project.normalizedText(description)
195 }
196
197 var ownerDisplayName: String {
198 Project.normalizedText(owner.canonicalName) ?? "~unknown"
199 }
200
201 var webURL: URL? {
202 SRHTWebURL.projectSource(self)
203 }
204}
205
206extension Project.Tracker {
207 var displayName: String {
208 Project.normalizedText(name) ?? "Untitled Tracker"
209 }
210
211 var displayDescription: String? {
212 Project.normalizedText(description)
213 }
214
215 var ownerDisplayName: String {
216 Project.normalizedText(owner.canonicalName) ?? "~unknown"
217 }
218
219 var webURL: URL? {
220 SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: name)
221 }
222}
223
224extension String {
225 var srhtUsername: String {
226 hasPrefix("~") ? String(dropFirst()) : self
227 }
228}
229
230extension Visibility {
231 var displayName: String {
232 switch self {
233 case .publicVisibility:
234 "Public"
235 case .unlisted:
236 "Unlisted"
237 case .privateVisibility:
238 "Private"
239 }
240 }
241}