From 299d55e884c8abf937533cb470d65242b9082791 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:01:04 +0400 Subject: [PATCH] feature(core): OneDrive transport settings, locator, and watcher recordUncommented flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SendTransport/TransportSettings (UserDefaults-backed, mirrors FolderSettings) and OneDriveLocator, which finds a OneDrive-* sync root under ~/Library/CloudStorage and resolves the Redline send/watch folder inside it (MMD-named roots preferred). TransportSettings.effectiveFolders() is the one function that combines the transport choice with FolderSettings/OneDriveLocator. ReturnWatcher gains recordUncommented (default true, today's AirDrop behaviour): when false, a document with zero human marks is neither recorded into the ledger nor returned by scanNow. This is needed because in OneDrive mode the outbox and watch folder are the same folder, so a freshly written, unmarked PDF must not be treated as a return — only a later, actually marked-up save of the same file should be. Adds ShotdeckError.oneDriveFolderUnavailable(path:) for when the OneDrive folder is missing or unwritable at send time. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- .../ShotdeckCore/Model/ShotdeckError.swift | 3 + .../ShotdeckCore/Returns/ReturnWatcher.swift | 13 ++ .../Support/TransportSettings.swift | 145 ++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 Sources/ShotdeckCore/Support/TransportSettings.swift diff --git a/Sources/ShotdeckCore/Model/ShotdeckError.swift b/Sources/ShotdeckCore/Model/ShotdeckError.swift index a188a64..ab5b063 100644 --- a/Sources/ShotdeckCore/Model/ShotdeckError.swift +++ b/Sources/ShotdeckCore/Model/ShotdeckError.swift @@ -10,6 +10,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable { case pdfCompositionFailed(reason: String) case airDropUnavailable case noCommentedReturns + case oneDriveFolderUnavailable(path: String) public var errorDescription: String? { switch self { @@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable { return "AirDrop is not available right now." case .noCommentedReturns: return "None of the returned PDFs have comments on them." + case .oneDriveFolderUnavailable(let path): + return "Your OneDrive folder is not available: \(path). Check that OneDrive is signed in, or choose another folder in Settings." } } } diff --git a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift index 829d3b8..9cadb4e 100644 --- a/Sources/ShotdeckCore/Returns/ReturnWatcher.swift +++ b/Sources/ShotdeckCore/Returns/ReturnWatcher.swift @@ -10,6 +10,12 @@ public actor ReturnWatcher { private var bridge: FSEventBridge? private var pendingScanTask: Task? private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents") + /// When false, a document with zero human marks is neither recorded into the ledger + /// nor included in scanNow's/onChange's results — needed for OneDrive mode, where the + /// outbox and watch folder are the same folder and a freshly written, unmarked PDF + /// must not be treated as a return. Defaults to true (today's AirDrop behaviour). + /// A document that IS commented is always recorded, regardless of this flag. + public var recordUncommented: Bool = true /// Watch folder is `paths.watchFolder`, which production constructs from /// `FolderSettings.resolve().watch`. This type never calls FolderSettings; @@ -29,6 +35,12 @@ public actor ReturnWatcher { onChange(found) } + /// Sets `recordUncommented`. A `func` (not a plain property set) only because + /// callers outside this actor must `await` it like any other actor mutation. + public func setRecordUncommented(_ value: Bool) { + recordUncommented = value + } + /// Idempotent. Stops and releases the FSEventStream if one is running; safe to call /// when never started or already stopped. Cancels any pending debounced scan. public func stop() { @@ -77,6 +89,7 @@ public actor ReturnWatcher { guard let document = PDFDocument(url: url), AnnotationInspector.isShotdeckDocument(document) else { continue } guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue } + if !recordUncommented, !inspected.isCommented { continue } try await ledger.record(inspected) results.append(inspected) } diff --git a/Sources/ShotdeckCore/Support/TransportSettings.swift b/Sources/ShotdeckCore/Support/TransportSettings.swift new file mode 100644 index 0000000..aca2648 --- /dev/null +++ b/Sources/ShotdeckCore/Support/TransportSettings.swift @@ -0,0 +1,145 @@ +import Foundation + +/// The two ways a composed PDF can reach the iPad and come back marked up. +public enum SendTransport: String, Codable, Sendable, CaseIterable { + case airDrop + case oneDrive + + public var displayName: String { + switch self { + case .airDrop: return "AirDrop" + case .oneDrive: return "OneDrive folder" + } + } +} + +/// User-configurable transport choice plus the OneDrive folder override, backed by +/// UserDefaults the same way `FolderSettings` is. See `FolderSettings` for why a plain +/// path (not a security-scoped bookmark) is correct for this unsandboxed app. +public enum TransportSettings { + public static let transportDefaultsKey = "ai.flowmaster.shotdeck.transport" + public static let oneDriveFolderDefaultsKey = "ai.flowmaster.shotdeck.oneDriveFolder" + + /// Defaults to `.airDrop` when unset or when the stored value cannot be parsed. + public static func transport(defaults: UserDefaults = .standard) -> SendTransport { + guard let raw = defaults.string(forKey: transportDefaultsKey), + let value = SendTransport(rawValue: raw) + else { return .airDrop } + return value + } + + public static func setTransport(_ value: SendTransport, defaults: UserDefaults = .standard) { + defaults.set(value.rawValue, forKey: transportDefaultsKey) + } + + /// Raw stored path (or nil if never set / cleared). Does NOT validate that the + /// directory still exists. + public static func storedOneDriveFolderPath(defaults: UserDefaults = .standard) -> String? { + defaults.string(forKey: oneDriveFolderDefaultsKey) + } + + public static func setOneDriveFolder(_ url: URL, defaults: UserDefaults = .standard) { + defaults.set(url.path, forKey: oneDriveFolderDefaultsKey) + } + + public static func resetOneDriveFolder(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: oneDriveFolderDefaultsKey) + } + + /// The outbox/watch folders Redline should actually use right now, for the current + /// transport. AirDrop mode delegates to `FolderSettings.resolve()` unchanged. + /// OneDrive mode uses the SAME folder for both outbox and watch — see + /// `OneDriveLocator.resolveOneDriveFolder`. When no OneDrive folder can be resolved + /// at all (no sync root, no override), this falls back to the AirDrop folders so the + /// app always has somewhere to write; `send(anchor:)` performs its own live + /// existence check before ever composing into a OneDrive send, so that fallback is + /// never mistaken for a valid OneDrive destination. + public static func effectiveFolders( + defaults: UserDefaults = .standard, + fileManager: FileManager = .default + ) -> (outbox: URL, watch: URL, transport: SendTransport) { + let transport = transport(defaults: defaults) + switch transport { + case .airDrop: + let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager) + return (folders.outbox, folders.watch, transport) + case .oneDrive: + if let folder = OneDriveLocator.resolveOneDriveFolder( + defaults: defaults, + home: fileManager.homeDirectoryForCurrentUser, + fileManager: fileManager + ) { + return (folder, folder, transport) + } + let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager) + return (folders.outbox, folders.watch, transport) + } + } +} + +/// Pure path logic for locating a OneDrive sync root under +/// `~/Library/CloudStorage` and the Redline folder inside it. No side effects — never +/// creates a directory. Fully unit-testable with a fake home tree. +public enum OneDriveLocator { + /// Every directory directly under `/Library/CloudStorage` whose name starts + /// with "OneDrive-", sorted so a name containing "MMD" (case-insensitive) sorts + /// first, then alphabetically. Empty when CloudStorage does not exist. + public static func syncRoots( + home: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) -> [URL] { + let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: cloudStorage.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { return [] } + + let items = (try? fileManager.contentsOfDirectory( + at: cloudStorage, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + + let roots = items.filter { url in + guard url.lastPathComponent.hasPrefix("OneDrive-") else { return false } + var itemIsDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: url.path, isDirectory: &itemIsDirectory) + return exists && itemIsDirectory.boolValue + } + + return roots.sorted { a, b in + let aName = a.lastPathComponent + let bName = b.lastPathComponent + let aIsMMD = aName.localizedCaseInsensitiveContains("MMD") + let bIsMMD = bName.localizedCaseInsensitiveContains("MMD") + if aIsMMD != bIsMMD { return aIsMMD } + return aName.localizedStandardCompare(bName) == .orderedAscending + } + } + + /// First sync root's "Redline" subfolder, or nil when there is no sync root at all. + public static func defaultRedlineFolder( + home: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) -> URL? { + guard let first = syncRoots(home: home, fileManager: fileManager).first else { return nil } + return first.appendingPathComponent("Redline", isDirectory: true) + } + + /// The stored override when it is set AND still exists as a directory; otherwise + /// `defaultRedlineFolder`. Never creates anything. + public static func resolveOneDriveFolder( + defaults: UserDefaults = .standard, + home: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) -> URL? { + if let storedPath = TransportSettings.storedOneDriveFolderPath(defaults: defaults) { + var isDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory) + if exists, isDirectory.boolValue { + return URL(fileURLWithPath: storedPath, isDirectory: true) + } + } + return defaultRedlineFolder(home: home, fileManager: fileManager) + } +}