onyx-tasks/docs/API.md
Tristan Michael 513acc5606 Document WebDAV sync, workspace theming, and credential hardening
Update CLAUDE.md to reflect completed WebDAV sync and expanded config/sync features. The docs now describe AppConfig fields (mode, theme, WebDAV URL), WorkspaceMode enum, WebDAV client details and credential handling (scoped keyring keys `com.onyx.webdav.<domain>::<username>` with auto-migration from legacy dot-separated format on load), three-way sync with a 60s timeout and auto-creation of the Onyx/ remote subfolder, a 10MB PROPFIND response cap, plus UI changes (setup flow, per-workspace theme options) and other sync/setup UI improvements.
2026-04-03 11:26:12 -07:00

10 KiB

Onyx Core - API Documentation

Overview

The onyx-core library provides a complete backend for managing tasks in a local-first manner. Tasks are stored as markdown files with YAML frontmatter, compatible with Obsidian and other markdown editors.

Core Concepts

Data Models

Task

Represents an individual task.

pub struct Task {
    pub id: Uuid,
    pub title: String,
    pub description: String,
    pub status: TaskStatus,
    pub due_date: Option<DateTime<Utc>>,
    pub has_time: bool,            // Whether due_date includes a specific time
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub parent_id: Option<Uuid>,
}

pub enum TaskStatus {
    Backlog,     // Not yet completed
    Completed,   // Done
}

Creating a Task:

use onyx_core::Task;

// Simple task
let task = Task::new("Buy groceries".to_string());

// Task with description and due date
let task = Task::new("Review PR #123".to_string())
    .with_description("Check the authentication changes".to_string())
    .with_due_date(chrono::Utc::now() + chrono::Duration::days(2));

TaskList

Represents a collection of tasks.

pub struct TaskList {
    pub id: Uuid,
    pub title: String,
    pub tasks: Vec<Task>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub group_by_due_date: bool,
}

Configuration

AppConfig

Global application configuration supporting multiple workspaces.

pub struct AppConfig {
    pub workspaces: HashMap<String, WorkspaceConfig>,
    pub current_workspace: Option<String>,
}

Location:

  • Windows: %APPDATA%/onyx/config.json
  • Linux: ~/.config/onyx/config.json
  • macOS: ~/Library/Application Support/onyx/config.json

Usage:

use onyx_core::AppConfig;

// Load config
let config_path = AppConfig::get_config_path();
let mut config = AppConfig::load_from_file(&config_path)?;

// Add workspace
config.add_workspace(
    "personal".to_string(),
    WorkspaceConfig::new(PathBuf::from("/home/user/tasks"))
);

// Set current workspace
config.set_current_workspace("personal".to_string())?;

// Save config
config.save_to_file(&config_path)?;

WorkspaceConfig

Configuration for a single workspace.

pub struct WorkspaceConfig {
    pub path: PathBuf,
    pub webdav_url: Option<String>,
    pub last_sync: Option<DateTime<Utc>>,
}

TaskRepository API

The main interface for interacting with tasks and lists.

Initialization

use onyx_core::TaskRepository;
use std::path::PathBuf;

// Open existing repository
let repo = TaskRepository::new(PathBuf::from("/path/to/tasks"))?;

// Initialize new repository
let repo = TaskRepository::init(PathBuf::from("/path/to/tasks"))?;

Task Operations

Create Task

let task = Task::new("My task".to_string());
let created_task = repo.create_task(list_id, task)?;

Get Task

let task = repo.get_task(list_id, task_id)?;

Update Task

let mut task = repo.get_task(list_id, task_id)?;
task.title = "Updated title".to_string();
task.complete();
repo.update_task(list_id, task)?;

Delete Task

repo.delete_task(list_id, task_id)?;

List Tasks

let tasks = repo.list_tasks(list_id)?;

List Operations

Create List

let list = repo.create_list("My List".to_string())?;

Get Lists

let lists = repo.get_lists()?;

Get Specific List

let list = repo.get_list(list_id)?;

Delete List

repo.delete_list(list_id)?;

Task Ordering

Reorder Task

// Move task to position 0 (first)
repo.reorder_task(list_id, task_id, 0)?;

Get Task Order

let order = repo.get_task_order(list_id)?;
// Returns: Vec<Uuid> - ordered list of task IDs

Grouping

Enable/Disable Group by Due Date

// Enable grouping
repo.set_group_by_due_date(list_id, true)?;

// Disable grouping
repo.set_group_by_due_date(list_id, false)?;

// Check current setting
let is_grouped = repo.get_group_by_due_date(list_id)?;

File Format

Task Files

Tasks are stored as .md files with YAML frontmatter:

---
id: 550e8400-e29b-41d4-a716-446655440000
status: backlog
due: 2026-11-15T14:00:00Z
created: 2026-10-26T10:00:00Z
updated: 2026-10-26T12:30:00Z
parent: 550e8400-e29b-41d4-a716-446655440001
---

Task description and notes go here in **markdown** format.

- Can include lists
- Rich formatting
- Links, etc.

The filename (without .md) becomes the task title.

List Metadata

Each list folder contains a .listdata.json file:

{
  "id": "list-uuid-1",
  "created_at": "2026-10-26T10:00:00Z",
  "updated_at": "2026-10-27T14:30:00Z",
  "group_by_due_date": false,
  "task_order": [
    "task-uuid-1",
    "task-uuid-2",
    "task-uuid-3"
  ]
}

Root Metadata

The root folder contains a .metadata.json file:

{
  "version": 1,
  "list_order": ["list-uuid-1", "list-uuid-2"],
  "last_opened_list": "list-uuid-1"
}

WebDAV & Sync

The sync module provides bi-directional WebDAV synchronization with three-way diff, offline queuing, and platform keychain credential storage.

Sync Functions

Sync functions live in the onyx_core::sync module as standalone functions (not on TaskRepository).

Sync a Workspace

use onyx_core::sync::{sync_workspace, SyncMode};
use std::path::Path;

// Full bi-directional sync
let result = sync_workspace(
    Path::new("/home/user/tasks"),
    "https://nextcloud.example.com/remote.php/dav/files/user/Tasks",
    "username",
    "password",
    SyncMode::Full,
).await?;

// Push-only or pull-only
sync_workspace(path, url, user, pass, SyncMode::PushOnly).await?;
sync_workspace(path, url, user, pass, SyncMode::PullOnly).await?;

Check Sync Status

use onyx_core::sync::get_sync_status;

let status = get_sync_status(Path::new("/home/user/tasks"))?;
// Returns SyncStatusInfo with last sync time, pending changes, etc.

Credential Storage

Credentials are stored in the platform keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service).

Keyring service keys use the format com.onyx.webdav.<domain>::<username> — the :: separator prevents key collisions when usernames contain dots. On first load, credentials stored in the legacy .-separated format (com.onyx.webdav.<domain>.<username>) are automatically migrated to the scoped format and the old entries are removed.

use onyx_core::webdav::{store_credentials, load_credentials, delete_credentials};

// Store credentials
store_credentials("nextcloud.example.com", "username", "password")?;

// Load credentials
let (username, password) = load_credentials("nextcloud.example.com")?;

// Delete credentials
delete_credentials("nextcloud.example.com")?;

WebDAV Client

use onyx_core::webdav::WebDavClient;

let client = WebDavClient::new(
    "https://nextcloud.example.com/remote.php/dav/files/user/Tasks",
    "username",
    "password",
);

// Test connection
client.test_connection().await?;

// List remote files
let files = client.list_files("/").await?;

// Upload/download
client.put_file("My Tasks/task.md", content).await?;
let data = client.get_file("My Tasks/task.md").await?;

// Directory operations
client.ensure_dir("My Tasks").await?;
client.delete_file("old-task.md").await?;

Sync Strategy

  • Three-way diff: Compares local state, remote state, and last-known baseline to determine actions (upload, download, delete local/remote)
  • Conflict resolution: Last-write-wins using file timestamps
  • Offline queue: Pending operations are queued and replayed when connectivity returns
  • Sync state: Stored in .syncstate.json within the workspace directory
  • Response size cap: PROPFIND responses are limited to 10 MB (checked via Content-Length header and actual body size) to prevent memory exhaustion from malicious servers

Error Handling

All operations return Result<T, Error> where Error is:

pub enum Error {
    Io(io::Error),
    Serialization(String),
    NotFound(String),
    InvalidData(String),
    WorkspaceNotFound(String),
    ListNotFound(String),
    TaskNotFound(String),
    WebDav(String),
    Sync(String),
    Credential(String),
}

Example: Complete Workflow

use onyx_core::{TaskRepository, Task, AppConfig, WorkspaceConfig};
use std::path::PathBuf;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize repository
    let path = PathBuf::from("/home/user/tasks");
    let mut repo = TaskRepository::init(path.clone())?;

    // Create a list
    let list = repo.create_list("My Tasks".to_string())?;

    // Create tasks
    let task1 = Task::new("Buy groceries".to_string());
    let task1 = repo.create_task(list.id, task1)?;

    let task2 = Task::new("Call dentist".to_string())
        .with_due_date(chrono::Utc::now() + chrono::Duration::days(1));
    let task2 = repo.create_task(list.id, task2)?;

    // List all tasks
    let tasks = repo.list_tasks(list.id)?;
    for task in tasks {
        println!("- [{}] {}",
            if task.status == TaskStatus::Completed { "✓" } else { " " },
            task.title
        );
    }

    // Complete a task
    let mut task = repo.get_task(list.id, task1.id)?;
    task.complete();
    repo.update_task(list.id, task)?;

    // Configure workspace
    let mut config = AppConfig::new();
    config.add_workspace("personal".to_string(), WorkspaceConfig::new(path));
    config.set_current_workspace("personal".to_string())?;
    config.save_to_file(&AppConfig::get_config_path())?;

    Ok(())
}

Testing

The core library includes comprehensive tests. Run them with:

cargo test -p onyx-core

Key test areas:

  • Task CRUD operations
  • List management
  • Task ordering
  • Markdown parsing
  • Metadata persistence
  • Error handling

Thread Safety

The Storage trait requires Send + Sync, and TaskRepository wraps Box<dyn Storage + Send + Sync>, so repository instances can be shared across threads behind a Mutex. The Tauri GUI uses Mutex<AppState> for this purpose.

For concurrent access:

  1. Wrap TaskRepository in Mutex or RwLock (the Tauri app does this)
  2. Or create separate repository instances per thread (file system handles locking)