sync app filled in
This commit is contained in:
398
apps/macos-sync/Sources/cairnquire-cli/main.swift
Normal file
398
apps/macos-sync/Sources/cairnquire-cli/main.swift
Normal file
@@ -0,0 +1,398 @@
|
||||
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
|
||||
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(" 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
|
||||
|
||||
The menubar app and CLI use the same saved configuration.
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry Point
|
||||
|
||||
@main
|
||||
struct CairnquireCLI {
|
||||
static func main() async {
|
||||
await CLI.run()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user