diff --git a/Outspire/Core/Services/TSIMS/CASServiceV2.swift b/Outspire/Core/Services/TSIMS/CASServiceV2.swift index e482548..fd7a45a 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,152 @@ 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 + ) { + 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): + // 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))) } + } + } + } + } + } + + 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] { + // "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) + 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 } + } + } + + // Deduplicate while preserving order + var seen = Set() + return texts.filter { seen.insert($0).inserted } + } + + 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..a1eedec 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,20 +172,85 @@ class ClubInfoViewModel: ObservableObject { ) self.groupInfo = info self.instructorName = detail?.TeacherName + self.members = [] + self.memberLoadError = nil + + // 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 { + 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 + } + 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. + 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 { + self.memberLoadError = "Unable to load the member roster: \(lastDetailError.localizedDescription)" + } else { + self.memberLoadError = "Unable to load the member roster for this club." + } + loadGroup.leave() + return + } + + CASServiceV2.shared.fetchGroupDetail(groupId: identifiers[index]) { [weak self] res in + guard let self = self else { return } + + switch res { + 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 + } + loadGroup.leave() + case let .failure(error): + lastDetailError = error + loadGroupDetail(using: identifiers, index: index + 1) + } + } + } + + 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 - 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 - } } } } 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