board: a card turns over to a checklist, labels and a description

Enter on a card floats a sheet over the board: its labels, a markdown
description written in the vim editor (:w writes it to the card), and a
checklist walked with j/k, ticked with Space, grown with o. Labels take
their colour from their name. A repeating card comes back with its
checklist reopened.

Code in the editor and in rendered fences is coloured from nvim's
yorha.lua, one class per highlight group, for every language
@codemirror/language-data knows; the languages load on first use.
This commit is contained in:
nvrl committed 2026-10-21 23:53:24 +02:00
1 parent 1fd1aa1eb7
commit 3c4c383f72
24 files changed
+1274 -127

No files matched your search

+10 -3
View File
@@ -29,7 +29,8 @@ Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki).
on a phone. Math by KaTeX, `$x^2$` and `$$…$$`, and ` ```typst ` blocks on a phone. Math by KaTeX, `$x^2$` and `$$…$$`, and ` ```typst ` blocks
typeset by Typst on the server. `[[Title]]` links that are typeset by Typst on the server. `[[Title]]` links that are
rewritten when what they point at is renamed or moved, and backlinks. rewritten when what they point at is renamed or moved, and backlinks.
- **Plan.** A kanban board per space, due dates said the way you say them - **Plan.** A kanban board per space, cards with a checklist, labels and a
markdown description, due dates said the way you say them
(`fri`, `+3d`), cards that come back once done, and an agenda of what is late, (`fri`, `+3d`), cards that come back once done, and an agenda of what is late,
due or in hand on every board. due or in hand on every board.
- **Remember.** A diary with a calendar, templates, the entries from this day - **Remember.** A diary with a calendar, templates, the entries from this day
@@ -266,7 +267,7 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor.
| `j` / `k` | down / up the cards of that list | | `j` / `k` | down / up the cards of that 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 on the left / right | | `H` / `L` | move the card to the list on the left / right |
| `Enter` | the card's notes: its document, made on first use; `q` comes back | | `Enter` | turn the card over: labels, a markdown description, a checklist |
| `Space` | into the done column, or back out to the inbox | | `Space` | into the done column, or back out to the inbox |
| `o`, `c` | a new card on this list | | `o`, `c` | a new card on this list |
| `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` | | `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` |
@@ -276,8 +277,14 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor.
| `x`, `dd` | delete the card | | `x`, `dd` | delete the card |
| `q`, `Esc` | back to the tree | | `q`, `Esc` | back to the tree |
On the back of a card, `j`/`k` walk the checklist, `Space` ticks a step, `o`
adds one and `dd` drops it; `e` writes the description in the vim editor, `:w`
saving it to the card; `a` puts a label on. Code in the description is
coloured as nvim colours it. See the
[wiki](docs/wiki/Boards-and-Agenda.md#the-back-of-a-card) for every key.
A card can point at a document: `:card!` while reading one makes a card for it, A card can point at a document: `:card!` while reading one makes a card for it,
and `Enter` on that card opens it again. `Enter` on a card without one makes a and `gf` on its back opens it again. `gf` on a card without one makes a
document named after the card in the space's root and opens it to write in, and document named after the card in the space's root and opens it to write in, and
closing that document returns to the board on the same card. The link is deliberately loose — closing that document returns to the board on the same card. The link is deliberately loose —
deleting the document leaves the card standing, because the work is still open deleting the document leaves the card standing, because the work is still open
+35 -2
View File
@@ -57,7 +57,7 @@ a week fold away until `z` shows them.
| `H` / `L` | move the card to the list on the left / right | | `H` / `L` | move the card to the list on the left / right |
| `Space` | into the done list, or back out to the inbox | | `Space` | into the done list, or back out to the inbox |
| `o`, `c` | a new card on this list | | `o`, `c` | a new card on this list |
| `Enter` | the card's notes | | `Enter` | turn the card over: labels, description, checklist |
| `D` | due date | | `D` | due date |
| `R` | rename the card | | `R` | rename the card |
| `z` | show done cards older than a week | | `z` | show done cards older than a week |
@@ -65,6 +65,39 @@ a week fold away until `z` shows them.
| `x`, `dd` | delete the card | | `x`, `dd` | delete the card |
| `q`, `Esc` | leave the special workspace | | `q`, `Esc` | leave the special workspace |
### The back of a card
`Enter` on a card turns it over: a sheet floats over the board with its
labels, its description and its checklist. The card stays the one under the
board's cursor, so `D`, `R`, `H` and `L` act on it as they do on the board.
| key | what it does |
| --- | --- |
| `j` / `k`, `gg` / `G` | down / up the checklist, its first / last step |
| `Space`, `x` | tick the step, or untick it |
| `o` | a new step below the cursor (`:task <text>`) |
| `r` | reword the step (`:task! <text>`) |
| `J` / `K` | move the step down / up |
| `dd` | drop the step |
| `e`, `i`, `Enter` | write the description |
| `a` | put a label on, or take it off (`:label <name>`) |
| `gf` | the card's notes, below |
| `q`, `Esc` | back to the board |
The description is markdown, written in the same editor as a document, vim
and all: `:w` writes it to the card, `:q` or `Esc` in normal mode leaves, and
`:q!` throws away what was not written. A fenced block in a language is
coloured the way nvim colours it, written and read.
Labels are lower case, and each name keeps its colour on every card and every
board, without anything to set: the colour comes from the name. `Tab` on
`:label` offers the ones the board already uses. The board shows a card's
labels over its title, and `☑ 2/5` and `≡` under it when it has a checklist and
a description.
A repeating card comes back with the same labels and the same checklist, every
step open again.
### Capturing a card ### Capturing a card
`c` anywhere outside the special workspace opens `:card ` on the command line. The card goes `c` anywhere outside the special workspace opens `:card ` on the command line. The card goes
@@ -73,7 +106,7 @@ you stay where you were. From outside the browser, see [Capture](Capture.md).
### Notes ### Notes
`Enter` on a card opens its notes: a document named after the card, made in the `gf` on the back of a card opens its notes: a document named after the card, made in the
root of the space the first time. Closing it goes back to the board, on the root of the space the first time. Closing it goes back to the board, on the
same card. `:card!` while reading a document makes a card that points at it. same card. `:card!` while reading a document makes a card that points at it.
+5 -2
View File
@@ -76,7 +76,7 @@ The first list is the inbox, the last list is done.
| `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 |
| `Enter` | the card's notes — a document, made on first use; q comes back | | `Enter` | turn the card over: its labels, description and checklist |
| `o · c` | new card on this list | | `o · c` | new card on this list |
| `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 |
@@ -131,6 +131,9 @@ do: `:w!`, `:space! <name>`, `:import!`.
| `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears | | `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears |
| `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it | | `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it |
| `:done` | move the selected card into the done list, or back out to the inbox | | `:done` | move the selected card into the done list, or back out to the inbox |
| `:task, :step <text>` | add a step to the open card's checklist, below the cursor |
| `:task!` | reword the step under the cursor |
| `:label <name>` | put a label on the card, or take it off when it has it — each name keeps its colour everywhere |
| `:list <name>` | add a column to the board | | `:list <name>` | add a column to the board |
| `:list!` | delete the column under the cursor and every card on it | | `:list!` | delete the column under the cursor and every card on it |
| `:agenda, :ag` | what is late, due this week, or in hand — across every board | | `:agenda, :ag` | what is late, due this week, or in hand — across every board |
@@ -172,7 +175,7 @@ do: `:w!`, `:space! <name>`, `:import!`.
| command | what it does | | command | what it does |
| --- | --- | | --- | --- |
| `:write, :w` | save the open document | | `:write, :w` | save the open document — or the card's description being written |
| `:write!` | save even over a newer version saved somewhere else since this one was opened | | `:write!` | save even over a newer version saved somewhere else since this one was opened |
| `:wq, :x` | save and leave insert/editor mode | | `:wq, :x` | save and leave insert/editor mode |
| `:wq!` | save over a newer version, as :w! does, and leave the editor | | `:wq!` | save over a newer version, as :w! does, and leave the editor |
+8
View File
@@ -0,0 +1,8 @@
-- What a card holds besides its title, the way a card on a paper board does:
-- a checklist of the steps it takes, and labels that say what kind of work
-- it is. Both are small, read with the card and written with it, so they are
-- JSON on the card rather than tables of their own: `checklist` is an array
-- of `{"text": …, "done": …}`, `labels` an array of names. The description is
-- the card's `body`, which it has had from the start.
ALTER TABLE cards ADD COLUMN checklist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE cards ADD COLUMN labels TEXT NOT NULL DEFAULT '[]';
+167 -16
View File
@@ -70,11 +70,83 @@ pub struct Card {
pub done_at: Option<i64>, pub done_at: Option<i64>,
/// The rule it comes back by once finished, as `Repeat::name` writes it. /// The rule it comes back by once finished, as `Repeat::name` writes it.
pub repeat: Option<String>, pub repeat: Option<String>,
pub checklist: Vec<Task>,
pub labels: Vec<String>,
pub position: i64, pub position: i64,
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
} }
/// A step on a card's checklist.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Task {
pub text: String,
#[serde(default)]
pub done: bool,
}
/// The most a card holds of each, so a runaway client cannot grow one row
/// without end.
const MAX_TASKS: usize = 200;
const MAX_LABELS: usize = 12;
const MAX_LABEL_LEN: usize = 32;
/// A checklist as it is kept: each step trimmed, the empty ones dropped.
fn tidy_checklist(tasks: Vec<Task>) -> AppResult<Vec<Task>> {
let tasks: Vec<Task> = tasks
.into_iter()
.filter_map(|task| {
let text = task.text.trim();
(!text.is_empty()).then(|| Task {
text: text.to_string(),
done: task.done,
})
})
.collect();
if tasks.len() > MAX_TASKS {
return Err(AppError::BadRequest(format!(
"a checklist holds at most {MAX_TASKS} steps"
)));
}
Ok(tasks)
}
/// Labels as they are kept: lower case, trimmed, each once, in the order given.
fn tidy_labels(labels: Vec<String>) -> AppResult<Vec<String>> {
let mut kept: Vec<String> = Vec::new();
for label in labels {
let label = label.trim().to_lowercase();
if label.is_empty() || kept.contains(&label) {
continue;
}
if label.chars().count() > MAX_LABEL_LEN {
return Err(AppError::BadRequest(format!(
"a label is at most {MAX_LABEL_LEN} characters"
)));
}
kept.push(label);
}
if kept.len() > MAX_LABELS {
return Err(AppError::BadRequest(format!(
"a card carries at most {MAX_LABELS} labels"
)));
}
Ok(kept)
}
/// A JSON column read back, or empty when it does not parse: a card with a
/// mangled checklist still shows, which beats the whole board failing.
fn json_column<T: serde::de::DeserializeOwned + Default>(
row: &sqlx::sqlite::SqliteRow,
name: &str,
) -> T {
serde_json::from_str(row.get::<&str, _>(name)).unwrap_or_default()
}
fn to_json<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("a checklist or label list serialises")
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ListInput { pub struct ListInput {
pub name: String, pub name: String,
@@ -107,6 +179,11 @@ pub struct CardPatch {
pub due_at: Option<Option<i64>>, pub due_at: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
pub repeat: Option<Option<String>>, pub repeat: Option<Option<String>>,
/// The whole checklist, as it should now read.
#[serde(default)]
pub checklist: Option<Vec<Task>>,
#[serde(default)]
pub labels: Option<Vec<String>>,
pub done: Option<bool>, pub done: Option<bool>,
} }
@@ -229,7 +306,7 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard
FROM board_lists GROUP BY space_id FROM board_lists GROUP BY space_id
) )
SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
c.repeat, c.position, c.created_at, c.updated_at, c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at,
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name, (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name,
s.id AS space_id, s.slug AS space_slug, l.name AS list_name, s.id AS space_id, s.slug AS space_slug, l.name AS list_name,
l.position = b.first AS is_first l.position = b.first AS is_first
@@ -303,6 +380,8 @@ fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card {
due_at: row.get("due_at"), due_at: row.get("due_at"),
done_at: row.get("done_at"), done_at: row.get("done_at"),
repeat: row.get("repeat"), repeat: row.get("repeat"),
checklist: json_column(row, "checklist"),
labels: json_column(row, "labels"),
position: row.get("position"), position: row.get("position"),
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
@@ -470,8 +549,8 @@ pub(crate) async fn restore(
for card in &list.cards { for card in &list.cards {
sqlx::query( sqlx::query(
"INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, repeat, "INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, repeat,
position, created_at, updated_at) checklist, labels, position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
) )
.bind(id) .bind(id)
.bind(card.document.as_ref().and_then(|path| documents.get(path))) .bind(card.document.as_ref().and_then(|path| documents.get(path)))
@@ -487,6 +566,8 @@ pub(crate) async fn restore(
.and_then(Repeat::parse) .and_then(Repeat::parse)
.map(Repeat::name), .map(Repeat::name),
) )
.bind(to_json(&tidy_checklist(card.checklist.clone())?))
.bind(to_json(&tidy_labels(card.labels.clone())?))
.bind(position) .bind(position)
.bind(card.created_at) .bind(card.created_at)
.bind(card.updated_at) .bind(card.updated_at)
@@ -541,7 +622,7 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult<
// is small, and the round trips are what would be felt. // is small, and the round trips are what would be felt.
let card_rows = sqlx::query( let card_rows = sqlx::query(
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
c.repeat, c.position, c.created_at, c.updated_at, c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at,
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
FROM cards c FROM cards c
JOIN board_lists l ON l.id = c.list_id JOIN board_lists l ON l.id = c.list_id
@@ -814,18 +895,28 @@ pub async fn update_card(
Some(None) => None, Some(None) => None,
None => current.repeat.clone(), None => current.repeat.clone(),
}; };
let checklist = match patch.checklist {
Some(tasks) => tidy_checklist(tasks)?,
None => current.checklist.clone(),
};
let labels = match patch.labels {
Some(labels) => tidy_labels(labels)?,
None => current.labels.clone(),
};
let mut tx = crate::db::begin_write(&state.db).await?; let mut tx = crate::db::begin_write(&state.db).await?;
sqlx::query( sqlx::query(
"UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, repeat = ?5, "UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, repeat = ?5,
updated_at = ?6 checklist = ?6, labels = ?7, updated_at = ?8
WHERE id = ?7", WHERE id = ?9",
) )
.bind(&title) .bind(&title)
.bind(patch.body.as_ref().unwrap_or(&current.body)) .bind(patch.body.as_ref().unwrap_or(&current.body))
.bind(patch.node_id.unwrap_or(current.node_id)) .bind(patch.node_id.unwrap_or(current.node_id))
.bind(patch.due_at.unwrap_or(current.due_at)) .bind(patch.due_at.unwrap_or(current.due_at))
.bind(&repeat) .bind(&repeat)
.bind(to_json(&checklist))
.bind(to_json(&labels))
.bind(now) .bind(now)
.bind(card_id) .bind(card_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -838,6 +929,8 @@ pub async fn update_card(
node_id: patch.node_id.unwrap_or(current.node_id), node_id: patch.node_id.unwrap_or(current.node_id),
due_at: patch.due_at.unwrap_or(current.due_at), due_at: patch.due_at.unwrap_or(current.due_at),
repeat, repeat,
checklist,
labels,
..current ..current
}; };
@@ -1011,8 +1104,9 @@ async fn relocate(
} }
/// The next one of a repeating card that has just been finished, at the /// The next one of a repeating card that has just been finished, at the
/// bottom of its board's inbox: the same title, notes, document and rule, /// bottom of its board's inbox: the same title, notes, document, labels and
/// due on the rule's next date from today on. A card without a date counts /// rule, and the same checklist with every step open again, due on the
/// rule's next date from today on. A card without a date counts
/// from the moment it was finished. The finished card gives its rule up, so /// from the moment it was finished. The finished card gives its rule up, so
/// taking it out of done and finishing it again leaves no second copy. /// taking it out of done and finishing it again leaves no second copy.
async fn come_back( async fn come_back(
@@ -1038,11 +1132,11 @@ async fn come_back(
.fetch_one(&mut *db) .fetch_one(&mut *db)
.await?; .await?;
let id: i64 = sqlx::query_scalar( let id: i64 = sqlx::query_scalar(
"INSERT INTO cards (list_id, node_id, title, body, due_at, repeat, position, "INSERT INTO cards (list_id, node_id, title, body, due_at, repeat, checklist, labels,
created_at, updated_at) position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
(SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1), (SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1),
?7, ?7) ?9, ?9)
RETURNING id", RETURNING id",
) )
.bind(inbox) .bind(inbox)
@@ -1051,6 +1145,8 @@ async fn come_back(
.bind(&card.body) .bind(&card.body)
.bind(due_at) .bind(due_at)
.bind(rule.name()) .bind(rule.name())
.bind(to_json(&reopened(&card.checklist)))
.bind(to_json(&card.labels))
.bind(now) .bind(now)
.fetch_one(&mut *db) .fetch_one(&mut *db)
.await?; .await?;
@@ -1062,10 +1158,21 @@ async fn come_back(
Ok(Some(id)) Ok(Some(id))
} }
/// A checklist to go through again: the same steps, none of them ticked.
fn reopened(tasks: &[Task]) -> Vec<Task> {
tasks
.iter()
.map(|task| Task {
done: false,
..task.clone()
})
.collect()
}
async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> { async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
let row = sqlx::query( let row = sqlx::query(
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
c.repeat, c.position, c.created_at, c.updated_at, c.repeat, c.checklist, c.labels, c.position, c.created_at, c.updated_at,
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
FROM cards c WHERE c.id = ?1", FROM cards c WHERE c.id = ?1",
) )
@@ -1079,7 +1186,10 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{agenda_cards, card, done_after_move, relocate, reordered, restore, settle}; use super::{
agenda_cards, card, done_after_move, relocate, reordered, restore, settle, tidy_checklist,
tidy_labels, Task,
};
use crate::routes::manifest::{CardMeta, ListMeta}; use crate::routes::manifest::{CardMeta, ListMeta};
use std::collections::HashMap; use std::collections::HashMap;
@@ -1132,8 +1242,11 @@ mod tests {
let (inbox, doing, done) = (lists[0], lists[1], lists[2]); let (inbox, doing, done) = (lists[0], lists[1], lists[2]);
let day = 86400; let day = 86400;
let id: i64 = sqlx::query_scalar( let id: i64 = sqlx::query_scalar(
"INSERT INTO cards (list_id, title, body, due_at, repeat, position, created_at, updated_at) r#"INSERT INTO cards (list_id, title, body, due_at, repeat, checklist, labels,
VALUES (?1, 'water the plants', 'the big one too', ?2, 'weekly', 0, 0, 0) RETURNING id", position, created_at, updated_at)
VALUES (?1, 'water the plants', 'the big one too', ?2, 'weekly',
'[{"text":"ferns","done":true},{"text":"cactus","done":false}]',
'["home"]', 0, 0, 0) RETURNING id"#,
) )
.bind(doing) .bind(doing)
.bind(10 * day + 12 * 3600) .bind(10 * day + 12 * 3600)
@@ -1150,6 +1263,14 @@ mod tests {
assert_eq!(copy.due_at, Some(17 * day + 12 * 3600)); assert_eq!(copy.due_at, Some(17 * day + 12 * 3600));
assert_eq!(copy.repeat.as_deref(), Some("weekly")); assert_eq!(copy.repeat.as_deref(), Some("weekly"));
assert_eq!(copy.done_at, None); assert_eq!(copy.done_at, None);
// The same steps to go through again, none of them ticked yet.
let steps: Vec<(&str, bool)> = copy
.checklist
.iter()
.map(|t| (t.text.as_str(), t.done))
.collect();
assert_eq!(steps, [("ferns", false), ("cactus", false)]);
assert_eq!(copy.labels, ["home"]);
assert_eq!(card(&db, id).await.unwrap().repeat, None); assert_eq!(card(&db, id).await.unwrap().repeat, None);
assert_eq!(finish(&db, id, inbox, done, 12 * day).await, None); assert_eq!(finish(&db, id, inbox, done, 12 * day).await, None);
@@ -1210,6 +1331,8 @@ mod tests {
due_at: Some(0), due_at: Some(0),
done_at: None, done_at: None,
repeat: repeat.map(String::from), repeat: repeat.map(String::from),
checklist: Vec::new(),
labels: Vec::new(),
created_at: 0, created_at: 0,
updated_at: 0, updated_at: 0,
document: None, document: None,
@@ -1394,6 +1517,34 @@ mod tests {
assert!(done_at(db.clone()).await.is_some()); assert!(done_at(db.clone()).await.is_some());
} }
#[test]
fn a_checklist_keeps_its_steps_trimmed_and_drops_the_empty_ones() {
let task = |text: &str, done| Task {
text: text.into(),
done,
};
let kept = tidy_checklist(vec![
task(" buy paint ", true),
task(" ", false),
task("paint", false),
])
.unwrap();
assert_eq!(kept, [task("buy paint", true), task("paint", false)]);
assert!(tidy_checklist(vec![task("x", false); 201]).is_err());
}
#[test]
fn labels_are_lower_case_and_each_once() {
let labels = |names: &[&str]| tidy_labels(names.iter().map(|n| n.to_string()).collect());
assert_eq!(
labels(&["Bug", " bug ", "", "uni"]).unwrap(),
["bug", "uni"]
);
assert!(labels(&[&"x".repeat(33)]).is_err());
let many: Vec<String> = (0..13).map(|n| n.to_string()).collect();
assert!(tidy_labels(many).is_err());
}
#[test] #[test]
fn arriving_in_the_last_list_finishes_a_card_and_leaving_reopens_it() { fn arriving_in_the_last_list_finishes_a_card_and_leaving_reopens_it() {
let (inbox, doing, done) = (1, 2, 3); let (inbox, doing, done) = (1, 2, 3);
+4 -1
View File
@@ -227,7 +227,8 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
.map(|row| (row.get("id"), row.get("space_id"), row.get("name"))) .map(|row| (row.get("id"), row.get("space_id"), row.get("name")))
.collect(); .collect();
let cards: Vec<(i64, CardMeta, Option<i64>)> = sqlx::query( let cards: Vec<(i64, CardMeta, Option<i64>)> = sqlx::query(
"SELECT list_id, title, body, due_at, done_at, repeat, created_at, updated_at, node_id "SELECT list_id, title, body, due_at, done_at, repeat, checklist, labels,
created_at, updated_at, node_id
FROM cards ORDER BY position, id", FROM cards ORDER BY position, id",
) )
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -240,6 +241,8 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
due_at: row.get("due_at"), due_at: row.get("due_at"),
done_at: row.get("done_at"), done_at: row.get("done_at"),
repeat: row.get("repeat"), repeat: row.get("repeat"),
checklist: serde_json::from_str(row.get("checklist")).unwrap_or_default(),
labels: serde_json::from_str(row.get("labels")).unwrap_or_default(),
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
document: None, document: None,
+8
View File
@@ -11,6 +11,8 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::routes::boards::Task;
pub const FILE_NAME: &str = ".workspace.json"; pub const FILE_NAME: &str = ".workspace.json";
/// Bumped when a change would make an older import misread a newer file. /// Bumped when a change would make an older import misread a newer file.
@@ -76,6 +78,12 @@ pub struct CardMeta {
/// as one that does not repeat, which loses the rule but misreads nothing. /// as one that does not repeat, which loses the rule but misreads nothing.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat: Option<String>, pub repeat: Option<String>,
/// The checklist and the labels, added the same way as `repeat`: an older
/// build imports the card without them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checklist: Vec<Task>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
/// The archive path of the document that holds the card's notes. /// The archive path of the document that holds the card's notes.
+61
View File
@@ -9,6 +9,7 @@
"@codemirror/commands": "^6.7.1", "@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1", "@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.8", "@codemirror/language": "^6.10.8",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.5.1", "@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2", "@codemirror/view": "^6.36.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -34,22 +35,62 @@
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="], "@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
"@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="],
"@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
"@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw=="],
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
"@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="],
"@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-U9EnyzWuAx1TX3MZSKDXydKH734n1Aogk/ijKdW+jdIwmbf9zSPQYOIKggC/b2PTRjQb7ddvMktmxnfTLuv/SQ=="],
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="], "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="],
"@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="],
"@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="],
"@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="],
"@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="],
"@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="],
"@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="],
"@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="],
"@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], "@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
"@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
"@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.5", "", { "dependencies": { "@codemirror/streamparser": "^6.0.0" } }, "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="], "@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="],
"@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="], "@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="],
"@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="],
"@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="], "@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -120,18 +161,38 @@
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/cpp": ["@lezer/cpp@1.1.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA=="],
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="], "@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
"@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
"@lezer/java": ["@lezer/java@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q=="],
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/markdown": ["@lezer/markdown@1.7.2", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="], "@lezer/markdown": ["@lezer/markdown@1.7.2", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="],
"@lezer/php": ["@lezer/php@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw=="],
"@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="],
"@lezer/rust": ["@lezer/rust@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ=="],
"@lezer/sass": ["@lezer/sass@1.1.0", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="],
"@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="],
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="], "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
+1
View File
@@ -21,6 +21,7 @@
"@codemirror/commands": "^6.7.1", "@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1", "@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.8", "@codemirror/language": "^6.10.8",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.5.1", "@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2", "@codemirror/view": "^6.36.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
+98 -4
View File
@@ -17,6 +17,7 @@
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte' import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte' import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
import Backlinks, { openBacklink } from './lib/Backlinks.svelte' import Backlinks, { openBacklink } from './lib/Backlinks.svelte'
import CardSheet from './lib/CardSheet.svelte'
import History, { moveHistory, restoreShown } from './lib/History.svelte' import History, { moveHistory, restoreShown } from './lib/History.svelte'
import Actions from './lib/Actions.svelte' import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte' import Prompt from './lib/Prompt.svelte'
@@ -669,7 +670,7 @@
workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0) workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0)
return true return true
case 'Enter': case 'Enter':
void workspace.guard(() => workspace.openCard()) workspace.openCardSheet()
return true return true
case 'o': case 'o':
case 'c': case 'c':
@@ -708,6 +709,90 @@
} }
} }
/*
* The card sheet reads the list keys over its checklist, and the board's
* card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do
* on the board under it. While the description is being written the keys
* are the editor's.
*/
function cardKey(event: KeyboardEvent): boolean {
const key = event.key
const previous = pending
pending = ''
if (previous === 'g' && key === 'g') return void workspace.moveTask(-Infinity), true
if (previous === 'g' && key === 'f') {
workspace.overlay = 'none'
void workspace.guard(() => workspace.openCard())
return true
}
if (previous === 'd' && key === 'd') return void workspace.guard(() => workspace.deleteTask()), true
switch (key) {
case 'g':
case 'd':
pending = key
return true
case 'j':
case 'ArrowDown':
void workspace.moveTask(1)
return true
case 'k':
case 'ArrowUp':
void workspace.moveTask(-1)
return true
case 'G':
void workspace.moveTask(Infinity)
return true
case 'J':
void workspace.guard(() => workspace.nudgeTask(1))
return true
case 'K':
void workspace.guard(() => workspace.nudgeTask(-1))
return true
case ' ':
case 'x':
void workspace.guard(() => workspace.toggleTask())
return true
case 'o':
cmdline = ':task '
return true
case 'r': {
const task = workspace.card?.checklist[workspace.taskCursor]
if (task) cmdline = `:task! ${task.text}`
return true
}
case 'e':
case 'i':
case 'Enter':
workspace.describe()
return true
case 'a':
cmdline = ':label '
return true
case 'R':
cmdline = `:name ${workspace.card?.title ?? ''}`
return true
case 'D':
cmdline = ':due '
return true
case 'H':
void workspace.guard(() => workspace.sendCard(-1))
return true
case 'L':
void workspace.guard(() => workspace.sendCard(1))
return true
case ':':
cmdGlobal = false
cmdline = ':'
return true
case 'q':
case 'Escape':
workspace.closeCardSheet()
return true
default:
return false
}
}
function isTypingTarget(target: EventTarget | null) { function isTypingTarget(target: EventTarget | null) {
const node = target as HTMLElement | null const node = target as HTMLElement | null
return ( return (
@@ -722,7 +807,8 @@
function onkeydown(event: KeyboardEvent) { function onkeydown(event: KeyboardEvent) {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) { if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
event.preventDefault() event.preventDefault()
if (workspace.open) void workspace.guard(() => workspace.save()) if (workspace.describing) void workspace.guard(() => workspace.saveDescription())
else if (workspace.open) void workspace.guard(() => workspace.save())
return return
} }
@@ -742,6 +828,12 @@
// The command line over an overlay — `D` in the agenda — keeps its keys. // The command line over an overlay — `D` in the agenda — keeps its keys.
const listening = cmdline === null && !isTypingTarget(event.target) const listening = cmdline === null && !isTypingTarget(event.target)
if (workspace.overlay === 'card' && !workspace.describing && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (cardKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'help' && listening && !event.metaKey && !event.altKey) { if (workspace.overlay === 'help' && listening && !event.metaKey && !event.altKey) {
if (helpKey(event)) { if (helpKey(event)) {
event.preventDefault() event.preventDefault()
@@ -801,6 +893,8 @@
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
// The description's editor has had its Esc; the sheet stays up under it.
if (workspace.describing) return
if (workspace.overlay !== 'none') { if (workspace.overlay !== 'none') {
workspace.overlay = 'none' workspace.overlay = 'none'
return return
@@ -1285,9 +1379,9 @@
{#if workspace.overlay !== 'none'} {#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')} <div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay === 'card' ? workspace.closeCardSheet() : (workspace.overlay = 'none'))}
> >
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else}<MediaBrowser />{/if} {#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else if workspace.overlay === 'card'}<CardSheet />{:else}<MediaBrowser />{/if}
</div> </div>
{/if} {/if}
</div> </div>
+87 -4
View File
@@ -1,9 +1,11 @@
/* ── palette ─────────────────────────────────────────────────────────────── */ /* ── palette ─────────────────────────────────────────────────────────────── */
/* /*
* YoRHa dark, the palette of the desktop this runs on (quickshell Theme.qml, * YoRHa dark, the palette of the desktop this runs on (quickshell Theme.qml,
* hypr palette.lua) and of narl.io. A green-black ramp, sage-grey text, and * hypr palette.lua, nvim's yorha.lua) and of narl.io. A green-black ramp,
* only two colours that mean anything: `light` is focus, selection and * sage-grey text, and only two colours that mean anything: `light` is focus,
* whatever is live; `alarm` is something wrong. Nothing else carries hue. * selection and whatever is live; `alarm` is something wrong. The earth hues
* below are the terminal's, pulled down so they sit in the void: they colour
* code, the way nvim colours it, and a card's labels — and nothing else.
*/ */
:root { :root {
--ink: #090a08; --ink: #090a08;
@@ -24,6 +26,17 @@
--light-soft: #bcc1bf; --light-soft: #bcc1bf;
--alarm: #c8684f; --alarm: #c8684f;
--sand: #c9b27c;
--sand-br: #d8c491;
--moss: #8fa37e;
--moss-br: #a5b893;
--slate: #7f9aa8;
--slate-br: #96afbc;
--heather: #a48ba0;
--heather-br: #b8a0b4;
--dusk: #86a8a0;
--dusk-br: #9cbdb4;
--font-sans: "Noto Sans Variable", "Noto Sans", ui-sans-serif, system-ui, sans-serif; --font-sans: "Noto Sans Variable", "Noto Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace; --font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
@@ -512,7 +525,77 @@ input::placeholder { color: var(--muted); }
overflow-x: auto; overflow-x: auto;
margin: 0 0 1em; margin: 0 0 1em;
} }
.md pre code { background: none; border: 0; padding: 0; font-size: inherit; } .md pre code { background: none; border: 0; padding: 0; font-size: inherit; color: var(--fg); }
/* Inline code is moss on a lift and a block sits on the hull, as
render-markdown draws them in nvim. */
.md :not(pre) > code { color: var(--moss); }
.md pre { background: var(--hull); }
/* A card's label: its hue on a breath of the same, as nvim draws virtual text. */
.chip {
padding: 0 7px;
border-radius: var(--radius-s);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
letter-spacing: 0.06em;
color: var(--hue);
background: color-mix(in oklab, var(--hue) 14%, transparent);
}
.chip.sand { --hue: var(--sand); }
.chip.moss { --hue: var(--moss); }
.chip.dusk { --hue: var(--dusk); }
.chip.slate { --hue: var(--slate-br); }
.chip.heather { --hue: var(--heather); }
/* ── syntax ────────────────────────────────────────────────────────────── */
/*
* nvim's yorha.lua, group for group: structure is sand, names are the text,
* data is moss and heather, shapes are dusk, punctuation recedes into the
* grey. The classes are `highlight.ts`'s, and the same in the editor and in
* a rendered code block, so code reads alike in both.
*/
.tok-comment { color: var(--muted); font-style: italic; }
.tok-keyword { color: var(--sand); }
.tok-string { color: var(--moss); }
.tok-escape { color: var(--dusk-br); }
.tok-regexp { color: var(--dusk); }
.tok-number { color: var(--heather-br); }
.tok-constant { color: var(--heather); }
.tok-builtin { color: var(--heather); font-style: italic; }
.tok-variable { color: var(--fg); }
.tok-parameter { color: var(--subtle); font-style: italic; }
.tok-property { color: var(--slate-br); }
.tok-function { color: var(--light); }
.tok-type { color: var(--dusk); }
.tok-type-builtin { color: var(--dusk); font-style: italic; }
.tok-namespace { color: var(--light-soft); }
.tok-macro { color: var(--heather); }
.tok-tag { color: var(--sand); }
.tok-attribute { color: var(--slate-br); font-style: italic; }
.tok-operator { color: var(--subtle); }
.tok-punctuation { color: var(--muted); }
.tok-invalid { color: var(--alarm); font-weight: 700; }
.tok-inserted { color: var(--moss); }
.tok-deleted { color: var(--alarm); }
.tok-changed { color: var(--sand); }
/* markdown, as @markup.* is drawn */
.tok-heading1 { color: var(--light); font-weight: 700; }
.tok-heading2 { color: var(--light-soft); font-weight: 700; }
.tok-heading3 { color: var(--fg); font-weight: 700; }
.tok-heading4 { color: var(--subtle); font-weight: 700; }
.tok-heading5 { color: var(--subtle); }
.tok-heading6 { color: var(--muted); }
.tok-strong { color: var(--light); font-weight: 700; }
.tok-emphasis { font-style: italic; }
.tok-strikethrough { text-decoration: line-through; }
.tok-link { color: var(--slate-br); }
.tok-url { color: var(--slate); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--slate) 50%, transparent); }
.tok-raw { color: var(--moss); }
.tok-quote { color: var(--muted); font-style: italic; }
.tok-list { color: var(--light-dim); }
.tok-mark { color: var(--muted); }
.tok-rule { color: var(--rim); }
/* A long equation scrolls on its own rather than widening the page. */ /* A long equation scrolls on its own rather than widening the page. */
.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; } .md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; }
+21 -20
View File
@@ -4,9 +4,8 @@
* idiom as the list. The keys live in App.svelte with every other key, so * idiom as the list. The keys live in App.svelte with every other key, so
* this file only draws and — for a pointer — moves the two cursors. * this file only draws and — for a pointer — moves the two cursors.
*/ */
import type { Card } from './api'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatDay } from './util' import { formatDay, labelHue, urgency } from './util'
let columns = $state<HTMLDivElement>() let columns = $state<HTMLDivElement>()
@@ -16,19 +15,6 @@
columns?.querySelector<HTMLElement>(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) columns?.querySelector<HTMLElement>(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}) })
const midnight = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return Math.floor(today.getTime() / 1000)
}
/** `overdue`, `today` or nothing — a date only earns colour when it bites. */
function urgency(card: Card): '' | 'today' | 'overdue' {
if (card.due_at === null || card.done_at !== null) return ''
const start = midnight()
if (card.due_at < start) return 'overdue'
return card.due_at < start + 86400 ? 'today' : ''
}
const total = $derived(workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0) const total = $derived(workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0)
const lastIndex = $derived((workspace.board?.lists.length ?? 0) - 1) const lastIndex = $derived((workspace.board?.lists.length ?? 0) - 1)
@@ -95,15 +81,27 @@
class:done={card.done_at !== null} class:done={card.done_at !== null}
data-at="{listIndex}:{cardIndex}" data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)} onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.guard(() => workspace.openCard())} ondblclick={() => workspace.openCardSheet()}
title={card.node_id === null ? 'Enter starts a document for it' : 'Enter opens its document'} title="Enter turns the card over"
> >
{#if card.labels.length}
<span class="labels">
{#each card.labels as label (label)}<span class="chip {labelHue(label)}">{label}</span>{/each}
</span>
{/if}
<span class="title"> <span class="title">
<span class="tick" class:ticked={card.done_at !== null}></span> <span class="tick" class:ticked={card.done_at !== null}></span>
<span class="text">{card.title}</span> <span class="text">{card.title}</span>
</span> </span>
{#if card.due_at !== null || card.node_id !== null || card.repeat !== null} {#if card.due_at !== null || card.node_id !== null || card.repeat !== null || card.checklist.length || card.body.trim()}
{@const ticked = card.checklist.filter((task) => task.done).length}
<span class="meta"> <span class="meta">
{#if card.body.trim()}
<span title="has a description">≡</span>
{/if}
{#if card.checklist.length}
<span class:complete={ticked === card.checklist.length} title="steps done">☑ {ticked}/{card.checklist.length}</span>
{/if}
{#if card.due_at !== null} {#if card.due_at !== null}
<span class={urgency(card)}>due {formatDay(card.due_at)}</span> <span class={urgency(card)}>due {formatDay(card.due_at)}</span>
{/if} {/if}
@@ -147,7 +145,7 @@
<span><span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move</span> <span><span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move</span>
<span><span class="kbd">H</span><span class="kbd">L</span> send</span> <span><span class="kbd">H</span><span class="kbd">L</span> send</span>
<span><span class="kbd">␣</span> done</span> <span><span class="kbd">␣</span> done</span>
<span><span class="kbd">↵</span> notes</span> <span><span class="kbd">↵</span> open</span>
<span><span class="kbd">o</span> new</span> <span><span class="kbd">o</span> new</span>
<span><span class="kbd">D</span> due</span> <span><span class="kbd">D</span> due</span>
<span><span class="kbd">R</span> rename</span> <span><span class="kbd">R</span> rename</span>
@@ -286,7 +284,7 @@
.card:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; } .card:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; }
.card:active { transform: none; } .card:active { transform: none; }
.card.active, .card:focus-visible { background: var(--light); color: var(--ink); } .card.active, .card:focus-visible { background: var(--light); color: var(--ink); }
.card.active :is(.meta, .today, .overdue, .doc) { color: var(--ink); } .card.active :is(.meta, .today, .overdue, .doc, .complete) { color: var(--ink); }
.card.active .tick { border-color: var(--ink); } .card.active .tick { border-color: var(--ink); }
.card.active .tick.ticked { background: var(--ink); } .card.active .tick.ticked { background: var(--ink); }
.card.done .text { text-decoration: line-through; color: var(--muted); } .card.done .text { text-decoration: line-through; color: var(--muted); }
@@ -304,6 +302,9 @@
.tick.ticked { background: var(--light-dim); border-color: var(--light-dim); } .tick.ticked { background: var(--light-dim); border-color: var(--light-dim); }
.meta { display: flex; gap: 10px; font-size: 11px; padding-left: 18px; color: var(--muted); } .meta { display: flex; gap: 10px; font-size: 11px; padding-left: 18px; color: var(--muted); }
.meta .today { color: var(--light); } .meta .today { color: var(--light); }
.meta .complete { color: var(--moss); }
.card.active .chip { color: var(--ink); background: color-mix(in oklab, var(--hue) 55%, transparent); }
.labels { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 18px; }
.meta .overdue { color: var(--alarm); } .meta .overdue { color: var(--alarm); }
.doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; } .doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; }
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
/*
* A card's description, written in place on its sheet: the same editor as
* a document's — vim, markdown, the colours — over `workspace.description`
* instead of a tile. `:w` writes it to the card, `:q` leaves, and Esc in
* normal mode leaves too when nothing is unsaved, since there is no tree
* underneath to go back to.
*/
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { indentOnInput } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { drawSelection, EditorView, highlightActiveLine, keymap, placeholder } from '@codemirror/view'
import { getCM, Vim, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte'
import { bindVim } from './Editor.svelte'
import { editorTheme, highlighting, languages } from './editorTheme'
import { workspace } from './store.svelte'
let host = $state<HTMLDivElement>()
let view: EditorView | undefined
let mode = $state('normal')
/* Esc, before vim sees it: in normal mode it is the way out of the editor. */
function onkeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || (workspace.vimEnabled && mode !== 'normal')) return
event.preventDefault()
event.stopPropagation()
workspace.stopDescribing()
}
onMount(() => {
bindVim()
view = new EditorView({
parent: host!,
state: EditorState.create({
doc: workspace.description,
extensions: [
...(workspace.vimEnabled ? [vim({ status: false })] : []),
history(),
drawSelection(),
indentOnInput(),
highlightActiveLine(),
markdown({ base: markdownLanguage, codeLanguages: languages }),
highlighting,
EditorView.lineWrapping,
placeholder('what this card is about…'),
editorTheme,
EditorView.theme({ '.cm-content': { padding: '10px 14px', maxWidth: 'none' } }),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (void workspace.guard(() => workspace.saveDescription()), true),
},
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
workspace.description = update.state.doc.toString()
workspace.descriptionDirty = true
}),
],
}),
})
view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) {
const emitter = cm as unknown as { on?: (event: string, handler: (e: { mode: string }) => void) => void }
emitter.on?.('vim-mode-change', (event) => (mode = event.mode))
// An empty description is there to be written, so it starts in insert mode.
if (!workspace.description.trim()) Vim.handleKey(cm, 'i', 'workspace')
} else mode = 'insert'
})
onDestroy(() => {
view?.destroy()
view = undefined
})
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="writing" onkeydowncapture={onkeydown}>
<div class="editor" bind:this={host}></div>
<div class="modeline label">
<span class="mode" class:insert={mode !== 'normal'}>{mode}</span>
{#if workspace.descriptionDirty}<span class="dirty">+</span>{/if}
<span class="hint"><span class="kbd">:w</span> write · <span class="kbd">:q</span> leave · <span class="kbd">:q!</span> discard</span>
</div>
</div>
<style>
.writing {
display: flex;
flex-direction: column;
border: 1px solid var(--light-dim);
border-radius: var(--radius-m);
background: var(--base);
overflow: hidden;
}
.editor { min-height: 10em; max-height: 50vh; overflow: auto; }
.modeline {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px;
border-top: 1px solid var(--rim);
background: var(--hull);
font-size: 10px;
}
.mode { padding: 0 8px; border-radius: var(--radius-s); background: var(--light-soft); color: var(--ink); }
.mode.insert { background: var(--light); }
.dirty { color: var(--light); }
.hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; }
</style>
+204
View File
@@ -0,0 +1,204 @@
<script lang="ts">
/*
* Enter on a card turns it over: a window floated over the board, as the
* desktop floats one, with what the card holds on its back — labels, the
* description, the checklist. The keys live in App.svelte with the others;
* this draws and lets a pointer do the same.
*/
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
import { formatDay, labelHue, urgency } from './util'
let steps = $state<HTMLUListElement>()
const card = $derived(workspace.card)
const list = $derived(workspace.list)
const lists = $derived(workspace.board?.lists.length ?? 0)
const role = $derived(lists < 2 ? '' : workspace.listCursor === 0 ? 'inbox' : workspace.listCursor === lists - 1 ? 'done' : '')
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body) : '')
// A card deleted or moved out of sight under the sheet takes the sheet with it.
$effect(() => {
if (!card) workspace.overlay = 'none'
})
$effect(() => {
const at = workspace.taskCursor
steps?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
function pick(index: number) {
workspace.taskCursor = index
}
function tick(index: number) {
workspace.taskCursor = index
void workspace.guard(() => workspace.toggleTask())
}
</script>
{#if card}
<div class="sheet">
<header>
<span class="label rule">
<span class="marker"></span><span class="title">card</span>
{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{#if role}<span class="role">{role}</span>{/if}
</span>
<button onclick={() => workspace.closeCardSheet()}>esc</button>
</header>
<div class="scroll">
<h2 class:done={card.done_at !== null}>{card.title}</h2>
{#if card.labels.length || card.due_at !== null || card.repeat !== null || card.node_id !== null}
<div class="meta">
{#each card.labels as label (label)}<span class="chip {labelHue(label)}">{label}</span>{/each}
{#if card.due_at !== null}<span class={urgency(card)}>due {formatDay(card.due_at)}</span>{/if}
{#if card.repeat !== null}<span>↻ {card.repeat}</span>{/if}
{#if card.node_id !== null}
<button class="doc" onclick={() => workspace.guard(() => workspace.openCard())}>→ {card.node_name ?? `#${card.node_id}`}</button>
{/if}
</div>
{/if}
<section>
<div class="label head"><span class="marker"></span>description</div>
{#if workspace.describing}
{#await import('./CardEditor.svelte')}
<div class="empty label">loading editor</div>
{:then module}
<module.default />
{/await}
{:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<article class="md description" ondblclick={() => workspace.describe()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
{:else}
<button class="empty" onclick={() => workspace.describe()}>nothing written yet — <span class="kbd">e</span> writes it</button>
{/if}
</section>
<section>
<div class="label head">
<span class="marker"></span>checklist
{#if card.checklist.length}
<span class="count" class:complete={ticked === card.checklist.length}>{ticked}/{card.checklist.length}</span>
{/if}
</div>
{#if card.checklist.length}
<div class="progress"><span style="width: {(ticked / card.checklist.length) * 100}%"></span></div>
{/if}
<ul bind:this={steps}>
{#each card.checklist as task, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.taskCursor && !workspace.describing} class:ticked={task.done} data-index={index} onclick={() => pick(index)}>
<button class="tick" aria-label={task.done ? 'untick' : 'tick'} onclick={(e) => (e.stopPropagation(), tick(index))}></button>
<span class="text">{task.text}</span>
</li>
{:else}
<li class="none muted">no steps — <span class="kbd">o</span> adds one</li>
{/each}
</ul>
</section>
</div>
<footer class="label">
<span><span class="kbd">j</span><span class="kbd">k</span> step</span>
<span><span class="kbd">␣</span> tick</span>
<span><span class="kbd">o</span> add</span>
<span><span class="kbd">r</span> reword</span>
<span><span class="kbd">J</span><span class="kbd">K</span> order</span>
<span><span class="kbd">dd</span> drop</span>
<span><span class="kbd">e</span> describe</span>
<span><span class="kbd">a</span> label</span>
<span><span class="kbd">D</span> due</span>
<span><span class="kbd">H</span><span class="kbd">L</span> send</span>
<span><span class="kbd">gf</span> notes</span>
<span><span class="kbd">q</span> close</span>
</footer>
</div>
{/if}
<style>
/* A float, as Hyprland draws one over the tiles: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 100%;
width: min(100%, 76ch);
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.role { margin-left: 8px; color: var(--muted); font-size: 10px; letter-spacing: 0.18em; }
.scroll { display: flex; flex-direction: column; gap: 18px; min-height: 0; overflow-y: auto; }
h2 { margin: 0; font-family: var(--font-sans); font-size: 20px; font-weight: 600; color: var(--light); }
h2.done { text-decoration: line-through; color: var(--muted); }
.meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; font-size: 11px; color: var(--muted); }
.meta .today { color: var(--light); }
.meta .overdue { color: var(--alarm); }
.meta .doc { padding: 0 6px; border: 0; font-size: 11px; text-transform: none; letter-spacing: 0; color: var(--slate-br); }
section { display: flex; flex-direction: column; gap: 8px; }
.head { display: flex; align-items: center; gap: 10px; }
.count { font-variant-numeric: tabular-nums; }
.count.complete { color: var(--moss); }
.description { padding: 4px 2px; font-size: 14px; }
.description :global(> :last-child) { margin-bottom: 0; }
.empty { align-self: flex-start; border: 0; padding: 4px 2px; font-size: 12px; text-transform: none; letter-spacing: 0; color: var(--muted); }
.progress { height: 2px; border-radius: 1px; background: var(--surface-alt); overflow: hidden; }
.progress span { display: block; height: 100%; background: var(--moss); transition: width var(--t-base) var(--ease-glide); }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
li {
display: flex;
align-items: baseline;
gap: 10px;
padding: 5px 8px;
border-radius: var(--radius-s);
font-family: var(--font-sans);
font-size: 14px;
cursor: default;
}
li:hover { background: var(--surface); }
li.at { background: var(--light); color: var(--ink); }
li.ticked .text { text-decoration: line-through; color: var(--muted); }
li.at.ticked .text { color: var(--ink); }
li.none { font-family: var(--font-mono); font-size: 12px; }
.tick {
width: 11px;
height: 11px;
flex: none;
padding: 0;
border: 1px solid var(--outline);
border-radius: 2px;
transform: translateY(1px);
}
li.ticked .tick { background: var(--moss); border-color: var(--moss); }
li.at .tick { border-color: var(--ink); }
li.at.ticked .tick { background: var(--ink); }
footer { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 10px; }
footer > span { display: flex; gap: 3px; align-items: center; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
</style>
+5 -70
View File
@@ -120,7 +120,7 @@
Vim.mapCommand('gT', 'action', 'workspacePrevTile', {}, { context: 'normal' }) Vim.mapCommand('gT', 'action', 'workspacePrevTile', {}, { context: 'normal' })
} }
function bindVim() { export function bindVim() {
if (vimBound) return if (vimBound) return
vimBound = true vimBound = true
bridgeExCommands() bridgeExCommands()
@@ -136,7 +136,7 @@
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete' import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language' import { indentOnInput } from '@codemirror/language'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { import {
drawSelection, drawSelection,
@@ -145,10 +145,10 @@
keymap, keymap,
placeholder, placeholder,
} from '@codemirror/view' } from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { api } from './api' import { api } from './api'
import { hooks } from './commands' import { hooks } from './commands'
import { editorTheme, highlighting, languages } from './editorTheme'
import { embedSnippet } from './markdown' import { embedSnippet } from './markdown'
import { workspace, type Tile } from './store.svelte' import { workspace, type Tile } from './store.svelte'
@@ -159,71 +159,6 @@
let view: EditorView | undefined let view: EditorView | undefined
const focus = () => view?.focus() const focus = () => view?.focus()
const markdownHighlight = HighlightStyle.define([
{ tag: tags.heading, color: 'var(--light)', fontWeight: '600' },
{ tag: tags.strong, color: 'var(--light)', fontWeight: '700' },
{ tag: tags.emphasis, color: 'var(--subtle)', fontStyle: 'italic' },
{ tag: tags.link, color: 'var(--light)', textDecoration: 'underline', textDecorationColor: 'var(--overlay)' },
{ tag: tags.url, color: 'var(--light-dim)' },
{ tag: tags.monospace, color: 'var(--light-soft)', backgroundColor: 'var(--surface)' },
{ tag: tags.quote, color: 'var(--subtle)', fontStyle: 'italic' },
{ tag: tags.list, color: 'var(--light-dim)' },
{ tag: tags.contentSeparator, color: 'var(--muted)' },
{ tag: tags.processingInstruction, color: 'var(--muted)' },
])
/*
* CodeMirror's base theme paints panels and the caret for a light editor
* (`&light .cm-panels { color: black }`), and those selectors outrank
* anything the app stylesheet can say. Theme rules do win, so the parts that
* would otherwise render black on black live here rather than in app.css.
* `dark: true` also stops the light defaults applying in the first place.
*/
const editorTheme = EditorView.theme(
{
'&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { padding: '12px 28px', maxWidth: '90ch' },
'.cm-line': { padding: '0' },
'.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' },
'.cm-panels': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
borderColor: 'var(--rim)',
},
'.cm-panels-bottom': { borderTop: '1px solid var(--rim)' },
/*
* The `:` prompt is a bare text node in the panel; the input follows it.
* The input is an inline-block whose baseline sits below the text node's,
* so the two are laid out as flex items instead of aligned by baseline.
*/
'.cm-vim-panel': {
padding: '4px 12px',
color: 'var(--light)',
display: 'flex',
alignItems: 'center',
},
'.cm-tooltip': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
border: '1px solid var(--rim)',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--surface-alt)',
color: 'var(--light)',
},
'.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' },
'.cm-vim-panel input': {
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--light)',
caretColor: 'var(--light)',
flex: '1',
},
},
{ dark: true },
)
/* /*
* `[[` offers the titles of documents as the search finds them, each with * `[[` offers the titles of documents as the search finds them, each with
* where it sits; picking one closes the brackets. * where it sits; picking one closes the brackets.
@@ -298,9 +233,9 @@
indentOnInput(), indentOnInput(),
highlightActiveLine(), highlightActiveLine(),
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
markdown({ base: markdownLanguage, codeLanguages: [] }), markdown({ base: markdownLanguage, codeLanguages: languages }),
autocompletion({ override: [titleCompletions], icons: false }), autocompletion({ override: [titleCompletions], icons: false }),
syntaxHighlighting(markdownHighlight), highlighting,
EditorView.lineWrapping, EditorView.lineWrapping,
placeholder('write…'), placeholder('write…'),
editorTheme, editorTheme,
+19 -1
View File
@@ -106,7 +106,7 @@
['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'],
['Enter', "the card's notes — a document, made on first use; q comes back"], ['Enter', 'turn the card over: its labels, description and checklist'],
['o · c', 'new card on this list'], ['o · c', 'new card on this list'],
['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'],
@@ -117,6 +117,22 @@
['q · Esc', 'leave the special workspace'], ['q · Esc', 'leave the special workspace'],
] ]
const cardKeys: [string, string][] = [
['j / k', 'down / up the checklist'],
['gg / G', 'first / last step'],
['Space · x', 'tick the step, or untick it'],
['o', 'a new step below the cursor — :task on the line'],
['r', 'reword the step'],
['J / K', 'move the step down / up'],
['dd', 'drop the step'],
['e · i · Enter', 'write the description, in the editor — :w writes it, :q or Esc in normal mode leaves'],
['a', 'put on a label, or take it off — :label on the line, Tab offers the board\'s'],
['R · D', 'rename the card, its due date'],
['H / L', 'send the card to the list left / right'],
['gf', "the card's notes — a document, made on first use; q comes back"],
['q · Esc', 'back to the board'],
]
const agendaKeys: [string, string][] = [ const agendaKeys: [string, string][] = [
['j / k', 'down / up the cards, then what was written on this day in earlier years, then into the calendar'], ['j / k', 'down / up the cards, then what was written on this day in earlier years, then into the calendar'],
['h', 'to the board'], ['h', 'to the board'],
@@ -159,6 +175,7 @@
['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'], ['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'],
['drop · paste', 'an image, video or audio file attaches it'], ['drop · paste', 'an image, video or audio file attaches it'],
['$x^2$ · $$…$$', 'set as math'], ['$x^2$ · $$…$$', 'set as math'],
['```rust', 'a fenced block in a language is coloured as nvim colours it, written and read'],
['```typst', 'a block typst sets, shown as an image — math, tables, figures; no packages or files'], ['```typst', 'a block typst sets, shown as an image — math, tables, figures; no packages or files'],
[':12', 'jumps straight to entry 12'], [':12', 'jumps straight to entry 12'],
['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'], ['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'],
@@ -168,6 +185,7 @@
const sections: [string, Row[]][] = [ const sections: [string, Row[]][] = [
['keys', fromPairs(keys)], ['keys', fromPairs(keys)],
['board keys', fromPairs(boardKeys)], ['board keys', fromPairs(boardKeys)],
['card keys', fromPairs(cardKeys)],
['agenda keys', fromPairs(agendaKeys)], ['agenda keys', fromPairs(agendaKeys)],
['in the editor', fromPairs(editorKeys)], ['in the editor', fromPairs(editorKeys)],
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]), ...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
+12
View File
@@ -170,11 +170,20 @@ export interface Card {
done_at: number | null done_at: number | null
/** `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m` — or none. */ /** `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m` — or none. */
repeat: string | null repeat: string | null
checklist: Task[]
/** Lower case, each once. */
labels: string[]
position: number position: number
created_at: number created_at: number
updated_at: number updated_at: number
} }
/** A step on a card's checklist. */
export interface Task {
text: string
done: boolean
}
/** A card after a move or an edit, and the copy a repeating card it finished left in the inbox. */ /** A card after a move or an edit, and the copy a repeating card it finished left in the inbox. */
export interface Moved extends Card { export interface Moved extends Card {
again: Card | null again: Card | null
@@ -564,6 +573,9 @@ export const api = {
node_id?: number | null node_id?: number | null
due_at?: number | null due_at?: number | null
repeat?: string | null repeat?: string | null
/** The whole checklist, as it should now read. */
checklist?: Task[]
labels?: string[]
done?: boolean done?: boolean
}, },
) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }), ) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
+68
View File
@@ -0,0 +1,68 @@
/*
* A fenced code block, coloured the way the editor colours it: the same
* parser for the language named after the fence, the same classes. Each
* language is fetched the first time a block on the page is written in it,
* as KaTeX is for math; until it arrives the block shows plain. The languages
* are state, so markdown rendered without one renders again once it is here.
*/
import type { Language } from '@codemirror/language'
/** What colours a block once there is one to colour, fetched with its first language. */
type Engine = { highlightCode: typeof import('@lezer/highlight').highlightCode; highlighter: typeof import('./highlight').highlighter }
let engine: Engine | null = null
/** By the name after the fence; null for a name no language answers to. */
let loaded = $state.raw(new Map<string, Language | null>())
const asked = new Set<string>()
/** The block as coloured HTML, or null while its language loads or when there is none. */
export function highlighted(source: string, fence: string): string | null {
const name = fence.trim().split(/\s/)[0].toLowerCase()
if (!name) return null
const language = loaded.get(name)
if (language === undefined) {
load(name)
return null
}
if (language === null || !engine) return null
const { highlightCode, highlighter } = engine
let html = ''
highlightCode(
source,
language.parser.parse(source),
highlighter,
(text, classes) => (html += classes ? `<span class="${classes}">${escape(text)}</span>` : escape(text)),
() => (html += '\n'),
)
return html
}
function load(name: string) {
if (asked.has(name)) return
asked.add(name)
const settle = (language: Language | null) => (loaded = new Map(loaded).set(name, language))
// The list of languages and the parsers' machinery come with CodeMirror,
// which the reader does not otherwise need, so they are fetched with the
// first language.
Promise.all([
import('@codemirror/language'),
import('@codemirror/language-data'),
import('@lezer/highlight'),
import('./highlight'),
])
.then(([{ LanguageDescription }, { languages }, { highlightCode }, { highlighter }]) => {
engine = { highlightCode, highlighter }
const description = LanguageDescription.matchLanguageName(languages, name, true)
return description ? description.load().then((support) => support.language) : null
})
.then(settle)
.catch((e) => {
console.error(`code: could not load ${name}`, e)
settle(null)
})
}
function escape(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
+41 -2
View File
@@ -298,8 +298,8 @@ export const commands: CommandSpec[] = [
name: 'write', name: 'write',
aliases: ['w'], aliases: ['w'],
group: 'editing', group: 'editing',
help: 'save the open document', help: "save the open document — or the card's description being written",
run: () => workspace.guard(() => workspace.save()), run: () => workspace.guard(() => (workspace.describing ? workspace.saveDescription() : workspace.save())),
bang: { bang: {
help: 'save even over a newer version saved somewhere else since this one was opened', help: 'save even over a newer version saved somewhere else since this one was opened',
run: () => workspace.guard(() => workspace.save({ force: true })), run: () => workspace.guard(() => workspace.save({ force: true })),
@@ -311,6 +311,11 @@ export const commands: CommandSpec[] = [
group: 'editing', group: 'editing',
help: 'save and leave insert/editor mode', help: 'save and leave insert/editor mode',
run: async () => { run: async () => {
if (workspace.describing) {
await workspace.guard(() => workspace.saveDescription())
workspace.stopDescribing()
return
}
await workspace.guard(() => workspace.save()) await workspace.guard(() => workspace.save())
workspace.editing = false workspace.editing = false
hooks.focusList() hooks.focusList()
@@ -330,6 +335,7 @@ export const commands: CommandSpec[] = [
group: 'editing', group: 'editing',
help: 'close the editor, or the document when already reading', help: 'close the editor, or the document when already reading',
run: async () => { run: async () => {
if (workspace.describing) return void workspace.stopDescribing()
// A document `o` made and nothing was written in is not kept. // A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) { if (await workspace.abandonIfBlank()) {
if (workspace.returnTo === 'special') await workspace.guard(() => workspace.returnToSpecial()) if (workspace.returnTo === 'special') await workspace.guard(() => workspace.returnToSpecial())
@@ -348,6 +354,7 @@ export const commands: CommandSpec[] = [
bang: { bang: {
help: 'discard changes and close the editor', help: 'discard changes and close the editor',
run: async () => { run: async () => {
if (workspace.describing) return void workspace.stopDescribing(true)
const open = workspace.open const open = workspace.open
workspace.editing = false workspace.editing = false
workspace.dirty = false workspace.dirty = false
@@ -508,6 +515,38 @@ export const commands: CommandSpec[] = [
return workspace.guard(() => workspace.toggleDone()) return workspace.guard(() => workspace.toggleDone())
}, },
}, },
{
name: 'task',
aliases: ['step'],
args: '<text>',
group: 'board',
help: "add a step to the open card's checklist, below the cursor",
run: async (arg) => {
if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error')
if (!arg.trim()) return workspace.say('usage: :task <text>', 'error')
await workspace.guard(() => workspace.addTask(arg.trim()))
},
bang: {
help: 'reword the step under the cursor',
run: async (arg) => {
if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error')
if (!arg.trim()) return workspace.say('usage: :task! <text>', 'error')
await workspace.guard(() => workspace.renameTask(arg.trim()))
},
},
},
{
name: 'label',
args: '<name>',
group: 'board',
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels,
run: async (arg) => {
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg))
},
},
{ {
name: 'list', name: 'list',
args: '<name>', args: '<name>',
+65
View File
@@ -0,0 +1,65 @@
/*
* What every editor on the page looks like: the frame of it, and the colours
* of what is written in it, in any language a fence names.
*/
import { syntaxHighlighting } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { EditorView } from '@codemirror/view'
import { highlighter } from './highlight'
/** The languages a fence can be written in, each fetched the first time one is. */
export { languages }
export const highlighting = syntaxHighlighting(highlighter)
/*
* CodeMirror's base theme paints panels and the caret for a light editor
* (`&light .cm-panels { color: black }`), and those selectors outrank
* anything the app stylesheet can say. Theme rules do win, so the parts that
* would otherwise render black on black live here rather than in app.css.
* `dark: true` also stops the light defaults applying in the first place.
*/
export const editorTheme = EditorView.theme(
{
'&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { padding: '12px 28px', maxWidth: '90ch' },
'.cm-line': { padding: '0' },
'.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' },
'.cm-panels': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
borderColor: 'var(--rim)',
},
'.cm-panels-bottom': { borderTop: '1px solid var(--rim)' },
/*
* The `:` prompt is a bare text node in the panel; the input follows it.
* The input is an inline-block whose baseline sits below the text node's,
* so the two are laid out as flex items instead of aligned by baseline.
*/
'.cm-vim-panel': {
padding: '4px 12px',
color: 'var(--light)',
display: 'flex',
alignItems: 'center',
},
'.cm-tooltip': {
backgroundColor: 'var(--hull)',
color: 'var(--fg)',
border: '1px solid var(--rim)',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--surface-alt)',
color: 'var(--light)',
},
'.cm-completionDetail': { color: 'var(--muted)', fontStyle: 'normal', marginLeft: '1ch' },
'.cm-vim-panel input': {
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'var(--light)',
caretColor: 'var(--light)',
flex: '1',
},
},
{ dark: true },
)
+55
View File
@@ -0,0 +1,55 @@
/*
* The colours of code, as classes — `tok-keyword`, `tok-string` — painted in
* app.css from nvim's yorha.lua. The editor (editorTheme.ts) and a rendered
* code block (code.svelte.ts) are both given these, so a fence reads the same
* written and read. Only lezer here: the reader loads it with the app, and
* CodeMirror comes only with an editor.
*/
import { tagHighlighter, tags as t } from '@lezer/highlight'
/** Lezer's tags onto yorha.lua's groups. The most specific tag that matches wins. */
export const highlighter = tagHighlighter([
{ tag: t.comment, class: 'tok-comment' },
{ tag: [t.keyword, t.labelName], class: 'tok-keyword' },
{ tag: [t.string, t.character, t.attributeValue], class: 'tok-string' },
{ tag: [t.escape, t.special(t.string)], class: 'tok-escape' },
{ tag: t.regexp, class: 'tok-regexp' },
{ tag: [t.number, t.bool, t.null, t.unit], class: 'tok-number' },
{ tag: [t.atom, t.constant(t.variableName), t.constant(t.propertyName)], class: 'tok-constant' },
{ tag: [t.self, t.standard(t.variableName)], class: 'tok-builtin' },
{ tag: t.variableName, class: 'tok-variable' },
{ tag: [t.propertyName, t.definition(t.propertyName)], class: 'tok-property' },
{
tag: [t.function(t.variableName), t.function(t.propertyName), t.function(t.definition(t.variableName))],
class: 'tok-function',
},
{ tag: [t.typeName, t.className], class: 'tok-type' },
{ tag: t.standard(t.typeName), class: 'tok-type-builtin' },
{ tag: t.namespace, class: 'tok-namespace' },
{ tag: [t.macroName, t.meta, t.annotation], class: 'tok-macro' },
{ tag: t.tagName, class: 'tok-tag' },
{ tag: t.attributeName, class: 'tok-attribute' },
{ tag: t.operator, class: 'tok-operator' },
{ tag: [t.punctuation, t.angleBracket], class: 'tok-punctuation' },
{ tag: t.invalid, class: 'tok-invalid' },
{ tag: t.inserted, class: 'tok-inserted' },
{ tag: t.deleted, class: 'tok-deleted' },
{ tag: t.changed, class: 'tok-changed' },
{ tag: [t.heading, t.heading1], class: 'tok-heading1' },
{ tag: t.heading2, class: 'tok-heading2' },
{ tag: t.heading3, class: 'tok-heading3' },
{ tag: t.heading4, class: 'tok-heading4' },
{ tag: t.heading5, class: 'tok-heading5' },
{ tag: t.heading6, class: 'tok-heading6' },
{ tag: t.strong, class: 'tok-strong' },
{ tag: t.emphasis, class: 'tok-emphasis' },
{ tag: t.strikethrough, class: 'tok-strikethrough' },
{ tag: t.link, class: 'tok-link' },
{ tag: t.url, class: 'tok-url' },
{ tag: t.monospace, class: 'tok-raw' },
{ tag: t.quote, class: 'tok-quote' },
{ tag: t.list, class: 'tok-list' },
{ tag: t.processingInstruction, class: 'tok-mark' },
{ tag: t.contentSeparator, class: 'tok-rule' },
])
+4 -1
View File
@@ -1,5 +1,6 @@
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { marked, type TokenizerAndRendererExtension } from 'marked' import { marked, type TokenizerAndRendererExtension } from 'marked'
import { highlighted } from './code.svelte'
import { mathEngine } from './math.svelte' import { mathEngine } from './math.svelte'
import { typeset } from './typst.svelte' import { typeset } from './typst.svelte'
import { hardWrapped, titleHref } from './util' import { hardWrapped, titleHref } from './util'
@@ -94,7 +95,9 @@ marked.use({
code(token) { code(token) {
if (token.lang === 'math') return tex(token.text, true) if (token.lang === 'math') return tex(token.text, true)
if (token.lang === 'typst' || token.lang === 'typ') return typst(token.text) if (token.lang === 'typst' || token.lang === 'typ') return typst(token.text)
return false const html = token.lang ? highlighted(token.text, token.lang) : null
if (html === null) return false
return `<pre><code class="language-${escapeHtml(token.lang!.split(/\s/)[0])}">${html}</code></pre>\n`
}, },
}, },
}) })
+156 -1
View File
@@ -17,6 +17,7 @@ import {
type Revision, type Revision,
type RevisionSummary, type RevisionSummary,
type SpaceOverview, type SpaceOverview,
type Task,
type TrashItem, type TrashItem,
type VaultStatus, type VaultStatus,
} from './api' } from './api'
@@ -282,6 +283,13 @@ class Workspace {
}) })
listCursor = $state(0) listCursor = $state(0)
cardCursor = $state(0) cardCursor = $state(0)
/** The step of the open card's checklist under the cursor. */
taskCursor = $state(0)
/** The open card's description is being written, and what it says so far. */
describing = $state(false)
description = $state('')
descriptionDirty = $state(false)
/** `z` on the board: show done cards older than a week too. */ /** `z` on the board: show done cards older than a week too. */
showOldDone = $state(false) showOldDone = $state(false)
/* /*
@@ -296,7 +304,7 @@ class Workspace {
pane = $state<Pane>('list') pane = $state<Pane>('list')
flash = $state<Flash>(null) flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none') overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'card'>('none')
/** Which of the special workspace's tiles the keys go to. */ /** Which of the special workspace's tiles the keys go to. */
specialFocus = $state<SpecialTile>('board') specialFocus = $state<SpecialTile>('board')
/** /**
@@ -1679,6 +1687,153 @@ class Workspace {
this.say(`deleted "${card.title}"`) this.say(`deleted "${card.title}"`)
} }
/*
* The card sheet: one card opened up over its board, the way a card on a
* paper board is turned over to read its back — the description, the
* checklist, the labels. It is the card under the board's cursor, so the
* board keys that act on a card (`D`, `H`/`L`, `:repeat`) act on it here
* too, and the cursor follows it wherever a move puts it.
*/
openCardSheet() {
if (!this.card) return this.say('no card selected', 'error')
this.taskCursor = 0
this.describing = false
this.overlay = 'card'
}
/** `q`: the sheet away, unless that would lose a description being written. */
closeCardSheet() {
if (this.describing && !this.stopDescribing()) return
this.overlay = 'none'
}
/** `e`: write the description, from what it says now. */
describe() {
const card = this.card
if (!card) return
this.description = card.body
this.descriptionDirty = false
this.describing = true
}
async saveDescription() {
const card = this.card
if (!card || !this.describing) return
await this.patchCard({ body: this.description })
this.descriptionDirty = false
this.say(`"${card.title}" described`)
}
/** Stop writing; false, and a word why, while something is unsaved. */
stopDescribing(discard = false): boolean {
if (this.descriptionDirty && !discard) {
this.say('unsaved description — :w to write, :q! to discard', 'error')
return false
}
this.describing = false
this.descriptionDirty = false
return true
}
/*
* The checklist and the labels are written back whole, so two keys pressed
* faster than the board reloads would each start from the card as it was,
* and the second would undo the first. Each change waits for the one
* before it, and reads the card as that one left it.
*/
private cardWrites: Promise<unknown> = Promise.resolve()
private inTurn(change: () => Promise<unknown>): Promise<void> {
const next = this.cardWrites.then(change)
this.cardWrites = next.catch(() => {})
return next.then(() => {})
}
/** The checklist written back whole, with the cursor on step `at`. */
async writeChecklist(checklist: Task[], at = this.taskCursor) {
await this.patchCard({ checklist })
this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0))
}
/** `o` / `O`: a new step below or above the cursor. */
addTask(text: string, above = false) {
return this.inTurn(async () => {
const card = this.card
if (!card) return this.say('no card selected', 'error')
const tasks = [...card.checklist]
const at = tasks.length === 0 ? 0 : above ? this.taskCursor : this.taskCursor + 1
tasks.splice(at, 0, { text, done: false })
await this.writeChecklist(tasks, at)
})
}
/** Space: tick the step under the cursor, or untick it. */
toggleTask() {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
const task = tasks[this.taskCursor]
if (!task) return this.say('no step here — o adds one', 'error')
const next = tasks.map((t, i) => (i === this.taskCursor ? { ...t, done: !t.done } : t))
await this.writeChecklist(next)
this.say(`${next.filter((t) => t.done).length}/${next.length} done`)
})
}
renameTask(text: string) {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
if (!tasks[this.taskCursor]) return this.say('no step here — o adds one', 'error')
await this.writeChecklist(tasks.map((t, i) => (i === this.taskCursor ? { ...t, text } : t)))
})
}
deleteTask() {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
const task = tasks[this.taskCursor]
if (!task) return this.say('no step here', 'error')
await this.writeChecklist(tasks.filter((_, i) => i !== this.taskCursor))
this.say(`dropped "${task.text}"`)
})
}
/** `J` / `K`: the step takes the cursor with it. */
nudgeTask(delta: number) {
return this.inTurn(async () => {
const tasks = [...(this.card?.checklist ?? [])]
const to = this.taskCursor + delta
if (!tasks[this.taskCursor] || to < 0 || to >= tasks.length) return
;[tasks[this.taskCursor], tasks[to]] = [tasks[to], tasks[this.taskCursor]]
await this.writeChecklist(tasks, to)
})
}
/** `j`/`k`, and `gg`/`G` as ±Infinity — in turn too, so a move lands on the steps as written. */
moveTask(delta: number) {
return this.inTurn(async () => {
const count = this.card?.checklist.length ?? 0
this.taskCursor = Math.min(Math.max(this.taskCursor + delta, 0), Math.max(count - 1, 0))
})
}
/** `:label work`: on the card, or off it when it is on already. */
toggleLabel(name: string) {
return this.inTurn(async () => {
const card = this.card
if (!card) return this.say('no card selected', 'error')
const label = name.trim().toLowerCase()
const on = card.labels.includes(label)
await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] })
this.say(on ? `${label} off` : `${label} on`)
})
}
/** Every label on the board, for Tab on `:label`. */
get boardLabels(): string[] {
const all = (this.rawBoard?.lists ?? []).flatMap((list) => list.cards.flatMap((card) => card.labels))
return [...new Set(all)].sort()
}
async createList(name: string) { async createList(name: string) {
if (this.boardSpaceId === null) return if (this.boardSpaceId === null) return
this.rawBoard = await api.createList(this.boardSpaceId, name) this.rawBoard = await api.createList(this.boardSpaceId, name)
+22
View File
@@ -362,3 +362,25 @@ export function hitParts(excerpt: string): { text: string; lit: boolean }[] {
} }
return parts return parts
} }
/*
* A label's colour, from its name alone, so the same label is the same hue on
* every card and every board without anything to set. The hues are the
* terminal's earth tones; rust stays out, because rust means wrong.
*/
const LABEL_HUES = ['sand', 'moss', 'dusk', 'slate', 'heather'] as const
export function labelHue(name: string): (typeof LABEL_HUES)[number] {
let hash = 0
for (const char of name) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
return LABEL_HUES[hash % LABEL_HUES.length]
}
/** `overdue`, `today` or nothing — a card's date only earns colour when it bites. */
export function urgency(card: { due_at: number | null; done_at: number | null }): '' | 'today' | 'overdue' {
if (card.due_at === null || card.done_at !== null) return ''
const today = new Date()
today.setHours(0, 0, 0, 0)
const start = Math.floor(today.getTime() / 1000)
if (card.due_at < start) return 'overdue'
return card.due_at < start + 86400 ? 'today' : ''
}