No description
  • Rust 99.7%
  • Nix 0.3%
Find a file
2026-10-19 19:18:06 -07:00
examples Initial commit 2026-10-19 19:18:06 -07:00
src Initial commit 2026-10-19 19:18:06 -07:00
tests Initial commit 2026-10-19 19:18:06 -07:00
.envrc Initial commit 2026-10-19 19:18:06 -07:00
.gitignore Initial commit 2026-10-19 19:18:06 -07:00
Cargo.lock Initial commit 2026-10-19 19:18:06 -07:00
Cargo.toml Initial commit 2026-10-19 19:18:06 -07:00
devenv.lock Initial commit 2026-10-19 19:18:06 -07:00
devenv.nix Initial commit 2026-10-19 19:18:06 -07:00
devenv.yaml Initial commit 2026-10-19 19:18:06 -07:00
README.md Initial commit 2026-10-19 19:18:06 -07:00
VALIDATION.md Initial commit 2026-10-19 19:18:06 -07:00

odyssey-dash

Shared Rust core for DaggerDash and DnDDash. The vault API has no GPUI dependency, required async runtime, Obsidian plugin, or network service. Markdown files in a local vault remain the source of truth.

Development

devenv shell
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets --all-features
cargo test --doc --all-features

devenv test runs these checks. devenv.lock pins the same Nix inputs as the dashboard development environments. This crate needs no graphics libraries. Linux, macOS, and Windows use portable filesystem APIs and native notify backends. Devenv itself is for Linux/macOS; Windows users can use a native Rust toolchain and these Cargo commands. Platform-specific validation is recorded in VALIDATION.md.

Both applications use a sibling path dependency:

odyssey-dash = { path = "../odyssey-dash", features = ["settings"] }

Keep the three checkouts alongside each other. The library is an independent crate, with no parent Cargo workspace. It is not published to a registry.

Read and edit a session

use odyssey_dash::{Vault, VaultConfig};

# async fn example() -> odyssey_dash::Result<()> {
let vault = Vault::open(VaultConfig::new("/campaign/vault", "/app-data/drafts")).await?;
let index = vault.index(false).await?;
for entry in index.documents("session") {
    println!("{}", entry.path.display());
}
let document = vault.read_document("Sessions/Today.md").await?;
let events = vault.subscribe().await?;
let session = vault.open_session(document.path).await?;

// Success means this edit is durably recorded for recovery, not yet saved to the vault.
session.update_notes("The party reached the city.").await?;
session.flush().await?;
session.close().await?;
drop(events);
let report = vault.shutdown().await?;
assert!(report.unsaved.is_empty());
# Ok(())
# }

For a runnable CLI example:

cargo run --example session_notes -- /campaign/vault /app-data/drafts Sessions/Today.md
# An optional fourth argument supplies a notes file to replace the managed notes.

All paths passed to document operations are relative to the selected vault. Directory selection belongs to the application; .obsidian is not required. Hidden paths, symlinks, and traversal outside the vault are rejected. Indexing skips hidden files/directories and does not use .gitignore. Any non-Markdown regular file is indexed as an attachment. The index stores filenames and metadata, not the contents of every document. Index diagnostics report inaccessible directories and metadata; read failures are returned when opening a file.

Snapshots retain original Markdown and expose raw YAML frontmatter, headings, links and source byte ranges. Links inside code and frontmatter are excluded. Wikilink aliases, embed flags, percent-encoded Markdown paths, and heading/block fragments are retained. Explicit ./ and ../ paths resolve from the source document; vault paths resolve from the root. Bare Markdown paths also consider the source directory, and bare filenames can match anywhere in the vault. Multiple matches return Ambiguous; the library never guesses between duplicate names. Resolution is case-sensitive and file-level: fragments are exposed for the UI, not validated against Obsidian's block-reference syntax. Embeds are references, not expanded content. Frontmatter aliases are not used as filenames. External URIs are classified but never fetched or opened.

Managed section and saving

Opening and reading a document never changes it. The first nonempty edit appends:

## Session Notes
<!-- odyssey-dash:notes:start -->
Your editable notes.
<!-- odyssey-dash:notes:end -->

Both dashboards share the exact marker strings. Marker lines must stand alone outside code and frontmatter. Duplicate, reversed, or incomplete markers disable saving and remain readable as a diagnostic. The editor receives only the body, normalized to LF. One newline before the end marker is structural; additional newlines belong to the draft. Saves match the document's existing line endings and leave all bytes outside the body untouched. Clearing an existing section keeps its markers. Empty edits do not create a section. Draft text that would break the marker structure (for example an unclosed code fence) stays recoverable but cannot be saved until corrected.

The worker journals each edit before acknowledging it. It saves after 500 ms of inactivity, or at most two seconds after the first pending edit during continuous typing. Timings are configurable. flush() saves immediately, and close() flushes before detaching. On any failed update the application must keep the submitted text: it was not acknowledged as durable. After successful acknowledgment, later save failures retain the recoverable draft.

Every save re-reads disk content. Changes outside the notes section are kept. If the external section differs from the local baseline and the local draft, saving pauses with SaveStatus::Conflict. SessionState::conflict provides the original, local and external text. The application chooses a result and calls resolve_conflict(external_revision, chosen_notes). A stale external revision is rejected and the refreshed state is available for another decision. No automatic text merge or force-overwrite operation is provided.

Saving uses a synced temporary file beside the destination and atomic replacement, preserving basic file permissions. Unix directories are also synced. This prevents partial-file writes; it does not lock Obsidian out between the final content check and replacement. Concurrent independent editors can still race in that window. Unix permission bits are preserved; ownership, extended attributes, Windows ACLs, and power-loss durability on every filesystem are not guaranteed.

Watching, events and GPUI integration

WatchMode::Auto starts a native watcher and falls back to polling if startup fails. Native requires successful startup; Poll explicitly selects polling. Native events are coalesced for 100 ms and cause a rescan, handling editors that replace files instead of writing in place. Open session files are also checked every two seconds, and the full native index is reconciled every 30 seconds. Polling rescans on its configured interval. Native runtime errors switch to polling and emit a warning. Content revisions suppress redundant session change notifications from the library's own saves.

Each subscribe() call receives its own ordered event stream. Drain it in a long-lived task; receivers are unbounded. Cloning a receiver shares consumption, so call subscribe() again for an independent observer. Use Session::id() to route events. State includes the current draft, its revision, save status and the latest successfully read document. A missing file pauses saving without recreating it. Select another file and restore the draft explicitly after a rename.

GPUI should own Vault and Session handles, await these futures in its async tasks, and apply events through a weak entity/window handle. The library worker does file reads, scanning, journal writes, autosave and notifications. GPUI types, file pickers, Markdown rendering, campaign associations, window notifications, and database/UI refresh behavior stay in each dashboard. No extra Tokio runtime or GPUI adapter is necessary.

Once a request is queued it may run even if its future is dropped. Do not use future cancellation as an undo operation. Await session opening and closing; call shutdown() when leaving a vault. Dropping all handles stops the worker without implicit saves, leaving acknowledged drafts available for recovery.

Recovery lifecycle

Pass an application-specific recovery root outside the vault. The library creates a private subdirectory keyed by the canonical vault path and stores versioned JSON records containing the baseline document, latest draft and edit revision. These records contain plaintext notes; store them with the application's private data. An OS file lock prevents two workers from overwriting the same recovery records. This lock is not cross-app editing coordination and does not lock vault documents. Use a stable recovery root and explicit shutdown when changing vaults. Moving a vault changes its recovery identity; the application must retain the old location or migrate its recovery directory explicitly.

# async fn restore(vault: odyssey_dash::Vault) -> odyssey_dash::Result<()> {
let recoveries = vault.recoveries().await?;
for draft in recoveries.drafts {
    // None uses the original path. Some(relative_path) explicitly rebinds after a move.
    let session = vault.restore_draft(draft.id, None).await?;
    // Inspect session.state() for a conflict before offering a save.
    println!("{:?}", session.state().await?.status);
}
# Ok(())
# }

An existing record prevents open_session from replacing that draft. Corrupt or unsupported records produce diagnostics and are left intact. A recovered draft already present on disk is recognized and its record cleared without writing the note again. Conflicting recovered text requires explicit resolution. Rebinding writes the new record before removing the original.

Vault::shutdown() flushes attached sessions and stops all cloned vault handles. Its report lists sessions that could not save. Dropped sessions detach without saving, leaving their journal records for the next recovery enumeration. This is also the behavior after an application crash; no destructor is relied upon for durability.

Shared application settings

Enable the optional settings feature for SQLite-backed preferences. Vault-only consumers do not need SQLite. settings::SettingsStore borrows the app's existing rusqlite::Connection; the app keeps its database, migrations, connection options and GPUI notifications. There is no second database, migration, or settings file. Existing databases use:

CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);

settings::keys owns the common theme, font family, font size, collapsed sidebar, last campaign and last page keys. get, set, set_many and delete preserve the existing raw string format. Missing overrides remain absent, so each app can inherit its theme or default. set_many uses a savepoint and rolls back the whole group on failure, including when nested inside a campaign transaction. Unrelated settings are never rewritten.

Each dashboard can define extra types and namespaced keys locally. Use a separate key for each independent preference or a struct for a group that should save atomically. No core schema registration, enum variant, or crate update is needed:

# #[cfg(feature = "settings")]
# fn custom_preferences() -> Result<(), Box<dyn std::error::Error>> {
use odyssey_dash::settings::SettingsStore;
use serde::{Deserialize, Serialize};

#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct InitiativePreferences {
    roll_automatically: bool,
}

let connection = rusqlite::Connection::open_in_memory()?;
# connection.execute_batch("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")?;
let settings = SettingsStore::new(&connection);
let key = "dnddash.initiative";
let preferences = settings.get_json::<InitiativePreferences>(key)?.unwrap_or_default();
settings.set_json(key, &preferences)?;
settings.delete(key)?; // Reset to the consuming dashboard's default.
# Ok(())
# }
# #[cfg(feature = "settings")]
# custom_preferences().unwrap();

In the dashboards the same API is available as store::db(cx).settings(). Their existing Db methods are thin adapters so current UI call sites keep their save failure behavior. UI settings windows and font/theme application stay in the apps. Use raw methods for common keys and JSON methods for your own keys; JSON strings and booleans have different encodings from the existing raw values. The caller owns defaults, validation and schema evolution (for example Serde's default attribute). Invalid JSON and database failures return errors with the key and cause instead of silently resetting a preference.