sync app filled in
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct ClipboardNotice: Identifiable, Equatable {
|
||||
public let id = UUID()
|
||||
public let filename: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
@Published public var config: SyncConfig
|
||||
@Published public var isSyncing = false
|
||||
@Published public var lastSyncTime: Date?
|
||||
@Published public var syncStatus: String = "Ready"
|
||||
@Published public var pendingChanges = 0
|
||||
@Published public var uploadProgress: Double? = nil
|
||||
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
||||
@Published public private(set) var isLoadingRecentUploads = false
|
||||
@Published public private(set) var recentUploadsError: String?
|
||||
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
||||
|
||||
public var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let configStore = ConfigStore.shared
|
||||
private var apiClient: APIClient
|
||||
private var syncEngine: SyncEngine?
|
||||
private var uploadManager: UploadManager
|
||||
private var folderWatcher: FolderWatcher?
|
||||
private var syncTimer: Timer?
|
||||
|
||||
private var settingsWindow: NSWindow?
|
||||
private var uploadsWindow: NSWindow?
|
||||
private var dropZoneWindow: NSWindow?
|
||||
|
||||
public override init() {
|
||||
let config = ConfigStore.shared.load()
|
||||
self.config = config
|
||||
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
||||
self.uploadManager = UploadManager(client: apiClient, config: config)
|
||||
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
||||
super.init()
|
||||
|
||||
Task {
|
||||
await setupSync()
|
||||
}
|
||||
Task {
|
||||
await refreshRecentUploads()
|
||||
}
|
||||
}
|
||||
|
||||
public func updateConfig(_ newConfig: SyncConfig) {
|
||||
config = newConfig
|
||||
|
||||
Task {
|
||||
await configStore.save(newConfig)
|
||||
|
||||
if let url = newConfig.serverBaseURL {
|
||||
await apiClient.setAPIToken(newConfig.apiToken)
|
||||
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||
await refreshRecentUploads()
|
||||
await setupSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSync() async {
|
||||
// Stop existing watcher
|
||||
folderWatcher?.stop()
|
||||
syncTimer?.invalidate()
|
||||
|
||||
guard config.autoSync, let syncFolder = config.syncFolder else {
|
||||
syncStatus = "Auto-sync disabled"
|
||||
return
|
||||
}
|
||||
|
||||
// Setup folder watcher
|
||||
let watcher = FolderWatcher(folderPath: syncFolder)
|
||||
watcher.delegate = self
|
||||
watcher.start()
|
||||
self.folderWatcher = watcher
|
||||
|
||||
// Setup periodic sync
|
||||
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
await self?.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
await performSync()
|
||||
}
|
||||
|
||||
public func performSync() async {
|
||||
guard !isSyncing else { return }
|
||||
guard config.syncFolder != nil else {
|
||||
syncStatus = "No sync folder configured"
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing = true
|
||||
syncStatus = "Syncing..."
|
||||
|
||||
do {
|
||||
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
||||
syncEngine = engine
|
||||
try await engine.performSync()
|
||||
|
||||
lastSyncTime = Date()
|
||||
syncStatus = "Synced"
|
||||
} catch {
|
||||
syncStatus = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
// MARK: - Window Management
|
||||
|
||||
public func showSettings() {
|
||||
if let window = settingsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Cairnquire Settings"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
||||
self?.updateConfig(newConfig)
|
||||
window?.close()
|
||||
}))
|
||||
window.center()
|
||||
|
||||
settingsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showUploads() {
|
||||
if let window = uploadsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Uploaded Files"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
||||
window.center()
|
||||
|
||||
uploadsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showDropZone() {
|
||||
if let window = dropZoneWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Upload File"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
||||
window.center()
|
||||
|
||||
dropZoneWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func windowWillClose(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
|
||||
if window === settingsWindow {
|
||||
settingsWindow = nil
|
||||
} else if window === uploadsWindow {
|
||||
uploadsWindow = nil
|
||||
} else if window === dropZoneWindow {
|
||||
dropZoneWindow = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func present(_ window: NSWindow) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
public func quickUpload() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
|
||||
guard panel.runModal() == .OK else { return }
|
||||
|
||||
Task {
|
||||
await uploadURLList(panel.urls)
|
||||
}
|
||||
}
|
||||
|
||||
public func uploadFiles(_ urls: [URL]) {
|
||||
Task {
|
||||
await uploadURLList(urls)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadURLList(_ urls: [URL]) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
let total = Double(urls.count)
|
||||
for (index, url) in urls.enumerated() {
|
||||
let baseProgress = Double(index) / total
|
||||
let fileWeight = 1 / total
|
||||
uploadProgress = baseProgress
|
||||
syncStatus = "Uploading \(url.lastPathComponent)"
|
||||
|
||||
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
||||
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
||||
Task { @MainActor in
|
||||
self?.uploadProgress = combinedProgress
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
||||
uploadProgress = Double(index + 1) / total
|
||||
syncStatus = "Uploaded \(result.filename)"
|
||||
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||
await refreshRecentUploads()
|
||||
} catch {
|
||||
syncStatus = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRecentUploads() async {
|
||||
guard !isLoadingRecentUploads else { return }
|
||||
|
||||
isLoadingRecentUploads = true
|
||||
defer { isLoadingRecentUploads = false }
|
||||
|
||||
do {
|
||||
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
||||
recentUploadsError = nil
|
||||
} catch {
|
||||
recentUploadsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public func createNoteFromClipboard() {
|
||||
guard let text = NSPasteboard.general.string(forType: .string) else {
|
||||
syncStatus = "Clipboard is empty"
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await uploadManager.createNote(from: text)
|
||||
syncStatus = "Created note: \(result.title)"
|
||||
} catch {
|
||||
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarController: FolderWatcherDelegate {
|
||||
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
||||
Task { @MainActor in
|
||||
pendingChanges += paths.count
|
||||
syncStatus = "\(pendingChanges) pending changes"
|
||||
await performSync()
|
||||
pendingChanges = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user