ANW-28 docs: README and LICENSE for the public mirror
Replace the placeholder README with the full public-facing README, and switch licensing to BSD 3-Clause per aav's decision on ANW-28: add a single LICENSE file, drop the dual LICENSE-MIT / LICENSE-APACHE, and update the Cargo.toml license field to match. README/LICENSE content authored by kaa, landed verbatim. Assumed: dropping the old MIT/Apache files and updating Cargo.toml license = BSD-3-Clause is the consistent completion of aav's BSD decision (v0.0.0, nothing released relied on the old grant); flag if the dual license was meant to stay.
This commit is contained in:
parent
3b3e550860
commit
562f3b20a7
5 changed files with 181 additions and 252 deletions
181
README.md
181
README.md
|
|
@ -1,55 +1,178 @@
|
|||
# Anwesen
|
||||
|
||||
Read-only HTTP daemon over a markdown vault. Walks notes in place, parses
|
||||
YAML frontmatter, watches the directory for changes, and answers three kinds
|
||||
of question over JSON: read one note, list a folder, query by frontmatter.
|
||||
> *Anwesen* (German): the premises, the estate -- and, in Heidegger, a coming-to-presence. A read-only HTTP daemon that brings a markdown vault into presence over the network.
|
||||
|
||||
See the User Manual and ADRs in the project's design vault for the contract
|
||||
and rationale.
|
||||
Anwesen serves an Obsidian-style markdown vault read-only over HTTP. It walks the notes in place, parses their YAML frontmatter, and lets remote clients query notes by frontmatter fields, read individual notes, and list folders -- with no database, no copy, and no migration of the underlying directory. The same query and merge engine is also available offline as a one-shot CLI.
|
||||
|
||||
## Subcommands
|
||||
## Why
|
||||
|
||||
Obsidian is the odd one out among knowledge stores in not shipping a native HTTP API. Notion, Confluence, Wiki.js, Outline and the rest expose one out of the box; an Obsidian vault is just a directory of markdown files on disk. Anwesen brings that vault up to the same bar without converting it into something else: the files stay exactly where Obsidian wrote them and remain editable in Obsidian with no coordination. Programs that need the vault's contents stop re-implementing "walk the directory, parse the YAML, filter the notes" -- they ask Anwesen.
|
||||
|
||||
## What it does
|
||||
|
||||
```
|
||||
Obsidian vault -> Anwesen (walk + frontmatter index) -> HTTP/JSON -> consumers
|
||||
```
|
||||
|
||||
Anwesen answers three kinds of question over HTTP:
|
||||
|
||||
- *Give me this one note* -- `GET /notes/<path>`
|
||||
- *List the contents of this folder* -- `GET /notes/<folder>/`
|
||||
- *Find every note whose frontmatter matches these predicates* -- `GET /query?...`
|
||||
|
||||
The frontmatter index is built once at startup and kept current by watching the vault directory. The index lives in memory; a restart rebuilds it, and there is nothing on disk to corrupt or migrate.
|
||||
|
||||
The same query-and-merge engine also runs offline, with no server: `anwesen merge` walks a directory, evaluates a query, and writes the merged markdown to stdout (see [Local generation](#local-generation)).
|
||||
|
||||
## Quick start
|
||||
|
||||
Build (Rust, 2024 edition, stable toolchain):
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
anwesen serve --vault /path/to/vault
|
||||
```
|
||||
|
||||
Anwesen binds `127.0.0.1:8080` by default, walks the vault once, then serves the API and watches for changes. Stop it with `SIGINT`/`SIGTERM`; there is nothing on disk to clean up.
|
||||
|
||||
Check a vault before serving it:
|
||||
|
||||
```
|
||||
anwesen doctor --vault /path/to/vault
|
||||
```
|
||||
|
||||
Build one file out of many notes, without starting the daemon:
|
||||
|
||||
```
|
||||
anwesen merge --vault /path/to/vault --query 'tags=adr&__anw-order=title' > ADRs.md
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
anwesen serve --vault <path> [--bind <addr:port>] [--log-level <level>]
|
||||
anwesen doctor --vault <path> [--log-level <level>]
|
||||
anwesen doctor --vault <path>
|
||||
anwesen merge --vault <path> --query <query-string>
|
||||
anwesen version
|
||||
```
|
||||
|
||||
Each flag has a matching `ANWESEN_<UPPER>` environment variable; CLI wins
|
||||
over env.
|
||||
| Flag | Env var | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `--vault <path>` | `ANWESEN_VAULT` | *required* | Path to the vault root. |
|
||||
| `--bind <addr:port>` | `ANWESEN_BIND` | `127.0.0.1:8080` | Listen address for `serve`. |
|
||||
| `--log-level <level>` | `ANWESEN_LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`, or `trace`. |
|
||||
| `--query <query-string>` | -- | *required for `merge`* | A `/query` query string: frontmatter predicates plus `__anw-` controls. |
|
||||
|
||||
## Deployment
|
||||
Every flag has a matching `ANWESEN_<UPPER>` environment variable; CLI flags win over env vars.
|
||||
|
||||
A systemd unit and an example reverse-proxy config live in [`deploy/`](deploy/).
|
||||
Anwesen binds `127.0.0.1` and ships no authentication -- the proxy is the
|
||||
access boundary. See [`deploy/README.md`](deploy/README.md).
|
||||
- **`serve`** -- run the daemon: walk the vault, build the index, watch for changes, serve the API.
|
||||
- **`doctor`** -- walk the vault once and report what would stop clean ingestion: unreadable files, unparseable YAML, path collisions on the HTTP surface, and frontmatter type drift (the same key carrying incompatible types across notes). Read-only; non-zero exit if any issue is found.
|
||||
- **`merge`** -- one-shot local generation: walk the vault, evaluate `--query`, and write the merged markdown document to stdout. No server, no HTTP. See [Local generation](#local-generation).
|
||||
- **`version`** -- print version and exit.
|
||||
|
||||
## Development
|
||||
## HTTP API
|
||||
|
||||
All endpoints are `GET` and return JSON unless noted.
|
||||
|
||||
### `GET /notes/<path>` -- read one note
|
||||
|
||||
Returns `{ path, frontmatter, body, last_modified, etag, size }`, where `body` is the markdown with the frontmatter block stripped.
|
||||
|
||||
- `Accept: text/markdown` returns the raw file verbatim, frontmatter intact.
|
||||
- `If-None-Match: "<etag>"` returns `304 Not Modified` when the note is unchanged. ETags are strong -- the BLAKE3 hash of the file's raw bytes -- so they are stable across restarts and unaffected by identical-byte rewrites (format-on-save, sync clients, `git checkout` of the same revision).
|
||||
- `..` segments in the path are rejected with `400`.
|
||||
|
||||
### `GET /notes/<folder>/` -- directory listing
|
||||
|
||||
Note the trailing slash; that is how a folder index is distinguished from a note read. Returns the immediate children only (non-recursive), each with name, type (`file`/`dir`), `last_modified`, and size.
|
||||
|
||||
### `GET /query?...` -- frontmatter query
|
||||
|
||||
Returns `{ results, total, truncated }`. Each query-string `key=value` is a predicate on a frontmatter field; different keys combine with AND.
|
||||
|
||||
```
|
||||
scripts/ci.sh # fmt + clippy + test + hurl contract harness
|
||||
GET /query?tags=anwesen&kind=adr
|
||||
```
|
||||
|
||||
### HTTP contract tests
|
||||
**Field operators** (suffix on the key):
|
||||
|
||||
End-to-end HTTP behaviour is verified by [hurl](https://hurl.dev/) per
|
||||
ADR-008. Install once:
|
||||
| Suffix | Example | Meaning |
|
||||
|---|---|---|
|
||||
| *(none)* | `tags=python` | Exact match on scalars; "contains" if the field is a list. |
|
||||
| `__in` | `tags__in=python,go` | Any of the listed values matches. |
|
||||
| `__all` | `tags__all=python,fastapi` | List contains all listed values. |
|
||||
| `__not` | `status__not=draft` | Negation. |
|
||||
| `__exists` | `deprecated__exists=false` | Presence / absence of the key. |
|
||||
| `__regex` | `title__regex=^PDR-\d+` | Regex on a scalar (anchor it for speed). |
|
||||
| `__prefix` | `path__prefix=Projects` | String prefix. |
|
||||
| `__gt` `__gte` `__lt` `__lte` | `date__gte=2026-01-01` | Ordered comparison; numbers and ISO dates. |
|
||||
|
||||
ISO-8601 dates and RFC 3339 datetimes are coerced to typed dates at read time, so range operators work on them. Nested keys use dots: `author.name=...`. Unknown operators return `400` rather than being silently ignored. Unanchored substring (`__contains`) is intentionally not provided; use `__regex`.
|
||||
|
||||
**Control parameters** (`__anw-` prefix; configure the query rather than constrain matches):
|
||||
|
||||
| Parameter | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `__anw-recursive=<bool>` | `true` | Recurse into subdirectories. |
|
||||
| `__anw-path=<prefix>` | vault root | Restrict matches to a path prefix. |
|
||||
| `__anw-limit=<n>` | no limit | Cap the result list; `total` still reports the full match count. |
|
||||
| `__anw-order=<key>[:asc\|:desc]` | path order | Order fragments (merge mode only). |
|
||||
| `__anw-kind=<key>` | off | Refuse a mixed merge unless every matched note shares one value for the key (merge mode only). |
|
||||
|
||||
By default `/query` returns metadata only; fetch bodies with `/notes/<path>`.
|
||||
|
||||
#### Markdown-merge mode
|
||||
|
||||
`Accept: text/markdown` on `/query` returns the **bodies** of all matched notes concatenated into one markdown document, each fragment preceded by an HTML-comment source marker and separated by a blank line. This is the single-request path for building one file out of many notes:
|
||||
|
||||
```
|
||||
# Ubuntu / Debian
|
||||
curl -sSL -o /tmp/hurl.deb https://github.com/Orange-OpenSource/hurl/releases/download/8.0.0/hurl_8.0.0_amd64.deb
|
||||
sudo dpkg -i /tmp/hurl.deb
|
||||
<!-- source: ADR-001 Language and Foundation Libraries.md -->
|
||||
...body...
|
||||
|
||||
# Pinned at hurl 8.0.0. Other recent 8.x releases also work but are not
|
||||
# what CI runs.
|
||||
<!-- source: ADR-002 Filesystem Change Tracking.md -->
|
||||
...body...
|
||||
```
|
||||
|
||||
Then `scripts/ci.sh` -- or `tests/run-hurl.sh` to run just the contract
|
||||
suite (it builds the debug binary if missing, boots `anwesen serve` on
|
||||
`127.0.0.1:18086`, waits for `/health`, runs every `tests/hurl/**/*.hurl`,
|
||||
and tears the daemon down on exit). Override the port with
|
||||
`ANWESEN_TEST_PORT=<n>` if 18086 clashes locally.
|
||||
`__anw-order` sets fragment order; `__anw-kind` guards against merging notes that disagree on a key (returns `400` naming the offenders). Both evaluate over the full match set before `__anw-limit` truncates.
|
||||
|
||||
The same merged document is available offline, without the daemon, via `anwesen merge` (see [Local generation](#local-generation)).
|
||||
|
||||
### `GET /health` -- liveness and index freshness
|
||||
|
||||
Returns vault path, note count, last index/event timestamps, watcher state, an in-flight-rescan flag, and supervisor restart counters. Always returns `200` while the process is up; for a hard liveness signal use TCP connectivity.
|
||||
|
||||
### Status codes
|
||||
|
||||
`200` ok | `304` ETag matched | `400` bad request | `404` not found | `500` internal | `503` index not ready (retry shortly).
|
||||
|
||||
## Local generation
|
||||
|
||||
`anwesen merge` produces the markdown-merge document on the command line, with no server and no HTTP round-trip. It walks the vault, evaluates the query, and writes the merged document to stdout:
|
||||
|
||||
```
|
||||
anwesen merge --vault /path/to/vault --query 'tags=adr&__anw-order=title&__anw-kind=kind'
|
||||
```
|
||||
|
||||
The `--query` string is the exact `/query` grammar: frontmatter predicates plus the `__anw-` controls, including `__anw-order` for fragment order and `__anw-kind` for the homogeneity guard. The output is byte-identical to the HTTP merge mode for the same vault and query -- both run the same engine. A `__anw-kind` violation exits non-zero and names the offending values on stderr; an empty match set writes nothing and exits `0`.
|
||||
|
||||
This is the materialization path: build a `CLAUDE.md`, a skill bundle, or any single file assembled from many notes, driven from a script or a one-off shell.
|
||||
|
||||
## Design notes
|
||||
|
||||
- **In place, read-only.** Anwesen reads the same directory Obsidian writes to and never writes back. The vault stays editable in Obsidian with no coordination, and there is no write API by design.
|
||||
- **Frontmatter is the index.** Filtering is server-side and first-class; clients never walk-and-parse. Note *bodies* are served verbatim but not full-text indexed.
|
||||
- **No authentication.** Anwesen trusts every request it accepts and binds `127.0.0.1` by default. Put a reverse proxy (nginx, caddy, warpgate) in front for TLS and access control. The contract is simple: if you can reach Anwesen, you can read everything it indexes.
|
||||
- **Standalone.** Anwesen is its own repository with its own release cycle, reusable by any consumer -- not a sub-package of the first thing that used it.
|
||||
|
||||
## Implementation
|
||||
|
||||
Rust (2024 edition) on Tokio: `axum`/`tower` for HTTP, `clap` for the CLI, `tracing` for logs, `serde` (with `serde_yaml` and `serde_json`) for the data model, `notify` for filesystem watching, and BLAKE3 for ETags. The frontmatter index is evaluated in memory; there is no external search engine and no on-disk index.
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed under MIT or Apache-2.0 at your option.
|
||||
BSD 3-Clause. See [LICENSE](LICENSE).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue