Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23

Merged
kua-agent merged 20 commits from feat/redline-onedrive-transport-20260905 into main 2026-09-05 06:17:29 +00:00
3 changed files with 161 additions and 0 deletions
Showing only changes of commit 299d55e884 - Show all commits
@@ -10,6 +10,7 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
case pdfCompositionFailed(reason: String) case pdfCompositionFailed(reason: String)
case airDropUnavailable case airDropUnavailable
case noCommentedReturns case noCommentedReturns
case oneDriveFolderUnavailable(path: String)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@@ -31,6 +32,8 @@ public enum ShotdeckError: Error, LocalizedError, Sendable {
return "AirDrop is not available right now." return "AirDrop is not available right now."
case .noCommentedReturns: case .noCommentedReturns:
return "None of the returned PDFs have comments on them." 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."
} }
} }
} }
@@ -10,6 +10,12 @@ public actor ReturnWatcher {
private var bridge: FSEventBridge? private var bridge: FSEventBridge?
private var pendingScanTask: Task<Void, Never>? private var pendingScanTask: Task<Void, Never>?
private let eventQueue = DispatchQueue(label: "ai.flowmaster.shotdeck.returns.fsevents") 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 /// Watch folder is `paths.watchFolder`, which production constructs from
/// `FolderSettings.resolve().watch`. This type never calls FolderSettings; /// `FolderSettings.resolve().watch`. This type never calls FolderSettings;
@@ -29,6 +35,12 @@ public actor ReturnWatcher {
onChange(found) 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 /// Idempotent. Stops and releases the FSEventStream if one is running; safe to call
/// when never started or already stopped. Cancels any pending debounced scan. /// when never started or already stopped. Cancels any pending debounced scan.
public func stop() { public func stop() {
@@ -77,6 +89,7 @@ public actor ReturnWatcher {
guard let document = PDFDocument(url: url), guard let document = PDFDocument(url: url),
AnnotationInspector.isShotdeckDocument(document) else { continue } AnnotationInspector.isShotdeckDocument(document) else { continue }
guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue } guard let inspected = try? AnnotationInspector.inspect(fileURL: url) else { continue }
if !recordUncommented, !inspected.isCommented { continue }
try await ledger.record(inspected) try await ledger.record(inspected)
results.append(inspected) results.append(inspected)
} }
@@ -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 `<home>/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)
}
}