mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
1fd1aa1eb7
commit
3c4c383f72
24 files changed
+1274
-127
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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(¤t.body))
|
.bind(patch.body.as_ref().unwrap_or(¤t.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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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[]]),
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||||
|
}
|
||||||
+41
-2
@@ -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>',
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
@@ -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' },
|
||||||
|
])
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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' : ''
|
||||||
|
}
|
||||||
Reference in new issue
Block a user