From 6ed829e5bd5b052d605f7a4f49edbd79d88fd4f1 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Wed, 21 Oct 2026 12:07:28 -0300 Subject: [PATCH] feat: note scan into tasks, Templater inputs, note banner and title icon (MiniMax M3.1 Flash); agy feature-gap research Co-Authored-By: Claude Sonnet 5.5 --- crates/i18n/locales/en.toml | 3 + crates/tasks/src/lib.rs | 5 +- crates/tasks/src/scan.rs | 203 +++++++++++ crates/tasks/tests/scan.rs | 141 +++++++ crates/templater/src/inputs.rs | 231 ++++++++++++ crates/templater/src/lib.rs | 2 + crates/templater/src/render.rs | 142 +++++++- crates/templater/tests/inputs.rs | 156 ++++++++ crates/ui-egui/src/text/views/banner/mod.rs | 5 + crates/ui-egui/src/text/views/mod.rs | 1 + crates/ui-egui/src/views/note_view.rs | 2 + .../src/views/note_view/header/banner.rs | 344 ++++++++++++++++++ .../ui-egui/src/views/note_view/header/mod.rs | 5 + .../src/views/note_view/header/title_icon.rs | 28 ++ crates/ui-egui/tests/banner.rs | 289 +++++++++++++++ .../research/agy-feature-gaps.md | 83 +++++ 16 files changed, 1629 insertions(+), 11 deletions(-) create mode 100644 crates/tasks/src/scan.rs create mode 100644 crates/tasks/tests/scan.rs create mode 100644 crates/templater/src/inputs.rs create mode 100644 crates/templater/tests/inputs.rs create mode 100644 crates/ui-egui/src/text/views/banner/mod.rs create mode 100644 crates/ui-egui/src/views/note_view/header/banner.rs create mode 100644 crates/ui-egui/src/views/note_view/header/mod.rs create mode 100644 crates/ui-egui/src/views/note_view/header/title_icon.rs create mode 100644 crates/ui-egui/tests/banner.rs create mode 100644 specs/plugins-parity/research/agy-feature-gaps.md diff --git a/crates/i18n/locales/en.toml b/crates/i18n/locales/en.toml index 02a0e37..f12f5fe 100644 --- a/crates/i18n/locales/en.toml +++ b/crates/i18n/locales/en.toml @@ -1890,3 +1890,6 @@ settings-cache-size-desc = "In MB. The oldest tiles go first." settings-cache-status = "Cache: {used} of {max} MB, {tiles} tiles" settings-clear = "Clear cache" settings-clear-desc = "Deletes every stored tile." + +[ui.banner] +remote-off = "Remote banners are off" diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index f899e4f..6316fd2 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -3,7 +3,8 @@ //! A task line is a list item with a checkbox and, optionally, signifiers: //! `- [ ] Buy milk ๐Ÿ“… 2026-10-20`. [`parse_line`] reads one line into a //! [`Task`] and [`format_line`] writes it back with the signifiers in the -//! fixed order the plugin uses. +//! fixed order the plugin uses. [`scan_note`] reads the tasks of a whole +//! note, skipping the frontmatter and the fenced code blocks. //! //! ``` //! use notescraft_tasks::{format_line, parse_line}; @@ -18,6 +19,7 @@ mod error; mod parse; mod recur; +mod scan; mod status; mod task; mod toggle; @@ -25,6 +27,7 @@ mod toggle; pub use error::{Error, Result}; pub use parse::{format_line, parse_line}; pub use recur::Rule; +pub use scan::{ScanCfg, scan_note}; pub use status::{StatusType, next_status, status_type}; pub use task::{OnCompletion, Priority, Task}; pub use toggle::{Toggle, ToggleCfg, toggle}; diff --git a/crates/tasks/src/scan.rs b/crates/tasks/src/scan.rs new file mode 100644 index 0000000..8a0f390 --- /dev/null +++ b/crates/tasks/src/scan.rs @@ -0,0 +1,203 @@ +//! Scanning a note into its tasks. +//! +//! [`scan_note`] walks the lines of a note once and collects the tasks in it. +//! A line inside a fenced code block or inside the frontmatter of the note +//! is not a task. The nearest heading above a task is kept, and a task that +//! is nested under another task records the line of its parent. + +use notescraft_markdown::{fenced_blocks, yaml}; + +use crate::parse::parse_line; +use crate::task::Task; + +/// Most tasks read from one note. +const MAX_TASKS_PER_NOTE: usize = 1000; + +/// Most heading levels, `######` being the deepest. +const MAX_HEADING_LEVEL: u8 = 6; + +/// Most spaces before a heading. +const MAX_HEADING_INDENT: usize = 3; + +/// How the scan of a note is configured. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScanCfg { + /// The global filter of the `Tasks` plugin. When it is set, only the + /// lines that hold this text are tasks. The text is a word of its own, + /// a tag such as `#task` is one word. It is removed from the description + /// of the task; the source line keeps it. + pub global_filter: Option, +} + +/// Reads the tasks of one note. +/// +/// The path is the vault-relative path of the note and is kept in every task. +/// The text is the whole content of the note. The tasks come in the order of +/// the lines; at most a thousand tasks are read. +/// +/// ``` +/// use notescraft_tasks::{ScanCfg, scan_note}; +/// +/// let note = "# Plan\n\n- [ ] Write it ๐Ÿ“… 2026-10-20\n"; +/// let tasks = scan_note("notes/plan.md", note, &ScanCfg::default()); +/// assert_eq!(tasks.len(), 1); +/// assert_eq!(tasks[0].path, "notes/plan.md"); +/// assert_eq!(tasks[0].line, 2); +/// assert_eq!(tasks[0].heading.as_deref(), Some("Plan")); +/// ``` +#[must_use] +pub fn scan_note(path: &str, text: &str, cfg: &ScanCfg) -> Vec { + let frontmatter = frontmatter_lines(text); + let fences = fence_lines(text); + let mut fence_idx = 0usize; + let mut headings: Vec<(u8, String)> = Vec::new(); + let mut parents: Vec<(u16, u32)> = Vec::new(); + let mut tasks: Vec = Vec::new(); + + for (index, line) in text.lines().enumerate() { + let number = u32::try_from(index).unwrap_or(u32::MAX); + while fences.get(fence_idx).is_some_and(|&(_, end)| end < number) { + fence_idx += 1; + } + let in_fence = fences.get(fence_idx).is_some_and(|&(start, end)| number >= start && number <= end); + if number < frontmatter || in_fence { + continue; + } + if let Some((level, title)) = heading_of(line) { + headings.retain(|(depth, _)| *depth < level); + headings.push((level, title)); + continue; + } + let Some(mut task) = parse_line(line) else { continue }; + if let Some(filter) = cfg.global_filter.as_deref() { + if !has_filter(&task.description, filter) { + continue; + } + task.description = strip_filter(&task.description, filter); + task.tags = tags_of(&task.description); + } + path.clone_into(&mut task.path); + task.line = number; + task.heading = headings.last().map(|(_, title)| title.clone()); + while parents.last().is_some_and(|&(indent, _)| indent >= task.indent) { + parents.pop(); + } + task.parent_line = parents.last().map(|&(_, line)| line); + parents.push((task.indent, number)); + tasks.push(task); + if tasks.len() >= MAX_TASKS_PER_NOTE { + break; + } + } + tasks +} + +/// The number of lines the frontmatter block of `text` takes. +fn frontmatter_lines(text: &str) -> u32 { + let Some(range) = yaml::block_range(text) else { return 0 }; + newlines(text.get(range).unwrap_or("")) +} + +/// The first and the last line of every fenced code block of `text`. +fn fence_lines(text: &str) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + let mut counter = LineCounter::new(text); + for block in fenced_blocks(text) { + let start = counter.line_at(block.opener.start); + let last = block.closer.as_ref().map_or(block.body.end, |closer| closer.start); + let end = counter.line_at(last); + out.push((start, end)); + } + out +} + +/// The heading a line opens: its level and its text. +fn heading_of(line: &str) -> Option<(u8, String)> { + let trimmed = line.trim_start_matches([' ', '\t']); + if line.len().saturating_sub(trimmed.len()) > MAX_HEADING_INDENT { + return None; + } + let hashes = trimmed.chars().take_while(|ch| *ch == '#').count(); + if hashes == 0 || hashes > usize::from(MAX_HEADING_LEVEL) { + return None; + } + let rest = trimmed.get(hashes..)?; + if !(rest.is_empty() || rest.starts_with(char::is_whitespace)) { + return None; + } + let text = rest.trim(); + let text = text.strip_suffix('#').filter(|before| before.ends_with(char::is_whitespace)).map_or(text, |before| before.trim_end()); + Some((u8::try_from(hashes).unwrap_or(MAX_HEADING_LEVEL), text.to_owned())) +} + +/// Whether the description holds the filter text as a word of its own. +fn has_filter(description: &str, filter: &str) -> bool { + !filter.is_empty() && description.split_whitespace().any(|word| word == filter) +} + +/// Removes the filter text from the description. +fn strip_filter(description: &str, filter: &str) -> String { + if filter.is_empty() { + return description.trim().to_owned(); + } + let mut out = String::with_capacity(description.len()); + let mut rest = description; + loop { + let Some(at) = rest.find(filter) else { + out.push_str(rest); + break; + }; + let before = rest.get(..at).unwrap_or(""); + let after = rest.get(at + filter.len()..).unwrap_or(""); + if (before.is_empty() || before.ends_with(char::is_whitespace)) && (after.is_empty() || after.starts_with(char::is_whitespace)) { + out.push_str(before); + out.push_str(after.trim_start()); + break; + } + let end = at + filter.len(); + out.push_str(rest.get(..end).unwrap_or("")); + rest = rest.get(end..).unwrap_or(""); + } + out.trim().to_owned() +} + +/// The hashtags of a description. +fn tags_of(description: &str) -> Vec { + if description.is_empty() { + return Vec::new(); + } + notescraft_markdown::parse(description).all_tags() +} + +/// The number of line endings in `text`. +fn newlines(text: &str) -> u32 { + text.bytes().filter(|byte| *byte == b'\n').fold(0u32, |count, _| count.saturating_add(1)) +} + +/// Counts the line numbers of the byte offsets of a text, walking forward +/// only. +struct LineCounter<'a> { + /// The text the offsets belong to. + text: &'a str, + /// The offset counted up to. + byte: usize, + /// The line of that offset. + line: u32, +} + +impl<'a> LineCounter<'a> { + /// A counter at the start of `text`. + fn new(text: &'a str) -> Self { + Self { text, byte: 0, line: 0 } + } + + /// The number of the line that holds `offset`. + fn line_at(&mut self, offset: usize) -> u32 { + let end = offset.min(self.text.len()); + if end > self.byte { + self.line = self.line.saturating_add(newlines(self.text.get(self.byte..end).unwrap_or(""))); + self.byte = end; + } + self.line + } +} diff --git a/crates/tasks/tests/scan.rs b/crates/tasks/tests/scan.rs new file mode 100644 index 0000000..1a1fc75 --- /dev/null +++ b/crates/tasks/tests/scan.rs @@ -0,0 +1,141 @@ +//! Tests of the scan of a note. + +use notescraft_tasks::{ScanCfg, Task, scan_note}; + +/// Scans a note with the default configuration. +fn scan(text: &str) -> Vec { + scan_note("notes/n.md", text, &ScanCfg::default()) +} + +/// The descriptions of the tasks of a note. +fn descriptions(tasks: &[Task]) -> Vec<&str> { + tasks.iter().map(|task| task.description.as_str()).collect() +} + +#[test] +fn tasks_in_a_fence_are_ignored() { + let text = "- [ ] first\n```\n- [ ] hidden\n```\n- [ ] last\n"; + let tasks = scan(text); + assert_eq!(descriptions(&tasks), vec!["first", "last"]); + assert_eq!(tasks[0].line, 0); + assert_eq!(tasks[1].line, 4); +} + +#[test] +fn an_unclosed_fence_hides_the_rest_of_the_note() { + let text = "- [ ] first\n```\n- [ ] hidden\n"; + let tasks = scan(text); + assert_eq!(descriptions(&tasks), vec!["first"]); +} + +#[test] +fn frontmatter_lines_are_ignored() { + let text = "---\ntags: [work]\nstatus: open\n---\n- [ ] real\n"; + let tasks = scan(text); + assert_eq!(descriptions(&tasks), vec!["real"]); + assert_eq!(tasks[0].line, 4); +} + +#[test] +fn a_note_without_frontmatter_starts_at_the_first_line() { + let tasks = scan("- [ ] real\n"); + assert_eq!(tasks[0].line, 0); +} + +#[test] +fn the_heading_is_tracked_across_levels() { + let text = "# One\n## Two\n### Three\n- [ ] a\n## Four\n- [ ] b\n# Five\n- [ ] c\n"; + let tasks = scan(text); + assert_eq!(tasks.len(), 3); + assert_eq!(tasks[0].heading.as_deref(), Some("Three")); + assert_eq!(tasks[1].heading.as_deref(), Some("Four")); + assert_eq!(tasks[2].heading.as_deref(), Some("Five")); +} + +#[test] +fn a_task_without_a_heading_above_has_none() { + let tasks = scan("- [ ] a\n"); + assert_eq!(tasks[0].heading, None); +} + +#[test] +fn a_nested_task_knows_its_parent() { + let text = "- [ ] parent\n - [ ] child\n - [ ] grandchild\n- [ ] sibling\n"; + let tasks = scan(text); + assert_eq!(tasks.len(), 4); + assert_eq!(tasks[0].parent_line, None); + assert_eq!(tasks[1].parent_line, Some(0)); + assert_eq!(tasks[2].parent_line, Some(1)); + assert_eq!(tasks[3].parent_line, None); +} + +#[test] +fn a_child_of_a_filtered_out_task_has_no_parent() { + let cfg = ScanCfg { global_filter: Some("#task".to_owned()) }; + let text = "- [ ] parent\n - [ ] child #task\n"; + let tasks = scan_note("n.md", text, &cfg); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].parent_line, None); +} + +#[test] +fn the_global_filter_selects_the_lines() { + let cfg = ScanCfg { global_filter: Some("#task".to_owned()) }; + let text = "- [ ] a #task\n- [ ] b\n- [ ] c #task\n"; + let tasks = scan_note("n.md", text, &cfg); + assert_eq!(descriptions(&tasks), vec!["a", "c"]); +} + +#[test] +fn without_a_filter_every_checkbox_is_a_task() { + let text = "- [ ] a #task\n- [ ] b\n"; + let tasks = scan(text); + assert_eq!(descriptions(&tasks), vec!["a #task", "b"]); +} + +#[test] +fn the_filter_stays_in_the_source_line() { + let cfg = ScanCfg { global_filter: Some("#task".to_owned()) }; + let tasks = scan_note("n.md", "- [ ] a #task\n", &cfg); + assert_eq!(tasks[0].original, "- [ ] a #task"); + assert_eq!(tasks[0].description, "a"); +} + +#[test] +fn a_note_with_many_tasks_is_capped() { + let mut text = String::new(); + for number in 0..1500 { + text.push_str("- [ ] task "); + text.push_str(&number.to_string()); + text.push('\n'); + } + let tasks = scan(&text); + assert_eq!(tasks.len(), 1000); + assert_eq!(tasks[999].description, "task 999"); +} + +#[test] +fn carriage_returns_are_not_part_of_the_original() { + let text = "- [ ] a\r\n- [ ] b\r\n"; + let tasks = scan(text); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0].original, "- [ ] a"); + assert_eq!(tasks[1].original, "- [ ] b"); + assert!(tasks.iter().all(|task| !task.original.contains('\r'))); +} + +#[test] +fn the_path_and_the_line_are_filled() { + let tasks = scan_note("notes/deep/n.md", "text\n- [ ] a\n", &ScanCfg::default()); + assert_eq!(tasks[0].path, "notes/deep/n.md"); + assert_eq!(tasks[0].line, 1); +} + +#[test] +fn the_signifiers_of_a_task_are_read() { + let tasks = scan("- [ ] a ๐Ÿ“… 2026-10-20 ๐Ÿ”บ\n"); + assert_eq!(tasks[0].description, "a"); + assert_eq!(tasks[0].priority, notescraft_tasks::Priority::Highest); + let due = tasks[0].due.map(|date| format!("{:04}-{:02}-{:02}", date.year(), date.month(), date.day())); + assert_eq!(due, Some("2026-10-20".to_owned())); +} diff --git a/crates/templater/src/inputs.rs b/crates/templater/src/inputs.rs new file mode 100644 index 0000000..6b1f755 --- /dev/null +++ b/crates/templater/src/inputs.rs @@ -0,0 +1,231 @@ +//! The questions a template asks before it renders (spec D6). +//! +//! JavaScript Templater asks the user while the template runs. We cannot: the +//! template is scanned first, the user answers every question at once and the +//! template is rendered again with the answers. +//! +//! This module is the first phase. [`Template::inputs`] walks the commands of +//! the template and returns the questions they ask, in document order. A +//! question whose arguments are not literals is asked with the value the pure +//! context gives (spec D6); when that value cannot be computed the question is +//! reported as an error instead of being asked. + +use notescraft_bases::Value; + +use crate::ctx::Ctx; +use crate::expr::{Expr, parse_expr}; +use crate::render::{RenderError, eval_pure}; +use crate::segment::{Kind, Segment, Template}; +use crate::{Error, Result}; + +/// One question the user answers before the template renders. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Input { + /// The index of the command that asks the question. + pub id: u32, + /// What is asked. + pub kind: InputKind, +} + +/// The kind of question of an [`Input`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputKind { + /// A text question: `tp.system.prompt(text?, default?, throw_on_cancel?, multiline?)`. + Prompt { + /// The text shown to the user. + text: String, + /// The answer used when the user types nothing. + default: String, + /// Whether the answer is written in a text area of several lines. + multiline: bool, + }, + /// A choice: `tp.system.suggester(labels, values, throw_on_cancel?, placeholder?)`. + Suggester { + /// The texts shown to the user. + labels: Vec, + /// The texts written to the output, one per label. + values: Vec, + /// The text shown in the empty field. + placeholder: String, + }, +} + +/// The arguments of a prompt (spec D3). +pub(crate) struct PromptArgs { + /// The text shown to the user. + pub(crate) text: String, + /// The answer used when the user types nothing. + pub(crate) default: String, + /// Whether a cancelled prompt aborts the render. + pub(crate) throw_on_cancel: bool, + /// Whether the answer is written in a text area of several lines. + pub(crate) multiline: bool, +} + +/// The arguments of a suggester (spec D3). +pub(crate) struct SuggesterArgs { + /// The texts shown to the user. + pub(crate) labels: Vec, + /// The texts written to the output, one per label. + pub(crate) values: Vec, + /// Whether a cancelled suggester aborts the render. + pub(crate) throw_on_cancel: bool, + /// The text shown in the empty field. + pub(crate) placeholder: String, +} + +impl Template { + /// The questions the template asks, in document order (spec D6). + /// + /// The id of a question is the index of the command that asks it, so the + /// answers of the user can be given back by [`crate::Answers`]. + #[must_use] + pub fn inputs(&self, ctx: &Ctx<'_>) -> Vec { + self.scan(ctx).0 + } + + /// What went wrong while the questions were read (spec D6). + /// + /// A question that cannot be built (a broken expression, an argument that + /// the pure context cannot give) is reported here and is not asked. + #[must_use] + pub fn input_errors(&self, ctx: &Ctx<'_>) -> Vec { + self.scan(ctx).1 + } + + /// Reads the questions of the template and the errors of the scan. + fn scan(&self, ctx: &Ctx<'_>) -> (Vec, Vec) { + let mut inputs = Vec::new(); + let mut errors = Vec::new(); + let mut id: u32 = 0; + for segment in &self.segments { + let Segment::Command { src, line, kind } = segment else { + continue; + }; + let command = id; + id = id.saturating_add(1); + if *kind != Kind::Output { + continue; + } + let expr = match parse_expr(src) { + Ok(expr) => expr, + Err(error) => { + errors.push(RenderError { line: *line, message: error.to_string() }); + continue; + } + }; + let Some((name, args)) = system_call(&expr) else { + continue; + }; + let values = match eval_args(args, ctx) { + Ok(values) => values, + Err(error) => { + errors.push(RenderError { line: *line, message: error.to_string() }); + continue; + } + }; + let kind = match name { + "prompt" => match prompt_args(&values) { + Ok(args) => Some(InputKind::Prompt { text: args.text, default: args.default, multiline: args.multiline }), + Err(error) => { + errors.push(RenderError { line: *line, message: error.to_string() }); + None + } + }, + "suggester" => match suggester_args(&values) { + Ok(args) => Some(InputKind::Suggester { labels: args.labels, values: args.values, placeholder: args.placeholder }), + Err(error) => { + errors.push(RenderError { line: *line, message: error.to_string() }); + None + } + }, + _ => None, + }; + if let Some(kind) = kind { + inputs.push(Input { id: command, kind }); + } + } + (inputs, errors) + } +} + +/// The `tp.system` call of an expression, when the whole expression is one. +pub(crate) fn system_call(expr: &Expr) -> Option<(&str, &[Expr])> { + let Expr::Call { path, args } = expr else { + return None; + }; + match path.as_slice() { + [first, second, third] if first.as_str() == "tp" && second.as_str() == "system" => Some((third.as_str(), args)), + _ => None, + } +} + +/// Evaluates the arguments of a question in the pure context (spec D6). +fn eval_args(args: &[Expr], ctx: &Ctx<'_>) -> Result> { + let mut values = Vec::with_capacity(args.len()); + for arg in args { + values.push(eval_pure(arg, ctx)?); + } + Ok(values) +} + +/// Reads the arguments of a prompt. +pub(crate) fn prompt_args(values: &[Value]) -> Result { + Ok(PromptArgs { + text: text_arg(values, 0, "the text of the prompt")?, + default: text_arg(values, 1, "the default answer")?, + throw_on_cancel: flag_arg(values, 2, "the cancel flag")?, + multiline: flag_arg(values, 3, "the multiline flag")?, + }) +} + +/// Reads the arguments of a suggester. +pub(crate) fn suggester_args(values: &[Value]) -> Result { + let labels = list_arg(values, 0, "the labels")?; + let choices = list_arg(values, 1, "the values")?; + if labels.len() != choices.len() { + return Err(Error::Syntax { message: "the labels and the values of the suggester have different lengths".to_owned() }); + } + Ok(SuggesterArgs { + labels, + values: choices, + throw_on_cancel: flag_arg(values, 2, "the cancel flag")?, + placeholder: text_arg(values, 3, "the placeholder")?, + }) +} + +/// A text argument; absent or null means the empty text. +fn text_arg(values: &[Value], index: usize, what: &str) -> Result { + match values.get(index) { + None | Some(Value::Null) => Ok(String::new()), + Some(Value::Str(text)) => Ok(text.clone()), + Some(other) => Err(Error::Unsupported { what: format!("{what} of type {}", other.type_name()) }), + } +} + +/// A flag argument; absent or null means false. +fn flag_arg(values: &[Value], index: usize, what: &str) -> Result { + match values.get(index) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(flag)) => Ok(*flag), + Some(other) => Err(Error::Unsupported { what: format!("{what} of type {}", other.type_name()) }), + } +} + +/// A list of texts at the given index. +fn list_arg(values: &[Value], index: usize, what: &str) -> Result> { + match values.get(index) { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::List(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::Str(text) => out.push(text.clone()), + other => return Err(Error::Unsupported { what: format!("{what} of type {}", other.type_name()) }), + } + } + Ok(out) + } + Some(other) => Err(Error::Unsupported { what: format!("{what} of type {}", other.type_name()) }), + } +} diff --git a/crates/templater/src/lib.rs b/crates/templater/src/lib.rs index 8cd41f6..ae4caae 100644 --- a/crates/templater/src/lib.rs +++ b/crates/templater/src/lib.rs @@ -22,6 +22,7 @@ mod ctx; mod duration; mod error; mod expr; +mod inputs; mod modules; mod render; mod segment; @@ -30,6 +31,7 @@ pub use ctx::Ctx; pub use duration::{Dur, add_dur, parse_iso_duration}; pub use error::{Error, Result}; pub use expr::{Expr, MAX_EXPRESSION_BYTES, MAX_EXPRESSION_DEPTH, PathPart, parse_expr}; +pub use inputs::{Input, InputKind}; pub use modules::date::{DateCtx, call_date}; pub use render::{Answers, OUTPUT_EXTRA, OUTPUT_FACTOR, RenderError, Rendered}; pub use segment::{Kind, MAX_COMMANDS, MAX_TEMPLATE_BYTES, Segment, Template, parse}; diff --git a/crates/templater/src/render.rs b/crates/templater/src/render.rs index 703dfc2..9c2fa8d 100644 --- a/crates/templater/src/render.rs +++ b/crates/templater/src/render.rs @@ -14,6 +14,7 @@ use notescraft_bases::Value; use crate::ctx::Ctx; use crate::error::Error; use crate::expr::{Expr, PathPart, parse_expr}; +use crate::inputs::{prompt_args, suggester_args, system_call}; use crate::modules::date::{DateCtx, call_date}; use crate::modules::file::{FileCall, call_file, file_property}; use crate::modules::frontmatter::frontmatter_value; @@ -48,7 +49,7 @@ pub struct RenderError { /// /// The key is the index of the command, the value is the answer of the user; /// `None` means the user cancelled. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Answers(pub BTreeMap>); /// The largest output of a template, as a multiple of the template size. @@ -60,15 +61,18 @@ pub const OUTPUT_EXTRA: usize = 256 * 1024; /// The message of an output that is too large. const OUTPUT_TOO_LARGE: &str = "the output is too large"; +/// The message of a render aborted by a cancelled prompt. +const CANCELLED: &str = "cancelled"; + impl Template { /// Renders the template with the given context. /// - /// The answers of the prompts are not read yet: the prompt phase (spec D6) - /// comes with the inputs of the template. + /// The answer of the command with the id `n` is the answer given for that + /// command (spec D6). A command with no answer uses its default value; a + /// cancelled command with `throw_on_cancel` aborts the render. #[must_use] pub fn render(&self, ctx: &Ctx<'_>, answers: &Answers) -> Rendered { - let _ = answers; - let mut state = State::new(ctx, self.output_limit()); + let mut state = State::new(ctx, answers, self.output_limit()); for segment in &self.segments { state.segment(segment); } @@ -105,16 +109,33 @@ struct Mark { line: u32, } +/// The answer of the user for one command of a template. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Answer { + /// The question was never asked: the default value is used. + Default, + /// The user answered the question. + Given(String), + /// The user cancelled the question. + Cancelled, +} + /// The state of a rendering. struct State<'a, 'b> { /// The context the template reads. ctx: &'a Ctx<'b>, + /// The answers of the user, by command index. + answers: &'a Answers, /// The text written so far. out: String, /// The largest output allowed. limit: usize, /// Whether the output was cut. truncated: bool, + /// Whether the render was aborted by a cancelled prompt. + aborted: bool, + /// The index of the command being written. + next_id: u32, /// The line of the command being written. line: u32, /// The cursor marks of the template. @@ -129,20 +150,40 @@ struct State<'a, 'b> { impl<'a, 'b> State<'a, 'b> { /// A state that writes into a new output. - fn new(ctx: &'a Ctx<'b>, limit: usize) -> Self { - Self { ctx, out: String::new(), limit, truncated: false, line: 1, marks: Vec::new(), rename: None, move_to: None, errors: Vec::new() } + fn new(ctx: &'a Ctx<'b>, answers: &'a Answers, limit: usize) -> Self { + Self { + ctx, + answers, + out: String::new(), + limit, + truncated: false, + aborted: false, + next_id: 0, + line: 1, + marks: Vec::new(), + rename: None, + move_to: None, + errors: Vec::new(), + } } /// Writes one segment. fn segment(&mut self, segment: &Segment) { + if self.aborted { + return; + } match segment { Segment::Text(text) => self.write(text), - Segment::Command { src, line, kind } => self.command(src, *line, *kind), + Segment::Command { src, line, kind } => { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + self.command(src, *line, *kind, id); + } } } /// Writes one command. - fn command(&mut self, src: &str, line: u32, kind: Kind) { + fn command(&mut self, src: &str, line: u32, kind: Kind, id: u32) { self.line = line; match kind { Kind::Js => { @@ -158,6 +199,10 @@ impl<'a, 'b> State<'a, 'b> { return; } }; + if let Some((name, args)) = system_call(&expr) { + self.question(name, args, src, line, id); + return; + } match self.eval(&expr) { Ok(value) => { let text = value.to_display_string(); @@ -172,6 +217,70 @@ impl<'a, 'b> State<'a, 'b> { } } + /// Writes the answer of a prompt or of a suggester (spec D6). + fn question(&mut self, name: &str, args: &[Expr], src: &str, line: u32, id: u32) { + let mut values = Vec::with_capacity(args.len()); + for arg in args { + match self.eval(arg) { + Ok(value) => values.push(value), + Err(error) => { + self.fail(line, &message_of(&error)); + self.write(&format!("<% {src} %>")); + return; + } + } + } + let (throw_on_cancel, default) = match name { + "prompt" => match prompt_args(&values) { + Ok(args) => (args.throw_on_cancel, args.default), + Err(error) => { + self.fail(line, &message_of(&error)); + self.write(&format!("<% {src} %>")); + return; + } + }, + "suggester" => match suggester_args(&values) { + Ok(args) => (args.throw_on_cancel, String::new()), + Err(error) => { + self.fail(line, &message_of(&error)); + self.write(&format!("<% {src} %>")); + return; + } + }, + other => { + self.fail(line, &format!("tp.system.{other} is not supported")); + self.write(&format!("<% {src} %>")); + return; + } + }; + match self.answer(id) { + Answer::Given(text) => self.write(&text), + Answer::Cancelled => self.cancel(throw_on_cancel, line), + Answer::Default => self.write(&default), + } + } + + /// The answer of the user for the command with the given id. + fn answer(&self, id: u32) -> Answer { + match self.answers.0.get(&id) { + None => Answer::Default, + Some(None) => Answer::Cancelled, + Some(Some(text)) => Answer::Given(text.clone()), + } + } + + /// Handles a cancelled question: the render stops when the question asked + /// for it, otherwise the answer is the empty text (spec D4). + fn cancel(&mut self, throw_on_cancel: bool, line: u32) { + if !throw_on_cancel { + return; + } + self.aborted = true; + self.out.clear(); + self.errors.clear(); + self.errors.push(RenderError { line, message: CANCELLED.to_owned() }); + } + /// Writes text into the output, up to the limit. fn write(&mut self, text: &str) { if self.truncated { @@ -196,6 +305,9 @@ impl<'a, 'b> State<'a, 'b> { /// The result of the rendering. fn finish(mut self) -> Rendered { + if self.aborted { + return Rendered { text: String::new(), cursor: None, rename: None, move_to: None, errors: self.errors }; + } let winner = self.marks.iter().copied().min_by_key(|mark| (mark.order, mark.offset)); if let Some(winner) = winner { for mark in &self.marks { @@ -278,7 +390,7 @@ impl<'a, 'b> State<'a, 'b> { } (Some("system"), Some(name)) => match call_system(name, &values, self.ctx)? { SystemCall::Value(value) => Ok(value), - SystemCall::Prompt | SystemCall::Suggester => Ok(Value::Str(String::new())), + SystemCall::Prompt | SystemCall::Suggester => Err(Error::Unsupported { what: format!("tp.system.{name}") }), }, _ => Err(Error::Unsupported { what: text }), } @@ -311,6 +423,16 @@ impl<'a, 'b> State<'a, 'b> { } } +/// Evaluates an expression in the pure context, without any answer (spec D6). +/// +/// The evaluation has no effect on the note: it only reads the context, so it +/// can be used to build the questions of a template before it renders. +pub(crate) fn eval_pure(expr: &Expr, ctx: &Ctx<'_>) -> Result { + let answers = Answers::default(); + let mut state = State::new(ctx, &answers, 0); + state.eval(expr) +} + /// Adds two values: a concatenation when one of them is text, a sum otherwise. fn add(left: &Value, right: &Value) -> Result { if is_text(left) || is_text(right) { diff --git a/crates/templater/tests/inputs.rs b/crates/templater/tests/inputs.rs new file mode 100644 index 0000000..39ecc62 --- /dev/null +++ b/crates/templater/tests/inputs.rs @@ -0,0 +1,156 @@ +//! Tests of the questions a template asks before it renders (spec D6). + +use std::collections::BTreeMap; + +use notescraft_templater::{Answers, Ctx, InputKind, parse}; +use notescraft_templates::{Date, DateTime, Weekday}; + +/// The reference moment of the tests: Tuesday, 2026-10-13 15:04:05. +fn now() -> DateTime { + DateTime::new(Date::new(2026, 10, 13).unwrap(), 15, 4, 5).unwrap() +} + +/// Reads a note of the vault. +fn read_note(path: &str) -> Option { + if path == "missing.md" { + return None; + } + Some("body".to_owned()) +} + +/// Answers whether a path exists in the vault. +fn exists(_: &str) -> bool { + true +} + +/// The context of the tests. +fn test_ctx() -> Ctx<'static> { + Ctx { + title: "Note", + folder: "Folder", + path: "Folder/Note.md", + absolute_path: Some("/vault/Folder/Note.md"), + now: now(), + week_start: Weekday::Monday, + created: Some(now()), + modified: Some(now()), + tags: &[], + frontmatter: &[], + selection: "", + clipboard: "", + read_note: &read_note, + exists: &exists, + } +} + +#[test] +fn the_questions_are_asked_in_document_order() { + let template = parse( + r#"<% tp.system.prompt("Name") %> +<% tp.system.suggester(["a", "b"], ["1", "2"]) %> +<% tp.system.prompt("Other", "def") %>"#, + ) + .unwrap(); + let inputs = template.inputs(&test_ctx()); + assert_eq!(inputs.len(), 3); + assert_eq!(inputs[0].id, 0); + assert_eq!(inputs[0].kind, InputKind::Prompt { text: "Name".to_owned(), default: String::new(), multiline: false }); + assert_eq!(inputs[1].id, 1); + assert_eq!( + inputs[1].kind, + InputKind::Suggester { labels: vec!["a".to_owned(), "b".to_owned()], values: vec!["1".to_owned(), "2".to_owned()], placeholder: String::new() } + ); + assert_eq!(inputs[2].id, 2); + assert_eq!(inputs[2].kind, InputKind::Prompt { text: "Other".to_owned(), default: "def".to_owned(), multiline: false }); + assert_eq!(template.input_errors(&test_ctx()), Vec::new()); +} + +#[test] +fn a_multiline_prompt_is_reported() { + let template = parse(r#"<% tp.system.prompt("Notes", "", false, true) %>"#).unwrap(); + let inputs = template.inputs(&test_ctx()); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].kind, InputKind::Prompt { text: "Notes".to_owned(), default: String::new(), multiline: true }); +} + +#[test] +fn the_answers_are_written_in_the_output() { + let template = parse(r#"<% tp.system.prompt("Name") %>-<% tp.system.suggester(["a"], ["1"]) %>"#).unwrap(); + let answers = Answers(BTreeMap::from([(0, Some("Ada".to_owned())), (1, Some("1".to_owned()))])); + let rendered = template.render(&test_ctx(), &answers); + assert_eq!(rendered.text, "Ada-1"); + assert_eq!(rendered.errors, Vec::new()); +} + +#[test] +fn a_command_without_an_answer_uses_the_default() { + let template = parse(r#"<% tp.system.prompt("Name", "Ada") %>!"#).unwrap(); + let rendered = template.render(&test_ctx(), &Answers::default()); + assert_eq!(rendered.text, "Ada!"); + assert_eq!(rendered.errors, Vec::new()); +} + +#[test] +fn a_cancelled_prompt_with_throw_aborts_the_render() { + let template = parse(r#"before <% tp.system.prompt("Name", "", true) %> after"#).unwrap(); + let answers = Answers(BTreeMap::from([(0, None)])); + let rendered = template.render(&test_ctx(), &answers); + assert_eq!(rendered.text, ""); + assert_eq!(rendered.errors.len(), 1); + assert_eq!(rendered.errors[0].message, "cancelled"); +} + +#[test] +fn a_cancelled_prompt_without_throw_is_an_empty_answer() { + let template = parse(r#"before <% tp.system.prompt("Name", "", false) %> after"#).unwrap(); + let answers = Answers(BTreeMap::from([(0, None)])); + let rendered = template.render(&test_ctx(), &answers); + assert_eq!(rendered.text, "before after"); + assert_eq!(rendered.errors, Vec::new()); +} + +#[test] +fn a_cancelled_suggester_with_throw_aborts_the_render() { + let template = parse(r#"<% tp.system.suggester(["a"], ["1"], true) %>"#).unwrap(); + let answers = Answers(BTreeMap::from([(0, None)])); + let rendered = template.render(&test_ctx(), &answers); + assert_eq!(rendered.text, ""); + assert_eq!(rendered.errors.len(), 1); + assert_eq!(rendered.errors[0].message, "cancelled"); +} + +#[test] +fn a_prompt_argument_is_built_from_the_context() { + let template = parse(r#"<% tp.system.prompt(tp.file.title + " notes") %>"#).unwrap(); + let inputs = template.inputs(&test_ctx()); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].kind, InputKind::Prompt { text: "Note notes".to_owned(), default: String::new(), multiline: false }); +} + +#[test] +fn a_prompt_that_cannot_be_built_is_reported() { + let template = parse(r#"<% tp.system.prompt(tp.system.prompt("inner")) %>"#).unwrap(); + assert_eq!(template.inputs(&test_ctx()), Vec::new()); + let errors = template.input_errors(&test_ctx()); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].line, 1); +} + +#[test] +fn the_id_of_a_question_is_the_index_of_its_command() { + let template = parse(r#"<% tp.file.title %><% tp.system.prompt("Name") %>"#).unwrap(); + let inputs = template.inputs(&test_ctx()); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].id, 1); + let answers = Answers(BTreeMap::from([(1, Some("Ada".to_owned()))])); + let rendered = template.render(&test_ctx(), &answers); + assert_eq!(rendered.text, "NoteAda"); + assert_eq!(rendered.errors, Vec::new()); +} + +#[test] +fn a_command_that_is_not_a_question_is_not_asked() { + let template = parse(r"<% tp.file.title %><% tp.system.clipboard() %>").unwrap(); + assert_eq!(template.inputs(&test_ctx()), Vec::new()); + assert_eq!(template.input_errors(&test_ctx()), Vec::new()); +} diff --git a/crates/ui-egui/src/text/views/banner/mod.rs b/crates/ui-egui/src/text/views/banner/mod.rs new file mode 100644 index 0000000..5a9144a --- /dev/null +++ b/crates/ui-egui/src/text/views/banner/mod.rs @@ -0,0 +1,5 @@ +//! Copy for the banner of a note. + +table! { + remote_off = "ui.banner.remote-off"; +} diff --git a/crates/ui-egui/src/text/views/mod.rs b/crates/ui-egui/src/text/views/mod.rs index dee3e58..bab12f2 100644 --- a/crates/ui-egui/src/text/views/mod.rs +++ b/crates/ui-egui/src/text/views/mod.rs @@ -1,5 +1,6 @@ //! Copy for the content views. +pub mod banner; pub mod base_view; pub mod batch_view; pub mod blocks; diff --git a/crates/ui-egui/src/views/note_view.rs b/crates/ui-egui/src/views/note_view.rs index c4b197c..a46785b 100644 --- a/crates/ui-egui/src/views/note_view.rs +++ b/crates/ui-egui/src/views/note_view.rs @@ -20,6 +20,7 @@ mod edit_tests; mod flow; pub(crate) mod format; pub(crate) mod grip; +mod header; mod inlines; mod lines; mod painting; @@ -35,6 +36,7 @@ use blocks::rich::text_line::render_text_line; pub use blocks::{EmbedNotes, dismissed_offers, request_grammar, set_dismissed_offers, set_embeds, take_grammar_request}; use blocks::{callout_card, code_block, display_math, properties_card, table}; pub use edit::NoteEdit; +pub use header::{banner, title_icon}; pub use inlines::tag_target; use inlines::{Span, spans_for_line}; use lines::{atx_marker, fence_token, heading_size, item_heading, item_heading_level, items_by_line, line_table, next_line_after, quote_depth, strip_quotes}; diff --git a/crates/ui-egui/src/views/note_view/header/banner.rs b/crates/ui-egui/src/views/note_view/header/banner.rs new file mode 100644 index 0000000..54bb713 --- /dev/null +++ b/crates/ui-egui/src/views/note_view/header/banner.rs @@ -0,0 +1,344 @@ +//! The banner of a note: the header image at the top of the note view. +//! +//! The picture fills the banner like `object-fit: cover`, cropped around the focus of the +//! `banner_x` and `banner_y` properties. It is decoded off the UI thread into a bounded +//! cache of textures, so opening a note never blocks a frame. +//! +//! A banner that names an address is never fetched: it paints a placeholder that says the +//! remote banners are off. A banner that names a file the vault does not hold paints nothing. + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::OnceLock; + +use egui::{Align2, Color32, FontId, Pos2, Rect, TextureHandle, TextureId, TextureOptions, Ui, Vec2}; + +use crate::app::decor::Banner; +use crate::image_view::Loader; +use crate::theme::{Tokens, color}; + +use crate::text::banner as strings; + +/// The most banner textures kept in the cache. +pub const MAX_BANNERS: usize = 8; +/// The side of a banner texture, pixels. +const BANNER_SIDE: usize = 1024; +/// The height of a banner when the settings say nothing, in points. +pub const DEFAULT_HEIGHT: f32 = 180.0; +/// The tallest banner painted, in points. +const MAX_HEIGHT: f32 = 400.0; +/// The size of the icon of the placeholder, points. +const PLACEHOLDER_ICON: f32 = 28.0; +/// The size of the text of the placeholder, points. +const PLACEHOLDER_TEXT: f32 = 12.0; +/// The biggest banner file read, bytes. +const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; + +/// What one banner of the cache holds. +enum State { + /// The picture is being decoded on a worker. + Loading(Loader), + /// The picture is on the GPU. + Ready(TextureHandle), + /// The file cannot be shown. + Failed, +} + +/// One banner of the cache. +struct Slot { + /// The frame it was last asked for in. + used: u64, + /// What the banner holds. + state: State, +} + +/// The banner textures of the app. +#[derive(Default)] +struct Banners { + /// The vault the pictures are read from. + root: Option, + /// The banners, by vault path. + slots: BTreeMap, + /// The frame counter, for the least recently used order. + clock: u64, + /// How many pictures were asked of a worker. + started: u64, +} + +impl Banners { + /// Starts the decoding of the picture at `file`, when it is not known yet. + fn start(&mut self, key: &str, file: PathBuf, vector: bool) { + self.clock += 1; + self.started += 1; + let loader = Loader::start(file, vector, BANNER_SIDE); + self.slots.insert(key.to_owned(), Slot { used: self.clock, state: State::Loading(loader) }); + self.evict(key); + } + + /// Drops the least recently used banners beyond [`MAX_BANNERS`], never `keep`. + fn evict(&mut self, keep: &str) { + while self.slots.len() > MAX_BANNERS { + let Some(oldest) = self.slots.iter().filter(|(path, _)| path.as_str() != keep).min_by_key(|(_, slot)| slot.used).map(|(path, _)| path.clone()) + else { + return; + }; + self.slots.remove(&oldest); + } + } +} + +/// The banner textures of the app. +fn banners() -> &'static egui::mutex::Mutex { + static BANNERS: OnceLock> = OnceLock::new(); + BANNERS.get_or_init(|| egui::mutex::Mutex::new(Banners::default())) +} + +/// The number of banner textures the cache holds. +#[must_use] +pub fn cached_banners() -> usize { + banners().lock().slots.len() +} + +/// How many pictures were asked of a worker since the app started. +#[must_use] +pub fn started_loads() -> u64 { + banners().lock().started +} + +/// Forgets the pictures of another vault. +fn sync_root(root: &Path) { + let mut banners = banners().lock(); + if banners.root.as_deref() != Some(root) { + banners.root = Some(root.to_path_buf()); + banners.slots.clear(); + } +} + +/// The part of a picture that fills a box, around the focus of the crop. +/// +/// `picture` is the size of the picture in pixels, `box_size` the size of the box in points +/// and `focus` the point of the picture that stays in the middle of the box (0.0 to 1.0 on +/// both axes). The result is the source rectangle, in the 0.0 to 1.0 range of the picture. +#[must_use] +pub fn crop_uv(picture: Vec2, box_size: Vec2, focus: Vec2) -> Rect { + if picture.x <= 0.0 || picture.y <= 0.0 || box_size.x <= 0.0 || box_size.y <= 0.0 { + return Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)); + } + let focus = Vec2::new(unit(focus.x), unit(focus.y)); + let (picture_ratio, box_ratio) = (picture.x / picture.y, box_size.x / box_size.y); + if picture_ratio > box_ratio { + // The picture is wider than the box: the crop is horizontal, the height is whole. + let keep = (box_ratio / picture_ratio).clamp(0.0, 1.0); + let start = (focus.x - keep / 2.0).clamp(0.0, 1.0 - keep); + Rect::from_min_max(Pos2::new(start, 0.0), Pos2::new(start + keep, 1.0)) + } else { + // The picture is taller than the box: the crop is vertical, the width is whole. + let keep = (picture_ratio / box_ratio).clamp(0.0, 1.0); + let start = (focus.y - keep / 2.0).clamp(0.0, 1.0 - keep); + Rect::from_min_max(Pos2::new(0.0, start), Pos2::new(1.0, start + keep)) + } +} + +/// A number between 0.0 and 1.0; anything else is the middle. +fn unit(value: f32) -> f32 { + if value.is_finite() { value.clamp(0.0, 1.0) } else { 0.5 } +} + +/// What the cache holds for a banner. +enum Ready { + /// The picture is on the GPU, with its size in pixels. + Texture(TextureId, Vec2), + /// The picture is still being decoded. + Loading, + /// The file is not there, or cannot be shown. + Missing, +} + +/// Paints the banner of the note at `path` across the full width, when it has one. +/// +/// `root` is the vault the picture is read from and `height` the height of the banner in +/// points. Returns whether a banner was painted. +pub fn show(ui: &mut Ui, tokens: &Tokens, root: &Path, path: &str, height: f32) -> bool { + let Some(item) = crate::tree::decor_item(ui, path) else { return false }; + let Some(banner) = item.banner.as_ref() else { return false }; + paint(ui, tokens, banner, root, height) +} + +/// Paints one banner. +fn paint(ui: &mut Ui, tokens: &Tokens, banner: &Banner, root: &Path, height: f32) -> bool { + let height = if height.is_finite() { height.clamp(0.0, MAX_HEIGHT) } else { DEFAULT_HEIGHT }; + let width = ui.available_width(); + if height <= 0.0 || width <= 0.0 { + return false; + } + sync_root(root); + let radius = crate::style::radius_u8(tokens.radius_medium()); + let rect = Rect::from_min_size(ui.cursor().min, Vec2::new(width, height)); + if banner.url { + paint_placeholder(ui, tokens, rect, radius); + } else { + let Some(file) = banner.path.as_deref() else { return false }; + match texture(ui.ctx(), root, file) { + Ready::Texture(id, size) => paint_picture(ui, id, size, rect, banner, radius), + Ready::Loading => { + paint_fill(ui, rect, color(tokens.theme.colors.surface_2), radius); + } + Ready::Missing => return false, + } + } + ui.allocate_space(Vec2::new(width, height)); + true +} + +/// Paints the placeholder of a banner that names an address: nothing is fetched. +fn paint_placeholder(ui: &Ui, tokens: &Tokens, rect: Rect, radius: u8) { + let colors = &tokens.theme.colors; + let painter = ui.painter(); + paint_fill(ui, rect, color(colors.surface_2), radius); + let icon = Rect::from_center_size(rect.center() - Vec2::new(0.0, 12.0), Vec2::splat(PLACEHOLDER_ICON)); + crate::icons::paint(ui, "image", icon, color(colors.text_muted)); + painter.text( + rect.center() + Vec2::new(0.0, 18.0), + Align2::CENTER_CENTER, + strings::remote_off(), + FontId::proportional(PLACEHOLDER_TEXT), + color(colors.text_muted), + ); +} + +/// Paints the picture of a banner, cropped around the focus of the crop. +fn paint_picture(ui: &Ui, id: TextureId, size: Vec2, rect: Rect, banner: &Banner, radius: u8) { + if size.x <= 0.0 || size.y <= 0.0 { + return; + } + let uv = crop_uv(size, Vec2::new(rect.width(), rect.height()), Vec2::new(banner.x, banner.y)); + let mut shape = egui::epaint::RectShape::new(rect, bottom_corners(radius), Color32::WHITE, egui::Stroke::NONE, egui::StrokeKind::Inside); + shape.brush = Some(std::sync::Arc::new(egui::epaint::Brush { fill_texture_id: id, uv })); + ui.painter().add(egui::Shape::Rect(shape)); +} + +/// Paints a filled box with the bottom corners rounded, like the card of the note. +fn paint_fill(ui: &Ui, rect: Rect, fill: Color32, radius: u8) { + let shape = egui::epaint::RectShape::new(rect, bottom_corners(radius), fill, egui::Stroke::NONE, egui::StrokeKind::Inside); + ui.painter().add(egui::Shape::Rect(shape)); +} + +/// The corners of a banner: the bottom ones are rounded, the top ones are not. +fn bottom_corners(radius: u8) -> egui::CornerRadius { + egui::CornerRadius { nw: 0, ne: 0, sw: radius, se: radius } +} + +/// The texture of the banner at vault path `path`, when it has one. +fn texture(ctx: &egui::Context, root: &Path, path: &str) -> Ready { + let mut banners = banners().lock(); + if banners.root.as_deref() != Some(root) { + banners.root = Some(root.to_path_buf()); + banners.slots.clear(); + } + let Some(file) = file_of(root, path) else { return Ready::Missing }; + let key = path.to_owned(); + if !banners.slots.contains_key(&key) { + banners.start(&key, file.clone(), is_vector(&file)); + } + banners.clock += 1; + let clock = banners.clock; + let Some(slot) = banners.slots.get_mut(&key) else { return Ready::Missing }; + slot.used = clock; + if let State::Loading(loader) = &mut slot.state { + match loader.poll() { + Some(Ok(mut picture)) => { + let handle = ctx.load_texture(format!("note-banner:{key}"), picture.take_image(), TextureOptions::LINEAR); + slot.state = State::Ready(handle); + } + Some(Err(_)) => slot.state = State::Failed, + None => ctx.request_repaint(), + } + } + match &slot.state { + State::Ready(handle) => Ready::Texture(handle.id(), handle.size_vec2()), + State::Loading(_) => Ready::Loading, + State::Failed => Ready::Missing, + } +} + +/// The file of the vault the banner `path` names, when it may be used. +fn file_of(root: &Path, path: &str) -> Option { + let relative = inside_vault(path)?; + let file = root.join(relative); + let metadata = std::fs::metadata(&file).ok()?; + if metadata.len() > MAX_FILE_BYTES { + log::warn!("note banner: {} is {} bytes, over the cap", file.display(), metadata.len()); + return None; + } + Some(file) +} + +/// The path of `target`, when it names a file inside the vault. +fn inside_vault(target: &str) -> Option<&Path> { + if target.is_empty() { + return None; + } + let path = Path::new(target); + if path.is_absolute() { + return None; + } + if path.components().any(|part| !matches!(part, Component::Normal(_))) { + return None; + } + Some(path) +} + +/// Whether the file is a vector picture. +fn is_vector(path: &Path) -> bool { + path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) +} + +#[cfg(test)] +mod tests { + use egui::vec2; + + use super::*; + + /// The source rectangle of a picture that fills a box. + fn uv(picture: Vec2, box_size: Vec2, focus: Vec2) -> (f32, f32, f32, f32) { + let rect = crop_uv(picture, box_size, focus); + (rect.min.x, rect.min.y, rect.max.x, rect.max.y) + } + + #[test] + fn a_tall_picture_is_cropped_around_the_vertical_focus() { + // A picture of 200x400 in a box of 200x180: the top 45% of the picture shows. + assert_eq!(uv(vec2(200.0, 400.0), vec2(200.0, 180.0), vec2(0.5, 0.2)), (0.0, 0.0, 1.0, 0.45)); + // The focus at the bottom shows the bottom of the picture. + assert_eq!(uv(vec2(200.0, 400.0), vec2(200.0, 180.0), vec2(0.5, 0.9)), (0.0, 0.55, 1.0, 1.0)); + } + + #[test] + fn a_wide_picture_is_cropped_around_the_horizontal_focus() { + // A picture of 400x200 in a box of 200x180: the middle 55% of the width shows. + let (left, top, right, bottom) = uv(vec2(400.0, 200.0), vec2(200.0, 180.0), vec2(0.5, 0.5)); + assert!((left - 0.2222).abs() < 0.001, "{left}"); + assert!((right - 0.7778).abs() < 0.001, "{right}"); + assert_eq!((top, bottom), (0.0, 1.0)); + } + + #[test] + fn a_focus_outside_the_picture_picks_the_nearest_edge() { + assert_eq!(uv(vec2(200.0, 400.0), vec2(200.0, 180.0), vec2(0.5, -3.0)), (0.0, 0.0, 1.0, 0.45)); + assert_eq!(uv(vec2(200.0, 400.0), vec2(200.0, 180.0), vec2(0.5, 7.0)), (0.0, 0.55, 1.0, 1.0)); + } + + #[test] + fn an_empty_picture_or_box_shows_the_whole_picture() { + assert_eq!(uv(Vec2::ZERO, vec2(200.0, 180.0), vec2(0.5, 0.5)), (0.0, 0.0, 1.0, 1.0)); + assert_eq!(uv(vec2(200.0, 400.0), Vec2::ZERO, vec2(0.5, 0.5)), (0.0, 0.0, 1.0, 1.0)); + } + + #[test] + fn a_path_out_of_the_vault_is_refused() { + assert!(inside_vault("cover.jpg").is_some()); + assert!(inside_vault("../outside.jpg").is_none()); + assert!(inside_vault("/etc/hosts").is_none()); + assert!(inside_vault("").is_none()); + } +} diff --git a/crates/ui-egui/src/views/note_view/header/mod.rs b/crates/ui-egui/src/views/note_view/header/mod.rs new file mode 100644 index 0000000..eebf909 --- /dev/null +++ b/crates/ui-egui/src/views/note_view/header/mod.rs @@ -0,0 +1,5 @@ +//! The header of a note: the banner at the top of the note view and the icon above the +//! title. + +pub mod banner; +pub mod title_icon; diff --git a/crates/ui-egui/src/views/note_view/header/title_icon.rs b/crates/ui-egui/src/views/note_view/header/title_icon.rs new file mode 100644 index 0000000..35dc492 --- /dev/null +++ b/crates/ui-egui/src/views/note_view/header/title_icon.rs @@ -0,0 +1,28 @@ +//! The icon of a note, above its title. +//! +//! The icon comes from the `icon` property of the note (or the emoji of `banner_icon`): a +//! Lucide icon, an emoji or a picture of the vault. It wears the tint of the note, so the +//! colour of the property reaches the icon. + +use egui::{Rect, Ui, Vec2}; + +use crate::design::note_icon; +use crate::theme::Tokens; + +/// The size of the icon above the title, in points. +pub const TITLE_ICON_SIZE: f32 = 40.0; +/// The room under the icon, in points. +const GAP: f32 = 8.0; + +/// Paints the icon of the note at `path` above its title, when it has one. +/// +/// Returns whether an icon was painted. +pub fn show(ui: &mut Ui, tokens: &Tokens, path: &str) -> bool { + let Some(item) = crate::tree::decor_item(ui, path) else { return false }; + let Some(icon) = item.icon.as_ref() else { return false }; + let tint = note_icon::tint(item.color.as_ref(), tokens); + let rect = Rect::from_min_size(ui.cursor().min, Vec2::splat(TITLE_ICON_SIZE)); + note_icon::paint(ui, icon, rect, tint); + ui.allocate_space(Vec2::new(TITLE_ICON_SIZE, TITLE_ICON_SIZE + GAP)); + true +} diff --git a/crates/ui-egui/tests/banner.rs b/crates/ui-egui/tests/banner.rs new file mode 100644 index 0000000..9c50db6 --- /dev/null +++ b/crates/ui-egui/tests/banner.rs @@ -0,0 +1,289 @@ +//! The header of a note: the banner at the top of the note view and the icon above the +//! title. +//! +//! A note with a banner paints the picture across the full width, cropped around the focus +//! of the crop; a banner that names an address paints a placeholder and fetches nothing; a +//! banner that names a file the vault does not hold paints nothing. The icon of a note is +//! painted above the title, in the tint of the note. + +use std::path::{Path, PathBuf}; + +use egui::{Color32, Context, RawInput, Rect, Vec2, pos2, vec2}; +use notescraft_ui_egui::app::decor::DecorSnapshot; +use notescraft_ui_egui::note_view::{banner, title_icon}; +use notescraft_ui_egui::theme::Tokens; +use notescraft_ui_egui::{fonts, motion, tree}; +use serde_json::{Value, json}; + +/// A picture of the vault, used as the banner of a note. +const PICTURE: &[u8] = include_bytes!("fixtures/cover-a.png"); +/// The name of the picture inside the vault. +const COVER: &str = "cover.png"; +/// The note the banner belongs to. +const NOTE: &str = "note.md"; +/// The emoji icon of a note. +const ROCKET: &str = "๐Ÿš€"; + +/// The lock the tests take: the cache of the banners is shared by the whole process. +static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// The lock of the tests, taken while a test runs. +fn lock() -> std::sync::MutexGuard<'static, ()> { + LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The tokens of the default theme. +fn tokens() -> Tokens { + let theme = notescraft_themes::builtin("catppuccin-mocha").expect("the theme is bundled"); + Tokens::new(theme) +} + +/// A context with the fonts, the image loaders and no motion. +fn context() -> Context { + let ctx = Context::default(); + fonts::install(&ctx); + motion::set_enabled(&ctx, false); + ctx.all_styles_mut(|style| style.animation_time = 0.0); + egui_extras::install_image_loaders(&ctx); + ctx +} + +/// A vault with the picture of a banner in it. +fn vault(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("notescraft-banner-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("the temp vault is made"); + std::fs::write(root.join(COVER), PICTURE).expect("the picture is written"); + root +} + +/// The snapshot of a vault with the decorations of `items`. +fn snapshot(items: &Value) -> DecorSnapshot { + let mut snapshot = DecorSnapshot::default(); + assert!(snapshot.apply(&json!({ "items": items, "generation": 1 })), "the answer fills the snapshot"); + snapshot +} + +/// The textures of a frame, applied to the context so the next frame can paint them. +fn apply_textures(ctx: &egui::Context, output: &mut egui::FullOutput) { + let manager = ctx.tex_manager(); + let mut manager = manager.write(); + for (id, deltas) in output.textures_delta.set.drain() { + for delta in deltas { + manager.set(id, delta); + } + } +} + +/// The shapes one frame of the UI paints. +fn frame(ctx: &Context, mut draw: impl FnMut(&mut egui::Ui)) -> Vec { + let mut output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0))), ..Default::default() }, |ui| draw(ui)); + apply_textures(ctx, &mut output); + output.shapes.into_iter().map(|shape| shape.shape).collect() +} + +/// The shapes of the banner of a note, painted in a frame of its own. +fn banner_shapes(ctx: &Context, root: &Path, height: f32) -> Vec { + let tokens = tokens(); + frame(ctx, |ui| { + banner::show(ui, &tokens, root, NOTE, height); + }) +} + +/// The shapes of the icon above the title of a note. +fn icon_shapes(ctx: &Context) -> Vec { + let tokens = tokens(); + frame(ctx, |ui| { + title_icon::show(ui, &tokens, NOTE); + }) +} + +/// The texts one frame paints. +fn texts(shapes: &[egui::Shape]) -> Vec { + shapes + .iter() + .filter_map(|shape| match shape { + egui::Shape::Text(text) => Some(text.galley.text().to_owned()), + _ => None, + }) + .collect() +} + +/// The pictures one frame paints: a textured rectangle or a mesh. +fn pictures(shapes: &[egui::Shape]) -> Vec { + shapes.iter().filter(|shape| is_textured(shape)).cloned().collect() +} + +/// Whether the shape paints a texture of its own. +fn is_textured(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Rect(rect) => rect.brush.as_deref().is_some_and(|brush| brush.uv != Rect::ZERO), + egui::Shape::Mesh(mesh) => mesh.texture_id != egui::TextureId::default(), + _ => false, + } +} + +/// The box the picture of a frame covers. +fn picture_box(shapes: &[egui::Shape]) -> Option { + let mut bounds: Option = None; + for shape in shapes { + match shape { + egui::Shape::Rect(rect) if rect.brush.as_deref().is_some_and(|brush| brush.uv != Rect::ZERO) => { + bounds = Some(bounds.map_or(rect.rect, |box_| box_.union(rect.rect))); + } + egui::Shape::Mesh(mesh) => { + for vertex in &mesh.vertices { + let point = Rect::from_min_size(vertex.pos, Vec2::ZERO); + bounds = Some(bounds.map_or(point, |box_| box_.union(point))); + } + } + _ => {} + } + } + bounds +} + +/// The colour of the text one frame paints. +fn text_color(shapes: &[egui::Shape], text: &str) -> Option { + shapes.iter().find_map(|shape| match shape { + egui::Shape::Text(painted) if painted.galley.text() == text => painted.galley.job.sections.first().map(|section| section.format.color), + _ => None, + }) +} + +/// The banner of a note that points at the picture of the vault. +fn banner_of_the_picture() -> Value { + json!({ "banner": { "path": COVER, "x": 0.5, "y": 0.2 } }) +} + +#[test] +fn a_note_with_a_banner_paints_the_picture() { + let _guard = lock(); + let ctx = context(); + let root = vault("picture"); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: banner_of_the_picture() }))); + + let before = banner::started_loads(); + let mut shapes = Vec::new(); + for _ in 0..200 { + shapes = banner_shapes(&ctx, &root, 180.0); + if !pictures(&shapes).is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!pictures(&shapes).is_empty(), "the picture of the banner is painted"); + assert_eq!(banner::started_loads() - before, 1, "the picture is asked for once"); + assert_eq!(banner::cached_banners(), 1, "the picture is kept in the cache"); +} + +#[test] +fn the_banner_fills_the_width_of_the_note() { + let _guard = lock(); + let ctx = context(); + let root = vault("width"); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: banner_of_the_picture() }))); + let mut shapes = Vec::new(); + for _ in 0..200 { + shapes = banner_shapes(&ctx, &root, 180.0); + if !pictures(&shapes).is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let Some(box_) = picture_box(&shapes) else { + panic!("the banner paints a picture: {shapes:?}"); + }; + assert!((box_.width() - 600.0).abs() < 1.0, "the banner is as wide as the note: {box_:?}"); + assert!((box_.height() - 180.0).abs() < 1.0, "the banner is as tall as the settings say: {box_:?}"); + let painted = shapes + .iter() + .find_map(|shape| match shape { + egui::Shape::Rect(rect) if rect.brush.as_deref().is_some_and(|brush| brush.uv != Rect::ZERO) => Some(rect), + _ => None, + }) + .expect("the banner paints a rectangle"); + assert_eq!(painted.corner_radius.nw, 0, "the top corners of the banner are square"); + assert!(painted.corner_radius.sw > 0, "the bottom corners of the banner are rounded"); +} + +#[test] +fn the_crop_of_the_banner_follows_the_focus() { + // A picture of 200x400 in a box of 200x180: the focus at 0.2 shows the top of it. + let rect = banner::crop_uv(vec2(200.0, 400.0), vec2(200.0, 180.0), vec2(0.5, 0.2)); + assert_eq!((rect.min.x, rect.min.y, rect.max.x, rect.max.y), (0.0, 0.0, 1.0, 0.45)); + // The focus at the bottom shows the bottom of it. + let rect = banner::crop_uv(vec2(200.0, 400.0), vec2(200.0, 180.0), vec2(0.5, 0.9)); + assert_eq!((rect.min.x, rect.min.y, rect.max.x, rect.max.y), (0.0, 0.55, 1.0, 1.0)); +} + +#[test] +fn a_remote_banner_paints_a_placeholder_and_fetches_nothing() { + let _guard = lock(); + let ctx = context(); + let root = vault("remote"); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: { "banner": { "url": "https://example.com/a.png", "x": 0.5, "y": 0.5 } } }))); + + let before = banner::started_loads(); + let shapes = banner_shapes(&ctx, &root, 180.0); + let shown = texts(&shapes); + assert!(shown.iter().any(|text| text.contains("Remote banners are off")), "the placeholder is shown: {shown:?}"); + assert_eq!(banner::started_loads() - before, 0, "no file of the vault is read"); + assert_eq!(banner::cached_banners(), 0, "no picture is kept"); +} + +#[test] +fn a_note_without_a_banner_paints_nothing() { + let _guard = lock(); + let ctx = context(); + let root = vault("none"); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: { "icon": { "kind": "emoji", "value": ROCKET } } }))); + + let before = banner::started_loads(); + let shapes = banner_shapes(&ctx, &root, 180.0); + assert!(texts(&shapes).is_empty(), "no text is painted: {:?}", texts(&shapes)); + assert!(pictures(&shapes).is_empty(), "no picture is painted"); + assert_eq!(banner::started_loads() - before, 0, "no file of the vault is read"); +} + +#[test] +fn a_banner_of_a_missing_file_paints_nothing() { + let _guard = lock(); + let ctx = context(); + let root = vault("missing"); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: { "banner": { "path": "not-there.png", "x": 0.5, "y": 0.5 } } }))); + + let before = banner::started_loads(); + let shapes = banner_shapes(&ctx, &root, 180.0); + assert!(texts(&shapes).is_empty(), "no text is painted: {:?}", texts(&shapes)); + assert!(pictures(&shapes).is_empty(), "no picture is painted"); + assert_eq!(banner::started_loads() - before, 0, "no file of the vault is read"); +} + +#[test] +fn the_icon_of_a_note_is_painted_above_its_title() { + let ctx = context(); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: { "icon": { "kind": "emoji", "value": ROCKET } } }))); + + let shapes = icon_shapes(&ctx); + let shown = texts(&shapes); + assert!(shown.iter().any(|text| text.contains(ROCKET)), "the icon is painted: {shown:?}"); +} + +#[test] +fn the_icon_wears_the_colour_of_the_note() { + let ctx = context(); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: { "icon": { "kind": "emoji", "value": ROCKET }, "color": { "kind": "rgb", "value": "#ff8800" } } }))); + + let shapes = icon_shapes(&ctx); + assert_eq!(text_color(&shapes, ROCKET), Some(Color32::from_rgb(0xff, 0x88, 0x00)), "the icon wears the colour of the note"); +} + +#[test] +fn a_note_without_an_icon_paints_nothing_above_its_title() { + let ctx = context(); + tree::publish_decor(&ctx, &snapshot(&json!({ NOTE: { "banner": { "path": COVER, "x": 0.5, "y": 0.5 } } }))); + + let shapes = icon_shapes(&ctx); + assert!(texts(&shapes).is_empty(), "no text is painted: {:?}", texts(&shapes)); +} diff --git a/specs/plugins-parity/research/agy-feature-gaps.md b/specs/plugins-parity/research/agy-feature-gaps.md new file mode 100644 index 0000000..9667d06 --- /dev/null +++ b/specs/plugins-parity/research/agy-feature-gaps.md @@ -0,0 +1,83 @@ +# 25 High-Value Unplanned Features for NotesCraft + +| Rank | Feature | Who Has It | Why Users Want It | Effort | Source | +|---|---|---|---|---|---| +| 1 | **PDF Deep Annotation & Visual Excerpts** | Logseq, Heptabase, SiYuan | Direct highlights and rectangular crops in PDFs linked to notes (`[[file.pdf#page=4&rect=...]]`) with click-to-jump. | M | [Logseq PDF Docs](https://docs.logseq.com/#/page/pdf%20highlights) | +| 2 | **Web Clipper (Markdown + Local Assets)** | Obsidian (Official Clipper), Notion, Joplin, Bear | 1-click browser capture extracting clean Markdown, metadata, and local media into the vault without telemetry. | M | [Obsidian Clipper](https://obsidian.md/clipper) | +| 3 | **Native Spaced Repetition (FSRS / SM-2)** | Logseq, SiYuan, RemNote (Obsidian plugin: 400k+ DL) | Converts inline Q&A or cloze deletions (`==highlight==`) into active recall flashcards directly inside markdown notes. | S | [Logseq Flashcards](https://docs.logseq.com/#/page/flashcards) | +| 4 | **Local Whisper Voice Transcription** | Reflect, Tana, Apple Notes (iOS 18) | On-device, offline audio-to-text for meeting logs and voice notes without cloud APIs or recurring fees. | M | [Reflect Whisper](https://reflect.app/whisper) | +| 5 | **Per-Note & Secret Folder AES Encryption** | Apple Notes, Joplin, SiYuan, Obsidian #4113 | Zero-knowledge client-side encryption (AES-256-GCM + Argon2id) for sensitive notes stored on disk. | S | [Obsidian Forum #4113](https://forum.obsidian.md/t/password-protect-lock-folder-encryption-at-rest/4113) | +| 6 | **Object Types & Supertags with Inheritance** | Tana (Supertags), Capacities, Anytype | Polymorphic entity types on tags (e.g., `#person`, `#book`) enforcing schemas, typed fields, and templates. | M | [Tana Supertags](https://tana.inc/features/supertags) | +| 7 | **Synced Blocks (Editable Transclusion)** | Notion, Logseq, Roam, SiYuan, Obsidian #15339 | Embedded blocks (`![[note#^id]]`) editable in place, propagating mutations live to source markdown ASTs. | M | [Notion Synced Blocks](https://www.notion.com/help/synced-blocks) | +| 8 | **Attachment OCR (Image & PDF Text Search)** | Apple Notes, Bear, Joplin, OneNote | Full-text indexing and clipboard text extraction for receipts, whiteboard photos, diagrams, and scanned PDFs. | M | [Bear OCR Search](https://blog.bear.app/2020/09/bear-1-8-brings-ocr-search/) | +| 9 | **Smart Folders (Dynamic Virtual Folders)** | Apple Notes, Bear, Joplin | Rule-based virtual folders in Explorer sidebar dynamically populated from metadata and BM25 queries. | S | [Apple Notes Smart Folders](https://support.apple.com/guide/notes/use-smart-folders-apd8f60d6945/mac) | +| 10 | **Rich OpenGraph Link Bookmark Cards** | Notion, Capacities, Bear, Craft | Paste URL, fetch page title, description, favicon, and hero thumbnail; caches locally for offline card display. | S | [Capacities Weblinks](https://capacities.io/docs/objects/weblinks) | +| 11 | **Global Quick-Capture Floating HUD** | Reflect, Raycast Notes, Apple Notes | System-wide shortcut summons frameless overlay; appends quick thought or task to Inbox/Daily note without app focus. | S | [Apple Quick Note](https://support.apple.com/guide/mac-help/create-a-quick-note-mchl40376171/mac) | +| 12 | **Zotero & BibTeX Citation Manager** | Logseq (Native), Obsidian Citation Suite | Inline academic citations (`[@citekey]`), CSL bibliography formatting, and 1-click paper metadata sync. | M | [Logseq Zotero](https://docs.logseq.com/#/page/zotero) | +| 13 | **Hybrid Doc-to-Canvas Mode (Edgeless)** | AFFiNE | 1-click toggle switches standard linear note into infinite 2D spatial canvas where sections become cards. | M | [AFFiNE Edgeless](https://affine.pro) | +| 14 | **Auto-Generated Outline Mind Map** | Heptabase, SiYuan, Obsidian Markmind | Visual radial/tree graph dynamically rendered from heading and list indentation for spatial thinking. | M | [Obsidian Mind Map](https://github.com/lynchjames/obsidian-mind-map) | +| 15 | **Table Spreadsheet Formulas** | Notion, Emacs Org-mode, Obsidian Advanced Tables | Formula execution (`=SUM(B1:B5)`, `=A1*1.2`) inside standard markdown pipe tables without external sheets. | S | [Advanced Tables](https://github.com/tgrosinger/obsidian-advanced-tables) | +| 16 | **Markdown Presentation / Slides Mode** | Obsidian (Core Slides), Marp, SiYuan | Fullscreen slide presentation view driven directly by markdown `---` dividers with keyboard navigation. | S | [Obsidian Slides](https://help.obsidian.md/Plugins/Slides) | +| 17 | **Document Scanner & Perspective Correction** | Apple Notes, Joplin, Evernote | Camera capture with automatic edge detection, perspective homography rectification, and contrast filter. | M | [Apple Doc Scanner](https://support.apple.com/en-us/HT210336) | +| 18 | **EPUB Book Reader & In-Text Annotation** | SiYuan, Readwise Reader, Logseq | Read EPUB books in-app; highlight passages and extract quotes with CFI bookmarks linking back to source. | M | [Obsidian EPUB](https://github.com/brianpetro/obsidian-epub-reader) | +| 19 | **Native RSS / Atom Feed Reader & Inbox** | Capacities (Reader), Readwise Reader | Subscribe to RSS/Atom feeds; triage feed items into permanent markdown knowledge notes directly in-vault. | M | [Capacities Reader](https://capacities.io/whats-new/archive/release-40) | +| 20 | **Visual Chronological Timeline View** | Heptabase (Timeline), Aeon Timeline | Horizontal time-axis layout organizing notes and events by frontmatter date ranges with variable zoom. | M | [Heptabase Timeline](https://heptabase.com/features) | +| 21 | **Note Visual Diff & Time Machine Slider** | Bear, Apple Notes, Google Docs | Timeline slider over recovery snapshots / git commits showing character/word diffs with 1-click revert. | S | [Obsidian Forum #4988](https://forum.obsidian.md/t/version-history-diff-comparison-view/4988) | +| 22 | **Auto-Numbering (Headings, Theorems, Math)** | SiYuan, Typst, Obsidian Number Headings | Automatic hierarchical numbering (`1.`, `1.1`, `1.1.1`) for headings and equations with dynamic updates. | S | [Obsidian Number Headings](https://github.com/danielsloof/obsidian-number-headings-plugin) | +| 23 | **Deep Media Timestamp Links (`#t=MM:SS`)** | Logseq, RemNote, Obsidian Media Extended | Wikilinks linking to exact playback offsets in audio/video attachments (`[[rec.mp3#t=04:12]]`) with seek on click. | S | [Media Extended](https://github.com/aidenlx/media-extended) | +| 24 | **Smart Word/Web Clean-Paste Sanitizer** | Bear, Notion, Apple Notes | Sanitizes messy clipboard HTML from Word, Docs, or browsers into pure, semantic GFM markdown. | S | [Obsidian Forum #2115](https://forum.obsidian.md/t/paste-html-as-clean-markdown/2115) | +| 25 | **Inline Math & Soulver Calculator Blocks** | Reflect, NotePlan, Soulver, Obsidian Solve | Evaluates contextual arithmetic and unit/currency conversions inline (e.g., `12 * $40/hr + 10% = $528`). | S | [Obsidian Solve](https://github.com/lukas-manning/obsidian-solve) | + +--- + +## Top 10 Deep Dives + +### 1. PDF Deep Annotation & Visual Excerpts +* **Problem:** Researchers must juggle external PDF readers or manually copy text quotes without spatial context. +* **Why Users Want It:** Allows selecting text or drawing rectangular bounding boxes on PDF pages, generating a wikilink (`[[paper.pdf#page=3&rect=100,200,300,150]]`). Pasting that wikilink embeds the visual crop or quote inside any note or whiteboard. Clicking it reopens the PDF and highlights the exact coordinates. +* **Rust/egui Architecture:** Layer L1 crate `notescraft-pdf-annot`. Uses `lopdf` or `pdfium-render` to parse text runs and geometry. Paints rectangular selection handles and highlight overlays via egui canvas painters. Link resolver maps `#page=N&rect=...` to PDF viewport offset. + +### 2. Web Clipper (Markdown + Local Assets) +* **Problem:** Saving web articles via copy-paste loses formatting, breaks images, and pollutes vaults with unformatted HTML spans. +* **Why Users Want It:** 1-click browser extension (Chrome/Firefox/Safari) that extracts clean reader-mode Markdown using Readability heuristics, captures metadata (source URL, author, publish date) into frontmatter, and downloads web images locally into the vault attachment folder. +* **Rust/egui Architecture:** Browser extension talks to NotesCraftโ€™s existing loopback control-channel JSON server via `POST /clip`. App executes `engine.note_create` with downloaded media routed through `craft-atomic`. No cloud servers required. + +### 3. Native Spaced Repetition (FSRS / SM-2) +* **Problem:** Users must export notes to Anki or install heavyweight JS plugins to memorize knowledge captured in their vault. +* **Why Users Want It:** Inline card syntax (`Question::Answer` or `cloze with ==deletion==`) detected automatically during vault indexing. A dedicated review tab runs the modern Free Spaced Repetition Scheduler (FSRS) algorithm, presenting due flashcards with ease rating buttons. +* **Rust/egui Architecture:** Layer L2 crate `notescraft-srs`. Uses `fsrs-rs` scheduler crate. Stores card intervals and review histories in `.notescraft/srs.sqlite` or frontmatter. Review view renders standard Markdown card faces in egui. + +### 4. Local Whisper Voice Transcription +* **Problem:** Recording voice notes or meeting memos requires manual transcription or sending private audio to third-party APIs (OpenAI/Cloud). +* **Why Users Want It:** Fast, frictionless dictation on desktop and mobile. User records audio; local machine runs quantized Whisper (tiny/base/small) completely offline, producing timestamped markdown text with bulleted summaries directly in the daily note. +* **Rust/egui Architecture:** Layer L4 background service using `whisper-rs` (C++ whisper.cpp bindings, Metal/OpenVINO accelerated). Audio captured via `cpal` to 16kHz WAV, processed on worker thread, result written via `engine.note_append`. + +### 5. Per-Note & Secret Folder AES Encryption +* **Problem:** Plain-text markdown vaults leave sensitive notes (finances, journals, credentials) visible to anyone with filesystem access. +* **Why Users Want It:** Right-click note or folder -> "Lock". Content is encrypted at rest using AES-256-GCM with key derived via Argon2id from a master passphrase. Note displays password prompt in UI; decrypts in memory only while open. +* **Rust/egui Architecture:** Layer L0/L1 crate `notescraft-crypto`. Uses `aes-gcm` and `argon2`. Encrypted files stored as `.md.enc` with binary header (salt, nonce, ciphertext tag). Memory zeroized on tab close with `secrecy` crate. + +### 6. Object Types & Supertags with Inheritance +* **Problem:** Tags in Obsidian are flat strings (`#meeting`), requiring manual frontmatter template boilerplate on every new note. +* **Why Users Want It:** Tagging `#meeting` converts the note into a typed Object with required fields (`attendees:`, `date:`, `agenda:`), inherited parent schemas (`#meeting/client` inherits `#meeting`), and custom badge rendering in visual cards. +* **Rust/egui Architecture:** Layer L1 crate `notescraft-schema`. Frontmatter schemas defined in YAML (`.notescraft/schemas/*.yaml`). Validated during AST parse; typed input controls displayed in the properties panel. + +### 7. Synced Blocks (Editable Transclusion) +* **Problem:** Transcluded markdown blocks (`![[note#^block-id]]`) in Obsidian are strictly read-only mirrors; editing requires locating the original file. +* **Why Users Want It:** Direct in-place editing of transcluded sections across multiple notes or dashboards. Modifying the block in Note B immediately updates Note A on disk. +* **Rust/egui Architecture:** Layer L5 editor enhancement. Text mutation events in embedded block regions emit diff transactions mapped back to the origin note file path and byte offset. Origin file updated via `craft-atomic` with buffer reload signals dispatched to open tabs. + +### 8. Attachment OCR (Image & PDF Text Search) +* **Problem:** Text inside book snapshots, whiteboard captures, screenshots, and scanned receipts is invisible to full-text search. +* **Why Users Want It:** BM25 search finds matches inside images and scanned documents. Hovering or right-clicking an image in live preview allows selecting and copying detected text lines. +* **Rust/egui Architecture:** Layer L1/L4 worker using native platform OCR (Apple Vision on macOS, Windows.Media.Ocr on Windows, `ocrs` pure-Rust on Linux). Extracted text tokens indexed into BM25 index with bounding-box metadata cached in `.notescraft/ocr.db`. + +### 9. Smart Folders (Dynamic Virtual Folders) +* **Problem:** Physical folder hierarchies force a note into a single path, while saved searches require manual query execution. +* **Why Users Want It:** Virtual folders in the Explorer sidebar defined by queries (e.g., `folder:Projects AND tag:#urgent AND mtime:7d`). Clicking the folder renders matching notes dynamically as a folder listing without duplicating files. +* **Rust/egui Architecture:** Layer L5 UI filter over Layer L1 `notescraft-index`. Stored in `.notescraft/smart_folders.json`. Virtual folder tree node queries the existing BM25/metadata index on expansion, rendering virtual rows in the virtualized Explorer. + +### 10. Rich OpenGraph Link Bookmark Cards +* **Problem:** Pasting external web URLs as plain text creates uninformative, visually bland lists of links. +* **Why Users Want It:** Pasting a URL gives option to render an interactive web bookmark card displaying site favicon, OpenGraph title, description, and thumbnail image, cached offline. +* **Rust/egui Architecture:** Layer L5 preview component. Pure Rust background fetcher (`ureq`) retrieves `` tags. Metadata and thumbnails cached in `.notescraft/cache/web/`. egui renders rounded clickable card with domain badge.