krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.0: 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 init(
75 id: String,
76 name: String,
77 description: String?,
78 website: String?,
79 visibility: Visibility,
80 tags: [String],
81 updated: Date,
82 mailingLists: [MailingList],
83 sources: [SourceRepo],
84 trackers: [Tracker],
85 isFullyLoaded: Bool = true
86 ) {
87 self.id = id
88 self.name = name
89 self.description = description
90 self.website = website
91 self.visibility = visibility
92 self.tags = tags
93 self.updated = updated
94 self.mailingLists = mailingLists
95 self.sources = sources
96 self.trackers = trackers
97 self.isFullyLoaded = isFullyLoaded
98 }
99
100 var resourceSummary: String? {
101 let parts = [
102 Self.resourceCountText(count: sources.count, singular: "repo"),
103 Self.resourceCountText(count: trackers.count, singular: "tracker"),
104 Self.resourceCountText(count: mailingLists.count, singular: "list")
105 ].compactMap { $0 }
106
107 if !parts.isEmpty {
108 return parts.joined(separator: " • ")
109 }
110
111 if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
112 return "Website linked"
113 }
114
115 return nil
116 }
117
118 var displayName: String {
119 Self.normalizedText(name) ?? "Untitled Project"
120 }
121
122 var displayDescription: String? {
123 Self.normalizedText(description)
124 }
125
126 var displayTags: [String] {
127 var seen = Set<String>()
128 return tags.compactMap { tag in
129 guard let normalized = Self.normalizedText(tag) else { return nil }
130 let key = normalized.lowercased()
131 guard seen.insert(key).inserted else { return nil }
132 return normalized
133 }
134 }
135
136 var websiteURL: URL? {
137 guard let website = Self.normalizedText(website) else { return nil }
138 return URL(string: website)
139 }
140
141 var hasLinkedResources: Bool {
142 !sources.isEmpty || !trackers.isEmpty || !mailingLists.isEmpty
143 }
144
145 var metadataLine: String {
146 var parts = [visibility.displayName, updated.relativeDescription]
147 if let summary = resourceSummary {
148 parts.append(summary)
149 }
150 return parts.joined(separator: " • ")
151 }
152
153 private static func resourceCountText(count: Int, singular: String) -> String? {
154 guard count > 0 else { return nil }
155 let label = count == 1 ? singular : "\(singular)s"
156 return "\(count) \(label)"
157 }
158
159 fileprivate static func normalizedText(_ value: String?) -> String? {
160 guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
161 !trimmed.isEmpty else {
162 return nil
163 }
164 return trimmed
165 }
166}
167
168extension Project.MailingList {
169 var displayName: String {
170 Project.normalizedText(name) ?? "Untitled Mailing List"
171 }
172
173 var displayDescription: String? {
174 Project.normalizedText(description)
175 }
176
177 var ownerDisplayName: String {
178 Project.normalizedText(owner.canonicalName) ?? "~unknown"
179 }
180}
181
182extension Project.SourceRepo {
183 var displayName: String {
184 Project.normalizedText(name) ?? "Untitled Repository"
185 }
186
187 var displayDescription: String? {
188 Project.normalizedText(description)
189 }
190
191 var ownerDisplayName: String {
192 Project.normalizedText(owner.canonicalName) ?? "~unknown"
193 }
194
195 var webURL: URL? {
196 SRHTWebURL.projectSource(self)
197 }
198}
199
200extension Project.Tracker {
201 var displayName: String {
202 Project.normalizedText(name) ?? "Untitled Tracker"
203 }
204
205 var displayDescription: String? {
206 Project.normalizedText(description)
207 }
208
209 var ownerDisplayName: String {
210 Project.normalizedText(owner.canonicalName) ?? "~unknown"
211 }
212
213 var webURL: URL? {
214 SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: name)
215 }
216}
217
218extension String {
219 var srhtUsername: String {
220 hasPrefix("~") ? String(dropFirst()) : self
221 }
222}
223
224extension Visibility {
225 var displayName: String {
226 switch self {
227 case .public:
228 "Public"
229 case .unlisted:
230 "Unlisted"
231 case .private:
232 "Private"
233 }
234 }
235}