407 lines
13 KiB
Swift
407 lines
13 KiB
Swift
import Foundation
|
|
import Darwin
|
|
import CairnquireSync
|
|
|
|
// MARK: - CLI
|
|
|
|
enum CLIError: Error {
|
|
case invalidArguments
|
|
case missingServerURL
|
|
case missingToken
|
|
case fileNotFound
|
|
}
|
|
|
|
final class TerminalProgress: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var lastPercent = -1
|
|
|
|
func update(_ progress: Double) {
|
|
let percent = min(max(Int(progress * 100), 0), 100)
|
|
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
guard percent != lastPercent else { return }
|
|
lastPercent = percent
|
|
print("\rUploading... \(percent)%", terminator: "")
|
|
fflush(stdout)
|
|
}
|
|
|
|
func finishLine() {
|
|
print("")
|
|
}
|
|
}
|
|
|
|
func readClipboard() -> String? {
|
|
let task = Process()
|
|
task.launchPath = "/usr/bin/pbpaste"
|
|
let pipe = Pipe()
|
|
task.standardOutput = pipe
|
|
do {
|
|
try task.run()
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
return String(data: data, encoding: .utf8)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
struct CLI {
|
|
static func run() async {
|
|
let args = Array(CommandLine.arguments.dropFirst())
|
|
|
|
do {
|
|
try await execute(args: args)
|
|
} catch {
|
|
print("Error: \(error.localizedDescription)")
|
|
exit(1)
|
|
}
|
|
}
|
|
|
|
static func execute(args: [String]) async throws {
|
|
guard let command = args.first else {
|
|
printUsage()
|
|
return
|
|
}
|
|
|
|
switch command {
|
|
case "upload", "up":
|
|
try await uploadCommand(args: Array(args.dropFirst()))
|
|
case "note", "n":
|
|
try await noteCommand(args: Array(args.dropFirst()))
|
|
case "sync":
|
|
try await syncCommand(args: Array(args.dropFirst()))
|
|
case "config":
|
|
try await configCommand(args: Array(args.dropFirst()))
|
|
case "help", "-h", "--help":
|
|
printHelp(for: args.dropFirst().first)
|
|
default:
|
|
print("Unknown command: \(command)")
|
|
printUsage()
|
|
}
|
|
}
|
|
|
|
// MARK: - Commands
|
|
|
|
static func uploadCommand(args: [String]) async throws {
|
|
if args.contains("--help") || args.contains("-h") {
|
|
printUploadUsage()
|
|
return
|
|
}
|
|
guard let filePath = args.first else {
|
|
printUploadUsage()
|
|
throw CLIError.invalidArguments
|
|
}
|
|
|
|
let config = ConfigStore.shared.load()
|
|
guard let token = config.apiToken else {
|
|
print("No API token configured. Run: cairnquire-cli config --token <token>")
|
|
throw CLIError.missingToken
|
|
}
|
|
guard let baseURL = config.serverBaseURL else {
|
|
print("No server URL configured. Run: cairnquire-cli config --server <url>")
|
|
throw CLIError.missingServerURL
|
|
}
|
|
|
|
let fileURL = URL(fileURLWithPath: filePath)
|
|
guard FileManager.default.fileExists(atPath: filePath) else {
|
|
print("File not found: \(filePath)")
|
|
throw CLIError.fileNotFound
|
|
}
|
|
|
|
let client = APIClient(baseURL: baseURL, apiToken: token)
|
|
let manager = UploadManager(client: client, config: config)
|
|
|
|
print("Uploading \(fileURL.lastPathComponent)...")
|
|
let progress = TerminalProgress()
|
|
let result = try await uploadFileResolvingDuplicates(manager: manager, fileURL: fileURL, progress: progress)
|
|
progress.finishLine()
|
|
|
|
print("✓ Uploaded successfully")
|
|
print(" URL: \(result.url)")
|
|
print(" Hash: \(result.hash)")
|
|
print(" Copied to clipboard")
|
|
}
|
|
|
|
static func noteCommand(args: [String]) async throws {
|
|
if args.contains("--help") || args.contains("-h") {
|
|
printNoteUsage()
|
|
return
|
|
}
|
|
let config = ConfigStore.shared.load()
|
|
guard let token = config.apiToken else {
|
|
print("No API token configured. Run: cairnquire-cli config --token <token>")
|
|
throw CLIError.missingToken
|
|
}
|
|
guard let baseURL = config.serverBaseURL else {
|
|
print("No server URL configured. Run: cairnquire-cli config --server <url>")
|
|
throw CLIError.missingServerURL
|
|
}
|
|
|
|
let content: String
|
|
if args.contains("--clipboard") || args.isEmpty {
|
|
guard let clipboard = readClipboard() else {
|
|
print("Clipboard is empty")
|
|
throw CLIError.invalidArguments
|
|
}
|
|
content = clipboard
|
|
} else {
|
|
// Read from stdin or args
|
|
if args.first == "-" {
|
|
content = String(data: FileHandle.standardInput.availableData, encoding: .utf8) ?? ""
|
|
} else {
|
|
content = args.joined(separator: " ")
|
|
}
|
|
}
|
|
|
|
guard !content.isEmpty else {
|
|
print("No content provided")
|
|
throw CLIError.invalidArguments
|
|
}
|
|
|
|
let title = args.first(where: { $0.hasPrefix("--title=") })?.replacingOccurrences(of: "--title=", with: "")
|
|
|
|
let client = APIClient(baseURL: baseURL, apiToken: token)
|
|
let manager = UploadManager(client: client, config: config)
|
|
|
|
print("Creating note...")
|
|
let result = try await manager.createNote(from: content, title: title)
|
|
|
|
print("✓ Note created")
|
|
print(" Path: \(result.path)")
|
|
print(" Title: \(result.title)")
|
|
print(" Hash: \(result.hash)")
|
|
}
|
|
|
|
static func syncCommand(args: [String]) async throws {
|
|
if args.contains("--help") || args.contains("-h") {
|
|
printSyncUsage()
|
|
return
|
|
}
|
|
let config = ConfigStore.shared.load()
|
|
guard let token = config.apiToken else {
|
|
print("No API token configured")
|
|
throw CLIError.missingToken
|
|
}
|
|
guard let baseURL = config.serverBaseURL else {
|
|
print("No server URL configured")
|
|
throw CLIError.missingServerURL
|
|
}
|
|
guard let syncFolder = config.syncFolder else {
|
|
print("No sync folder configured")
|
|
throw CLIError.invalidArguments
|
|
}
|
|
|
|
let client = APIClient(baseURL: baseURL, apiToken: token)
|
|
let engine = SyncEngine(client: client, config: config)
|
|
|
|
print("Syncing \(syncFolder)...")
|
|
try await engine.performSync()
|
|
print("✓ Sync complete")
|
|
}
|
|
|
|
static func configCommand(args: [String]) async throws {
|
|
if args.contains("--help") || args.contains("-h") {
|
|
printConfigUsage()
|
|
return
|
|
}
|
|
|
|
var config = ConfigStore.shared.load()
|
|
var i = 0
|
|
while i < args.count {
|
|
let arg = args[i]
|
|
guard i + 1 < args.count else {
|
|
print("Missing value for \(arg)")
|
|
throw CLIError.invalidArguments
|
|
}
|
|
let value = args[i + 1]
|
|
switch arg {
|
|
case "--server":
|
|
config.serverURL = value
|
|
case "--token":
|
|
config.apiToken = value
|
|
case "--folder":
|
|
config.syncFolder = value
|
|
case "--upload-folder":
|
|
config.defaultUploadFolder = value
|
|
case "--new-note":
|
|
guard let action = NewNoteAction(rawValue: value) else {
|
|
print("Invalid new note action: \(value). Use clipboard or editor.")
|
|
throw CLIError.invalidArguments
|
|
}
|
|
config.newNoteAction = action
|
|
default:
|
|
print("Unknown config option: \(arg)")
|
|
throw CLIError.invalidArguments
|
|
}
|
|
i += 2
|
|
}
|
|
|
|
await ConfigStore.shared.save(config)
|
|
|
|
print("Configuration saved:")
|
|
print(" Server: \(config.serverURL)")
|
|
print(" Sync Folder: \(config.syncFolder ?? "(none)")")
|
|
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
|
|
print(" New Note: \(config.effectiveNewNoteAction.rawValue)")
|
|
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
|
|
}
|
|
|
|
static func uploadFileResolvingDuplicates(
|
|
manager: UploadManager,
|
|
fileURL: URL,
|
|
progress: TerminalProgress
|
|
) async throws -> UploadResult {
|
|
var duplicateAction: UploadDuplicateAction?
|
|
|
|
while true {
|
|
do {
|
|
return try await manager.uploadFile(
|
|
fileURL,
|
|
duplicateAction: duplicateAction,
|
|
onProgress: { value in progress.update(value) }
|
|
)
|
|
} catch let error as APIError where error.isUploadConflict {
|
|
progress.finishLine()
|
|
duplicateAction = try promptForDuplicate(error, filename: fileURL.lastPathComponent)
|
|
print("Uploading \(fileURL.lastPathComponent)...")
|
|
}
|
|
}
|
|
}
|
|
|
|
static func promptForDuplicate(_ error: APIError, filename: String) throws -> UploadDuplicateAction {
|
|
if error.conflictType == "document" {
|
|
print("A document named \(filename) already exists in the inbox.")
|
|
if let existingPath = error.existingPath, !existingPath.isEmpty {
|
|
print("Existing document: \(existingPath)")
|
|
}
|
|
print("[k] Keep both [r] Replace existing [c] Cancel")
|
|
print("Choice: ", terminator: "")
|
|
fflush(stdout)
|
|
|
|
switch readLine()?.lowercased() {
|
|
case "k", "keep", "keep-both":
|
|
return .rename
|
|
case "r", "replace", "overwrite":
|
|
return .overwrite
|
|
default:
|
|
throw UploadCancelledError()
|
|
}
|
|
}
|
|
|
|
print("\(filename) was already uploaded.")
|
|
if let existingURL = error.existingUrl, !existingURL.isEmpty {
|
|
print("Existing URL: \(existingURL)")
|
|
}
|
|
print("[u] Use existing URL [c] Cancel")
|
|
print("Choice: ", terminator: "")
|
|
fflush(stdout)
|
|
|
|
switch readLine()?.lowercased() {
|
|
case "u", "use", "reuse":
|
|
return .reuse
|
|
default:
|
|
throw UploadCancelledError()
|
|
}
|
|
}
|
|
|
|
static func printUsage() {
|
|
print("""
|
|
Cairnquire CLI - macOS sync and upload tool
|
|
|
|
USAGE:
|
|
cairnquire-cli <command> [options]
|
|
|
|
COMMANDS:
|
|
upload, up <file> Upload a file and copy URL to clipboard
|
|
note, n [text] Create a new note from clipboard or arguments
|
|
sync Sync the configured folder once
|
|
config [options] Configure the shared app and CLI settings
|
|
help [command] Show general or command-specific help
|
|
|
|
EXAMPLES:
|
|
cairnquire-cli upload ~/Documents/report.pdf
|
|
cairnquire-cli note --clipboard --title="Meeting Notes"
|
|
echo "Hello World" | cairnquire-cli note -
|
|
cairnquire-cli config --server http://localhost:8080 --token cq_pat_xxx
|
|
cairnquire-cli sync
|
|
|
|
Run 'cairnquire-cli help <command>' for details.
|
|
""")
|
|
}
|
|
|
|
static func printHelp(for command: String?) {
|
|
switch command {
|
|
case "upload", "up":
|
|
printUploadUsage()
|
|
case "note", "n":
|
|
printNoteUsage()
|
|
case "sync":
|
|
printSyncUsage()
|
|
case "config":
|
|
printConfigUsage()
|
|
default:
|
|
printUsage()
|
|
}
|
|
}
|
|
|
|
static func printUploadUsage() {
|
|
print("""
|
|
USAGE:
|
|
cairnquire-cli upload <file>
|
|
|
|
Upload one file and copy its canonical Cairnquire URL to the clipboard.
|
|
Readable text files become rendered documents. Other files remain attachments.
|
|
If the inbox name or attachment content already exists, the CLI asks what to do.
|
|
""")
|
|
}
|
|
|
|
static func printNoteUsage() {
|
|
print("""
|
|
USAGE:
|
|
cairnquire-cli note [--clipboard] [--title="Title"]
|
|
cairnquire-cli note <text>
|
|
cairnquire-cli note -
|
|
|
|
Create a markdown note from clipboard content, arguments, or stdin.
|
|
""")
|
|
}
|
|
|
|
static func printSyncUsage() {
|
|
print("""
|
|
USAGE:
|
|
cairnquire-cli sync
|
|
|
|
Run one bidirectional sync for the folder configured with:
|
|
cairnquire-cli config --folder <path>
|
|
|
|
The menubar app also watches this folder when auto-sync is enabled in Settings.
|
|
""")
|
|
}
|
|
|
|
static func printConfigUsage() {
|
|
print("""
|
|
USAGE:
|
|
cairnquire-cli config [options]
|
|
|
|
OPTIONS:
|
|
--server <url> Set the Cairnquire server URL
|
|
--token <token> Set the API token
|
|
--folder <path> Set the local folder used by sync
|
|
--upload-folder <path> Set the server folder for readable uploads
|
|
--new-note <action> Set New Note behavior: clipboard or editor
|
|
|
|
The menubar app and CLI use the same saved configuration.
|
|
""")
|
|
}
|
|
}
|
|
|
|
// MARK: - Entry Point
|
|
|
|
@main
|
|
struct CairnquireCLI {
|
|
static func main() async {
|
|
await CLI.run()
|
|
}
|
|
}
|