import Foundation struct AppConfig: Codable, Equatable { var appToken: String = BundledDefault.appToken var tableId: String = BundledDefault.tableId var assigneeName: String = "" var fieldMappings: FieldMappings = FieldMappings() var pollIntervalSeconds: Int = 300 /// Daily refresh times in "HH:mm" format, e.g. ["08:00", "14:00"]. /// Only used when pollIntervalSeconds == -1. var dailyRefreshTimes: [String] = [] var showFloatingWidget: Bool = false var launchAtLogin: Bool = true var feishuBaseDomain: String = BundledDefault.feishuBaseDomain /// When false (the default), Bugger waits for the next scheduled poll /// instead of fetching immediately on launch. Avoids a crash-on-start /// loop if the table fetch is failing. var refreshOnStart: Bool = false /// Base URL of the feishu-app change-notification service, e.g. /// "http://localhost:8000". When set, Bugger opens an SSE stream to /// `{feishuAppBaseURL}/api/v1/bitable/events` and refreshes immediately /// on a change push. Empty = disabled (polling only). var feishuAppBaseURL: String = "" /// When true, Bugger periodically queries bugger-feishu for the list of /// record IDs assigned to the current user and triggers a fetchNow() if /// the list doesn't match the bugs in BugStore. Disabled by default — /// only useful when feishuAppBaseURL is set and push reliability is a /// concern. var calibrationEnabled: Bool = false struct FieldMappings: Codable, Equatable { var titleField: String = "Title" var priorityField: String = "Priority" var statusField: String = "Status" var assigneeField: String = "Assignee" var reporterField: String = "Reporter" var customerField: String = "CustomerName" var createdAtField: String = "Created At" var updatedAtField: String = "Updated At" /// Map Feishu display values to standard BugStatus cases. /// Keys are the Feishu select-option text; values are the standard status. /// Unknown values default to .unknown (shown as active). var statusMappings: [String: BugStatus] = [ "Bug Triage": .open, "Open": .open, "New": .open, "待处理": .open, "开发进行中": .inProgress, "In Progress": .inProgress, "验收中": .inReview, "In Review": .inReview, "验收完毕": .resolved, "Resolved": .resolved, "Fixed": .resolved, "取消": .closed, "Closed": .closed, "Won't fix": .closed, "转需求": .closed, "验收不通过": .closed, ] } var isConfigured: Bool { !appToken.isEmpty && !tableId.isEmpty } // Explicit keys + tolerant decoding so a config persisted before a new // field was added (missing key) doesn't fail to decode and wipe settings. enum CodingKeys: String, CodingKey { case appToken, tableId, assigneeName, fieldMappings case pollIntervalSeconds, dailyRefreshTimes case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart case feishuAppBaseURL, calibrationEnabled } init() {} init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) appToken = try c.decodeIfPresent(String.self, forKey: .appToken) ?? BundledDefault.appToken tableId = try c.decodeIfPresent(String.self, forKey: .tableId) ?? BundledDefault.tableId assigneeName = try c.decodeIfPresent(String.self, forKey: .assigneeName) ?? "" fieldMappings = try c.decodeIfPresent(FieldMappings.self, forKey: .fieldMappings) ?? FieldMappings() pollIntervalSeconds = try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? 300 dailyRefreshTimes = try c.decodeIfPresent([String].self, forKey: .dailyRefreshTimes) ?? [] showFloatingWidget = try c.decodeIfPresent(Bool.self, forKey: .showFloatingWidget) ?? false launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? BundledDefault.feishuBaseDomain refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false feishuAppBaseURL = try c.decodeIfPresent(String.self, forKey: .feishuAppBaseURL) ?? "" calibrationEnabled = try c.decodeIfPresent(Bool.self, forKey: .calibrationEnabled) ?? false } } /// Values baked into the app at build time via Config.xcconfig → Info.plist. /// Used as defaults so internal users don't have to enter them in Settings. enum BundledDefault { static var appToken: String { string(for: "FEISHU_APP_TOKEN") } static var tableId: String { string(for: "FEISHU_TABLE_ID") } static var feishuBaseDomain: String { string(for: "FEISHU_BASE_DOMAIN") } private static func string(for key: String) -> String { let raw = (Bundle.main.object(forInfoDictionaryKey: key) as? String) ?? "" // Xcode leaves an undefined $(...) substitution as the literal string; // treat that as "not set" rather than showing "$(FEISHU_APP_TOKEN)". if raw.hasPrefix("$(") { return "" } return raw.trimmingCharacters(in: .whitespacesAndNewlines) } }