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) + } +}