Compare commits

..

No commits in common. "b5ad9c1ec317540396a7657c867af52866d75df0" and "a0b1fb8aa32642b8eb87bfc2306b00862e8c9364" have entirely different histories.

3 changed files with 168 additions and 252 deletions

View File

@ -8,6 +8,6 @@ struct BuggerApp: App {
Settings { Settings {
SettingsView() SettingsView()
} }
.windowResizability(.contentMinSize) .windowResizability(.contentSize)
} }
} }

View File

@ -58,12 +58,15 @@ final class FeishuService {
accessToken: String, accessToken: String,
userOpenId: String? userOpenId: String?
) async throws -> [RecordItem] { ) async throws -> [RecordItem] {
let fieldNames = configFieldNames(config)
// Tier 1: search endpoint with open_id (Person fields) // Tier 1: search endpoint with open_id (Person fields)
if let openId = userOpenId, !openId.isEmpty { if let openId = userOpenId, !openId.isEmpty {
do { do {
let records = try await searchRecords( let records = try await searchRecords(
config: config, config: config,
accessToken: accessToken, accessToken: accessToken,
fieldNames: fieldNames,
assigneeField: config.fieldMappings.assigneeField, assigneeField: config.fieldMappings.assigneeField,
openId: openId openId: openId
) )
@ -83,6 +86,7 @@ final class FeishuService {
let records = try await listAllRecords( let records = try await listAllRecords(
config: config, config: config,
accessToken: accessToken, accessToken: accessToken,
fieldNames: fieldNames,
filter: filterStr filter: filterStr
) )
if !records.isEmpty { if !records.isEmpty {
@ -94,20 +98,31 @@ final class FeishuService {
BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back") BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back")
} }
// Tier 3: unfiltered list (original behavior) // Tier 3: unfiltered list (current behavior)
BuggerLog.debug("FeishuService: tier 3 (unfiltered list)") BuggerLog.debug("FeishuService: tier 3 (unfiltered list)")
return try await listAllRecords( return try await listAllRecords(
config: config, config: config,
accessToken: accessToken, accessToken: accessToken,
fieldNames: fieldNames,
filter: nil filter: nil
) )
} }
/// Build the list of field names to request from the Feishu API, to reduce
/// the per-record payload. Includes all mapped fields.
private func configFieldNames(_ config: AppConfig) -> [String] {
let m = config.fieldMappings
return [m.titleField, m.priorityField, m.statusField, m.assigneeField,
m.reporterField, m.customerField, m.createdAtField, m.updatedAtField]
.filter { !$0.isEmpty }
}
// MARK: - List endpoint (GET /records) // MARK: - List endpoint (GET /records)
private func listAllRecords( private func listAllRecords(
config: AppConfig, config: AppConfig,
accessToken: String, accessToken: String,
fieldNames: [String],
filter: String? filter: String?
) async throws -> [RecordItem] { ) async throws -> [RecordItem] {
var allRecords: [RecordItem] = [] var allRecords: [RecordItem] = []
@ -125,7 +140,8 @@ final class FeishuService {
tableId: config.tableId, tableId: config.tableId,
pageToken: pageToken, pageToken: pageToken,
accessToken: accessToken, accessToken: accessToken,
filter: filter filter: filter,
fieldNames: fieldNames
) )
allRecords.append(contentsOf: page.items) allRecords.append(contentsOf: page.items)
pageToken = page.hasMore ? page.pageToken : nil pageToken = page.hasMore ? page.pageToken : nil
@ -140,7 +156,8 @@ final class FeishuService {
pageToken: String?, pageToken: String?,
pageSize: Int = 500, pageSize: Int = 500,
accessToken: String, accessToken: String,
filter: String? = nil filter: String? = nil,
fieldNames: [String]? = nil
) async throws -> RecordListData { ) async throws -> RecordListData {
guard !appToken.isEmpty, !tableId.isEmpty else { guard !appToken.isEmpty, !tableId.isEmpty else {
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.") throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
@ -157,6 +174,10 @@ final class FeishuService {
if let filter, !filter.isEmpty { if let filter, !filter.isEmpty {
queryItems.append(URLQueryItem(name: "filter", value: filter)) queryItems.append(URLQueryItem(name: "filter", value: filter))
} }
if let fieldNames, !fieldNames.isEmpty {
let jsonArray = "[\(fieldNames.map { "\"\($0)\"" }.joined(separator: ","))]"
queryItems.append(URLQueryItem(name: "field_names", value: jsonArray))
}
components.queryItems = queryItems components.queryItems = queryItems
guard let url = components.url else { guard let url = components.url else {
@ -200,6 +221,7 @@ final class FeishuService {
private func searchRecords( private func searchRecords(
config: AppConfig, config: AppConfig,
accessToken: String, accessToken: String,
fieldNames: [String],
assigneeField: String, assigneeField: String,
openId: String openId: String
) async throws -> [RecordItem] { ) async throws -> [RecordItem] {
@ -218,6 +240,7 @@ final class FeishuService {
tableId: config.tableId, tableId: config.tableId,
pageToken: pageToken, pageToken: pageToken,
accessToken: accessToken, accessToken: accessToken,
fieldNames: fieldNames,
assigneeField: assigneeField, assigneeField: assigneeField,
openId: openId openId: openId
) )
@ -233,6 +256,7 @@ final class FeishuService {
tableId: String, tableId: String,
pageToken: String?, pageToken: String?,
accessToken: String, accessToken: String,
fieldNames: [String],
assigneeField: String, assigneeField: String,
openId: String openId: String
) async throws -> RecordListData { ) async throws -> RecordListData {
@ -261,6 +285,9 @@ final class FeishuService {
if let pageToken, !pageToken.isEmpty { if let pageToken, !pageToken.isEmpty {
body["page_token"] = pageToken body["page_token"] = pageToken
} }
if !fieldNames.isEmpty {
body["field_names"] = fieldNames
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"

View File

@ -1,10 +1,6 @@
import ServiceManagement import ServiceManagement
import SwiftUI import SwiftUI
// MARK: - Constants
private let kLabelWidth: CGFloat = 130
struct SettingsView: View { struct SettingsView: View {
@State private var config: AppConfig @State private var config: AppConfig
@State private var isTestingConnection = false @State private var isTestingConnection = false
@ -17,31 +13,55 @@ struct SettingsView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(spacing: 12) {
// MARK: Your Name card // Highlighted "Start here" card lives outside the Form so its
yourNameCard // Spacer + multi-element HStack don't disturb the Form's
// two-column label/control layout (which was pushing the
// Feishu Bitable fields off the right edge of the window).
HStack(spacing: 12) {
Image(systemName: "person.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Your Name")
.font(.headline)
TextField("e.g. Alice", text: $config.assigneeName)
.textFieldStyle(.roundedBorder)
Spacer()
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
// MARK: Feishu Bitable Form {
settingsSection("Feishu Bitable") { Section("Feishu Bitable") {
labeledRow("App Token") { TextField("App Token (from Bitable URL)", text: $config.appToken)
TextField("from Bitable URL", text: $config.appToken) TextField("Table ID", text: $config.tableId)
.textFieldStyle(.roundedBorder) TextField("Feishu domain", text: $config.feishuBaseDomain)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Button("Test Connection") {
testConnection()
} }
labeledRow("Table ID") { .disabled(isTestingConnection || !config.isConfigured)
TextField("", text: $config.tableId)
.textFieldStyle(.roundedBorder) if isTestingConnection {
ProgressView()
.scaleEffect(0.7)
}
}
if let connectionResult {
Text(connectionResult)
.font(.caption)
.foregroundStyle(connectionResult.contains("") ? .green : .red)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
} }
labeledRow("Feishu domain") {
TextField("e.g. feishu.cn", text: $config.feishuBaseDomain)
.textFieldStyle(.roundedBorder)
} }
connectionTestRow
} }
// MARK: Polling Section("Polling") {
settingsSection("Polling") { Picker("Check every", selection: $config.pollIntervalSeconds) {
labeledRow("Check every") {
Picker("", selection: $config.pollIntervalSeconds) {
Text("1 minute").tag(60) Text("1 minute").tag(60)
Text("5 minutes").tag(300) Text("5 minutes").tag(300)
Text("10 minutes").tag(600) Text("10 minutes").tag(600)
@ -55,114 +75,8 @@ struct SettingsView: View {
Divider() Divider()
Text("Daily schedule").tag(-1) Text("Daily schedule").tag(-1)
} }
.labelsHidden()
.fixedSize()
}
if config.pollIntervalSeconds == -1 { if config.pollIntervalSeconds == -1 {
dailyScheduleEditor
}
VStack(alignment: .leading, spacing: 4) {
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: Real-time updates
settingsSection("Real-time updates") {
labeledRow("Subscribe URL") {
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
Text("Base URL of the change-notification service. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: Calibration
settingsSection("Calibration") {
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: Display
settingsSection("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
.onChange(of: config.showFloatingWidget) { _, enabled in
if enabled {
AppDelegate.shared?.showFloatingWidget()
} else {
AppDelegate.shared?.hideFloatingWidget()
}
}
Toggle("Launch at login", isOn: $config.launchAtLogin)
}
// MARK: Advanced
advancedSection
// MARK: Actions
actionButtons
}
.frame(maxWidth: 700, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(20)
}
.frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
}
// MARK: - Your Name Card
private var yourNameCard: some View {
HStack(spacing: 12) {
Image(systemName: "person.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Your Name")
.font(.headline)
TextField("e.g. Alice", text: $config.assigneeName)
.textFieldStyle(.roundedBorder)
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
}
// MARK: - Connection Test
private var connectionTestRow: some View {
HStack(spacing: 8) {
Button("Test Connection") {
testConnection()
}
.disabled(isTestingConnection || !config.isConfigured)
if isTestingConnection {
ProgressView()
.scaleEffect(0.7)
}
if let connectionResult {
Text(connectionResult)
.font(.caption)
.foregroundStyle(connectionResult.contains("") ? .green : .red)
.lineLimit(3)
}
}
}
// MARK: - Daily Schedule Editor
private var dailyScheduleEditor: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Refresh at these times:") Text("Refresh at these times:")
.font(.caption) .font(.caption)
@ -189,61 +103,66 @@ struct SettingsView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.leading, kLabelWidth + 8)
} }
// MARK: - Advanced Section VStack(alignment: .leading, spacing: 4) {
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var advancedSection: some View { Section("Real-time updates") {
VStack(alignment: .leading, spacing: 4) {
TextField("Subscribe base URL", text: $config.feishuAppBaseURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
Text("Base URL of the change-notification service, e.g. http://localhost:8000. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Section("Calibration") {
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
.onChange(of: config.showFloatingWidget) { _, enabled in
if enabled {
AppDelegate.shared?.showFloatingWidget()
} else {
AppDelegate.shared?.hideFloatingWidget()
}
}
Toggle("Launch at login", isOn: $config.launchAtLogin)
}
Section {
DisclosureGroup(isExpanded: $showAdvanced) { DisclosureGroup(isExpanded: $showAdvanced) {
VStack(alignment: .leading, spacing: 16) {
// Field Mappings
VStack(alignment: .leading, spacing: 8) {
Text("Field Mappings") Text("Field Mappings")
.font(.subheadline) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
labeledRow("Title field") { TextField("Title field", text: $config.fieldMappings.titleField)
TextField("", text: $config.fieldMappings.titleField) TextField("Priority field", text: $config.fieldMappings.priorityField)
.textFieldStyle(.roundedBorder) TextField("Status field", text: $config.fieldMappings.statusField)
} TextField("Assignee field", text: $config.fieldMappings.assigneeField)
labeledRow("Priority field") { TextField("Reporter field", text: $config.fieldMappings.reporterField)
TextField("", text: $config.fieldMappings.priorityField) TextField("Customer field", text: $config.fieldMappings.customerField)
.textFieldStyle(.roundedBorder) TextField("Created At field", text: $config.fieldMappings.createdAtField)
} TextField("Updated At field", text: $config.fieldMappings.updatedAtField)
labeledRow("Status field") {
TextField("", text: $config.fieldMappings.statusField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Assignee field") {
TextField("", text: $config.fieldMappings.assigneeField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Reporter field") {
TextField("", text: $config.fieldMappings.reporterField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Customer field") {
TextField("", text: $config.fieldMappings.customerField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Created At field") {
TextField("", text: $config.fieldMappings.createdAtField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Updated At field") {
TextField("", text: $config.fieldMappings.updatedAtField)
.textFieldStyle(.roundedBorder)
}
}
Divider()
// Status Mappings
VStack(alignment: .leading, spacing: 8) {
Text("Status Mappings") Text("Status Mappings")
.font(.subheadline) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Map your Feishu status values to standard Bugger statuses.") Text("Map your Feishu status values to standard Bugger statuses.")
@ -253,18 +172,12 @@ struct SettingsView: View {
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
statusMappingRow(for: status) statusMappingRow(for: status)
} }
}
}
.padding(.top, 8)
} label: { } label: {
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3") Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
} }
} }
// MARK: - Action Buttons HStack {
private var actionButtons: some View {
HStack(spacing: 12) {
Button("Save") { Button("Save") {
save() save()
} }
@ -278,35 +191,11 @@ struct SettingsView: View {
} }
.foregroundStyle(.red) .foregroundStyle(.red)
} }
} // Form
} // VStack
.padding()
} }
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 1200)
// MARK: - Layout Helpers
/// A consistently-aligned labelcontrol row.
private func labeledRow<Content: View>(
_ label: String,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(label)
.frame(width: kLabelWidth, alignment: .trailing)
.foregroundStyle(.secondary)
content()
}
}
/// A section with a left-aligned header and divider.
private func settingsSection<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Divider()
content()
}
} }
// MARK: - Status Mapping Row // MARK: - Status Mapping Row
@ -336,8 +225,8 @@ struct SettingsView: View {
} }
) )
return VStack(alignment: .leading, spacing: 4) { return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack {
StatusPill(status: status) StatusPill(status: status)
Text(status.label) Text(status.label)
.font(.caption) .font(.caption)