mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
docs, help, checks: the README and the help say what the app does
README: the API table lists every route, twelve of which were missing; an SFTP section says how to turn the mount on and register a key; the keys table and the command list cover what was only in the code (digits, Tab, w, f, Alt-h/l, Ctrl-D/U, m/p, :today, :edit, :next and the rest, :search!, :set clipboard, :logout!). Three claims that were wrong are fixed: there is no board button above the list, R does not rename a space, and Esc/q close the document. Help gains the agenda keys and the board's gg/G and r. :t no longer means :today, which read as the agenda's key. make check runs cargo fmt --check, after the one formatting commit before this, which .git-blame-ignore-revs names (git config blame.ignoreRevsFile .git-blame-ignore-revs). Cargo.toml states rust-version 1.96, the floor the Proton crates set, and the build is checked on it. make build installs the frontend from the lockfile. A test reads the route table out of routes/mod.rs and asks every route without a cookie: all but the login, the health check and the share links must answer 401, including any route added later.
This commit is contained in:
1 parent
e0f0ebb646
commit
9749c500cf
9 files changed
+229
-40
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
e0f0ebb64693dcdd5867d87854ea1506d0ff8bce
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
name = "narl-workspace"
|
name = "narl-workspace"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
# The Proton crates set the floor.
|
||||||
|
rust-version = "1.96"
|
||||||
description = "A terminal-themed personal diary for the browser"
|
description = "A terminal-themed personal diary for the browser"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ dev-web:
|
|||||||
|
|
||||||
# Everything, as one self-contained release binary at target/release/narl-workspace.
|
# Everything, as one self-contained release binary at target/release/narl-workspace.
|
||||||
build:
|
build:
|
||||||
cd web && bun install && bun run build
|
cd web && bun install --frozen-lockfile && bun run build
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./target/release/narl-workspace
|
./target/release/narl-workspace
|
||||||
|
|
||||||
check:
|
check:
|
||||||
|
cargo fmt --check
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo test
|
cargo test
|
||||||
cd web && bun run check
|
cd web && bun run check
|
||||||
|
|||||||
@@ -63,18 +63,26 @@ it; in the document pane it is that document. In the browse pane:
|
|||||||
|
|
||||||
| key | action |
|
| key | action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `j` / `k` | move down / up |
|
| `1` … `9`, `0` | switch to that space |
|
||||||
| `gg` / `G` | first / last entry |
|
| `Shift-1` … `9` | send the marked or selected node to that space |
|
||||||
|
| `Tab`, `Shift-Tab` | next / previous space |
|
||||||
|
| `w` | overview of every space; `hjkl` then `Enter` |
|
||||||
|
| `f` | fullscreen the focused pane |
|
||||||
|
| `Alt-h` / `Alt-l` | focus the list / the document, from the editor too |
|
||||||
|
| `j` / `k` | move down / up, or scroll the document when it has focus |
|
||||||
|
| `gg` / `G` | first / last entry, or top / bottom of the document |
|
||||||
|
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
|
||||||
| `Enter`, `l` | open the highlighted entry |
|
| `Enter`, `l` | open the highlighted entry |
|
||||||
| `o` | a new document here, straight into insert mode |
|
| `o` | a new document here, straight into insert mode |
|
||||||
| `O` | a new folder here |
|
| `O` | a new folder here |
|
||||||
| `i`, `a` | edit the open entry |
|
| `i`, `a` | edit the open entry |
|
||||||
| `R` | rename what is highlighted — document, folder or space |
|
| `R` | rename what is highlighted — a document, a folder or a file (`:name!` renames the space) |
|
||||||
|
| `m` / `p` | pick a node up, then drop it into the folder you walk to |
|
||||||
| `b` | the board of this space |
|
| `b` | the board of this space |
|
||||||
| `t` | the agenda: what is late, due this week, or in hand, on every board |
|
| `t` | the agenda: what is late, due this week, or in hand, on every board |
|
||||||
| `T` | today's diary entry, started if there is none |
|
| `T` | today's diary entry, started if there is none |
|
||||||
| `c` | capture a card into this space's inbox without leaving where you are |
|
| `c` | capture a card into this space's inbox without leaving where you are |
|
||||||
| `Esc`, `q` | back out to the document |
|
| `Esc`, `q` | close the document |
|
||||||
| `h` | up one level in the tree |
|
| `h` | up one level in the tree |
|
||||||
| `/` | full-text search in this space (`n` clears it) |
|
| `/` | full-text search in this space (`n` clears it) |
|
||||||
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match |
|
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match |
|
||||||
@@ -88,7 +96,13 @@ Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
|
|||||||
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
|
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
|
||||||
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
|
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
|
||||||
`:upload`, `:media`, `:search`, `:backup` and
|
`:upload`, `:media`, `:search`, `:backup` and
|
||||||
`:set novim`. The tree has its own: `:space [name]` switches (`:space! <name>`
|
`:set novim`. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`)
|
||||||
|
opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the
|
||||||
|
list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
|
||||||
|
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
|
||||||
|
and lines, `:search!` searches every space, `:set clipboard` / `:set
|
||||||
|
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the
|
||||||
|
session and `:logout!` every session. The tree has its own: `:space [name]` switches (`:space! <name>`
|
||||||
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
|
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
|
||||||
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
|
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
|
||||||
selected, and `:import` uploads a folder into the one being browsed
|
selected, and `:import` uploads a folder into the one being browsed
|
||||||
@@ -108,12 +122,35 @@ has today's entry, an upload into the folder, a search across every space,
|
|||||||
and on the board the card moves, renames and deletes; the agenda has a done
|
and on the board the card moves, renames and deletes; the agenda has a done
|
||||||
button per card.
|
button per card.
|
||||||
|
|
||||||
|
## The SFTP mount
|
||||||
|
|
||||||
|
The workspace can be mounted as a filesystem over SFTP, so an editor or a file
|
||||||
|
manager on another machine works on it directly. It is off until
|
||||||
|
`WORKSPACE_SFTP_BIND` is set (`127.0.0.1:2222`, or `0.0.0.0:2222` inside a
|
||||||
|
container, with the port published in `compose.override.yml`). Only public
|
||||||
|
keys get in, and only the ones registered from inside the workspace:
|
||||||
|
|
||||||
|
```text
|
||||||
|
:sshkey add laptop ssh-ed25519 AAAA… you@laptop
|
||||||
|
:sshkey lists them, with their fingerprints
|
||||||
|
:sshkey rm laptop and this one is out, mounted or not
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sshfs -p 2222 me@your-host:/ ~/workspace # the user name is ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
The root holds the spaces, `mkdir` there makes one, and anywhere else a
|
||||||
|
folder. A document is `<slug>.md`, read as `# Title` followed by its body, and a
|
||||||
|
leading heading written back renames it. The server's host key is made on first
|
||||||
|
start as `sftp_host_key` in the data directory.
|
||||||
|
|
||||||
## Boards
|
## Boards
|
||||||
|
|
||||||
`b`, the `board` button above the list, or `:board` swaps the tree for the board
|
`b`, the board door on the rail, or `:board` swaps the tree for the board
|
||||||
of the space being browsed, and swaps it back. Every space has one except the
|
of the space being browsed, and swaps it back. Every space has one except the
|
||||||
diary — a journal has no backlog — and `:set board` / `:set noboard` changes
|
diary — a journal has no backlog — and `:set board` / `:set noboard` changes
|
||||||
that for the space you are in (the `+board` button above the list does the
|
that for the space you are in (the dimmed board door on the rail does the
|
||||||
same). A new board starts with `backlog`, `doing` and `done`, and the columns mean
|
same). A new board starts with `backlog`, `doing` and `done`, and the columns mean
|
||||||
something by where they stand rather than by their names: the first is the
|
something by where they stand rather than by their names: the first is the
|
||||||
inbox new cards land in, the last is done. A card is finished exactly when it
|
inbox new cards land in, the last is done. A card is finished exactly when it
|
||||||
@@ -492,7 +529,14 @@ sealed or not.
|
|||||||
| `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
| `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
||||||
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
||||||
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
|
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
|
||||||
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
|
| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns |
|
||||||
|
| `GET` | `/api/spaces/overview` | every space with what was last written in it |
|
||||||
|
| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) |
|
||||||
|
| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out |
|
||||||
|
| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it |
|
||||||
|
| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault |
|
||||||
|
| `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one |
|
||||||
|
| `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts |
|
||||||
| `GET` | `/api/share/{token}/{key}` | public: read a shared document |
|
| `GET` | `/api/share/{token}/{key}` | public: read a shared document |
|
||||||
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document |
|
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document |
|
||||||
|
|
||||||
|
|||||||
+2
-30
@@ -219,38 +219,10 @@ mod tests {
|
|||||||
use axum::http::{HeaderMap, HeaderValue};
|
use axum::http::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
use super::{credentials_match, is_same_origin, issue_token, verify_token};
|
use super::{credentials_match, is_same_origin, issue_token, verify_token};
|
||||||
use crate::config::{BackupConfig, Config, MailConfig, SftpConfig};
|
use crate::config::Config;
|
||||||
|
|
||||||
fn config(session_days: i64) -> Config {
|
fn config(session_days: i64) -> Config {
|
||||||
Config {
|
Config::for_tests(session_days)
|
||||||
username: "narl".into(),
|
|
||||||
password: "hunter2".into(),
|
|
||||||
secret: b"a-secret-of-at-least-16-bytes".to_vec(),
|
|
||||||
bind: "127.0.0.1:0".parse().unwrap(),
|
|
||||||
data_dir: ".".into(),
|
|
||||||
session_days,
|
|
||||||
max_upload_bytes: 1,
|
|
||||||
secure_cookie: false,
|
|
||||||
vault_idle: std::time::Duration::from_secs(60),
|
|
||||||
backup: BackupConfig {
|
|
||||||
device_name: "narl-diary".into(),
|
|
||||||
interval: None,
|
|
||||||
debounce: std::time::Duration::from_secs(0),
|
|
||||||
prune: false,
|
|
||||||
session_key_file: None,
|
|
||||||
},
|
|
||||||
mail: MailConfig {
|
|
||||||
url: None,
|
|
||||||
from: String::new(),
|
|
||||||
to: String::new(),
|
|
||||||
timezone: chrono_tz::UTC,
|
|
||||||
reminder_at: None,
|
|
||||||
card_due_at: None,
|
|
||||||
digest_at: None,
|
|
||||||
kinds: Vec::new(),
|
|
||||||
},
|
|
||||||
sftp: SftpConfig { bind: None },
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -311,6 +311,42 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Config {
|
||||||
|
/// A configuration with nothing switched on, for tests.
|
||||||
|
pub fn for_tests(session_days: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
username: "narl".into(),
|
||||||
|
password: "hunter2".into(),
|
||||||
|
secret: b"a-secret-of-at-least-16-bytes".to_vec(),
|
||||||
|
bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
|
data_dir: ".".into(),
|
||||||
|
session_days,
|
||||||
|
max_upload_bytes: 1,
|
||||||
|
secure_cookie: false,
|
||||||
|
vault_idle: std::time::Duration::from_secs(60),
|
||||||
|
backup: BackupConfig {
|
||||||
|
device_name: "narl-diary".into(),
|
||||||
|
interval: None,
|
||||||
|
debounce: std::time::Duration::from_secs(0),
|
||||||
|
prune: false,
|
||||||
|
session_key_file: None,
|
||||||
|
},
|
||||||
|
mail: MailConfig {
|
||||||
|
url: None,
|
||||||
|
from: String::new(),
|
||||||
|
to: String::new(),
|
||||||
|
timezone: chrono_tz::UTC,
|
||||||
|
reminder_at: None,
|
||||||
|
card_due_at: None,
|
||||||
|
digest_at: None,
|
||||||
|
kinds: Vec::new(),
|
||||||
|
},
|
||||||
|
sftp: SftpConfig { bind: None },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MailConfig {
|
impl MailConfig {
|
||||||
fn from_env() -> Result<Self> {
|
fn from_env() -> Result<Self> {
|
||||||
let url = var("SMTP_URL");
|
let url = var("SMTP_URL");
|
||||||
|
|||||||
@@ -89,3 +89,117 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
.route("/ssh-keys/{id}", delete(ssh_keys::remove))
|
.route("/ssh-keys/{id}", delete(ssh_keys::remove))
|
||||||
.layer(DefaultBodyLimit::max(JSON_LIMIT))
|
.layer(DefaultBodyLimit::max(JSON_LIMIT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::{atomic::AtomicI64, Arc};
|
||||||
|
|
||||||
|
use axum::{body::Body, http::Request};
|
||||||
|
use regex::Regex;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use crate::{backup::Backup, config::Config, notify::Notifier, state::AppState, vault::Vault};
|
||||||
|
|
||||||
|
/// The routes that answer someone who has not logged in: to log in, to
|
||||||
|
/// say the server is up, and the share links, which carry their own key.
|
||||||
|
const PUBLIC: &[&str] = &[
|
||||||
|
"/health",
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
"/share/{token}/{key}",
|
||||||
|
"/share/{token}/{key}/media/{id}",
|
||||||
|
];
|
||||||
|
|
||||||
|
async fn state() -> AppState {
|
||||||
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
||||||
|
let config = Arc::new(Config::for_tests(30));
|
||||||
|
AppState {
|
||||||
|
vault: Vault::new(db.clone(), config.vault_idle),
|
||||||
|
backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()),
|
||||||
|
notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(),
|
||||||
|
login_throttle: Arc::default(),
|
||||||
|
vault_throttle: Arc::default(),
|
||||||
|
epoch: Arc::new(AtomicI64::new(0)),
|
||||||
|
config,
|
||||||
|
db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every route this file registers, read from the file itself, so a
|
||||||
|
/// route added later is covered without anyone remembering to.
|
||||||
|
fn routes() -> Vec<(String, String)> {
|
||||||
|
let path = Regex::new(r#"^\s*"([^"]+)""#).unwrap();
|
||||||
|
let method = Regex::new(r"\b(get|post|put|delete)\(").unwrap();
|
||||||
|
let source = include_str!("mod.rs");
|
||||||
|
let source = &source[..source.find("#[cfg(test)]").unwrap()];
|
||||||
|
let mut found = Vec::new();
|
||||||
|
// `.route(...)` up to its matching bracket, however rustfmt wrapped it.
|
||||||
|
for (at, _) in source.match_indices(".route(") {
|
||||||
|
let rest = &source[at + ".route(".len()..];
|
||||||
|
let mut depth = 1;
|
||||||
|
let close = rest
|
||||||
|
.char_indices()
|
||||||
|
.find(|&(_, c)| {
|
||||||
|
depth += match c {
|
||||||
|
'(' => 1,
|
||||||
|
')' => -1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
depth == 0
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap();
|
||||||
|
let call = &rest[..close];
|
||||||
|
let Some(caps) = path.captures(call) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for m in method.captures_iter(call) {
|
||||||
|
found.push((m[1].to_uppercase(), caps[1].to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_route_but_the_public_ones_wants_a_session() {
|
||||||
|
let routes = routes();
|
||||||
|
assert!(
|
||||||
|
routes.len() > 40,
|
||||||
|
"the route table was not read: {routes:?}"
|
||||||
|
);
|
||||||
|
let app = super::api_router(1024 * 1024).with_state(state().await);
|
||||||
|
|
||||||
|
for (method, path) in routes {
|
||||||
|
let uri = path
|
||||||
|
.replace("{id}", "1")
|
||||||
|
.replace("{token}", "t")
|
||||||
|
.replace("{key}", "k");
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(method.as_str())
|
||||||
|
.uri(&uri)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from("{}"))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if PUBLIC.contains(&path.as_str()) {
|
||||||
|
assert_ne!(status, 401, "{method} {path} is public and answered 401");
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
status, 401,
|
||||||
|
"{method} {path} answered {status} without a session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
['rule', 'the first list is the inbox, the last list is done'],
|
['rule', 'the first list is the inbox, the last list is done'],
|
||||||
['h / l', 'to the list left / right'],
|
['h / l', 'to the list left / right'],
|
||||||
['j / k', 'down / up the cards'],
|
['j / k', 'down / up the cards'],
|
||||||
|
['gg / G', 'first / last card of the list'],
|
||||||
['J / K', 'move the card down / up its list'],
|
['J / K', 'move the card down / up its list'],
|
||||||
['H / L', 'move the card to the list left / right'],
|
['H / L', 'move the card to the list left / right'],
|
||||||
['Space', 'into the done list, or back out to the inbox'],
|
['Space', 'into the done list, or back out to the inbox'],
|
||||||
@@ -49,10 +50,19 @@
|
|||||||
['D', 'due date: fri, tm, +3d, 10-31 …'],
|
['D', 'due date: fri, tm, +3d, 10-31 …'],
|
||||||
['R', 'rename the card, or the list when it is empty'],
|
['R', 'rename the card, or the list when it is empty'],
|
||||||
['z', 'show done cards older than a week'],
|
['z', 'show done cards older than a week'],
|
||||||
|
['r', 'read the board again, for changes made elsewhere'],
|
||||||
['x · dd', 'delete the card (asks y/N)'],
|
['x · dd', 'delete the card (asks y/N)'],
|
||||||
['q · Esc', 'back to the tree'],
|
['q · Esc', 'back to the tree'],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const agendaKeys: [string, string][] = [
|
||||||
|
['j / k', 'down / up the cards'],
|
||||||
|
['Enter', 'to the card, on its own board'],
|
||||||
|
['Space', 'done, where it stands'],
|
||||||
|
['T', "today's diary entry"],
|
||||||
|
['t · q · Esc', 'close the agenda'],
|
||||||
|
]
|
||||||
|
|
||||||
const titles: Record<Group, string> = {
|
const titles: Record<Group, string> = {
|
||||||
tree: 'spaces & folders',
|
tree: 'spaces & folders',
|
||||||
board: 'boards',
|
board: 'boards',
|
||||||
@@ -99,6 +109,16 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="keys">
|
||||||
|
<h2>agenda keys</h2>
|
||||||
|
<dl>
|
||||||
|
{#each agendaKeys as [key, description] (key)}
|
||||||
|
<dt>{key}</dt>
|
||||||
|
<dd>{description}</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
{#each grouped as [group, specs] (group)}
|
{#each grouped as [group, specs] (group)}
|
||||||
<section>
|
<section>
|
||||||
<h2>{titles[group]}</h2>
|
<h2>{titles[group]}</h2>
|
||||||
|
|||||||
@@ -387,7 +387,6 @@ export const commands: CommandSpec[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'today',
|
name: 'today',
|
||||||
aliases: ['t'],
|
|
||||||
group: 'documents',
|
group: 'documents',
|
||||||
help: "open today's diary entry, starting one if there is none yet",
|
help: "open today's diary entry, starting one if there is none yet",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user