Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions Outspire/Core/Services/TSIMS/CASServiceV2.swift
Original file line number Diff line number Diff line change
@@ -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? }
Expand Down Expand Up @@ -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<GroupDetailParsed, NetworkError>) -> 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<String>()
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[..<open.lowerBound]).trimmingCharacters(in: .whitespaces)
var nick = String(trimmed[open.upperBound...])
if nick.hasSuffix(")") { nick.removeLast() }
let nickTrim = nick.trimmingCharacters(in: .whitespacesAndNewlines)
nickname = nickTrim.isEmpty ? nil : nickTrim
} else {
name = trimmed
nickname = nil
}
if name.isEmpty { return nil }
return Member(
StudentID: "html-member-\(index)",
S_Name: name,
S_Nickname: nickname,
S_STel: nil,
S_Email: nil,
LeaderYes: leaderYes,
C_Secede: nil
)
}

for title in titles {
guard let body = try title.nextElementSibling() else { continue }
let titleText = (try title.text()).trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedTitle = titleText.lowercased()
let bodyText = (try body.text()).trimmingCharacters(in: .whitespacesAndNewlines)
let itemTexts = try sectionItemTexts(from: body)
let bodyPreview = bodyText.replacingOccurrences(of: "\n", with: " ").prefix(80)
debugSections.append("\(titleText) [items=\(itemTexts.count)] \(bodyPreview)")

if normalizedTitle.contains("supervisor") || titleText.contains("指导") {
matchedSectionCount += 1
supervisor = bodyText.isEmpty ? nil : bodyText
} else if normalizedTitle.contains("president") || normalizedTitle.contains("leader") || titleText.contains("会长") {
matchedSectionCount += 1
if let raw = itemTexts.first {
if let m = memberFromItemText(raw, leaderYes: "2", index: memberIndex) {
president = m
memberIndex += 1
}
}
} else if normalizedTitle.contains("member") || titleText.contains("成员") {
matchedSectionCount += 1
for raw in itemTexts {
if let m = memberFromItemText(raw, leaderYes: "0", index: memberIndex) {
members.append(m)
memberIndex += 1
}
}
}
}

if matchedSectionCount == 0 {
struct GroupDetailParseError: LocalizedError {
var errorDescription: String? { "Unable to locate member sections in group detail page" }
}
throw GroupDetailParseError()
}

return GroupDetailParsed(
supervisor: supervisor,
president: president,
members: members,
debugSections: debugSections
)
}

func fetchRecords(
groupId: String,
pageIndex: Int = 1,
Expand Down
70 changes: 70 additions & 0 deletions Outspire/Core/Services/TSIMS/TSIMSClientV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,76 @@ final class TSIMSClientV2 {
makeRequest(retried: false)
}

/// GET a server-rendered HTML page (e.g. `/Stu/Cas/GroupDetail/{id}`).
/// Unlike `getJSON`, `text/html` is the expected content-type here; we detect expired
/// sessions by looking at the *final* URL after redirects — if URLSession landed us on
/// `/Home/Login`, the session needs refreshing and we retry once.
func getHTMLRaw(
path: String,
completion: @escaping (Result<String, NetworkError>) -> 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<T: Decodable>(
Expand Down
82 changes: 74 additions & 8 deletions Outspire/Features/CAS/ViewModels/ClubInfoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
17 changes: 16 additions & 1 deletion Outspire/Features/CAS/Views/ClubInfoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading