From ea329a9d5b539b821472cf4a62fb5840cd20ed1f Mon Sep 17 00:00:00 2001 From: Ludwig Kent <124366668+Gavin-WangSC@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:48:59 +0800 Subject: [PATCH 1/2] Show club members from CAS group detail page --- .../Core/Services/TSIMS/CASServiceV2.swift | 141 ++++++++++++++++++ .../Core/Services/TSIMS/TSIMSClientV2.swift | 70 +++++++++ .../CAS/ViewModels/ClubInfoViewModel.swift | 83 +++++++++-- .../Features/CAS/Views/ClubInfoView.swift | 17 ++- 4 files changed, 301 insertions(+), 10 deletions(-) diff --git a/Outspire/Core/Services/TSIMS/CASServiceV2.swift b/Outspire/Core/Services/TSIMS/CASServiceV2.swift index e482548..191eb48 100644 --- a/Outspire/Core/Services/TSIMS/CASServiceV2.swift +++ b/Outspire/Core/Services/TSIMS/CASServiceV2.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftSoup struct V2Group: Decodable { let Id: Int; let Name: String } struct V2GroupAlt: Decodable { let GroupNo: String; let NameC: String?; let NameE: String? } @@ -185,6 +186,146 @@ final class CASServiceV2 { } } + // MARK: - Group detail (HTML page with Supervisor / President / members) + + struct GroupDetailParsed { + let supervisor: String? + let president: Member? + let members: [Member] + let debugSections: [String] + } + + func fetchGroupDetail( + groupId: String, + completion: @escaping (Result) -> Void + ) { + TSIMSClientV2.shared.getHTMLRaw(path: "/Stu/Cas/GroupDetail/\(groupId)") { result in + switch result { + case let .failure(err): + completion(.failure(err)) + case let .success(html): + do { + let parsed = try Self.parseGroupDetailHTML(html) + completion(.success(parsed)) + } catch { + completion(.failure(.decodingError(error))) + } + } + } + } + + private static func parseGroupDetailHTML(_ html: String) throws -> GroupDetailParsed { + let doc = try SwiftSoup.parse(html) + let titles = try doc.select("form.layui-form div.group-title, form.layui-form .group-title") + + var supervisor: String? = nil + var president: Member? = nil + var members: [Member] = [] + var memberIndex = 0 + var matchedSectionCount = 0 + var debugSections: [String] = [] + + func sectionItemTexts(from body: Element) throws -> [String] { + let selectors = "div.memeber-item, div.member-item, .memeber-item, .member-item, li, p" + var texts = try body.select(selectors).array().compactMap { element in + let text = try? element.text().trimmingCharacters(in: .whitespacesAndNewlines) + return (text?.isEmpty == false) ? text : nil + } + + if texts.isEmpty { + texts = try body.children().array().compactMap { element in + let text = try? element.text().trimmingCharacters(in: .whitespacesAndNewlines) + return (text?.isEmpty == false) ? text : nil + } + } + + if texts.isEmpty { + let raw = try body.text().trimmingCharacters(in: .whitespacesAndNewlines) + if !raw.isEmpty { + texts = raw + .components(separatedBy: CharacterSet.newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + } + + return Array(NSOrderedSet(array: texts)) as? [String] ?? texts + } + + func memberFromItemText(_ raw: String, leaderYes: String, index: Int) -> Member? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed == "()" { return nil } + let name: String + let nickname: String? + if let open = trimmed.range(of: " (", options: .backwards) { + name = String(trimmed[..) -> Void + ) { + guard let url = URL(string: Configuration.tsimsV2BaseURL + path) else { + completion(.failure(.invalidURL)) + return + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.httpShouldHandleCookies = true + request.setValue("text/html,application/xhtml+xml,*/*;q=0.8", forHTTPHeaderField: "Accept") + request.setValue(Configuration.tsimsV2BaseURL + "/", forHTTPHeaderField: "Referer") + request.setValue(Configuration.tsimsV2BaseURL, forHTTPHeaderField: "Origin") + request.setValue( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile Safari", + forHTTPHeaderField: "User-Agent" + ) + request.setValue("zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7", forHTTPHeaderField: "Accept-Language") + + log("GET (html) \(path) cookies=[\(cookieSummary(for: url))]") + func makeRequest(retried: Bool) { + session.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { completion(.failure(.requestFailed(error))); return } + guard let http = response as? HTTPURLResponse else { completion(.failure(.noData)); return } + let finalPath = (http.url?.path ?? "").lowercased() + let landedOnLogin = finalPath.contains("/home/login") || finalPath.hasSuffix("/home/index") + if http.statusCode == 302 || http.statusCode == 401 || landedOnLogin { + self.log("RESP unauthorized status=\(http.statusCode) finalPath=\(finalPath)") + if !retried { + AuthServiceV2.shared.refreshSessionIfNeeded { ok in + if ok { makeRequest(retried: true) } + else { + NotificationCenter.default.post(name: .tsimsV2Unauthorized, object: nil) + completion(.failure(.unauthorized)) + } + } + } else { + NotificationCenter.default.post(name: .tsimsV2Unauthorized, object: nil) + completion(.failure(.unauthorized)) + } + return + } + guard http.statusCode < 400 else { + self.log("RESP serverError status=\(http.statusCode)") + completion(.failure(.serverError(http.statusCode))) + return + } + guard let data = data, !data.isEmpty else { + completion(.failure(.noData)) + return + } + guard let html = String(data: data, encoding: .utf8) else { + completion(.failure(.noData)) + return + } + self.log("RESP ok html bytes=\(data.count)") + completion(.success(html)) + } + }.resume() + } + makeRequest(retried: false) + } + // MARK: - Internal private func handleResponse( diff --git a/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift b/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift index b179b1b..d8ce357 100644 --- a/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift +++ b/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift @@ -9,6 +9,7 @@ class ClubInfoViewModel: ObservableObject { @Published var groups: [ClubGroup] = [] @Published var groupInfo: GroupInfo? @Published var members: [Member] = [] + @Published var memberLoadError: String? @Published var instructorName: String? @Published var errorMessage: String? @Published var isLoading: Bool = false @@ -171,22 +172,86 @@ class ClubInfoViewModel: ObservableObject { ) self.groupInfo = info self.instructorName = detail?.TeacherName - // Determine membership by checking MyGroups (match by Id or GroupNo) - CASServiceV2.shared.fetchMyGroups { [weak self] res in - guard let self = self else { return } + self.members = [] + self.memberLoadError = nil + + // Two async fetches: membership status + member roster. Merge `isLoading` so the + // skeleton stays up until both finish. + var myGroupsDone = false + var detailDone = false + let finish = { [weak self] in + guard let self = self, myGroupsDone, detailDone else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self.isLoading = false self.isFromURLNavigation = false + } + } + + // Determine membership by checking MyGroups (match by Id or GroupNo) + CASServiceV2.shared.fetchMyGroups { [weak self] res in + guard let self = self else { return } + switch res { + case let .success(myGroups): + let targetKeys = Set([group.C_GroupsID, group.C_GroupNo].filter { !$0.isEmpty }) + let myKeys = Set(myGroups.flatMap { [$0.C_GroupsID, $0.C_GroupNo] }.filter { !$0.isEmpty }) + self.isUserMember = !targetKeys.isDisjoint(with: myKeys) + case .failure: + self.isUserMember = false + } + myGroupsDone = true + finish() + } + + // Fetch the rendered GroupDetail page for Supervisor / President / members. + // Some endpoints accept numeric group IDs while others accept group numbers, so retry + // with both identifiers before surfacing an error. + let detailIdentifiers = Array(NSOrderedSet(array: [group.C_GroupsID, group.C_GroupNo].filter { !$0.isEmpty })) as? [String] ?? [] + var lastDetailError: NetworkError? + func loadGroupDetail(using identifiers: [String], index: Int = 0) { + guard index < identifiers.count else { + if let lastDetailError { + self.memberLoadError = "Unable to load the member roster: \(lastDetailError.localizedDescription)" + } else { + self.memberLoadError = "Unable to load the member roster for this club." + } + detailDone = true + finish() + return + } + + CASServiceV2.shared.fetchGroupDetail(groupId: identifiers[index]) { [weak self] res in + guard let self = self else { return } + switch res { - case let .success(myGroups): - let targetKeys = Set([group.C_GroupsID, group.C_GroupNo].filter { !$0.isEmpty }) - let myKeys = Set(myGroups.flatMap { [$0.C_GroupsID, $0.C_GroupNo] }.filter { !$0.isEmpty }) - self.isUserMember = !targetKeys.isDisjoint(with: myKeys) - case .failure: - self.isUserMember = false + case let .success(parsed): + var roster: [Member] = [] + if let pres = parsed.president { roster.append(pres) } + roster.append(contentsOf: parsed.members) + self.members = roster + if roster.isEmpty { + if Configuration.debugNetworkLogging, !parsed.debugSections.isEmpty { + self.memberLoadError = "Parsed detail but found no members. Sections: \(parsed.debugSections.joined(separator: " | "))" + } else { + self.memberLoadError = "Parsed detail but found no members for this club." + } + } else { + self.memberLoadError = nil + } + if let sup = parsed.supervisor?.trimmingCharacters(in: .whitespacesAndNewlines), + !sup.isEmpty + { + self.instructorName = sup + } + detailDone = true + finish() + case let .failure(error): + lastDetailError = error + loadGroupDetail(using: identifiers, index: index + 1) } } } + + loadGroupDetail(using: detailIdentifiers) } private func retryFetchWithSession(parameters: [String: String]) { /* no-op in V2 */ } diff --git a/Outspire/Features/CAS/Views/ClubInfoView.swift b/Outspire/Features/CAS/Views/ClubInfoView.swift index 23a7290..369cb31 100644 --- a/Outspire/Features/CAS/Views/ClubInfoView.swift +++ b/Outspire/Features/CAS/Views/ClubInfoView.swift @@ -583,6 +583,7 @@ struct ClubInfoView: View { Section(header: memberSectionHeader) { MembersListView( members: viewModel.members, + memberLoadError: viewModel.memberLoadError, isLoading: viewModel.isLoading, selectedGroup: viewModel.selectedGroup, animateList: animateList, @@ -870,6 +871,7 @@ struct ClubDetailView: View { struct MembersListView: View { let members: [Member] + let memberLoadError: String? let isLoading: Bool let selectedGroup: ClubGroup? let animateList: Bool @@ -879,6 +881,8 @@ struct MembersListView: View { Group { if isLoading { memberLoadingView + } else if let memberLoadError, members.isEmpty { + memberErrorView(message: memberLoadError) } else if members.isEmpty { emptyMembersView } else { @@ -908,13 +912,24 @@ struct MembersListView: View { } private var emptyMembersView: some View { - // New TSIMS: MemberList is not provided in this view; show a neutral placeholder regardless of auth Text("No members listed for this club.") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) } + private func memberErrorView(message: String) -> some View { + VStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + Text(message) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + private var membersList: some View { ForEach(Array(members.enumerated()), id: \.element.id) { index, member in // Break down the complex member row into smaller components From d44ab372d97f583b2857ef03f03f622a99ddece9 Mon Sep 17 00:00:00 2001 From: Alan Ye Date: Wed, 15 Apr 2026 00:03:47 +0800 Subject: [PATCH 2/2] refactor: improve group detail parsing robustness - URL-encode groupId in fetchGroupDetail path - Move HTML parsing off main thread to avoid UI hitches - Replace Bool flags with DispatchGroup for parallel load coordination - Use Swift-native Set dedup instead of NSOrderedSet - Replace NSError with LocalizedError for parse failures - Add comment noting intentional TSIMS "memeber" typo in CSS selectors --- .../Core/Services/TSIMS/CASServiceV2.swift | 30 +++++++++------ .../CAS/ViewModels/ClubInfoViewModel.swift | 37 ++++++++++--------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/Outspire/Core/Services/TSIMS/CASServiceV2.swift b/Outspire/Core/Services/TSIMS/CASServiceV2.swift index 191eb48..fd7a45a 100644 --- a/Outspire/Core/Services/TSIMS/CASServiceV2.swift +++ b/Outspire/Core/Services/TSIMS/CASServiceV2.swift @@ -199,16 +199,20 @@ final class CASServiceV2 { groupId: String, completion: @escaping (Result) -> Void ) { - TSIMSClientV2.shared.getHTMLRaw(path: "/Stu/Cas/GroupDetail/\(groupId)") { result in + let encodedId = groupId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? groupId + TSIMSClientV2.shared.getHTMLRaw(path: "/Stu/Cas/GroupDetail/\(encodedId)") { result in switch result { case let .failure(err): completion(.failure(err)) case let .success(html): - do { - let parsed = try Self.parseGroupDetailHTML(html) - completion(.success(parsed)) - } catch { - completion(.failure(.decodingError(error))) + // Parse off main thread to avoid blocking UI on large pages + DispatchQueue.global(qos: .userInitiated).async { + do { + let parsed = try Self.parseGroupDetailHTML(html) + DispatchQueue.main.async { completion(.success(parsed)) } + } catch { + DispatchQueue.main.async { completion(.failure(.decodingError(error))) } + } } } } @@ -226,6 +230,7 @@ final class CASServiceV2 { var debugSections: [String] = [] func sectionItemTexts(from body: Element) throws -> [String] { + // "memeber" is a typo in the TSIMS HTML; both spellings are matched intentionally. let selectors = "div.memeber-item, div.member-item, .memeber-item, .member-item, li, p" var texts = try body.select(selectors).array().compactMap { element in let text = try? element.text().trimmingCharacters(in: .whitespacesAndNewlines) @@ -249,7 +254,9 @@ final class CASServiceV2 { } } - return Array(NSOrderedSet(array: texts)) as? [String] ?? texts + // Deduplicate while preserving order + var seen = Set() + return texts.filter { seen.insert($0).inserted } } func memberFromItemText(_ raw: String, leaderYes: String, index: Int) -> Member? { @@ -311,11 +318,10 @@ final class CASServiceV2 { } if matchedSectionCount == 0 { - throw NSError( - domain: "CASServiceV2.GroupDetail", - code: -11, - userInfo: [NSLocalizedDescriptionKey: "Unable to locate member sections in group detail page"] - ) + struct GroupDetailParseError: LocalizedError { + var errorDescription: String? { "Unable to locate member sections in group detail page" } + } + throw GroupDetailParseError() } return GroupDetailParsed( diff --git a/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift b/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift index d8ce357..a1eedec 100644 --- a/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift +++ b/Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift @@ -175,19 +175,11 @@ class ClubInfoViewModel: ObservableObject { self.members = [] self.memberLoadError = nil - // Two async fetches: membership status + member roster. Merge `isLoading` so the - // skeleton stays up until both finish. - var myGroupsDone = false - var detailDone = false - let finish = { [weak self] in - guard let self = self, myGroupsDone, detailDone else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - self.isLoading = false - self.isFromURLNavigation = false - } - } + // Two async fetches run in parallel; DispatchGroup gates `isLoading` until both complete. + let loadGroup = DispatchGroup() // Determine membership by checking MyGroups (match by Id or GroupNo) + loadGroup.enter() CASServiceV2.shared.fetchMyGroups { [weak self] res in guard let self = self else { return } switch res { @@ -198,15 +190,18 @@ class ClubInfoViewModel: ObservableObject { case .failure: self.isUserMember = false } - myGroupsDone = true - finish() + loadGroup.leave() } // Fetch the rendered GroupDetail page for Supervisor / President / members. // Some endpoints accept numeric group IDs while others accept group numbers, so retry // with both identifiers before surfacing an error. - let detailIdentifiers = Array(NSOrderedSet(array: [group.C_GroupsID, group.C_GroupNo].filter { !$0.isEmpty })) as? [String] ?? [] + var detailIdentifiers: [String] = [] + for id in [group.C_GroupsID, group.C_GroupNo] where !id.isEmpty { + if !detailIdentifiers.contains(id) { detailIdentifiers.append(id) } + } var lastDetailError: NetworkError? + loadGroup.enter() func loadGroupDetail(using identifiers: [String], index: Int = 0) { guard index < identifiers.count else { if let lastDetailError { @@ -214,8 +209,7 @@ class ClubInfoViewModel: ObservableObject { } else { self.memberLoadError = "Unable to load the member roster for this club." } - detailDone = true - finish() + loadGroup.leave() return } @@ -242,8 +236,7 @@ class ClubInfoViewModel: ObservableObject { { self.instructorName = sup } - detailDone = true - finish() + loadGroup.leave() case let .failure(error): lastDetailError = error loadGroupDetail(using: identifiers, index: index + 1) @@ -252,6 +245,14 @@ class ClubInfoViewModel: ObservableObject { } loadGroupDetail(using: detailIdentifiers) + + loadGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.isLoading = false + self.isFromURLNavigation = false + } + } } private func retryFetchWithSession(parameters: [String: String]) { /* no-op in V2 */ }