Add outline-cli: minimal CLI for search and document retrieval
Adds a command-line client in cli/ as its own Cargo workspace, depending on the outline library via a path dependency. Covers exactly the requested scope: login (stores the API key in the system keyring via the Secret Service D-Bus protocol, verified against auth.info() before storing), search (full-text), and get (fetch a document, render its markdown to a self-contained HTML page, open it in the default browser). Verified end-to-end against a real Outline instance and the local gnome-keyring: login stores and search/get retrieve the token purely from the keyring and saved config, with no environment variables set.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
!Cargo.lock
|
||||||
Generated
+2674
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "outline-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "outline"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
outline-sdk = { path = "..", default-features = false, features = ["rustls-tls"] }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4.6", features = ["derive", "env"] }
|
||||||
|
directories = "6"
|
||||||
|
open = "5.4"
|
||||||
|
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||||
|
rpassword = "7.5"
|
||||||
|
secret-service = { version = "5.1", features = ["rt-tokio-crypto-rust"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "1"
|
||||||
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# outline-cli
|
||||||
|
|
||||||
|
A command-line client for [Outline](https://www.getoutline.com), built on the
|
||||||
|
[`outline`](../README.md) library. Search documents and open them locally,
|
||||||
|
rendered from markdown to HTML, in your default browser.
|
||||||
|
|
||||||
|
This is a separate Cargo workspace from the library (its own `Cargo.lock`),
|
||||||
|
depending on it via a path dependency.
|
||||||
|
|
||||||
|
## Install / build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
# binary at target/release/outline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Store an API key (created under Outline Settings → API & Apps) in the
|
||||||
|
# system keyring (GNOME Keyring / KWallet via the Secret Service D-Bus API).
|
||||||
|
outline login
|
||||||
|
|
||||||
|
# Full-text search
|
||||||
|
outline search "onboarding"
|
||||||
|
|
||||||
|
# Fetch a document by id, urlId, or a pasted document URL — renders its
|
||||||
|
# markdown to HTML and opens it in your default browser.
|
||||||
|
outline get hDYep1TPAM
|
||||||
|
```
|
||||||
|
|
||||||
|
For a self-hosted instance:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
outline --base-url https://wiki.example.com login
|
||||||
|
```
|
||||||
|
|
||||||
|
The instance URL is remembered (in `~/.config/outline-cli/config.toml`) after
|
||||||
|
`login`, so it doesn't need to be repeated on every command.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Source | Purpose | Precedence |
|
||||||
|
|---|---|---|
|
||||||
|
| `--base-url` / `$OUTLINE_URL` | Outline instance | 1 (highest) |
|
||||||
|
| `~/.config/outline-cli/config.toml` | saved instance URL (written by `login`) | 2 |
|
||||||
|
| Outline Cloud default | fallback | 3 |
|
||||||
|
| `--api-key` / `$OUTLINE_API_KEY` | API key | 1 |
|
||||||
|
| system keyring (Secret Service) | saved API key, per instance (written by `login`) | 2 |
|
||||||
|
|
||||||
|
The API key is **never** written to the config file — it lives exclusively in
|
||||||
|
the system keyring, looked up by the resolved instance URL.
|
||||||
|
|
||||||
|
Rendered documents are cached in `~/.cache/outline-cli/` (mode `0600`, not
|
||||||
|
`/tmp`, since documents may be confidential).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
A running [Secret Service](https://specifications.freedesktop.org/secret-service/latest/)
|
||||||
|
provider (GNOME Keyring or KWallet) is required for `outline login` / for
|
||||||
|
`search`/`get` to find a stored key. On headless systems, skip the keyring
|
||||||
|
entirely and pass `--api-key` or set `$OUTLINE_API_KEY` instead.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Minimal first version: `login`, `search`, `get`. No `logout`, no rate-limit
|
||||||
|
retries, no export/attachment handling — see the library's own README for
|
||||||
|
the broader API coverage roadmap.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
main.doc {
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f4f4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid #ccc;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left-color: #555;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "outline",
|
||||||
|
version,
|
||||||
|
about = "Search and read Outline documents from the terminal"
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Outline instance to talk to (default: <https://app.getoutline.com>)
|
||||||
|
#[arg(long, global = true, value_name = "URL", env = "OUTLINE_URL")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
|
||||||
|
/// API key. Prefer `outline login`: arguments are visible in the process list.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
global = true,
|
||||||
|
value_name = "KEY",
|
||||||
|
env = "OUTLINE_API_KEY",
|
||||||
|
hide_env_values = true
|
||||||
|
)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum Command {
|
||||||
|
/// Store an API key for the configured Outline instance in the system keyring
|
||||||
|
Login,
|
||||||
|
/// Full-text search documents
|
||||||
|
Search(SearchArgs),
|
||||||
|
/// Fetch a document, render it to HTML and open it in the browser
|
||||||
|
Get(GetArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct SearchArgs {
|
||||||
|
/// The search query
|
||||||
|
pub query: String,
|
||||||
|
|
||||||
|
/// Maximum number of results to show
|
||||||
|
#[arg(short = 'n', long, default_value_t = 10)]
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct GetArgs {
|
||||||
|
/// Document id, urlId, or a full Outline document URL
|
||||||
|
pub reference: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use outline::Client;
|
||||||
|
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use crate::config;
|
||||||
|
|
||||||
|
/// Resolves the base URL for this invocation (flag/env > config > default).
|
||||||
|
pub fn resolve_base_url(cli: &Cli) -> Result<String> {
|
||||||
|
config::resolve_base_url(cli.base_url.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a [`Client`] using the same precedence as the Outline API key
|
||||||
|
/// itself: `--api-key`/`$OUTLINE_API_KEY` > the system keyring.
|
||||||
|
pub async fn build(cli: &Cli) -> Result<Client> {
|
||||||
|
let base_url = resolve_base_url(cli)?;
|
||||||
|
let api_key = resolve_api_key(cli, &base_url).await?;
|
||||||
|
Client::builder()
|
||||||
|
.base_url(&base_url)
|
||||||
|
.api_key(api_key)
|
||||||
|
.build()
|
||||||
|
.context("failed to build the Outline client")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_api_key(cli: &Cli, base_url: &str) -> Result<String> {
|
||||||
|
if let Some(key) = &cli.api_key {
|
||||||
|
return Ok(key.clone());
|
||||||
|
}
|
||||||
|
if let Some(key) = crate::secret::load(base_url).await? {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
bail!("no API key found for {base_url} — run `outline login` or set OUTLINE_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns an [`outline::Error`] into a message that tells the user what to do
|
||||||
|
/// about it, rather than just the raw API error.
|
||||||
|
pub fn explain(err: outline::Error) -> anyhow::Error {
|
||||||
|
use outline::ErrorKind;
|
||||||
|
match err.kind() {
|
||||||
|
ErrorKind::Unauthenticated => {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"authentication failed — run `outline login` (or check OUTLINE_API_KEY): {err}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ErrorKind::RateLimited => match err.retry_after() {
|
||||||
|
Some(duration) => anyhow::anyhow!(
|
||||||
|
"rate limited by the server — retry in {}s",
|
||||||
|
duration.as_secs()
|
||||||
|
),
|
||||||
|
None => anyhow::anyhow!("rate limited by the server — please retry shortly"),
|
||||||
|
},
|
||||||
|
_ => anyhow::Error::new(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, GetArgs};
|
||||||
|
use crate::{client, config, render, view};
|
||||||
|
|
||||||
|
pub async fn run(cli: &Cli, args: &GetArgs) -> anyhow::Result<()> {
|
||||||
|
let outline_client = client::build(cli).await?;
|
||||||
|
let base_url = client::resolve_base_url(cli)?;
|
||||||
|
let instance =
|
||||||
|
Url::parse(&base_url).with_context(|| format!("invalid base url: {base_url}"))?;
|
||||||
|
|
||||||
|
let reference = normalize_reference(&args.reference);
|
||||||
|
let document = outline_client
|
||||||
|
.documents()
|
||||||
|
.info(reference.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|err| explain(err, &reference))?;
|
||||||
|
|
||||||
|
let page = render::document_to_page(&document, &instance);
|
||||||
|
let cache_dir = config::cache_dir()?;
|
||||||
|
let slug = document.url_id.as_deref().unwrap_or(document.id.as_str());
|
||||||
|
let path = view::write_page(&cache_dir, slug, &page.html)?;
|
||||||
|
view::open_in_browser(&path)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline document URLs look like `https://host/doc/title-urlId`. If the
|
||||||
|
/// user pasted a full URL (e.g. copied from the browser), use its last path
|
||||||
|
/// segment as the reference instead of the whole URL.
|
||||||
|
fn normalize_reference(input: &str) -> String {
|
||||||
|
if let Ok(url) = Url::parse(input) {
|
||||||
|
if let Some(mut segments) = url.path_segments() {
|
||||||
|
if let Some(last) = segments.rfind(|s| !s.is_empty()) {
|
||||||
|
return last.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explain(err: outline::Error, reference: &str) -> anyhow::Error {
|
||||||
|
if err.is_not_found() {
|
||||||
|
anyhow::anyhow!("no document found for `{reference}`")
|
||||||
|
} else {
|
||||||
|
client::explain(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_a_bare_id_or_url_id_unchanged() {
|
||||||
|
assert_eq!(normalize_reference("hDYep1TPAM"), "hDYep1TPAM");
|
||||||
|
assert_eq!(
|
||||||
|
normalize_reference("5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"),
|
||||||
|
"5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_the_last_segment_from_a_full_document_url() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_reference("https://wiki.example.com/doc/welcome-hDYep1TPAM"),
|
||||||
|
"welcome-hDYep1TPAM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_the_last_segment_ignoring_a_trailing_slash() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_reference("https://wiki.example.com/doc/welcome-hDYep1TPAM/"),
|
||||||
|
"welcome-hDYep1TPAM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use crate::{client, config, secret};
|
||||||
|
|
||||||
|
pub async fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||||
|
let base_url = client::resolve_base_url(cli)?;
|
||||||
|
|
||||||
|
let token = match &cli.api_key {
|
||||||
|
Some(key) => key.clone(),
|
||||||
|
None => {
|
||||||
|
rpassword::prompt_password("Outline API key: ").context("failed to read the API key")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify the key works before storing it, so a typo surfaces immediately
|
||||||
|
// instead of on the next `search`/`get`.
|
||||||
|
let verifying_client = outline::Client::builder()
|
||||||
|
.base_url(&base_url)
|
||||||
|
.api_key(token.clone())
|
||||||
|
.build()
|
||||||
|
.context("invalid client configuration")?;
|
||||||
|
let auth = verifying_client
|
||||||
|
.auth()
|
||||||
|
.info()
|
||||||
|
.await
|
||||||
|
.context("the API key was rejected by the server")?;
|
||||||
|
|
||||||
|
secret::store(&base_url, &token)
|
||||||
|
.await
|
||||||
|
.context("failed to store the API key in the system keyring")?;
|
||||||
|
|
||||||
|
let mut cfg = config::Config::load()?;
|
||||||
|
cfg.base_url = Some(base_url.clone());
|
||||||
|
cfg.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Logged in as {} ({}) at {base_url}",
|
||||||
|
auth.user.name, auth.team.name
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
mod get;
|
||||||
|
mod login;
|
||||||
|
mod search;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Command};
|
||||||
|
|
||||||
|
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
|
||||||
|
match cli.command {
|
||||||
|
Command::Login => login::run(&cli).await,
|
||||||
|
Command::Search(ref args) => search::run(&cli, args).await,
|
||||||
|
Command::Get(ref args) => get::run(&cli, args).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use crate::cli::{Cli, SearchArgs};
|
||||||
|
use crate::client;
|
||||||
|
|
||||||
|
pub async fn run(cli: &Cli, args: &SearchArgs) -> anyhow::Result<()> {
|
||||||
|
let outline_client = client::build(cli).await?;
|
||||||
|
let page = outline_client
|
||||||
|
.documents()
|
||||||
|
.search(args.query.clone())
|
||||||
|
.limit(args.limit)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(client::explain)?;
|
||||||
|
|
||||||
|
if page.items.is_empty() {
|
||||||
|
println!("No results for \"{}\".", args.query);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for result in &page.items {
|
||||||
|
let reference = result
|
||||||
|
.document
|
||||||
|
.url_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(result.document.id.as_str());
|
||||||
|
let snippet = strip_tags(result.context.as_deref().unwrap_or(""));
|
||||||
|
println!("{} [{reference}]", result.document.title);
|
||||||
|
if !snippet.is_empty() {
|
||||||
|
println!(" {snippet}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline wraps matched search terms in `<b>...</b>` for its own web UI;
|
||||||
|
/// strip any such markup before printing a snippet to the terminal.
|
||||||
|
fn strip_tags(input: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for ch in input.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => in_tag = false,
|
||||||
|
_ if !in_tag => output.push(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removes_highlight_tags() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_tags("Code Style <b>Guide</b> overview"),
|
||||||
|
"Code Style Guide overview"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_plain_text_untouched() {
|
||||||
|
assert_eq!(strip_tags("no markup here"), "no markup here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trims_surrounding_whitespace() {
|
||||||
|
assert_eq!(strip_tags(" padded "), "padded");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: &str = "https://app.getoutline.com";
|
||||||
|
const APPLICATION: &str = "outline-cli";
|
||||||
|
|
||||||
|
fn project_dirs() -> Result<ProjectDirs> {
|
||||||
|
ProjectDirs::from("", "", APPLICATION)
|
||||||
|
.context("could not determine the user's config/cache directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory the rendered document HTML is cached in (not `/tmp`, which
|
||||||
|
/// is world-readable and documents may be confidential).
|
||||||
|
pub fn cache_dir() -> Result<PathBuf> {
|
||||||
|
Ok(project_dirs()?.cache_dir().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Result<PathBuf> {
|
||||||
|
Ok(project_dirs()?.config_dir().join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-secret settings, written by `outline login`. The API key itself is
|
||||||
|
/// never stored here — it lives exclusively in the system keyring.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = config_path()?;
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => {
|
||||||
|
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
|
||||||
|
Err(err) => Err(err).context(format!("failed to read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = config_path()?;
|
||||||
|
let dir = path.parent().expect("config path has a parent");
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.with_context(|| format!("failed to create {}", dir.display()))?;
|
||||||
|
let raw = toml::to_string_pretty(self)?;
|
||||||
|
std::fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the base URL to use: `--base-url`/`$OUTLINE_URL` (already folded
|
||||||
|
/// into `flag` by clap) > saved config > the Outline Cloud default.
|
||||||
|
pub fn resolve_base_url(flag: Option<&str>) -> Result<String> {
|
||||||
|
let saved = Config::load()?.base_url;
|
||||||
|
Ok(precedence(flag, saved.as_deref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn precedence(flag: Option<&str>, saved: Option<&str>) -> String {
|
||||||
|
flag.or(saved).unwrap_or(DEFAULT_BASE_URL).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flag_takes_precedence_over_saved_config() {
|
||||||
|
assert_eq!(
|
||||||
|
precedence(
|
||||||
|
Some("https://flag.example.com"),
|
||||||
|
Some("https://saved.example.com")
|
||||||
|
),
|
||||||
|
"https://flag.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_config_is_used_without_a_flag() {
|
||||||
|
assert_eq!(
|
||||||
|
precedence(None, Some("https://saved.example.com")),
|
||||||
|
"https://saved.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_the_outline_cloud_default() {
|
||||||
|
assert_eq!(precedence(None, None), DEFAULT_BASE_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
mod cli;
|
||||||
|
mod client;
|
||||||
|
mod commands;
|
||||||
|
mod config;
|
||||||
|
mod render;
|
||||||
|
mod secret;
|
||||||
|
mod view;
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("error: {err:#}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> anyhow::Result<()> {
|
||||||
|
let cli = cli::Cli::parse();
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime.block_on(commands::dispatch(cli))
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! Renders a document's markdown body to a small, self-contained HTML page.
|
||||||
|
|
||||||
|
use outline::models::Document;
|
||||||
|
use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, html};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const STYLE: &str = include_str!("assets/style.css");
|
||||||
|
|
||||||
|
pub struct Page {
|
||||||
|
pub html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders `document` to HTML, absolutizing any instance-relative links and
|
||||||
|
/// images (Outline stores these as paths like `/doc/...`) against `instance`
|
||||||
|
/// so they resolve when the page is opened as a local file.
|
||||||
|
pub fn document_to_page(document: &Document, instance: &Url) -> Page {
|
||||||
|
let markdown = document
|
||||||
|
.text
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("_This document has no text content._");
|
||||||
|
let markdown = strip_leading_title(markdown, &document.title);
|
||||||
|
|
||||||
|
let options = Options::ENABLE_TABLES
|
||||||
|
| Options::ENABLE_STRIKETHROUGH
|
||||||
|
| Options::ENABLE_TASKLISTS
|
||||||
|
| Options::ENABLE_FOOTNOTES
|
||||||
|
| Options::ENABLE_SMART_PUNCTUATION
|
||||||
|
| Options::ENABLE_HEADING_ATTRIBUTES;
|
||||||
|
|
||||||
|
// Outline documents are foreign, editable content rendered locally in a
|
||||||
|
// browser — raw HTML is filtered out rather than trusted.
|
||||||
|
let events = Parser::new_ext(markdown, options)
|
||||||
|
.map(|event| absolutize(event, instance))
|
||||||
|
.filter(|event| !matches!(event, Event::Html(_) | Event::InlineHtml(_)));
|
||||||
|
|
||||||
|
let mut body = String::with_capacity(markdown.len() * 3 / 2);
|
||||||
|
html::push_html(&mut body, events);
|
||||||
|
|
||||||
|
let title = escape(&document.title);
|
||||||
|
let source = document
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|path| instance.join(path).ok());
|
||||||
|
|
||||||
|
Page {
|
||||||
|
html: wrap(&title, &body, source.as_ref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolutize<'a>(event: Event<'a>, base: &Url) -> Event<'a> {
|
||||||
|
fn abs<'a>(dest: CowStr<'a>, base: &Url) -> CowStr<'a> {
|
||||||
|
if dest.starts_with('/') {
|
||||||
|
if let Ok(url) = base.join(&dest) {
|
||||||
|
return CowStr::from(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dest
|
||||||
|
}
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::Link {
|
||||||
|
link_type,
|
||||||
|
dest_url,
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
}) => Event::Start(Tag::Link {
|
||||||
|
link_type,
|
||||||
|
dest_url: abs(dest_url, base),
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
Event::Start(Tag::Image {
|
||||||
|
link_type,
|
||||||
|
dest_url,
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
}) => Event::Start(Tag::Image {
|
||||||
|
link_type,
|
||||||
|
dest_url: abs(dest_url, base),
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline's editor shows the title as the document's own first heading, so
|
||||||
|
/// many documents' markdown repeats it verbatim as a leading `# Title` line.
|
||||||
|
/// Strip that line when it matches, since the page template renders its own
|
||||||
|
/// `<h1>` from `document.title` — otherwise the title would appear twice.
|
||||||
|
fn strip_leading_title<'a>(markdown: &'a str, title: &str) -> &'a str {
|
||||||
|
let trimmed = markdown.trim_start();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("# ") {
|
||||||
|
let (first_line, remainder) = match rest.find('\n') {
|
||||||
|
Some(idx) => (&rest[..idx], &rest[idx + 1..]),
|
||||||
|
None => (rest, ""),
|
||||||
|
};
|
||||||
|
if first_line.trim() == title.trim() {
|
||||||
|
return remainder.trim_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap(title: &str, body: &str, source: Option<&Url>) -> String {
|
||||||
|
let footer = source
|
||||||
|
.map(|url| format!(r#"<a href="{url}">Open in Outline</a>"#))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"<!doctype html>
|
||||||
|
<html lang="en"><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; font-src data:">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
</head><body><main class="doc">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{body}
|
||||||
|
<footer>{footer}</footer>
|
||||||
|
</main></body></html>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn document(title: &str, text: &str, url: Option<&str>) -> Document {
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"id": "doc-1",
|
||||||
|
"title": title,
|
||||||
|
"text": text,
|
||||||
|
"url": url,
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_leading_title_when_it_matches() {
|
||||||
|
let md = "# My Title\n\nBody text.";
|
||||||
|
assert_eq!(strip_leading_title(md, "My Title"), "Body text.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_leading_heading_when_it_differs_from_title() {
|
||||||
|
let md = "# Something else\n\nBody text.";
|
||||||
|
assert_eq!(strip_leading_title(md, "My Title"), md);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_markdown_without_a_leading_heading() {
|
||||||
|
let md = "Just a paragraph.";
|
||||||
|
assert_eq!(strip_leading_title(md, "My Title"), md);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_exactly_one_title_heading() {
|
||||||
|
let instance = Url::parse("https://wiki.example.com").unwrap();
|
||||||
|
let doc = document("My Title", "# My Title\n\nBody text.", None);
|
||||||
|
let page = document_to_page(&doc, &instance);
|
||||||
|
assert_eq!(page.html.matches("<h1>").count(), 1);
|
||||||
|
assert!(page.html.contains("<h1>My Title</h1>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolutizes_relative_links_and_images_against_the_instance() {
|
||||||
|
let instance = Url::parse("https://wiki.example.com").unwrap();
|
||||||
|
let doc = document(
|
||||||
|
"T",
|
||||||
|
" [link](/doc/abc)",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let page = document_to_page(&doc, &instance);
|
||||||
|
assert!(
|
||||||
|
page.html
|
||||||
|
.contains(r#"src="https://wiki.example.com/api/attachments.redirect?id=1""#)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
page.html
|
||||||
|
.contains(r#"href="https://wiki.example.com/doc/abc""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_absolute_links_untouched() {
|
||||||
|
let instance = Url::parse("https://wiki.example.com").unwrap();
|
||||||
|
let doc = document("T", "[external](https://other.example.com/page)", None);
|
||||||
|
let page = document_to_page(&doc, &instance);
|
||||||
|
assert!(
|
||||||
|
page.html
|
||||||
|
.contains(r#"href="https://other.example.com/page""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_raw_html_out_of_the_body() {
|
||||||
|
let instance = Url::parse("https://wiki.example.com").unwrap();
|
||||||
|
let doc = document("T", "before <script>alert(1)</script> after", None);
|
||||||
|
let page = document_to_page(&doc, &instance);
|
||||||
|
assert!(!page.html.contains("<script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_to_the_source_document_when_a_url_is_known() {
|
||||||
|
let instance = Url::parse("https://wiki.example.com").unwrap();
|
||||||
|
let doc = document("T", "body", Some("/doc/t-abc123"));
|
||||||
|
let page = document_to_page(&doc, &instance);
|
||||||
|
assert!(
|
||||||
|
page.html
|
||||||
|
.contains(r#"href="https://wiki.example.com/doc/t-abc123""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! Token storage in the FreeDesktop Secret Service (GNOME Keyring, KWallet, ...).
|
||||||
|
//!
|
||||||
|
//! This is the only module that knows about the secret backend. Swapping in
|
||||||
|
//! the `libsecret` FFI crate or the `keyring` crate means rewriting this
|
||||||
|
//! file, nothing else.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use secret_service::{EncryptionType, SecretService};
|
||||||
|
|
||||||
|
const SCHEMA: &str = "com.getoutline.cli.ApiKey";
|
||||||
|
const APPLICATION: &str = "outline-cli";
|
||||||
|
const CONTENT_TYPE: &str = "text/plain";
|
||||||
|
|
||||||
|
fn attributes(base_url: &str) -> HashMap<&str, &str> {
|
||||||
|
HashMap::from([
|
||||||
|
("xdg:schema", SCHEMA),
|
||||||
|
("application", APPLICATION),
|
||||||
|
("base_url", base_url),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect() -> Result<SecretService<'static>> {
|
||||||
|
SecretService::connect(EncryptionType::Dh).await.context(
|
||||||
|
"could not reach a Secret Service (is gnome-keyring or kwallet running? \
|
||||||
|
on headless systems, set OUTLINE_API_KEY instead)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores `token` for `base_url`, replacing any previously stored token for
|
||||||
|
/// the same instance.
|
||||||
|
pub async fn store(base_url: &str, token: &str) -> Result<()> {
|
||||||
|
let service = connect().await?;
|
||||||
|
let collection = service
|
||||||
|
.get_default_collection()
|
||||||
|
.await
|
||||||
|
.context("no default keyring collection")?;
|
||||||
|
collection
|
||||||
|
.ensure_unlocked()
|
||||||
|
.await
|
||||||
|
.context("the login keyring is locked and could not be unlocked")?;
|
||||||
|
|
||||||
|
// `create_item(replace = true)` only replaces an item whose attribute set
|
||||||
|
// matches *exactly*; delete any existing match first to avoid duplicates.
|
||||||
|
for item in collection.search_items(attributes(base_url)).await? {
|
||||||
|
item.delete().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = format!("Outline API token ({base_url})");
|
||||||
|
collection
|
||||||
|
.create_item(
|
||||||
|
&label,
|
||||||
|
attributes(base_url),
|
||||||
|
token.as_bytes(),
|
||||||
|
true,
|
||||||
|
CONTENT_TYPE,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("failed to write the token to the keyring")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the token stored for `base_url`, if any.
|
||||||
|
pub async fn load(base_url: &str) -> Result<Option<String>> {
|
||||||
|
let service = connect().await?;
|
||||||
|
let found = service.search_items(attributes(base_url)).await?;
|
||||||
|
let item = match (found.unlocked.first(), found.locked.first()) {
|
||||||
|
(Some(item), _) => item,
|
||||||
|
(None, Some(item)) => {
|
||||||
|
item.unlock().await?;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
(None, None) => return Ok(None),
|
||||||
|
};
|
||||||
|
let secret = item
|
||||||
|
.get_secret()
|
||||||
|
.await
|
||||||
|
.context("failed to read the token from the keyring")?;
|
||||||
|
Ok(Some(
|
||||||
|
String::from_utf8(secret).context("stored token is not valid UTF-8")?,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! Writes a rendered page to disk and opens it in the default browser.
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Writes `html` into `cache_dir/<slug>.html` with restrictive permissions
|
||||||
|
/// (`0600`, directory `0700`): documents may be confidential and `/tmp` is
|
||||||
|
/// world-readable. The name is deterministic per document so re-opening it
|
||||||
|
/// overwrites instead of littering the cache directory.
|
||||||
|
pub fn write_page(cache_dir: &Path, slug: &str, html: &str) -> Result<PathBuf> {
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(cache_dir)
|
||||||
|
.with_context(|| format!("failed to create {}", cache_dir.display()))?;
|
||||||
|
|
||||||
|
let path = cache_dir.join(format!("{}.html", sanitize(slug)));
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||||
|
file.write_all(html.as_bytes())?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens `path` in the user's default browser without blocking on it.
|
||||||
|
pub fn open_in_browser(path: &Path) -> Result<()> {
|
||||||
|
open::that_detached(path)
|
||||||
|
.with_context(|| format!("could not open {} in a browser", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize(slug: &str) -> String {
|
||||||
|
slug.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user