bugger/Sources/Models/AppConfig.swift

155 lines
8.4 KiB
Swift

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 = "问题描述"
var priorityField: String = "优先级"
var statusField: String = "状态"
var assigneeField: String = "负责人"
var reporterField: String = "创建人"
var customerField: String = "客户名称"
var createdAtField: String = "提出时间"
var updatedAtField: String = "最后更新时间"
/// Field shown as the description in the bug detail window.
/// Empty = not mapped (description section hidden).
var descriptionField: String = ""
/// 对应模块 — module (multi-select) shown in the bug detail window.
var moduleField: String = "对应模块"
/// 来源 — where the bug was reported.
var sourceField: String = "来源"
/// 流转状态 — workflow status shown as raw text in the detail window.
var flowStatusField: String = "流转状态"
/// 修复备注 — fix notes shown in the bug detail window.
var fixNotesField: String = "修复备注"
/// 相关截图 — attachment field whose images are shown in the detail window.
var screenshotsField: String = "相关截图"
/// 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,
]
// Tolerant decoding: configs persisted before a field was added
// (missing key) must not fail to decode and wipe settings.
enum CodingKeys: String, CodingKey {
case titleField, priorityField, statusField, assigneeField
case reporterField, customerField, createdAtField, updatedAtField
case descriptionField, statusMappings
case moduleField, sourceField, flowStatusField, fixNotesField, screenshotsField
}
init() {}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let defaults = FieldMappings()
titleField = try c.decodeIfPresent(String.self, forKey: .titleField) ?? defaults.titleField
priorityField = try c.decodeIfPresent(String.self, forKey: .priorityField) ?? defaults.priorityField
statusField = try c.decodeIfPresent(String.self, forKey: .statusField) ?? defaults.statusField
assigneeField = try c.decodeIfPresent(String.self, forKey: .assigneeField) ?? defaults.assigneeField
reporterField = try c.decodeIfPresent(String.self, forKey: .reporterField) ?? defaults.reporterField
customerField = try c.decodeIfPresent(String.self, forKey: .customerField) ?? defaults.customerField
createdAtField = try c.decodeIfPresent(String.self, forKey: .createdAtField) ?? defaults.createdAtField
updatedAtField = try c.decodeIfPresent(String.self, forKey: .updatedAtField) ?? defaults.updatedAtField
descriptionField = try c.decodeIfPresent(String.self, forKey: .descriptionField) ?? defaults.descriptionField
moduleField = try c.decodeIfPresent(String.self, forKey: .moduleField) ?? defaults.moduleField
sourceField = try c.decodeIfPresent(String.self, forKey: .sourceField) ?? defaults.sourceField
flowStatusField = try c.decodeIfPresent(String.self, forKey: .flowStatusField) ?? defaults.flowStatusField
fixNotesField = try c.decodeIfPresent(String.self, forKey: .fixNotesField) ?? defaults.fixNotesField
screenshotsField = try c.decodeIfPresent(String.self, forKey: .screenshotsField) ?? defaults.screenshotsField
statusMappings = try c.decodeIfPresent([String: BugStatus].self, forKey: .statusMappings) ?? defaults.statusMappings
}
}
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)
}
}