Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user