sync app filled in
This commit is contained in:
@@ -6,3 +6,4 @@ tmp
|
||||
|
||||
coverage
|
||||
*.log
|
||||
.build
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,3 +5,10 @@ coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
apps/server/bin/
|
||||
|
||||
# Swift Package Manager and Xcode
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
content
|
||||
|
||||
70
.project/macos-app-api-gaps.md
Normal file
70
.project/macos-app-api-gaps.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# macOS App API Gaps
|
||||
|
||||
## Missing API Endpoints
|
||||
|
||||
### 1. List Uploaded Attachments
|
||||
**Status:** IMPLEMENTED (see `apps/server/internal/httpserver/handlers.go` `handleAttachmentsList`)
|
||||
|
||||
The app needs a way to show an index of all uploaded files. The current API only allows uploading (`POST /api/uploads`) and downloading (`GET /attachments/{hash}`).
|
||||
|
||||
**Needed:**
|
||||
```
|
||||
GET /api/attachments
|
||||
Response: {
|
||||
"attachments": [
|
||||
{
|
||||
"hash": "sha256",
|
||||
"originalName": "filename.pdf",
|
||||
"contentType": "application/pdf",
|
||||
"sizeBytes": 12345,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"url": "/attachments/{hash}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Document from Content
|
||||
**Status:** AVAILABLE BY PATH
|
||||
|
||||
The existing `POST /api/documents/{path}` works but requires knowing the path upfront. For the "new note from clipboard" feature, it would be cleaner to have:
|
||||
|
||||
```
|
||||
POST /api/documents
|
||||
Body: {
|
||||
"content": "# Note Title\n\nBody...",
|
||||
"folder": "inbox" // optional, defaults to root
|
||||
}
|
||||
Response: {
|
||||
"path": "inbox/note-title.md",
|
||||
"title": "Note Title",
|
||||
"hash": "sha256"
|
||||
}
|
||||
```
|
||||
|
||||
The current workaround is to POST to `/api/documents/{path}` with the desired path.
|
||||
|
||||
### 3. Inbox/Upload Folder Concept
|
||||
**Status:** NOT IMPLEMENTED
|
||||
|
||||
The server has no concept of a default upload folder or inbox. All documents live in a flat-ish hierarchy under the content source directory. The macOS app will:
|
||||
- Allow the user to configure a default upload folder (e.g., "inbox/")
|
||||
- Fall back to root if not configured
|
||||
- Document this as a client-side convention
|
||||
|
||||
## Stub Implementations
|
||||
|
||||
The attachment list placeholder has been replaced with a repository-backed implementation. Native sync deltas now apply non-conflicting create/update/delete/rename changes and return `newSnapshotId`; create/update changes should include inline markdown `content` or reference a hash already present in the content store.
|
||||
|
||||
## Authentication
|
||||
|
||||
The app uses the existing Bearer token (API key) auth flow:
|
||||
1. User creates an API token via the web UI at `/account`
|
||||
2. Token is stored in macOS Keychain
|
||||
3. All requests include `Authorization: Bearer <token>`
|
||||
|
||||
## Notes
|
||||
|
||||
- File sync uses the existing `/api/sync/*` protocol. Clients should persist `newSnapshotId` after each delta response.
|
||||
- Upload uses `POST /api/uploads`. Markdown, plain-text, and HTML files are promoted into readable documents; other supported files remain attachments.
|
||||
- Uploaded file listing uses the repository-backed `GET /api/attachments` endpoint. Its `url` field points to the rendered page for promoted documents.
|
||||
@@ -192,6 +192,7 @@ Response:
|
||||
"type": "update",
|
||||
"path": "guide.md",
|
||||
"hash": "client-hash",
|
||||
"content": "# Guide\n\nClient edit\n",
|
||||
"size": 58,
|
||||
"modified": "2026-05-14T12:05:00Z"
|
||||
}
|
||||
@@ -212,6 +213,7 @@ Response:
|
||||
"modified": "2026-05-14T12:03:00Z"
|
||||
}
|
||||
],
|
||||
"newSnapshotId": "snap:device:new-timestamp",
|
||||
"conflicts": [
|
||||
{
|
||||
"path": "guide.md",
|
||||
@@ -225,6 +227,11 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
For create/update deltas, native clients should send inline UTF-8 markdown in
|
||||
`content`. The server stores the bytes content-addressably, verifies any claimed
|
||||
`hash`, writes the markdown into the content tree, and returns a fresh
|
||||
`newSnapshotId` for the client's next sync request.
|
||||
|
||||
### Resolve
|
||||
|
||||
`POST /api/sync/resolve`
|
||||
|
||||
@@ -112,6 +112,7 @@ Upload client changes and receive server changes + conflicts.
|
||||
"type": "update",
|
||||
"path": "hello.md",
|
||||
"hash": "d4e5f6...",
|
||||
"content": "# Hello\n\nUpdated content\n",
|
||||
"size": 50,
|
||||
"modified": "2024-01-15T10:05:00Z"
|
||||
}
|
||||
@@ -131,6 +132,7 @@ Upload client changes and receive server changes + conflicts.
|
||||
"modified": "2024-01-15T10:02:00Z"
|
||||
}
|
||||
],
|
||||
"newSnapshotId": "snap:macbook-pro-1:1234567999",
|
||||
"conflicts": [
|
||||
{
|
||||
"path": "hello.md",
|
||||
@@ -144,6 +146,10 @@ Upload client changes and receive server changes + conflicts.
|
||||
}
|
||||
```
|
||||
|
||||
For client create/update changes, the server accepts inline UTF-8 markdown in
|
||||
`content` and recomputes the SHA-256 hash before writing the file. If `content`
|
||||
is omitted, the supplied `hash` must already exist in the server content store.
|
||||
|
||||
### POST /api/sync/resolve
|
||||
|
||||
Resolve conflicts and finalize the sync.
|
||||
|
||||
30
README.md
30
README.md
@@ -21,6 +21,7 @@ This repository now contains the Milestone 1 foundation scaffold:
|
||||
- Docker (optional)
|
||||
- CGO-capable toolchain for `go-libsql`
|
||||
- `air` for Go live reload (`go install github.com/air-verse/air@v1.65.1`)
|
||||
- Xcode command-line tools for the optional macOS sync app and CLI
|
||||
|
||||
Install the pinned toolchain with:
|
||||
|
||||
@@ -48,6 +49,28 @@ mise run server-run
|
||||
|
||||
The server listens on `http://localhost:8080` by default.
|
||||
|
||||
### macOS Sync and CLI
|
||||
|
||||
The menu bar sync app and companion CLI live in `apps/macos-sync`. For local
|
||||
development, start the server with its local-only auth shortcut:
|
||||
|
||||
```bash
|
||||
PORT=9000 CAIRNQUIRE_DEV_MODE=1 mise run server-run
|
||||
|
||||
cd apps/macos-sync
|
||||
swift run cairnquire-cli config \
|
||||
--server http://localhost:9000 \
|
||||
--token dev \
|
||||
--folder "$HOME/Documents/Cairnquire"
|
||||
swift run cairnquire-cli sync
|
||||
swift run CairnquireSync
|
||||
```
|
||||
|
||||
The app automatically uses the `dev` token for localhost servers. For a
|
||||
deployed server, create an API token from `/account` and store that token in
|
||||
the app settings. See [`apps/macos-sync/README.md`](apps/macos-sync/README.md)
|
||||
for upload, sync, and Keychain details.
|
||||
|
||||
### Fresh Server Deployment
|
||||
|
||||
The Compose deployment keeps its database and attachment store in `./data` and
|
||||
@@ -86,6 +109,7 @@ Supported variables:
|
||||
|
||||
- `CAIRNQUIRE_PUBLIC_ORIGIN` - Required for deployment. Exact public HTTPS origin used for passkeys and device-flow URLs, for example `https://cairnquire.example.com`.
|
||||
- `CAIRNQUIRE_SERVER_ADDR` - Listen address. Defaults to `:8080`.
|
||||
- `PORT` - Optional shorthand for the listen port, for example `PORT=9000`.
|
||||
- `CAIRNQUIRE_DATABASE_PATH` - Local SQLite/libsql path. Defaults to `../../data/db.sqlite` when running from `apps/server`.
|
||||
- `CAIRNQUIRE_DATABASE_PRIMARY_URL` - Optional remote libsql primary URL for an embedded replica.
|
||||
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
|
||||
@@ -97,6 +121,12 @@ Supported variables:
|
||||
- `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`.
|
||||
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.
|
||||
|
||||
## Dev Mode
|
||||
|
||||
With `CAIRNQUIRE_DEV_MODE=1`, API requests using `Authorization: Bearer dev`
|
||||
authenticate as the local development admin. This is intended only for
|
||||
localhost testing.
|
||||
|
||||
## Noted Gaps
|
||||
|
||||
- The docs call for `golang-migrate`, but the current foundation uses an internal migration runner while `go-libsql` integration details are validated.
|
||||
|
||||
34
apps/macos-sync/Package.swift
Normal file
34
apps/macos-sync/Package.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "macos-sync",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(
|
||||
name: "CairnquireSync",
|
||||
targets: ["CairnquireSyncApp"]
|
||||
),
|
||||
.executable(
|
||||
name: "cairnquire-cli",
|
||||
targets: ["cairnquire-cli"]
|
||||
),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CairnquireSync",
|
||||
dependencies: []
|
||||
),
|
||||
.executableTarget(
|
||||
name: "CairnquireSyncApp",
|
||||
dependencies: ["CairnquireSync"],
|
||||
path: "Sources/CairnquireSyncApp"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "cairnquire-cli",
|
||||
dependencies: ["CairnquireSync"],
|
||||
path: "Sources/cairnquire-cli"
|
||||
),
|
||||
]
|
||||
)
|
||||
204
apps/macos-sync/README.md
Normal file
204
apps/macos-sync/README.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Cairnquire macOS Sync
|
||||
|
||||
A macOS menubar application for syncing and uploading files to a Cairnquire server.
|
||||
|
||||
## Features
|
||||
|
||||
- **Tray Icon**: `tray.circle.fill` as the default menubar icon with upload state animations
|
||||
- **Drop on Menubar**: Drag files directly onto the menubar icon — it bounces, then uploads
|
||||
- **Upload Progress**: Icon switches to `tray.circle` (outline) while uploading
|
||||
- **Menubar Popover**: Click the icon to see a beautiful status panel with quick actions
|
||||
- **Auto Sync**: Watch a local folder and automatically sync markdown files to the server
|
||||
- **Drag & Drop Upload**: Drop files onto the upload zone to upload and copy the URL to clipboard
|
||||
- **Quick Upload**: Upload files via the menubar menu or popover
|
||||
- **Clipboard Notes**: Create new notes from clipboard content
|
||||
- **Upload Index**: View all uploaded files with timestamps
|
||||
- **Custom Icons**: Choose from 11 different SF Symbols for the menubar icon
|
||||
- **CLI Tool**: Command-line interface for one-off uploads and note creation
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 13.0+
|
||||
- Xcode 15.0+ (for building)
|
||||
- A running Cairnquire server
|
||||
|
||||
## Building
|
||||
|
||||
### Menubar App
|
||||
|
||||
```bash
|
||||
cd apps/macos-sync
|
||||
swift build
|
||||
swift run CairnquireSync
|
||||
```
|
||||
|
||||
Or open in Xcode:
|
||||
```bash
|
||||
open Package.swift
|
||||
```
|
||||
|
||||
### CLI Tool
|
||||
|
||||
```bash
|
||||
cd apps/macos-sync
|
||||
swift build --product cairnquire-cli
|
||||
swift run cairnquire-cli --help
|
||||
```
|
||||
|
||||
You can run the built executable directly after building:
|
||||
|
||||
```bash
|
||||
.build/debug/cairnquire-cli help
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Start your Cairnquire server
|
||||
2. Create an API token from the web UI at `/account`
|
||||
3. Open the app settings (click the menubar icon → Settings)
|
||||
4. Enter your server URL and API token
|
||||
5. Optionally configure a sync folder and default upload folder
|
||||
|
||||
## Usage
|
||||
|
||||
### Menubar App
|
||||
|
||||
**Left-click** the icon to open a beautiful status popover showing:
|
||||
- Live sync status with color indicator
|
||||
- Last sync time
|
||||
- Quick action buttons (Sync, Upload, Note, Drop Zone)
|
||||
- Recent uploads preview
|
||||
- Settings and server links
|
||||
|
||||
**Right-click** the icon for the traditional menu with all options.
|
||||
|
||||
- **Drop on Icon**: Drag files directly onto the menubar icon — it bounces, then uploads
|
||||
- **Sync Now**: Manually trigger a sync
|
||||
- **Upload File**: Select files to upload
|
||||
- **Create Note from Clipboard**: Create a new markdown note from clipboard text
|
||||
- **Drop Zone**: Open a drag-and-drop upload window
|
||||
- **View Uploads**: See all uploaded files with timestamps
|
||||
- **Change Icon**: Pick from 11 SF Symbols in Settings (default: `tray.circle.fill`)
|
||||
|
||||
### CLI Tool
|
||||
|
||||
Configure the shared app and CLI settings first. Use the port where your server is actually listening:
|
||||
|
||||
```bash
|
||||
cd apps/macos-sync
|
||||
swift run cairnquire-cli config \
|
||||
--server http://localhost:9000 \
|
||||
--token dev \
|
||||
--folder "$HOME/Documents/Cairnquire" \
|
||||
--upload-folder Inbox
|
||||
```
|
||||
|
||||
Run one bidirectional sync:
|
||||
|
||||
```bash
|
||||
swift run cairnquire-cli sync
|
||||
```
|
||||
|
||||
Start the menubar app to keep watching the configured folder when auto-sync is enabled:
|
||||
|
||||
```bash
|
||||
swift run CairnquireSync
|
||||
```
|
||||
|
||||
Other CLI commands:
|
||||
|
||||
```bash
|
||||
# Upload a file
|
||||
swift run cairnquire-cli upload ~/Documents/report.pdf
|
||||
|
||||
# Create a note from clipboard
|
||||
swift run cairnquire-cli note --clipboard --title="Meeting Notes"
|
||||
|
||||
# Create a note from stdin
|
||||
echo "Hello World" | swift run cairnquire-cli note -
|
||||
|
||||
# Create a note from arguments
|
||||
swift run cairnquire-cli note "This is a note body"
|
||||
|
||||
# Show command-specific documentation
|
||||
swift run cairnquire-cli help upload
|
||||
swift run cairnquire-cli help sync
|
||||
```
|
||||
|
||||
Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. If the same inbox filename already exists, the app asks whether to keep both files, replace the existing document, or cancel. If identical attachment bytes were already uploaded, the app asks whether to copy the existing URL or cancel.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in:
|
||||
- Settings: `~/Library/Preferences/com.cairnquire.sync.plist`
|
||||
- API Token: macOS Keychain
|
||||
- Sync state: `~/Library/Application Support/CairnquireSync/`
|
||||
|
||||
SwiftPM debug executables are ad-hoc signed. macOS may ask for Keychain access
|
||||
again after rebuilding because the replacement executable does not have a
|
||||
stable Developer ID identity. A packaged, consistently signed app avoids that
|
||||
development-time prompt. Updating settings preserves the existing Keychain
|
||||
item instead of recreating it.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Sources/
|
||||
├── CairnquireSync/ # Shared library
|
||||
│ ├── API/
|
||||
│ │ ├── Client.swift # HTTP client for Cairnquire API
|
||||
│ │ └── Models.swift # API response models
|
||||
│ ├── Sync/
|
||||
│ │ ├── SyncEngine.swift # Bidirectional sync engine
|
||||
│ │ └── FolderWatcher.swift # FSEvents-based folder watcher
|
||||
│ ├── Upload/
|
||||
│ │ └── UploadManager.swift # File upload handling
|
||||
│ ├── Settings/
|
||||
│ │ └── Config.swift # Configuration and keychain storage
|
||||
│ └── UI/
|
||||
│ ├── MenuBarController.swift # Main controller
|
||||
│ ├── StatusPopoverView.swift # Menubar popover UI
|
||||
│ ├── SettingsView.swift # Settings UI
|
||||
│ ├── DropZoneView.swift # Drag & drop UI
|
||||
│ └── UploadsListView.swift # Upload index UI
|
||||
├── CairnquireSyncApp/ # Menubar app
|
||||
│ ├── App.swift # App entry point
|
||||
│ └── StatusItemView.swift # Custom status item view with drag & drop
|
||||
└── cairnquire-cli/ # CLI tool
|
||||
└── main.swift # CLI entry point
|
||||
```
|
||||
|
||||
## Dev Mode
|
||||
|
||||
When running the server locally with `CAIRNQUIRE_DEV_MODE=1`, you can use the hardcoded API key `dev` instead of generating a real API token.
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
CAIRNQUIRE_DEV_MODE=1 mise run server-run
|
||||
```
|
||||
|
||||
Or set in your config:
|
||||
|
||||
```json
|
||||
{
|
||||
"devMode": true
|
||||
}
|
||||
```
|
||||
|
||||
### macOS App
|
||||
|
||||
The app automatically detects localhost servers and defaults to the `dev` token. No configuration needed when running locally.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cairnquire-cli config --server http://localhost:8080 --token dev
|
||||
cairnquire-cli upload ~/Documents/report.pdf
|
||||
```
|
||||
|
||||
## API Gaps
|
||||
|
||||
See `.project/macos-app-api-gaps.md` for documented API endpoints that need server-side implementation.
|
||||
|
||||
Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. Other supported files remain attachments. The upload index returns the canonical URL for either kind.
|
||||
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
@@ -0,0 +1,263 @@
|
||||
import Foundation
|
||||
|
||||
public typealias UploadProgressHandler = @Sendable (Double) -> Void
|
||||
|
||||
private final class UploadProgressTaskDelegate: NSObject, URLSessionTaskDelegate {
|
||||
private let onProgress: UploadProgressHandler
|
||||
|
||||
init(onProgress: @escaping UploadProgressHandler) {
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didSendBodyData bytesSent: Int64,
|
||||
totalBytesSent: Int64,
|
||||
totalBytesExpectedToSend: Int64
|
||||
) {
|
||||
guard totalBytesExpectedToSend > 0 else { return }
|
||||
let progress = min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1)
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
public actor APIClient {
|
||||
public let baseURL: URL
|
||||
public var apiToken: String?
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
public init(baseURL: URL, apiToken: String? = nil) {
|
||||
self.baseURL = baseURL
|
||||
self.apiToken = apiToken
|
||||
if self.apiToken == nil && Self.isLocalhost(baseURL) {
|
||||
self.apiToken = "dev"
|
||||
}
|
||||
self.session = URLSession(configuration: .default)
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
self.encoder = JSONEncoder()
|
||||
self.encoder.dateEncodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
private static func isLocalhost(_ url: URL) -> Bool {
|
||||
guard let host = url.host else { return false }
|
||||
return host == "localhost" || host == "127.0.0.1"
|
||||
}
|
||||
|
||||
public func setAPIToken(_ token: String?) {
|
||||
self.apiToken = token
|
||||
}
|
||||
|
||||
private func request(for path: String) -> URLRequest {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
if let token = apiToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return request
|
||||
}
|
||||
|
||||
private func decodeResponse<T: Decodable>(data: Data, response: URLResponse) throws -> T {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError(error: "invalid response")
|
||||
}
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
if let error = try? decoder.decode(APIError.self, from: data) {
|
||||
throw error
|
||||
}
|
||||
throw APIError(error: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
if T.self == EmptyResponse.self {
|
||||
return EmptyResponse() as! T
|
||||
}
|
||||
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
return try decodeResponse(data: data, response: response)
|
||||
}
|
||||
|
||||
private func performUpload<T: Decodable>(
|
||||
_ request: URLRequest,
|
||||
body: Data,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> T {
|
||||
onProgress?(0)
|
||||
let delegate = onProgress.map { UploadProgressTaskDelegate(onProgress: $0) }
|
||||
let (data, response) = try await session.upload(for: request, from: body, delegate: delegate)
|
||||
onProgress?(1)
|
||||
return try decodeResponse(data: data, response: response)
|
||||
}
|
||||
|
||||
// MARK: - Uploads
|
||||
|
||||
public func uploadFile(
|
||||
url: URL,
|
||||
folder: String? = nil,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResponse {
|
||||
guard let token = apiToken else {
|
||||
throw APIError(error: "no API token configured")
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
let filename = url.lastPathComponent
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
if let folder, !folder.isEmpty {
|
||||
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(folder.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
if let duplicateAction {
|
||||
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(duplicateAction.rawValue.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
return try await performUpload(request, body: body, onProgress: onProgress)
|
||||
}
|
||||
|
||||
public func uploadData(
|
||||
_ data: Data,
|
||||
filename: String,
|
||||
folder: String? = nil,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResponse {
|
||||
guard let token = apiToken else {
|
||||
throw APIError(error: "no API token configured")
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
if let folder, !folder.isEmpty {
|
||||
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(folder.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
if let duplicateAction {
|
||||
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(duplicateAction.rawValue.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
return try await performUpload(request, body: body, onProgress: onProgress)
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
public func listAttachments() async throws -> [AttachmentRecord] {
|
||||
let request = self.request(for: "api/attachments")
|
||||
let response: AttachmentsListResponse = try await perform(request)
|
||||
return response.attachments
|
||||
}
|
||||
|
||||
public func attachmentURL(hash: String) -> URL {
|
||||
baseURL.appendingPathComponent("attachments/\(hash)")
|
||||
}
|
||||
|
||||
// MARK: - Documents
|
||||
|
||||
public func listDocuments() async throws -> [DocumentRecord] {
|
||||
let request = self.request(for: "api/documents")
|
||||
let response: DocumentsListResponse = try await perform(request)
|
||||
return response.documents
|
||||
}
|
||||
|
||||
public func saveDocument(path: String, content: String, baseHash: String? = nil) async throws -> DocumentSaveResponse {
|
||||
var request = self.request(for: "api/documents/\(path)")
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = DocumentSaveRequest(content: content, baseHash: baseHash)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
// MARK: - Sync
|
||||
|
||||
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {
|
||||
var request = self.request(for: "api/sync/init")
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = SyncInitRequest(deviceId: deviceId, rootPath: rootPath)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
public func syncDelta(snapshotId: String, clientDelta: [Change]) async throws -> DeltaResponse {
|
||||
var request = self.request(for: "api/sync/delta")
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = DeltaRequest(snapshotId: snapshotId, clientDelta: clientDelta)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
public func fetchContent(hash: String) async throws -> Data {
|
||||
let request = self.request(for: "api/content/\(hash)")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError(error: "invalid response")
|
||||
}
|
||||
if httpResponse.statusCode >= 400 {
|
||||
if let error = try? decoder.decode(APIError.self, from: data) {
|
||||
throw error
|
||||
}
|
||||
throw APIError(error: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
public func healthCheck() async throws -> Bool {
|
||||
let request = self.request(for: "health")
|
||||
let (_, response) = try await session.data(for: request)
|
||||
return (response as? HTTPURLResponse)?.statusCode == 200
|
||||
}
|
||||
}
|
||||
|
||||
public struct EmptyResponse: Decodable {}
|
||||
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
public struct AttachmentRecord: Codable, Identifiable {
|
||||
public var id: String { hash }
|
||||
public let hash: String
|
||||
public let originalName: String
|
||||
public let contentType: String
|
||||
public let sizeBytes: Int
|
||||
public let createdAt: Date
|
||||
public let url: String
|
||||
public let kind: String?
|
||||
public let documentPath: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hash
|
||||
case originalName
|
||||
case contentType
|
||||
case sizeBytes
|
||||
case createdAt
|
||||
case url
|
||||
case kind
|
||||
case documentPath
|
||||
}
|
||||
}
|
||||
|
||||
public struct UploadResponse: Codable {
|
||||
public let hash: String
|
||||
public let contentType: String
|
||||
public let attachmentUrl: String
|
||||
public let url: String?
|
||||
public let kind: String?
|
||||
}
|
||||
|
||||
public struct DocumentRecord: Codable, Identifiable {
|
||||
public let id: String
|
||||
public let path: String
|
||||
public let title: String
|
||||
public let hash: String
|
||||
public let createdAt: Date?
|
||||
public let updatedAt: Date?
|
||||
}
|
||||
|
||||
public struct DocumentSaveRequest: Codable {
|
||||
public let content: String
|
||||
public let baseHash: String?
|
||||
}
|
||||
|
||||
public struct DocumentSaveResponse: Codable {
|
||||
public let status: String
|
||||
public let path: String
|
||||
public let title: String
|
||||
public let hash: String
|
||||
}
|
||||
|
||||
public struct SyncInitRequest: Codable {
|
||||
public let deviceId: String
|
||||
public let rootPath: String
|
||||
}
|
||||
|
||||
public struct SyncInitResponse: Codable {
|
||||
public let snapshotId: String
|
||||
public let serverSnapshot: [FileEntry]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case snapshotId
|
||||
case serverSnapshot
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
snapshotId = try container.decode(String.self, forKey: .snapshotId)
|
||||
serverSnapshot = try container.decodeIfPresent([FileEntry].self, forKey: .serverSnapshot) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
public struct FileEntry: Codable {
|
||||
public let path: String
|
||||
public let hash: String
|
||||
public let size: Int64
|
||||
public let modified: Date
|
||||
}
|
||||
|
||||
public struct DeltaRequest: Codable {
|
||||
public let snapshotId: String
|
||||
public let clientDelta: [Change]
|
||||
}
|
||||
|
||||
public struct DeltaResponse: Codable {
|
||||
public let serverDelta: [Change]
|
||||
public let conflicts: [Conflict]
|
||||
public let newSnapshotId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serverDelta
|
||||
case conflicts
|
||||
case newSnapshotId
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
serverDelta = try container.decodeIfPresent([Change].self, forKey: .serverDelta) ?? []
|
||||
conflicts = try container.decodeIfPresent([Conflict].self, forKey: .conflicts) ?? []
|
||||
newSnapshotId = try container.decodeIfPresent(String.self, forKey: .newSnapshotId)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Change: Codable {
|
||||
public let type: String
|
||||
public let path: String
|
||||
public let oldPath: String?
|
||||
public let hash: String?
|
||||
public let content: String?
|
||||
public let size: Int64?
|
||||
public let modified: Date?
|
||||
|
||||
public init(
|
||||
type: String,
|
||||
path: String,
|
||||
oldPath: String?,
|
||||
hash: String?,
|
||||
content: String? = nil,
|
||||
size: Int64?,
|
||||
modified: Date?
|
||||
) {
|
||||
self.type = type
|
||||
self.path = path
|
||||
self.oldPath = oldPath
|
||||
self.hash = hash
|
||||
self.content = content
|
||||
self.size = size
|
||||
self.modified = modified
|
||||
}
|
||||
}
|
||||
|
||||
public struct Conflict: Codable {
|
||||
public let path: String
|
||||
public let serverHash: String
|
||||
public let clientHash: String
|
||||
public let serverModified: Date
|
||||
public let clientModified: Date
|
||||
}
|
||||
|
||||
public struct APIError: Error, Codable {
|
||||
public let error: String
|
||||
public let conflictType: String?
|
||||
public let existingHash: String?
|
||||
public let existingPath: String?
|
||||
public let existingUrl: String?
|
||||
|
||||
public init(
|
||||
error: String,
|
||||
conflictType: String? = nil,
|
||||
existingHash: String? = nil,
|
||||
existingPath: String? = nil,
|
||||
existingUrl: String? = nil
|
||||
) {
|
||||
self.error = error
|
||||
self.conflictType = conflictType
|
||||
self.existingHash = existingHash
|
||||
self.existingPath = existingPath
|
||||
self.existingUrl = existingUrl
|
||||
}
|
||||
|
||||
public var isUploadConflict: Bool {
|
||||
conflictType == "document" || conflictType == "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
extension APIError: LocalizedError {
|
||||
public var errorDescription: String? { error }
|
||||
}
|
||||
|
||||
public struct AttachmentsListResponse: Codable {
|
||||
public let attachments: [AttachmentRecord]
|
||||
}
|
||||
|
||||
public struct DocumentsListResponse: Codable {
|
||||
public let documents: [DocumentRecord]
|
||||
}
|
||||
|
||||
public enum UploadDuplicateAction: String {
|
||||
case overwrite
|
||||
case rename
|
||||
case reuse
|
||||
}
|
||||
152
apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
Normal file
152
apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public struct SyncConfig: Codable, Equatable {
|
||||
public var serverURL: String
|
||||
public var apiToken: String?
|
||||
public var syncFolder: String?
|
||||
public var defaultUploadFolder: String?
|
||||
public var autoSync: Bool
|
||||
public var syncInterval: Int // seconds
|
||||
public var deviceId: String
|
||||
public var iconName: String // SF Symbol name
|
||||
|
||||
public static let iconOptions = [
|
||||
"tray.circle.fill",
|
||||
"arrow.triangle.2.circlepath",
|
||||
"arrow.up.doc",
|
||||
"doc.badge.arrow.up",
|
||||
"arrow.up.and.down.and.arrow.left.and.right",
|
||||
"cloud",
|
||||
"externaldrive.connected.to.line.below",
|
||||
"note.text",
|
||||
"doc.text",
|
||||
"arrow.clockwise.circle",
|
||||
"rectangle.stack.badge.plus"
|
||||
]
|
||||
|
||||
public init(
|
||||
serverURL: String = "http://localhost:8080",
|
||||
apiToken: String? = nil,
|
||||
syncFolder: String? = nil,
|
||||
defaultUploadFolder: String? = nil,
|
||||
autoSync: Bool = true,
|
||||
syncInterval: Int = 30,
|
||||
deviceId: String? = nil,
|
||||
iconName: String = "tray.circle.fill"
|
||||
) {
|
||||
self.serverURL = serverURL
|
||||
self.apiToken = apiToken
|
||||
self.syncFolder = syncFolder
|
||||
self.defaultUploadFolder = defaultUploadFolder
|
||||
self.autoSync = autoSync
|
||||
self.syncInterval = syncInterval
|
||||
self.deviceId = deviceId ?? SyncConfig.generateDeviceId()
|
||||
self.iconName = iconName
|
||||
}
|
||||
|
||||
private static func generateDeviceId() -> String {
|
||||
if let existing = UserDefaults.standard.string(forKey: "cairnquire_device_id") {
|
||||
return existing
|
||||
}
|
||||
let id = "mac-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))"
|
||||
UserDefaults.standard.set(id, forKey: "cairnquire_device_id")
|
||||
return id
|
||||
}
|
||||
|
||||
public var serverBaseURL: URL? {
|
||||
URL(string: serverURL)
|
||||
}
|
||||
}
|
||||
|
||||
public actor ConfigStore {
|
||||
public static let shared = ConfigStore()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let keychainService = "com.cairnquire.sync"
|
||||
private let configKey = "cairnquire_config"
|
||||
|
||||
public init() {}
|
||||
|
||||
nonisolated public func load() -> SyncConfig {
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = defaults.data(forKey: configKey),
|
||||
let config = try? JSONDecoder().decode(SyncConfig.self, from: data) {
|
||||
var config = config
|
||||
// Load token from keychain
|
||||
if let token = loadTokenFromKeychain() {
|
||||
config.apiToken = token
|
||||
}
|
||||
return config
|
||||
}
|
||||
return SyncConfig()
|
||||
}
|
||||
|
||||
public func save(_ config: SyncConfig) {
|
||||
let defaults = UserDefaults.standard
|
||||
var configToSave = config
|
||||
let token = config.apiToken
|
||||
configToSave.apiToken = nil
|
||||
|
||||
if let data = try? JSONEncoder().encode(configToSave) {
|
||||
defaults.set(data, forKey: configKey)
|
||||
}
|
||||
|
||||
if let token = token {
|
||||
saveTokenToKeychain(token)
|
||||
} else {
|
||||
deleteTokenFromKeychain()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func saveTokenToKeychain(_ token: String) {
|
||||
let data = token.data(using: .utf8)!
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: "api_token"
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
|
||||
if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
|
||||
var newItem = query
|
||||
newItem.merge(attributes) { _, new in new }
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func loadTokenFromKeychain() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: "api_token",
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let token = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
private nonisolated func deleteTokenFromKeychain() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: "api_token"
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
|
||||
public protocol FolderWatcherDelegate: AnyObject {
|
||||
func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String])
|
||||
}
|
||||
|
||||
public class FolderWatcher {
|
||||
public weak var delegate: FolderWatcherDelegate?
|
||||
|
||||
private var stream: FSEventStreamRef?
|
||||
private let folderPath: String
|
||||
private var debounceTimer: Timer?
|
||||
private var pendingPaths: Set<String> = []
|
||||
private let queue = DispatchQueue(label: "com.cairnquire.folderwatcher")
|
||||
|
||||
public init(folderPath: String) {
|
||||
self.folderPath = folderPath
|
||||
}
|
||||
|
||||
public func start() {
|
||||
stop()
|
||||
|
||||
let pathsToWatch = [folderPath as CFString]
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: Unmanaged.passUnretained(self).toOpaque(),
|
||||
retain: nil,
|
||||
release: nil,
|
||||
copyDescription: nil
|
||||
)
|
||||
|
||||
let callback: FSEventStreamCallback = { (streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds) in
|
||||
let watcher = Unmanaged<FolderWatcher>.fromOpaque(clientCallBackInfo!).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
callback,
|
||||
&context,
|
||||
pathsToWatch as CFArray,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.5, // latency in seconds
|
||||
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
|
||||
)
|
||||
|
||||
if let stream = stream {
|
||||
FSEventStreamSetDispatchQueue(stream, queue)
|
||||
FSEventStreamStart(stream)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
if let stream = stream {
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
self.stream = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventPaths: UnsafeMutableRawPointer, eventFlags: UnsafePointer<FSEventStreamEventFlags>) {
|
||||
guard let paths = Unmanaged<CFArray>.fromOpaque(eventPaths).takeUnretainedValue() as? [String] else { return }
|
||||
|
||||
var changedPaths: [String] = []
|
||||
for i in 0..<numEvents {
|
||||
let path = paths[i]
|
||||
let flags = eventFlags[i]
|
||||
|
||||
// Filter for relevant file events
|
||||
if flags & UInt32(kFSEventStreamEventFlagItemCreated) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemRemoved) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 {
|
||||
|
||||
// Only track markdown files
|
||||
if path.hasSuffix(".md") {
|
||||
changedPaths.append(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !changedPaths.isEmpty else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.pendingPaths.formUnion(changedPaths)
|
||||
self.debounceTimer?.invalidate()
|
||||
self.debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
let paths = Array(self.pendingPaths)
|
||||
self.pendingPaths.removeAll()
|
||||
self.delegate?.folderWatcher(self, didDetectChanges: paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
@@ -0,0 +1,269 @@
|
||||
import Foundation
|
||||
|
||||
public actor SyncEngine {
|
||||
private let client: APIClient
|
||||
private let config: SyncConfig
|
||||
private let localState: LocalSyncState
|
||||
private var snapshotId: String?
|
||||
|
||||
public init(client: APIClient, config: SyncConfig) {
|
||||
self.client = client
|
||||
self.config = config
|
||||
self.localState = LocalSyncState(deviceId: config.deviceId)
|
||||
}
|
||||
|
||||
public func performSync() async throws {
|
||||
guard let syncFolder = config.syncFolder else {
|
||||
return
|
||||
}
|
||||
|
||||
let folderURL = URL(fileURLWithPath: syncFolder)
|
||||
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)
|
||||
|
||||
// 1. Initialize sync
|
||||
if snapshotId == nil {
|
||||
let initResponse = try await client.syncInit(deviceId: config.deviceId, rootPath: syncFolder)
|
||||
self.snapshotId = initResponse.snapshotId
|
||||
try await applyInitialServerSnapshot(initResponse.serverSnapshot, baseURL: folderURL)
|
||||
try await localState.updateSnapshot(initResponse.serverSnapshot)
|
||||
}
|
||||
|
||||
guard let currentSnapshotId = snapshotId else { return }
|
||||
|
||||
// 2. Compute local delta
|
||||
let localFiles = try scanLocalFolder(folderURL)
|
||||
let localDelta = try await localState.computeDelta(localFiles: localFiles)
|
||||
let outboundDelta = try addContent(to: localDelta, baseURL: folderURL)
|
||||
|
||||
// 3. Send local changes and check for server changes
|
||||
let deltaResponse = try await client.syncDelta(snapshotId: currentSnapshotId, clientDelta: outboundDelta)
|
||||
|
||||
// 4. Apply server changes
|
||||
try await applyServerDelta(deltaResponse.serverDelta, baseURL: folderURL)
|
||||
|
||||
// The server returns its current bytes in serverDelta for conflicts.
|
||||
// Until the native app has a merge UI, use the server copy locally.
|
||||
self.snapshotId = deltaResponse.newSnapshotId ?? currentSnapshotId
|
||||
|
||||
// 5. Update local state after applying inbound changes
|
||||
try await localState.updateSnapshot(scanLocalFolder(folderURL))
|
||||
}
|
||||
|
||||
private func scanLocalFolder(_ folderURL: URL) throws -> [FileEntry] {
|
||||
var files: [FileEntry] = []
|
||||
let fileManager = FileManager.default
|
||||
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: folderURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return files
|
||||
}
|
||||
|
||||
for case let fileURL as URL in enumerator {
|
||||
let relativePath = fileURL.path.replacingOccurrences(of: folderURL.path + "/", with: "")
|
||||
|
||||
// Only sync markdown files for now
|
||||
guard fileURL.pathExtension == "md" else { continue }
|
||||
|
||||
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
|
||||
let modificationDate = attributes[.modificationDate] as? Date ?? Date()
|
||||
let fileSize = attributes[.size] as? Int64 ?? 0
|
||||
|
||||
// Compute hash
|
||||
let content = try Data(contentsOf: fileURL)
|
||||
let hash = SHA256.hash(data: content).hexString
|
||||
|
||||
files.append(FileEntry(
|
||||
path: relativePath,
|
||||
hash: hash,
|
||||
size: fileSize,
|
||||
modified: modificationDate
|
||||
))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private func applyServerDelta(_ changes: [Change], baseURL: URL) async throws {
|
||||
for change in changes {
|
||||
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
|
||||
|
||||
switch change.type {
|
||||
case "create", "update":
|
||||
guard let hash = change.hash else {
|
||||
throw SyncEngineError.missingHash(change.path)
|
||||
}
|
||||
try await writeServerContent(hash: hash, to: fileURL)
|
||||
case "delete":
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
case "rename":
|
||||
if let oldPath = change.oldPath {
|
||||
let oldURL = try localFileURL(for: oldPath, baseURL: baseURL)
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try? FileManager.default.moveItem(at: oldURL, to: fileURL)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInitialServerSnapshot(_ files: [FileEntry], baseURL: URL) async throws {
|
||||
for file in files {
|
||||
let fileURL = try localFileURL(for: file.path, baseURL: baseURL)
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
try await writeServerContent(hash: file.hash, to: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addContent(to changes: [Change], baseURL: URL) throws -> [Change] {
|
||||
try changes.map { change in
|
||||
guard change.type == "create" || change.type == "update" else {
|
||||
return change
|
||||
}
|
||||
|
||||
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard let content = String(data: data, encoding: .utf8) else {
|
||||
throw SyncEngineError.invalidUTF8(change.path)
|
||||
}
|
||||
|
||||
return Change(
|
||||
type: change.type,
|
||||
path: change.path,
|
||||
oldPath: change.oldPath,
|
||||
hash: change.hash,
|
||||
content: content,
|
||||
size: change.size,
|
||||
modified: change.modified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeServerContent(hash: String, to fileURL: URL) async throws {
|
||||
let content = try await client.fetchContent(hash: hash)
|
||||
guard SHA256.hash(data: content).hexString == hash.lowercased() else {
|
||||
throw SyncEngineError.hashMismatch(fileURL.lastPathComponent)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try content.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
private func localFileURL(for path: String, baseURL: URL) throws -> URL {
|
||||
let root = baseURL.standardizedFileURL
|
||||
let fileURL = root.appendingPathComponent(path).standardizedFileURL
|
||||
guard fileURL.path.hasPrefix(root.path + "/"),
|
||||
fileURL.pathExtension.lowercased() == "md" else {
|
||||
throw SyncEngineError.invalidPath(path)
|
||||
}
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
public enum SyncEngineError: LocalizedError {
|
||||
case invalidPath(String)
|
||||
case invalidUTF8(String)
|
||||
case missingHash(String)
|
||||
case hashMismatch(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPath(let path):
|
||||
return "Invalid sync path: \(path)"
|
||||
case .invalidUTF8(let path):
|
||||
return "Sync only supports UTF-8 markdown files: \(path)"
|
||||
case .missingHash(let path):
|
||||
return "Server sync response is missing a content hash: \(path)"
|
||||
case .hashMismatch(let path):
|
||||
return "Downloaded sync content failed hash verification: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SHA256 Helper
|
||||
|
||||
import CryptoKit
|
||||
|
||||
extension SHA256.Digest {
|
||||
var hexString: String {
|
||||
map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local Sync State
|
||||
|
||||
public actor LocalSyncState {
|
||||
private let deviceId: String
|
||||
private var lastSnapshot: [String: FileEntry] = [:] // path -> entry
|
||||
|
||||
public init(deviceId: String) {
|
||||
self.deviceId = deviceId
|
||||
}
|
||||
|
||||
public func updateSnapshot(_ files: [FileEntry]) async throws {
|
||||
lastSnapshot = Dictionary(uniqueKeysWithValues: files.map { ($0.path, $0) })
|
||||
try persist()
|
||||
}
|
||||
|
||||
public func computeDelta(localFiles: [FileEntry]) async throws -> [Change] {
|
||||
var changes: [Change] = []
|
||||
let localMap = Dictionary(uniqueKeysWithValues: localFiles.map { ($0.path, $0) })
|
||||
|
||||
// Check for new or modified files
|
||||
for file in localFiles {
|
||||
if let last = lastSnapshot[file.path] {
|
||||
if last.hash != file.hash {
|
||||
changes.append(Change(
|
||||
type: "update",
|
||||
path: file.path,
|
||||
oldPath: nil,
|
||||
hash: file.hash,
|
||||
size: file.size,
|
||||
modified: file.modified
|
||||
))
|
||||
}
|
||||
} else {
|
||||
changes.append(Change(
|
||||
type: "create",
|
||||
path: file.path,
|
||||
oldPath: nil,
|
||||
hash: file.hash,
|
||||
size: file.size,
|
||||
modified: file.modified
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deleted files
|
||||
for (path, _) in lastSnapshot where localMap[path] == nil {
|
||||
changes.append(Change(
|
||||
type: "delete",
|
||||
path: path,
|
||||
oldPath: nil,
|
||||
hash: nil,
|
||||
size: nil,
|
||||
modified: nil
|
||||
))
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
// Persist to local JSON file
|
||||
let entries = Array(lastSnapshot.values)
|
||||
let data = try JSONEncoder().encode(entries)
|
||||
let url = stateFileURL()
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
private func stateFileURL() -> URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dir = appSupport.appendingPathComponent("CairnquireSync", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("sync_state_\(deviceId).json")
|
||||
}
|
||||
}
|
||||
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct DropZoneView: View {
|
||||
@State private var isDragging = false
|
||||
@State private var uploadResult: UploadResult?
|
||||
@State private var error: String?
|
||||
@State private var isUploading = false
|
||||
@State private var uploadProgress: Double = 0
|
||||
|
||||
public let uploadManager: UploadManager
|
||||
|
||||
public init(uploadManager: UploadManager) {
|
||||
self.uploadManager = uploadManager
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "arrow.up.doc.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(isDragging ? .blue : .secondary)
|
||||
|
||||
Text("Drop files here")
|
||||
.font(.title2)
|
||||
|
||||
if isUploading {
|
||||
ProgressView(value: uploadProgress)
|
||||
.frame(width: 180)
|
||||
Text("\(Int(uploadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else if let result = uploadResult {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Uploaded \(result.filename)")
|
||||
.font(.headline)
|
||||
|
||||
Text("URL copied to clipboard")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(result.url.absoluteString)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if let error = error {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.red)
|
||||
|
||||
Text("Upload failed")
|
||||
.font(.headline)
|
||||
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 250)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isDragging ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(isDragging ? Color.blue : Color.secondary.opacity(0.3), style: StrokeStyle(lineWidth: 2, dash: [8]))
|
||||
)
|
||||
)
|
||||
.onDrop(of: [.fileURL], isTargeted: $isDragging) { providers in
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, error in
|
||||
guard let url = url else { return }
|
||||
|
||||
Task {
|
||||
await MainActor.run {
|
||||
isUploading = true
|
||||
uploadProgress = 0
|
||||
self.error = nil
|
||||
self.uploadResult = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: { progress in
|
||||
Task { @MainActor in
|
||||
uploadProgress = min(max(progress, 0), 1)
|
||||
}
|
||||
})
|
||||
await MainActor.run {
|
||||
uploadResult = result
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error.localizedDescription
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// This preview won't work without a real UploadManager
|
||||
Text("DropZoneView Preview")
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct ClipboardNotice: Identifiable, Equatable {
|
||||
public let id = UUID()
|
||||
public let filename: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
@Published public var config: SyncConfig
|
||||
@Published public var isSyncing = false
|
||||
@Published public var lastSyncTime: Date?
|
||||
@Published public var syncStatus: String = "Ready"
|
||||
@Published public var pendingChanges = 0
|
||||
@Published public var uploadProgress: Double? = nil
|
||||
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
||||
@Published public private(set) var isLoadingRecentUploads = false
|
||||
@Published public private(set) var recentUploadsError: String?
|
||||
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
||||
|
||||
public var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let configStore = ConfigStore.shared
|
||||
private var apiClient: APIClient
|
||||
private var syncEngine: SyncEngine?
|
||||
private var uploadManager: UploadManager
|
||||
private var folderWatcher: FolderWatcher?
|
||||
private var syncTimer: Timer?
|
||||
|
||||
private var settingsWindow: NSWindow?
|
||||
private var uploadsWindow: NSWindow?
|
||||
private var dropZoneWindow: NSWindow?
|
||||
|
||||
public override init() {
|
||||
let config = ConfigStore.shared.load()
|
||||
self.config = config
|
||||
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
||||
self.uploadManager = UploadManager(client: apiClient, config: config)
|
||||
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
||||
super.init()
|
||||
|
||||
Task {
|
||||
await setupSync()
|
||||
}
|
||||
Task {
|
||||
await refreshRecentUploads()
|
||||
}
|
||||
}
|
||||
|
||||
public func updateConfig(_ newConfig: SyncConfig) {
|
||||
config = newConfig
|
||||
|
||||
Task {
|
||||
await configStore.save(newConfig)
|
||||
|
||||
if let url = newConfig.serverBaseURL {
|
||||
await apiClient.setAPIToken(newConfig.apiToken)
|
||||
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||
await refreshRecentUploads()
|
||||
await setupSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSync() async {
|
||||
// Stop existing watcher
|
||||
folderWatcher?.stop()
|
||||
syncTimer?.invalidate()
|
||||
|
||||
guard config.autoSync, let syncFolder = config.syncFolder else {
|
||||
syncStatus = "Auto-sync disabled"
|
||||
return
|
||||
}
|
||||
|
||||
// Setup folder watcher
|
||||
let watcher = FolderWatcher(folderPath: syncFolder)
|
||||
watcher.delegate = self
|
||||
watcher.start()
|
||||
self.folderWatcher = watcher
|
||||
|
||||
// Setup periodic sync
|
||||
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
await self?.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
await performSync()
|
||||
}
|
||||
|
||||
public func performSync() async {
|
||||
guard !isSyncing else { return }
|
||||
guard config.syncFolder != nil else {
|
||||
syncStatus = "No sync folder configured"
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing = true
|
||||
syncStatus = "Syncing..."
|
||||
|
||||
do {
|
||||
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
||||
syncEngine = engine
|
||||
try await engine.performSync()
|
||||
|
||||
lastSyncTime = Date()
|
||||
syncStatus = "Synced"
|
||||
} catch {
|
||||
syncStatus = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
// MARK: - Window Management
|
||||
|
||||
public func showSettings() {
|
||||
if let window = settingsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Cairnquire Settings"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
||||
self?.updateConfig(newConfig)
|
||||
window?.close()
|
||||
}))
|
||||
window.center()
|
||||
|
||||
settingsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showUploads() {
|
||||
if let window = uploadsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Uploaded Files"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
||||
window.center()
|
||||
|
||||
uploadsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showDropZone() {
|
||||
if let window = dropZoneWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Upload File"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
||||
window.center()
|
||||
|
||||
dropZoneWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func windowWillClose(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
|
||||
if window === settingsWindow {
|
||||
settingsWindow = nil
|
||||
} else if window === uploadsWindow {
|
||||
uploadsWindow = nil
|
||||
} else if window === dropZoneWindow {
|
||||
dropZoneWindow = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func present(_ window: NSWindow) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
public func quickUpload() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
|
||||
guard panel.runModal() == .OK else { return }
|
||||
|
||||
Task {
|
||||
await uploadURLList(panel.urls)
|
||||
}
|
||||
}
|
||||
|
||||
public func uploadFiles(_ urls: [URL]) {
|
||||
Task {
|
||||
await uploadURLList(urls)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadURLList(_ urls: [URL]) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
let total = Double(urls.count)
|
||||
for (index, url) in urls.enumerated() {
|
||||
let baseProgress = Double(index) / total
|
||||
let fileWeight = 1 / total
|
||||
uploadProgress = baseProgress
|
||||
syncStatus = "Uploading \(url.lastPathComponent)"
|
||||
|
||||
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
||||
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
||||
Task { @MainActor in
|
||||
self?.uploadProgress = combinedProgress
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
||||
uploadProgress = Double(index + 1) / total
|
||||
syncStatus = "Uploaded \(result.filename)"
|
||||
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||
await refreshRecentUploads()
|
||||
} catch {
|
||||
syncStatus = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRecentUploads() async {
|
||||
guard !isLoadingRecentUploads else { return }
|
||||
|
||||
isLoadingRecentUploads = true
|
||||
defer { isLoadingRecentUploads = false }
|
||||
|
||||
do {
|
||||
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
||||
recentUploadsError = nil
|
||||
} catch {
|
||||
recentUploadsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public func createNoteFromClipboard() {
|
||||
guard let text = NSPasteboard.general.string(forType: .string) else {
|
||||
syncStatus = "Clipboard is empty"
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await uploadManager.createNote(from: text)
|
||||
syncStatus = "Created note: \(result.title)"
|
||||
} catch {
|
||||
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarController: FolderWatcherDelegate {
|
||||
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
||||
Task { @MainActor in
|
||||
pendingChanges += paths.count
|
||||
syncStatus = "\(pendingChanges) pending changes"
|
||||
await performSync()
|
||||
pendingChanges = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
245
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct SettingsView: View {
|
||||
@State public var config: SyncConfig
|
||||
public var onSave: (SyncConfig) -> Void
|
||||
|
||||
@State private var serverURL: String = ""
|
||||
@State private var apiToken: String = ""
|
||||
@State private var syncFolder: String = ""
|
||||
@State private var defaultUploadFolder: String = ""
|
||||
@State private var autoSync: Bool = true
|
||||
@State private var syncInterval: Double = 30
|
||||
@State private var selectedIcon: String = "tray.circle.fill"
|
||||
|
||||
private let labelWidth: CGFloat = 120
|
||||
|
||||
public init(config: SyncConfig, onSave: @escaping (SyncConfig) -> Void) {
|
||||
self.config = config
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
serverSection
|
||||
Divider().padding(.vertical, 16)
|
||||
syncSection
|
||||
Divider().padding(.vertical, 16)
|
||||
uploadsSection
|
||||
Divider().padding(.vertical, 16)
|
||||
appearanceSection
|
||||
|
||||
Spacer(minLength: 32)
|
||||
|
||||
saveButton
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(width: 520, height: 600)
|
||||
.onAppear {
|
||||
serverURL = config.serverURL
|
||||
apiToken = config.apiToken ?? ""
|
||||
syncFolder = config.syncFolder ?? ""
|
||||
defaultUploadFolder = config.defaultUploadFolder ?? ""
|
||||
autoSync = config.autoSync
|
||||
syncInterval = Double(config.syncInterval)
|
||||
selectedIcon = config.iconName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var serverSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Server", icon: "server.rack")
|
||||
|
||||
formRow(label: "Server URL") {
|
||||
TextField("", text: $serverURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
formRow(label: "API Token") {
|
||||
SecureField("", text: $apiToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Create an API token from the web app at /account")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(width: 280, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var syncSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Sync", icon: "arrow.triangle.2.circlepath")
|
||||
|
||||
formRow(label: "Sync Folder") {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $syncFolder)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 200)
|
||||
|
||||
Button("Choose...") {
|
||||
chooseFolder()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
formRow(label: "") {
|
||||
Toggle("Auto Sync", isOn: $autoSync)
|
||||
}
|
||||
|
||||
if autoSync {
|
||||
formRow(label: "Interval") {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: $syncInterval, in: 5...300, step: 5)
|
||||
.frame(width: 160)
|
||||
Text("\(Int(syncInterval))s")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 40, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var uploadsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Uploads", icon: "arrow.up.doc")
|
||||
|
||||
formRow(label: "Upload Folder") {
|
||||
TextField("e.g. inbox", text: $defaultUploadFolder)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Notes and uploads will be saved to this folder")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 280, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appearanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Appearance", icon: "paintbrush")
|
||||
|
||||
formRow(label: "Menu Icon") {
|
||||
Picker("", selection: $selectedIcon) {
|
||||
ForEach(SyncConfig.iconOptions, id: \.self) { icon in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 20, alignment: .center)
|
||||
Text(iconNameToLabel(icon))
|
||||
}
|
||||
.tag(icon)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
let newConfig = SyncConfig(
|
||||
serverURL: serverURL,
|
||||
apiToken: apiToken.isEmpty ? nil : apiToken,
|
||||
syncFolder: syncFolder.isEmpty ? nil : syncFolder,
|
||||
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
|
||||
autoSync: autoSync,
|
||||
syncInterval: Int(syncInterval),
|
||||
deviceId: config.deviceId,
|
||||
iconName: selectedIcon
|
||||
)
|
||||
onSave(newConfig)
|
||||
} label: {
|
||||
Text("Save")
|
||||
.frame(width: 80)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.controlSize(.large)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sectionHeader(_ title: String, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func formRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
|
||||
content()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Select Folder"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
syncFolder = panel.url?.path ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func iconNameToLabel(_ name: String) -> String {
|
||||
let labels: [String: String] = [
|
||||
"tray.circle.fill": "Tray (Default)",
|
||||
"arrow.triangle.2.circlepath": "Sync",
|
||||
"arrow.up.doc": "Upload",
|
||||
"doc.badge.arrow.up": "Doc Upload",
|
||||
"arrow.up.and.down.and.arrow.left.and.right": "Transfer",
|
||||
"cloud": "Cloud",
|
||||
"externaldrive.connected.to.line.below": "Server",
|
||||
"note.text": "Notes",
|
||||
"doc.text": "Documents",
|
||||
"arrow.clockwise.circle": "Refresh",
|
||||
"rectangle.stack.badge.plus": "Stack"
|
||||
]
|
||||
return labels[name] ?? name
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView(config: SyncConfig()) { _ in }
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct StatusPopoverView: View {
|
||||
@ObservedObject public var controller: MenuBarController
|
||||
|
||||
public init(controller: MenuBarController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with status
|
||||
headerSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Quick actions
|
||||
actionsSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Recent uploads preview (last 3)
|
||||
recentUploadsSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer
|
||||
footerSection
|
||||
}
|
||||
.frame(width: 320)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
statusIndicator
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.syncStatus)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
|
||||
if let lastSync = controller.lastSyncTime {
|
||||
Text("Last sync: \(timeAgo(lastSync))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Never synced")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if controller.isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
if controller.pendingChanges > 0 {
|
||||
Text("\(controller.pendingChanges) pending changes")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var statusIndicator: some View {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(statusColor.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if controller.isSyncing { return .blue }
|
||||
if controller.syncStatus.contains("fail") || controller.syncStatus.contains("error") {
|
||||
return .red
|
||||
}
|
||||
if controller.pendingChanges > 0 { return .orange }
|
||||
return .green
|
||||
}
|
||||
|
||||
private var actionsSection: some View {
|
||||
HStack(spacing: 16) {
|
||||
ActionButton(
|
||||
icon: "arrow.clockwise",
|
||||
label: "Sync",
|
||||
color: .blue
|
||||
) {
|
||||
Task {
|
||||
await controller.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "arrow.up.doc",
|
||||
label: "Upload",
|
||||
color: .purple
|
||||
) {
|
||||
controller.quickUpload()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "doc.badge.plus",
|
||||
label: "Note",
|
||||
color: .green
|
||||
) {
|
||||
controller.createNoteFromClipboard()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "square.and.arrow.down",
|
||||
label: "Drop",
|
||||
color: .orange
|
||||
) {
|
||||
controller.showDropZone()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentUploadsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Recent Uploads")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("View All") {
|
||||
controller.showUploads()
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
if controller.isLoadingRecentUploads && controller.recentUploads.isEmpty {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else if controller.recentUploads.isEmpty {
|
||||
Text(controller.recentUploadsError == nil ? "No recent uploads" : "Unable to load recent uploads")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(controller.recentUploads) { upload in
|
||||
RecentUploadRow(upload: upload, baseURL: controller.config.serverBaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var footerSection: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
controller.showSettings()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "gear")
|
||||
Text("Settings")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "power")
|
||||
.font(.caption2)
|
||||
Text("Quit")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let url = controller.config.serverBaseURL {
|
||||
Button {
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Open Server")
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentUploadRow: View {
|
||||
let upload: AttachmentRecord
|
||||
let baseURL: URL?
|
||||
|
||||
var body: some View {
|
||||
Button(action: openUpload) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: upload.kind == "document" ? "doc.text" : "paperclip")
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(upload.originalName)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text(timeAgo(upload.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func openUpload() {
|
||||
guard let baseURL,
|
||||
let url = URL(string: upload.url, relativeTo: baseURL)?.absoluteURL else {
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Button
|
||||
|
||||
struct ActionButton: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let color: Color
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(color.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// Preview won't work without a real controller
|
||||
Text("StatusPopoverView Preview")
|
||||
}
|
||||
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct UploadsListView: View {
|
||||
@State private var attachments: [AttachmentRecord] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
|
||||
public let client: APIClient
|
||||
|
||||
public init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Uploaded Files")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: loadAttachments) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Spacer()
|
||||
} else if let error = error {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.orange)
|
||||
Text("Failed to load uploads")
|
||||
.font(.headline)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
} else if attachments.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
Text("No uploads yet")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Drop files on the menubar icon to upload")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List(attachments) { attachment in
|
||||
AttachmentRow(attachment: attachment, client: client)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: 400, height: 500)
|
||||
.onAppear {
|
||||
loadAttachments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAttachments() {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let items = try await client.listAttachments()
|
||||
await MainActor.run {
|
||||
attachments = items
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentRow: View {
|
||||
let attachment: AttachmentRecord
|
||||
let client: APIClient
|
||||
|
||||
@State private var showingCopied = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// File icon
|
||||
fileIcon
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(attachment.originalName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(attachment.contentType)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("·")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(formatBytes(attachment.sizeBytes))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("·")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(formatDate(attachment.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Copy button
|
||||
Button(action: copyURL) {
|
||||
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(showingCopied ? .green : .accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(showingCopied ? Color.green.opacity(0.1) : Color.accentColor.opacity(0.1))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Copy URL") {
|
||||
copyURL()
|
||||
}
|
||||
Button("Open in Browser") {
|
||||
if let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fileIcon: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(iconColor.opacity(0.1))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch attachment.contentType {
|
||||
case let type where type.hasPrefix("image/"):
|
||||
return "photo"
|
||||
case let type where type.hasPrefix("video/"):
|
||||
return "film"
|
||||
case let type where type.hasPrefix("audio/"):
|
||||
return "music.note"
|
||||
case "application/pdf":
|
||||
return "doc.text"
|
||||
case let type where type.hasPrefix("text/"):
|
||||
return "doc.plaintext"
|
||||
default:
|
||||
return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch attachment.contentType {
|
||||
case let type where type.hasPrefix("image/"):
|
||||
return .purple
|
||||
case let type where type.hasPrefix("video/"):
|
||||
return .red
|
||||
case let type where type.hasPrefix("audio/"):
|
||||
return .pink
|
||||
case "application/pdf":
|
||||
return .red
|
||||
case let type where type.hasPrefix("text/"):
|
||||
return .blue
|
||||
default:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func copyURL() {
|
||||
let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL
|
||||
?? client.baseURL.appendingPathComponent("attachments/\(attachment.hash)")
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url.absoluteString, forType: .string)
|
||||
|
||||
showingCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showingCopied = false
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Text("UploadsListView Preview")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
public actor UploadManager {
|
||||
private let client: APIClient
|
||||
private let config: SyncConfig
|
||||
|
||||
public init(client: APIClient, config: SyncConfig) {
|
||||
self.client = client
|
||||
self.config = config
|
||||
}
|
||||
|
||||
public func uploadFile(
|
||||
_ url: URL,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResult {
|
||||
let response = try await client.uploadFile(
|
||||
url: url,
|
||||
folder: config.defaultUploadFolder,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
let fullURL = resolvedURL(response.url ?? response.attachmentUrl)
|
||||
|
||||
// Copy URL to clipboard
|
||||
await MainActor.run {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(fullURL.absoluteString, forType: .string)
|
||||
}
|
||||
|
||||
return UploadResult(
|
||||
filename: url.lastPathComponent,
|
||||
hash: response.hash,
|
||||
url: fullURL,
|
||||
copiedToClipboard: true
|
||||
)
|
||||
}
|
||||
|
||||
public func uploadData(
|
||||
_ data: Data,
|
||||
filename: String,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResult {
|
||||
let response = try await client.uploadData(
|
||||
data,
|
||||
filename: filename,
|
||||
folder: config.defaultUploadFolder,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
let fullURL = resolvedURL(response.url ?? response.attachmentUrl)
|
||||
|
||||
// Copy URL to clipboard
|
||||
await MainActor.run {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(fullURL.absoluteString, forType: .string)
|
||||
}
|
||||
|
||||
return UploadResult(
|
||||
filename: filename,
|
||||
hash: response.hash,
|
||||
url: fullURL,
|
||||
copiedToClipboard: true
|
||||
)
|
||||
}
|
||||
|
||||
public func createNote(from text: String, title: String? = nil) async throws -> DocumentSaveResponse {
|
||||
let noteTitle = title ?? generateTitle(from: text)
|
||||
let folder = config.defaultUploadFolder ?? ""
|
||||
let path = folder.isEmpty ? "\(sanitizeFilename(noteTitle)).md" : "\(folder)/\(sanitizeFilename(noteTitle)).md"
|
||||
|
||||
let content = "# \(noteTitle)\n\n\(text)"
|
||||
|
||||
return try await client.saveDocument(path: path, content: content)
|
||||
}
|
||||
|
||||
private func generateTitle(from text: String) -> String {
|
||||
let lines = text.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
if let firstLine = lines.first {
|
||||
let title = String(firstLine).trimmingCharacters(in: .whitespaces)
|
||||
if title.count > 60 {
|
||||
return String(title.prefix(60)) + "..."
|
||||
}
|
||||
return title
|
||||
}
|
||||
return "Note \(ISO8601DateFormatter().string(from: Date()))"
|
||||
}
|
||||
|
||||
private func sanitizeFilename(_ name: String) -> String {
|
||||
let invalidChars = CharacterSet(charactersIn: ":/\\?%*|\"<>").union(.controlCharacters)
|
||||
return name.components(separatedBy: invalidChars).joined(separator: "-")
|
||||
}
|
||||
|
||||
private func resolvedURL(_ path: String) -> URL {
|
||||
URL(string: path, relativeTo: client.baseURL)?.absoluteURL ?? client.baseURL.appendingPathComponent(path)
|
||||
}
|
||||
}
|
||||
|
||||
public struct UploadResult {
|
||||
public let filename: String
|
||||
public let hash: String
|
||||
public let url: URL
|
||||
public let copiedToClipboard: Bool
|
||||
}
|
||||
|
||||
public struct UploadCancelledError: LocalizedError {
|
||||
public init() {}
|
||||
|
||||
public var errorDescription: String? { "Upload cancelled" }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func promptForUploadDuplicate(_ error: APIError, filename: String) -> UploadDuplicateAction? {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
|
||||
if error.conflictType == "document" {
|
||||
alert.messageText = "A document named \(filename) already exists"
|
||||
alert.informativeText = "Choose whether to replace the inbox document or keep both files."
|
||||
alert.addButton(withTitle: "Keep Both")
|
||||
alert.addButton(withTitle: "Replace")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .rename
|
||||
case .alertSecondButtonReturn:
|
||||
return .overwrite
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
alert.messageText = "\(filename) was already uploaded"
|
||||
alert.informativeText = "The server already has the same file content. Use its existing URL?"
|
||||
alert.addButton(withTitle: "Copy Existing URL")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
return alert.runModal() == .alertFirstButtonReturn ? .reuse : nil
|
||||
}
|
||||
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
@@ -0,0 +1,374 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import CairnquireSync
|
||||
|
||||
@main
|
||||
struct CairnquireSyncApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var statusItem: NSStatusItem!
|
||||
var controller: MenuBarController!
|
||||
var popover: NSPopover!
|
||||
var contextMenu: NSMenu!
|
||||
var dropButton: DropStatusButton!
|
||||
private var clipboardHUDPanel: NSPanel?
|
||||
private var clipboardHUDDismissWorkItem: DispatchWorkItem?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
controller = MenuBarController()
|
||||
|
||||
setupStatusItem()
|
||||
setupPopover()
|
||||
setupContextMenu()
|
||||
|
||||
// Observe config changes to update icon
|
||||
controller.$config
|
||||
.sink { [weak self] config in
|
||||
self?.updateIcon(config: config)
|
||||
}
|
||||
.store(in: &controller.cancellables)
|
||||
|
||||
// Observe upload progress to animate icon
|
||||
controller.$uploadProgress
|
||||
.sink { [weak self] progress in
|
||||
self?.updateIconForUpload(progress: progress)
|
||||
}
|
||||
.store(in: &controller.cancellables)
|
||||
|
||||
controller.$clipboardNotice
|
||||
.compactMap { $0 }
|
||||
.sink { [weak self] notice in
|
||||
self?.showClipboardHUD(notice)
|
||||
}
|
||||
.store(in: &controller.cancellables)
|
||||
|
||||
// Hide dock icon
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
|
||||
private func setupStatusItem() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
|
||||
// Create custom drop-aware button
|
||||
let button = DropStatusButton()
|
||||
button.imagePosition = .imageOnly
|
||||
button.target = self
|
||||
button.action = #selector(statusItemButtonClicked(_:))
|
||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
button.appDelegate = self
|
||||
|
||||
// Replace the default button with our custom one
|
||||
if let oldButton = statusItem.button {
|
||||
oldButton.superview?.addSubview(button)
|
||||
button.frame = oldButton.frame
|
||||
button.autoresizingMask = [.width, .height]
|
||||
oldButton.isHidden = true
|
||||
}
|
||||
|
||||
dropButton = button
|
||||
updateIcon(config: controller.config)
|
||||
}
|
||||
|
||||
func updateIcon(config: SyncConfig) {
|
||||
guard controller.uploadProgress == nil else { return }
|
||||
let iconName = config.iconName
|
||||
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire")
|
||||
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire")
|
||||
dropButton.image = image
|
||||
}
|
||||
|
||||
private func updateIconForUpload(progress: Double?) {
|
||||
if progress != nil {
|
||||
// During upload: show tray.circle (outline)
|
||||
let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading")
|
||||
dropButton.image = image
|
||||
} else {
|
||||
// Upload complete: restore normal icon
|
||||
updateIcon(config: controller.config)
|
||||
}
|
||||
}
|
||||
|
||||
func bounceIcon() {
|
||||
guard let button = dropButton else { return }
|
||||
|
||||
let originalOrigin = button.frame.origin
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
button.animator().setFrameOrigin(NSPoint(
|
||||
x: originalOrigin.x,
|
||||
y: originalOrigin.y - 6
|
||||
))
|
||||
} completionHandler: {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
button.animator().setFrameOrigin(originalOrigin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showClipboardHUD(_ notice: ClipboardNotice) {
|
||||
clipboardHUDDismissWorkItem?.cancel()
|
||||
|
||||
let panel = clipboardHUDPanel ?? makeClipboardHUDPanel()
|
||||
panel.contentView = NSHostingView(rootView: ClipboardHUDView(filename: notice.filename))
|
||||
positionClipboardHUD(panel)
|
||||
panel.alphaValue = 0
|
||||
panel.orderFrontRegardless()
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
panel.animator().alphaValue = 1
|
||||
}
|
||||
|
||||
let dismissWorkItem = DispatchWorkItem { [weak self, weak panel] in
|
||||
guard let self, let panel else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.2
|
||||
panel.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
self.clipboardHUDDismissWorkItem = nil
|
||||
}
|
||||
clipboardHUDDismissWorkItem = dismissWorkItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: dismissWorkItem)
|
||||
}
|
||||
|
||||
private func makeClipboardHUDPanel() -> NSPanel {
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 280, height: 72),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.level = .floating
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle]
|
||||
clipboardHUDPanel = panel
|
||||
return panel
|
||||
}
|
||||
|
||||
private func positionClipboardHUD(_ panel: NSPanel) {
|
||||
guard let buttonWindow = dropButton.window else { return }
|
||||
let buttonFrame = buttonWindow.convertToScreen(dropButton.convert(dropButton.bounds, to: nil))
|
||||
let panelSize = panel.frame.size
|
||||
let visibleFrame = buttonWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero
|
||||
let centeredX = buttonFrame.midX - panelSize.width / 2
|
||||
let origin = NSPoint(
|
||||
x: min(max(centeredX, visibleFrame.minX + 8), visibleFrame.maxX - panelSize.width - 8),
|
||||
y: buttonFrame.minY - panelSize.height - 8
|
||||
)
|
||||
panel.setFrameOrigin(origin)
|
||||
}
|
||||
|
||||
private func setupPopover() {
|
||||
popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: StatusPopoverView(controller: controller)
|
||||
)
|
||||
}
|
||||
|
||||
private func setupContextMenu() {
|
||||
contextMenu = NSMenu()
|
||||
|
||||
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Create Note from Clipboard", action: #selector(createNote), keyEquivalent: "n"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
|
||||
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
|
||||
|
||||
contextMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
contextMenu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
|
||||
contextMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
|
||||
}
|
||||
|
||||
@objc private func statusItemButtonClicked(_ sender: AnyObject?) {
|
||||
guard let event = NSApp.currentEvent else { return }
|
||||
|
||||
switch event.type {
|
||||
case .rightMouseUp:
|
||||
statusItem.menu = contextMenu
|
||||
statusItem.button?.performClick(nil)
|
||||
statusItem.menu = nil
|
||||
default:
|
||||
togglePopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePopover() {
|
||||
if popover.isShown {
|
||||
closePopover()
|
||||
} else {
|
||||
showPopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func showPopover() {
|
||||
guard let button = dropButton else { return }
|
||||
Task { @MainActor in
|
||||
await controller.refreshRecentUploads()
|
||||
}
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
|
||||
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||
guard let self = self, self.popover.isShown else { return }
|
||||
self.closePopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func closePopover() {
|
||||
popover.performClose(nil)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func syncNow() {
|
||||
Task { @MainActor in
|
||||
await controller.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func uploadFile() {
|
||||
Task { @MainActor in
|
||||
controller.quickUpload()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createNote() {
|
||||
Task { @MainActor in
|
||||
controller.createNoteFromClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showDropZone() {
|
||||
Task { @MainActor in
|
||||
controller.showDropZone()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showUploads() {
|
||||
Task { @MainActor in
|
||||
controller.showUploads()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
Task { @MainActor in
|
||||
controller.showSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func quit() {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClipboardHUDView: View {
|
||||
let filename: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundColor(.green)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("URL Copied to Clipboard")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(filename)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(width: 280, height: 72)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Status Button
|
||||
|
||||
class DropStatusButton: NSButton {
|
||||
weak var appDelegate: AppDelegate?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
setup()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
registerForDraggedTypes([.fileURL])
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
// Tint icon slightly to indicate drop zone is active
|
||||
if let image = self.image {
|
||||
self.image = image.tinted(with: .systemBlue)
|
||||
}
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
// Restore original icon
|
||||
if let appDelegate = appDelegate {
|
||||
appDelegate.updateIcon(config: appDelegate.controller.config)
|
||||
}
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
let pasteboard = sender.draggingPasteboard
|
||||
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
|
||||
!urls.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
appDelegate?.bounceIcon()
|
||||
|
||||
Task { @MainActor in
|
||||
appDelegate?.controller.uploadFiles(urls)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension NSImage {
|
||||
func tinted(with color: NSColor) -> NSImage {
|
||||
let image = self.copy() as! NSImage
|
||||
image.isTemplate = false
|
||||
|
||||
let rect = NSRect(origin: .zero, size: image.size)
|
||||
image.lockFocus()
|
||||
color.set()
|
||||
rect.fill(using: .sourceAtop)
|
||||
image.unlockFocus()
|
||||
|
||||
return image
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,9 @@ func Load() (Config, error) {
|
||||
}
|
||||
|
||||
overrideString(&cfg.Server.Addr, "CAIRNQUIRE_SERVER_ADDR")
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
cfg.Server.Addr = ":" + port
|
||||
}
|
||||
overrideString(&cfg.Database.Path, "CAIRNQUIRE_DATABASE_PATH")
|
||||
overrideString(&cfg.Database.PrimaryURL, "CAIRNQUIRE_DATABASE_PRIMARY_URL")
|
||||
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- SQLite column removal requires rebuilding the attachments table.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE attachments ADD COLUMN document_path TEXT;
|
||||
@@ -38,6 +38,7 @@ type AttachmentRecord struct {
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
DocumentPath string
|
||||
}
|
||||
|
||||
type PersistDocumentInput struct {
|
||||
@@ -238,13 +239,14 @@ func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecor
|
||||
|
||||
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at, document_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET
|
||||
original_name = excluded.original_name,
|
||||
content_type = excluded.content_type,
|
||||
size_bytes = excluded.size_bytes
|
||||
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
|
||||
size_bytes = excluded.size_bytes,
|
||||
document_path = excluded.document_path
|
||||
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339), record.DocumentPath); err != nil {
|
||||
return fmt.Errorf("save attachment: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -256,10 +258,10 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
|
||||
created string
|
||||
)
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '')
|
||||
FROM attachments
|
||||
WHERE hash = ?
|
||||
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created)
|
||||
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -273,7 +275,7 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
|
||||
|
||||
func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '')
|
||||
FROM attachments
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
@@ -286,7 +288,7 @@ func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, e
|
||||
for rows.Next() {
|
||||
var record AttachmentRecord
|
||||
var created string
|
||||
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created); err != nil {
|
||||
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath); err != nil {
|
||||
return nil, fmt.Errorf("scan attachment: %w", err)
|
||||
}
|
||||
record.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
|
||||
@@ -190,7 +190,17 @@ func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath st
|
||||
|
||||
record, _, err := s.resolveRecord(ctx, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if baseHash != "" {
|
||||
return nil, err
|
||||
}
|
||||
path, pathErr := normalizeWritableDocumentPath(requestPath)
|
||||
if pathErr != nil {
|
||||
return nil, pathErr
|
||||
}
|
||||
record = &DocumentRecord{Path: path}
|
||||
}
|
||||
|
||||
if baseHash != "" && record.CurrentHash != baseHash {
|
||||
@@ -270,6 +280,21 @@ func normalizeRequestPathCandidates(path string) []string {
|
||||
return []string{path}
|
||||
}
|
||||
|
||||
func normalizeWritableDocumentPath(path string) (string, error) {
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("document path is required")
|
||||
}
|
||||
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
|
||||
if path == "." || strings.HasPrefix(path, "../") || path == ".." || filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("invalid document path")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".md") {
|
||||
path += ".md"
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -150,6 +150,38 @@ func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSourcePageCreatesNewDocument(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
content := "# Inbox Note\n\nCreated from clipboard"
|
||||
page, err := service.SaveSourcePageWithBaseHash(ctx, "inbox/note", content, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
|
||||
}
|
||||
if page.Path != "inbox/note.md" {
|
||||
t.Fatalf("page path = %q, want inbox/note.md", page.Path)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "note.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new document: %v", err)
|
||||
}
|
||||
if string(written) != content {
|
||||
t.Fatalf("new document content = %q, want %q", string(written), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
|
||||
service, _ := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := service.SaveSourcePageWithBaseHash(ctx, "../outside", "# Outside\n", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
|
||||
body, contentType := multipartBody(t, "file", `unsafe"name.txt`, []byte("plain attachment\n"))
|
||||
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -198,8 +198,8 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
if payload.Hash == "" {
|
||||
t.Fatal("expected upload response hash")
|
||||
}
|
||||
if payload.ContentType != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("contentType = %q, want text/plain; charset=utf-8", payload.ContentType)
|
||||
if payload.ContentType != "application/pdf" {
|
||||
t.Fatalf("contentType = %q, want application/pdf", payload.ContentType)
|
||||
}
|
||||
if payload.AttachmentURL != "/attachments/"+payload.Hash {
|
||||
t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
|
||||
@@ -216,10 +216,10 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
|
||||
t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
|
||||
}
|
||||
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.txt"` {
|
||||
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.pdf"` {
|
||||
t.Fatalf("content-disposition = %q, want sanitized filename", got)
|
||||
}
|
||||
if downloadRecorder.Body.String() != "plain attachment\n" {
|
||||
if downloadRecorder.Body.String() != "%PDF-1.4\n" {
|
||||
t.Fatalf("download body = %q", downloadRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -342,6 +345,13 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "read upload")
|
||||
return
|
||||
}
|
||||
duplicateAction := r.FormValue("duplicateAction")
|
||||
switch duplicateAction {
|
||||
case "", "overwrite", "rename", "reuse":
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid duplicate action"})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(content)
|
||||
if !isAllowedContentType(contentType) {
|
||||
@@ -355,24 +365,131 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
existingAttachment, err := s.repository.GetAttachment(r.Context(), record.Hash)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing attachment")
|
||||
return
|
||||
}
|
||||
if existingAttachment != nil && duplicateAction != "overwrite" && duplicateAction != "rename" {
|
||||
existingURL := attachmentRecordURL(existingAttachment)
|
||||
if duplicateAction == "reuse" {
|
||||
writeUploadSuccess(w, http.StatusOK, existingAttachment.Hash, existingAttachment.ContentType, attachmentRecordKind(existingAttachment), existingURL)
|
||||
return
|
||||
}
|
||||
writeUploadConflict(w, "attachment", existingAttachment.Hash, existingAttachment.DocumentPath, existingURL)
|
||||
return
|
||||
}
|
||||
|
||||
uploadURL := "/attachments/" + record.Hash
|
||||
uploadKind := "attachment"
|
||||
documentPath := ""
|
||||
if path, readable, err := readableDocumentPath(header.Filename, r.FormValue("folder")); err != nil {
|
||||
s.renderError(w, r, http.StatusBadRequest, "invalid document upload path")
|
||||
return
|
||||
} else if readable && isReadableContentType(contentType) && utf8.Valid(content) {
|
||||
if _, err := s.documents.ListDocuments(r.Context()); err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
|
||||
return
|
||||
}
|
||||
existing, err := s.repository.GetDocumentByPath(r.Context(), path)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
switch duplicateAction {
|
||||
case "overwrite":
|
||||
case "rename":
|
||||
path, err = s.availableDocumentPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "choose document name")
|
||||
return
|
||||
}
|
||||
case "reuse":
|
||||
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
|
||||
return
|
||||
default:
|
||||
writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
|
||||
return
|
||||
}
|
||||
}
|
||||
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
|
||||
return
|
||||
}
|
||||
documentPath = page.Path
|
||||
uploadURL = documentPageURL(page.Path)
|
||||
uploadKind = "document"
|
||||
}
|
||||
|
||||
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
|
||||
Hash: record.Hash,
|
||||
OriginalName: header.Filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: record.Size,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
DocumentPath: documentPath,
|
||||
}); err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONWithStatus(w, http.StatusCreated, map[string]string{
|
||||
"hash": record.Hash,
|
||||
"contentType": contentType,
|
||||
"attachmentUrl": "/attachments/" + record.Hash,
|
||||
writeUploadSuccess(w, http.StatusCreated, record.Hash, contentType, uploadKind, uploadURL)
|
||||
}
|
||||
|
||||
func (s *Server) availableDocumentPath(ctx context.Context, path string) (string, error) {
|
||||
extension := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(path, extension)
|
||||
for suffix := 2; ; suffix++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension)
|
||||
existing, err := s.repository.GetDocumentByPath(ctx, candidate)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeUploadConflict(w http.ResponseWriter, conflictType, hash, path, url string) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "upload already exists",
|
||||
"conflictType": conflictType,
|
||||
"existingHash": hash,
|
||||
"existingPath": path,
|
||||
"existingUrl": url,
|
||||
})
|
||||
}
|
||||
|
||||
func writeUploadSuccess(w http.ResponseWriter, status int, hash, contentType, kind, url string) {
|
||||
writeJSONWithStatus(w, status, map[string]string{
|
||||
"hash": hash,
|
||||
"contentType": contentType,
|
||||
"kind": kind,
|
||||
"url": url,
|
||||
"attachmentUrl": url,
|
||||
})
|
||||
}
|
||||
|
||||
func attachmentRecordURL(record *docs.AttachmentRecord) string {
|
||||
if record.DocumentPath != "" {
|
||||
return documentPageURL(record.DocumentPath)
|
||||
}
|
||||
return "/attachments/" + record.Hash
|
||||
}
|
||||
|
||||
func attachmentRecordKind(record *docs.AttachmentRecord) string {
|
||||
if record.DocumentPath != "" {
|
||||
return "document"
|
||||
}
|
||||
return "attachment"
|
||||
}
|
||||
|
||||
func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
hash := chi.URLParam(r, "hash")
|
||||
attachment, err := s.repository.GetAttachment(r.Context(), hash)
|
||||
@@ -398,15 +515,6 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
|
||||
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
|
||||
attachments, err := s.repository.ListAttachments(r.Context())
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "list attachments"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"attachments": attachments})
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
@@ -435,6 +543,7 @@ func isAllowedContentType(contentType string) bool {
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"text/plain; charset=utf-8",
|
||||
"text/html; charset=utf-8",
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(candidate, contentType) {
|
||||
@@ -444,11 +553,83 @@ func isAllowedContentType(contentType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isReadableContentType(contentType string) bool {
|
||||
return strings.EqualFold(contentType, "text/plain; charset=utf-8") ||
|
||||
strings.EqualFold(contentType, "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
return strings.ReplaceAll(name, "\"", "")
|
||||
}
|
||||
|
||||
func readableDocumentPath(filename string, folder string) (string, bool, error) {
|
||||
extension := strings.ToLower(filepath.Ext(filename))
|
||||
switch extension {
|
||||
case ".md", ".markdown", ".txt", ".html", ".htm":
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(sanitizeFilename(filename), filepath.Ext(filename))
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "", false, fmt.Errorf("document filename is required")
|
||||
}
|
||||
|
||||
folder = strings.TrimSpace(folder)
|
||||
if filepath.IsAbs(filepath.FromSlash(folder)) {
|
||||
return "", false, fmt.Errorf("upload folder must be relative")
|
||||
}
|
||||
folder = filepath.ToSlash(filepath.Clean(filepath.FromSlash(folder)))
|
||||
if folder == "." {
|
||||
folder = ""
|
||||
}
|
||||
if folder == ".." || strings.HasPrefix(folder, "../") {
|
||||
return "", false, fmt.Errorf("invalid upload folder")
|
||||
}
|
||||
|
||||
return pathpkg.Join(folder, name+".md"), true, nil
|
||||
}
|
||||
|
||||
func documentPageURL(documentPath string) string {
|
||||
documentPath = strings.TrimSuffix(filepath.ToSlash(documentPath), ".md")
|
||||
parts := strings.Split(documentPath, "/")
|
||||
for index, part := range parts {
|
||||
parts[index] = url.PathEscape(part)
|
||||
}
|
||||
return "/docs/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := s.repository.ListAttachments(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
uploadURL := "/attachments/" + record.Hash
|
||||
uploadKind := "attachment"
|
||||
if record.DocumentPath != "" {
|
||||
uploadURL = documentPageURL(record.DocumentPath)
|
||||
uploadKind = "document"
|
||||
}
|
||||
results = append(results, map[string]any{
|
||||
"hash": record.Hash,
|
||||
"originalName": record.OriginalName,
|
||||
"contentType": record.ContentType,
|
||||
"sizeBytes": record.SizeBytes,
|
||||
"createdAt": record.CreatedAt.Format(time.RFC3339),
|
||||
"kind": uploadKind,
|
||||
"documentPath": record.DocumentPath,
|
||||
"url": uploadURL,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"attachments": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := s.documents.ListDocuments(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -9,7 +15,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
)
|
||||
|
||||
func TestBuildBrowserUsesFolderIndex(t *testing.T) {
|
||||
@@ -121,3 +130,287 @@ func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
|
||||
t.Fatalf("content-type = %q, want image/png", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
content string
|
||||
wantPath string
|
||||
wantURL string
|
||||
wantHTML string
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
filename: "release notes.md",
|
||||
content: "# Release Notes\n\nReadable markdown",
|
||||
wantPath: "inbox/release notes.md",
|
||||
wantURL: "/docs/inbox/release%20notes",
|
||||
wantHTML: "<p>Readable markdown</p>",
|
||||
},
|
||||
{
|
||||
name: "html",
|
||||
filename: "status.html",
|
||||
content: "<p>Readable HTML</p>",
|
||||
wantPath: "inbox/status.md",
|
||||
wantURL: "/docs/inbox/status",
|
||||
wantHTML: "<p>Readable HTML</p>",
|
||||
},
|
||||
{
|
||||
name: "plain text",
|
||||
filename: "summary.txt",
|
||||
content: "Readable plain text",
|
||||
wantPath: "inbox/summary.md",
|
||||
wantURL: "/docs/inbox/summary",
|
||||
wantHTML: "<p>Readable plain text</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server, sourceDir := setupUploadTestServer(t)
|
||||
request := multipartUploadRequest(t, tt.filename, []byte(tt.content), "inbox")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleUpload(recorder, request)
|
||||
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Hash string `json:"hash"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
AttachmentURL string `json:"attachmentUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Kind != "document" {
|
||||
t.Fatalf("kind = %q, want document", payload.Kind)
|
||||
}
|
||||
if payload.URL != tt.wantURL || payload.AttachmentURL != tt.wantURL {
|
||||
t.Fatalf("urls = (%q, %q), want %q", payload.URL, payload.AttachmentURL, tt.wantURL)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, filepath.FromSlash(tt.wantPath)))
|
||||
if err != nil {
|
||||
t.Fatalf("read promoted document: %v", err)
|
||||
}
|
||||
if string(written) != tt.content {
|
||||
t.Fatalf("promoted content = %q, want %q", string(written), tt.content)
|
||||
}
|
||||
|
||||
attachment, err := server.repository.GetAttachment(context.Background(), payload.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup upload history record: %v", err)
|
||||
}
|
||||
if attachment.DocumentPath != tt.wantPath {
|
||||
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
|
||||
}
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
|
||||
var listPayload struct {
|
||||
Attachments []struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
if err := json.NewDecoder(listRecorder.Result().Body).Decode(&listPayload); err != nil {
|
||||
t.Fatalf("decode upload history response: %v", err)
|
||||
}
|
||||
if len(listPayload.Attachments) != 1 || listPayload.Attachments[0].Kind != "document" || listPayload.Attachments[0].URL != tt.wantURL {
|
||||
t.Fatalf("upload history = %#v, want rendered document URL %q", listPayload.Attachments, tt.wantURL)
|
||||
}
|
||||
|
||||
page, err := server.documents.LoadPage(context.Background(), tt.wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load promoted page: %v", err)
|
||||
}
|
||||
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
|
||||
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadKeepsPDFAsAttachment(t *testing.T) {
|
||||
for _, filename := range []string{"brief.pdf", "renamed.md"} {
|
||||
t.Run(filename, func(t *testing.T) {
|
||||
server, _ := setupUploadTestServer(t)
|
||||
request := multipartUploadRequest(t, filename, []byte("%PDF-1.4\n"), "inbox")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleUpload(recorder, request)
|
||||
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Hash string `json:"hash"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Kind != "attachment" {
|
||||
t.Fatalf("kind = %q, want attachment", payload.Kind)
|
||||
}
|
||||
if payload.URL != "/attachments/"+payload.Hash {
|
||||
t.Fatalf("url = %q, want hash attachment URL", payload.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadRequiresResolutionForExistingDocumentName(t *testing.T) {
|
||||
server, sourceDir := setupUploadTestServer(t)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
server.handleUpload(first, multipartUploadRequest(t, "report.md", []byte("# First"), "inbox"))
|
||||
if first.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
conflict := httptest.NewRecorder()
|
||||
server.handleUpload(conflict, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox"))
|
||||
if conflict.Result().StatusCode != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
|
||||
}
|
||||
var payload struct {
|
||||
ConflictType string `json:"conflictType"`
|
||||
ExistingPath string `json:"existingPath"`
|
||||
}
|
||||
if err := json.NewDecoder(conflict.Result().Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if payload.ConflictType != "document" || payload.ExistingPath != "inbox/report.md" {
|
||||
t.Fatalf("conflict = %#v, want inbox document conflict", payload)
|
||||
}
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read original document: %v", err)
|
||||
}
|
||||
if string(written) != "# First" {
|
||||
t.Fatalf("original document = %q, want unchanged content", string(written))
|
||||
}
|
||||
|
||||
renamed := httptest.NewRecorder()
|
||||
server.handleUpload(renamed, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox", "rename"))
|
||||
if renamed.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("renamed upload status = %d, want %d", renamed.Result().StatusCode, http.StatusCreated)
|
||||
}
|
||||
renamedContent, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report-2.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read renamed document: %v", err)
|
||||
}
|
||||
if string(renamedContent) != "# Second" {
|
||||
t.Fatalf("renamed document = %q, want second content", string(renamedContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadCanReuseExistingAttachment(t *testing.T) {
|
||||
server, _ := setupUploadTestServer(t)
|
||||
content := []byte("%PDF-1.4\n")
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
server.handleUpload(first, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
|
||||
if first.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
conflict := httptest.NewRecorder()
|
||||
server.handleUpload(conflict, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
|
||||
if conflict.Result().StatusCode != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
|
||||
}
|
||||
var conflictPayload struct {
|
||||
ConflictType string `json:"conflictType"`
|
||||
ExistingURL string `json:"existingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(conflict.Result().Body).Decode(&conflictPayload); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if conflictPayload.ConflictType != "attachment" {
|
||||
t.Fatalf("conflict type = %q, want attachment", conflictPayload.ConflictType)
|
||||
}
|
||||
|
||||
reused := httptest.NewRecorder()
|
||||
server.handleUpload(reused, multipartUploadRequest(t, "brief.pdf", content, "inbox", "reuse"))
|
||||
if reused.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("reused upload status = %d, want %d", reused.Result().StatusCode, http.StatusOK)
|
||||
}
|
||||
var reusedPayload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(reused.Result().Body).Decode(&reusedPayload); err != nil {
|
||||
t.Fatalf("decode reused response: %v", err)
|
||||
}
|
||||
if reusedPayload.URL != conflictPayload.ExistingURL {
|
||||
t.Fatalf("reused URL = %q, want %q", reusedPayload.URL, conflictPayload.ExistingURL)
|
||||
}
|
||||
}
|
||||
|
||||
func setupUploadTestServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("libsql", "file:"+filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
contentStore, err := store.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("create content store: %v", err)
|
||||
}
|
||||
sourceDir := t.TempDir()
|
||||
repository := docs.NewRepository(db)
|
||||
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
|
||||
|
||||
return &Server{
|
||||
documents: documents,
|
||||
repository: repository,
|
||||
contentStore: contentStore,
|
||||
}, sourceDir
|
||||
}
|
||||
|
||||
func multipartUploadRequest(t *testing.T, filename string, content []byte, folder string, duplicateAction ...string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if folder != "" {
|
||||
if err := writer.WriteField("folder", folder); err != nil {
|
||||
t.Fatalf("write folder: %v", err)
|
||||
}
|
||||
}
|
||||
if len(duplicateAction) > 0 && duplicateAction[0] != "" {
|
||||
if err := writer.WriteField("duplicateAction", duplicateAction[0]); err != nil {
|
||||
t.Fatalf("write duplicate action: %v", err)
|
||||
}
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create file part: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write file part: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -104,8 +104,19 @@ func isSetupAllowedPath(path string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
var token string
|
||||
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
|
||||
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
} else if r.URL.Path == "/ws" {
|
||||
token = r.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
if s.config.DevMode && token == "dev" {
|
||||
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||
return principal, err == nil
|
||||
}
|
||||
principal, err := s.auth.ValidateBearerToken(r.Context(), token)
|
||||
if err == nil {
|
||||
return principal, true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -53,16 +54,31 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, sync.Delta{Changes: req.ClientDelta})
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sync.ErrInvalidPath) || errors.Is(err, sync.ErrInvalidHash) || errors.Is(err, sync.ErrContentRequired) || errors.Is(err, sync.ErrHashMismatch) || errors.Is(err, sync.ErrContentNotFound) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.logger.Error("sync delta failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to apply delta"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, sync.DeltaResponse{
|
||||
ServerDelta: result.ServerDelta,
|
||||
Conflicts: result.Conflicts,
|
||||
ServerDelta: result.ServerDelta,
|
||||
Conflicts: result.Conflicts,
|
||||
NewSnapshotID: result.NewSnapshotID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,8 +94,22 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, req.Resolutions)
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sync.ErrInvalidPath) || errors.Is(err, sync.ErrInvalidHash) || errors.Is(err, sync.ErrContentRequired) || errors.Is(err, sync.ErrHashMismatch) || errors.Is(err, sync.ErrContentNotFound) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.logger.Error("sync resolve failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflicts"})
|
||||
return
|
||||
@@ -99,6 +129,10 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
content, err := s.syncService.GetContent(hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrInvalidHash) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid content hash"})
|
||||
return
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "content not found"})
|
||||
return
|
||||
|
||||
@@ -46,6 +46,7 @@ type Change struct {
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"oldPath,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Modified time.Time `json:"modified,omitempty"`
|
||||
}
|
||||
@@ -67,8 +68,9 @@ type Conflict struct {
|
||||
|
||||
// DeltaResult is the server's response to a delta upload.
|
||||
type DeltaResult struct {
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
NewSnapshotID string `json:"newSnapshotId,omitempty"`
|
||||
}
|
||||
|
||||
// Resolution is a user's choice for resolving a conflict.
|
||||
@@ -76,6 +78,8 @@ type Resolution struct {
|
||||
Path string `json:"path"`
|
||||
Strategy ResolutionStrategy `json:"strategy"`
|
||||
NewPath string `json:"newPath,omitempty"` // for rename-both
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// InitRequest starts a new sync session.
|
||||
@@ -86,26 +90,27 @@ type InitRequest struct {
|
||||
|
||||
// InitResponse returns the server's current snapshot.
|
||||
type InitResponse struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ServerSnapshot []FileEntry `json:"serverSnapshot"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ServerSnapshot []FileEntry `json:"serverSnapshot"`
|
||||
}
|
||||
|
||||
// DeltaRequest uploads client changes.
|
||||
type DeltaRequest struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ClientDelta []Change `json:"clientDelta"`
|
||||
}
|
||||
|
||||
// DeltaResponse returns server changes and conflicts.
|
||||
type DeltaResponse struct {
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
NewSnapshotID string `json:"newSnapshotId,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveRequest uploads conflict resolutions.
|
||||
type ResolveRequest struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
Resolutions []Resolution `json:"resolutions"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
Resolutions []Resolution `json:"resolutions"`
|
||||
}
|
||||
|
||||
// ResolveResponse confirms the new snapshot.
|
||||
|
||||
@@ -2,22 +2,33 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errors.New("sync snapshot does not belong to user")
|
||||
ErrContentRequired = errors.New("sync content is required")
|
||||
ErrHashMismatch = errors.New("sync content hash mismatch")
|
||||
ErrInvalidPath = errors.New("invalid sync path")
|
||||
ErrInvalidHash = errors.New("invalid sync content hash")
|
||||
ErrContentNotFound = errors.New("sync content hash not found")
|
||||
)
|
||||
|
||||
// Service handles sync protocol business logic.
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
docService *docs.Service
|
||||
repo *Repository
|
||||
docService *docs.Service
|
||||
contentStore *store.ContentStore
|
||||
logger *slog.Logger
|
||||
sourceDir string
|
||||
logger *slog.Logger
|
||||
sourceDir string
|
||||
}
|
||||
|
||||
// NewService creates a new sync service.
|
||||
@@ -48,11 +59,14 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
|
||||
}
|
||||
|
||||
// ApplyDelta processes client changes and computes server delta + conflicts.
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
}
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serverFiles := make(map[string]FileEntry)
|
||||
for _, f := range snap.Files {
|
||||
@@ -83,8 +97,8 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
currentMap[f.Path] = f
|
||||
}
|
||||
|
||||
var serverDelta []Change
|
||||
var conflicts []Conflict
|
||||
serverDelta := make([]Change, 0)
|
||||
conflicts := make([]Conflict, 0)
|
||||
|
||||
// Detect server changes since snapshot
|
||||
for path, current := range currentMap {
|
||||
@@ -121,13 +135,15 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
}
|
||||
|
||||
// Check for conflicts: both client and server changed same file
|
||||
conflictPaths := make(map[string]struct{})
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate {
|
||||
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate || clientChange.Type == ChangeDelete {
|
||||
serverCurrent, serverHas := currentMap[clientChange.Path]
|
||||
serverOld, serverHad := serverFiles[clientChange.Path]
|
||||
|
||||
if serverHad && serverHas && serverOld.Hash != serverCurrent.Hash &&
|
||||
serverCurrent.Hash != clientChange.Hash {
|
||||
conflictPaths[clientChange.Path] = struct{}{}
|
||||
conflicts = append(conflicts, Conflict{
|
||||
Path: clientChange.Path,
|
||||
ServerHash: serverCurrent.Hash,
|
||||
@@ -137,73 +153,107 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
Strategy: ResolutionLastWriteWins,
|
||||
})
|
||||
}
|
||||
if !serverHad && serverHas && serverCurrent.Hash != clientChange.Hash {
|
||||
conflictPaths[clientChange.Path] = struct{}{}
|
||||
conflicts = append(conflicts, Conflict{
|
||||
Path: clientChange.Path,
|
||||
ServerHash: serverCurrent.Hash,
|
||||
ClientHash: clientChange.Hash,
|
||||
ServerModified: serverCurrent.Modified,
|
||||
ClientModified: clientChange.Modified,
|
||||
Strategy: ResolutionManualMerge,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if _, conflicted := conflictPaths[clientChange.Path]; conflicted {
|
||||
continue
|
||||
}
|
||||
if err := s.applyClientChange(ctx, clientChange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(clientDelta.Changes) > 0 {
|
||||
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
|
||||
return nil, fmt.Errorf("sync documents after client delta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
latestFiles, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
|
||||
}
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
|
||||
}
|
||||
|
||||
return &DeltaResult{
|
||||
ServerDelta: serverDelta,
|
||||
Conflicts: conflicts,
|
||||
ServerDelta: serverDelta,
|
||||
Conflicts: conflicts,
|
||||
NewSnapshotID: newSnap.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveConflicts applies resolved changes and creates a new snapshot.
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of resolutions by path
|
||||
resMap := make(map[string]Resolution)
|
||||
for _, r := range resolutions {
|
||||
resMap[r.Path] = r
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
// Get current server state
|
||||
currentFiles, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
|
||||
return nil, fmt.Errorf("build current snapshot: %w", err)
|
||||
}
|
||||
|
||||
currentMap := make(map[string]FileEntry)
|
||||
for _, f := range currentFiles {
|
||||
currentMap[f.Path] = f
|
||||
}
|
||||
|
||||
// Apply resolutions to create the merged state
|
||||
merged := make(map[string]FileEntry)
|
||||
for path, f := range currentMap {
|
||||
merged[path] = f
|
||||
}
|
||||
|
||||
for _, res := range resolutions {
|
||||
switch res.Strategy {
|
||||
case ResolutionClientWins:
|
||||
// In a real implementation, we'd apply the client's content here.
|
||||
// For now, we keep the server state and mark it resolved.
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Debug("conflict resolved: client wins", "path", res.Path)
|
||||
case ResolutionServerWins:
|
||||
// Keep server state (already in merged)
|
||||
s.logger.Debug("conflict resolved: server wins", "path", res.Path)
|
||||
case ResolutionRenameBoth:
|
||||
if existing, ok := merged[res.Path]; ok {
|
||||
merged[res.NewPath] = existing
|
||||
delete(merged, res.Path)
|
||||
if res.NewPath == "" {
|
||||
res.NewPath = conflictPath(res.Path)
|
||||
}
|
||||
if err := s.applyResolvedContent(res.NewPath, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResolutionLastWriteWins:
|
||||
// Keep whichever is newer - in practice server state since
|
||||
// we haven't received client content yet
|
||||
s.logger.Debug("conflict resolved: last-write-wins", "path", res.Path)
|
||||
if res.Content != "" {
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case ResolutionManualMerge:
|
||||
// Flag for later UI resolution
|
||||
if res.Content != "" {
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.logger.Debug("conflict deferred: manual merge", "path", res.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new snapshot from merged state
|
||||
var files []FileEntry
|
||||
for _, f := range merged {
|
||||
files = append(files, f)
|
||||
if len(resolutions) > 0 {
|
||||
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
|
||||
return nil, fmt.Errorf("sync documents after resolutions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
files, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build resolved snapshot: %w", err)
|
||||
}
|
||||
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
|
||||
@@ -217,12 +267,137 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resol
|
||||
|
||||
// GetContent returns raw file content by hash.
|
||||
func (s *Service) GetContent(hash string) ([]byte, error) {
|
||||
if !isSHA256Hex(hash) {
|
||||
return nil, ErrInvalidHash
|
||||
}
|
||||
return s.contentStore.Read(hash)
|
||||
}
|
||||
|
||||
func (s *Service) applyClientChange(_ context.Context, change Change) error {
|
||||
switch change.Type {
|
||||
case ChangeCreate, ChangeUpdate:
|
||||
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
|
||||
case ChangeDelete:
|
||||
path, err := s.safeContentPath(change.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("delete synced file %s: %w", change.Path, err)
|
||||
}
|
||||
return nil
|
||||
case ChangeRename:
|
||||
oldPath, err := s.safeContentPath(change.OldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPath, err := s.safeContentPath(change.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create rename directory %s: %w", change.Path, err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("rename synced file %s to %s: %w", change.OldPath, change.Path, err)
|
||||
}
|
||||
if change.Content != "" || change.Hash != "" {
|
||||
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyResolvedContent(requestPath, expectedHash, content string) error {
|
||||
path, err := s.safeContentPath(requestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
if content != "" {
|
||||
record, err := s.contentStore.PutBytes([]byte(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store synced content %s: %w", requestPath, err)
|
||||
}
|
||||
if expectedHash != "" && record.Hash != expectedHash {
|
||||
return fmt.Errorf("%w: %s", ErrHashMismatch, requestPath)
|
||||
}
|
||||
bytes = []byte(content)
|
||||
} else {
|
||||
if expectedHash == "" {
|
||||
return fmt.Errorf("%w: %s", ErrContentRequired, requestPath)
|
||||
}
|
||||
if !isSHA256Hex(expectedHash) {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidHash, expectedHash)
|
||||
}
|
||||
bytes, err = s.contentStore.Read(expectedHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %s", ErrContentNotFound, expectedHash)
|
||||
}
|
||||
return fmt.Errorf("read synced content %s: %w", expectedHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create synced file directory %s: %w", requestPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write synced file %s: %w", requestPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) safeContentPath(requestPath string) (string, error) {
|
||||
if requestPath == "" {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.Trim(requestPath, "/"))))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(clean) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
sourceRoot, err := filepath.Abs(s.sourceDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve source root: %w", err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(sourceRoot, filepath.FromSlash(clean)))
|
||||
if err != nil {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
relative, err := filepath.Rel(sourceRoot, target)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func conflictPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
stem := strings.TrimSuffix(path, ext)
|
||||
return stem + " (conflict)" + ext
|
||||
}
|
||||
|
||||
func isSHA256Hex(hash string) bool {
|
||||
if len(hash) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range hash {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildSnapshotFromDisk walks the source directory and builds a file list.
|
||||
func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error) {
|
||||
var files []FileEntry
|
||||
files := make([]FileEntry, 0)
|
||||
|
||||
err := filepath.WalkDir(s.sourceDir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -231,7 +406,7 @@ func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".md" {
|
||||
if strings.ToLower(filepath.Ext(path)) != ".md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -112,7 +116,7 @@ func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
// Client sends empty delta
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: nil})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -133,6 +137,31 @@ func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaMarshalsEmptyCollectionsAsArrays(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delta result: %v", err)
|
||||
}
|
||||
for _, expected := range []string{`"serverDelta":[]`, `"conflicts":[]`} {
|
||||
if !strings.Contains(string(payload), expected) {
|
||||
t.Fatalf("delta JSON = %s, want %s", payload, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -157,7 +186,7 @@ func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
||||
Modified: time.Now().UTC(),
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -189,15 +218,17 @@ func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||
t.Fatalf("update guide.md: %v", err)
|
||||
}
|
||||
|
||||
clientContent := "# Hello\n\nClient-side hello update"
|
||||
clientChange := Change{
|
||||
Type: ChangeUpdate,
|
||||
Path: "hello.md",
|
||||
Hash: "clienthash-different-file",
|
||||
Size: 128,
|
||||
Hash: sha256Hex(clientContent),
|
||||
Content: clientContent,
|
||||
Size: int64(len(clientContent)),
|
||||
Modified: time.Now().UTC(),
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -213,6 +244,71 @@ func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaAppliesClientCreateWithContent(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
content := "# Inbox\n\nCreated from macOS"
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
||||
Type: ChangeCreate,
|
||||
Path: "inbox/from-mac.md",
|
||||
Hash: sha256Hex(content),
|
||||
Content: content,
|
||||
Size: int64(len(content)),
|
||||
Modified: time.Now().UTC(),
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
if len(result.Conflicts) != 0 {
|
||||
t.Fatalf("expected no conflicts, got %#v", result.Conflicts)
|
||||
}
|
||||
if result.NewSnapshotID == "" || result.NewSnapshotID == snap.ID {
|
||||
t.Fatalf("new snapshot id = %q, old = %q", result.NewSnapshotID, snap.ID)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "from-mac.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read synced file: %v", err)
|
||||
}
|
||||
if string(written) != content {
|
||||
t.Fatalf("synced content = %q, want %q", string(written), content)
|
||||
}
|
||||
|
||||
stored, err := service.GetContent(sha256Hex(content))
|
||||
if err != nil {
|
||||
t.Fatalf("GetContent() error = %v", err)
|
||||
}
|
||||
if string(stored) != content {
|
||||
t.Fatalf("stored content = %q, want %q", string(stored), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaRejectsHashMismatch(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
||||
Type: ChangeCreate,
|
||||
Path: "bad.md",
|
||||
Hash: "not-the-content-hash",
|
||||
Content: "# Bad\n",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -227,7 +323,7 @@ func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||
t.Fatalf("update hello.md: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ResolveConflicts(ctx, snap.ID, []Resolution{{
|
||||
_, err = service.ResolveConflicts(ctx, snap.ID, "user:test", []Resolution{{
|
||||
Path: "hello.md",
|
||||
Strategy: ResolutionServerWins,
|
||||
}})
|
||||
@@ -260,7 +356,7 @@ func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
newSnap, err := service.ResolveConflicts(ctx, snap.ID, resolutions)
|
||||
newSnap, err := service.ResolveConflicts(ctx, snap.ID, "user:test", resolutions)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConflicts() error = %v", err)
|
||||
}
|
||||
@@ -300,6 +396,14 @@ func TestGetContentReturnsFileBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentRejectsInvalidHash(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
|
||||
if _, err := service.GetContent("short"); err == nil {
|
||||
t.Fatal("expected invalid hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -329,7 +433,7 @@ func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -343,3 +447,8 @@ func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
t.Fatalf("server delta path = %q, want note-050.md", result.ServerDelta[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ paths:
|
||||
type: string
|
||||
/api/uploads:
|
||||
post:
|
||||
summary: Upload a single attachment
|
||||
operationId: uploadAttachment
|
||||
summary: Upload a single file
|
||||
description: Markdown, plain-text, and HTML files become rendered documents. Other supported file types remain attachments.
|
||||
operationId: uploadFile
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -37,9 +38,32 @@ paths:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
folder:
|
||||
type: string
|
||||
description: Optional relative destination folder for readable documents.
|
||||
duplicateAction:
|
||||
type: string
|
||||
enum: [overwrite, rename, reuse]
|
||||
description: Explicit resolution after a 409 duplicate response. Use rename to keep both readable documents, overwrite to replace a readable document, or reuse to return an existing upload URL.
|
||||
responses:
|
||||
"200":
|
||||
description: Existing upload URL reused
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadResponse"
|
||||
"201":
|
||||
description: Attachment stored
|
||||
description: File stored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadResponse"
|
||||
"409":
|
||||
description: A readable document name or attachment hash already exists. Ask the user how to continue and retry with duplicateAction.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadConflict"
|
||||
/attachments/{hash}:
|
||||
get:
|
||||
summary: Download an attachment by content hash
|
||||
@@ -53,4 +77,38 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Attachment content
|
||||
|
||||
components:
|
||||
schemas:
|
||||
UploadResponse:
|
||||
type: object
|
||||
required: [hash, contentType, kind, url, attachmentUrl]
|
||||
properties:
|
||||
hash:
|
||||
type: string
|
||||
contentType:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [document, attachment]
|
||||
url:
|
||||
type: string
|
||||
description: Canonical rendered-document or attachment URL.
|
||||
attachmentUrl:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Compatibility alias for url.
|
||||
UploadConflict:
|
||||
type: object
|
||||
required: [error, conflictType, existingHash, existingUrl]
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
conflictType:
|
||||
type: string
|
||||
enum: [document, attachment]
|
||||
existingHash:
|
||||
type: string
|
||||
existingPath:
|
||||
type: string
|
||||
existingUrl:
|
||||
type: string
|
||||
|
||||
Reference in New Issue
Block a user