sync app filled in

This commit is contained in:
2026-06-01 10:53:28 -04:00
parent ebe0920f89
commit fb0673a1c5
36 changed files with 4527 additions and 98 deletions

View File

@@ -6,3 +6,4 @@ tmp
coverage
*.log
.build

7
.gitignore vendored
View File

@@ -5,3 +5,10 @@ coverage/
.DS_Store
*.log
apps/server/bin/
# Swift Package Manager and Xcode
.build/
.swiftpm/
DerivedData/
*.xcuserstate
content

View 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.

View File

@@ -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`

View File

@@ -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.

View File

@@ -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.

View 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
View 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.

View 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 {}

View 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
}

View 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)
}
}

View 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()
}
}

View 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")
}
}

View 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")
}

View File

@@ -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
}
}
}

View 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 }
}

View File

@@ -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")
}

View 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")
}

View File

@@ -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
}

View 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
}
}

View 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()
}
}

View File

@@ -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")

View File

@@ -0,0 +1 @@
-- SQLite column removal requires rebuilding the attachments table.

View File

@@ -0,0 +1 @@
ALTER TABLE attachments ADD COLUMN document_path TEXT;

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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())
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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[:])
}

View File

@@ -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