feat: note scan into tasks, Templater inputs, note banner and title icon (MiniMax M3.1 Flash); agy feature-gap research
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-21 12:07:28 -03:00
parent 16a0786a2d
commit 6ed829e5bd
16 changed files with 1629 additions and 11 deletions
+3
View File
@@ -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"
+4 -1
View File
@@ -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};
+203
View File
@@ -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<String>,
}
/// 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<Task> {
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<Task> = 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<String> {
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
}
}
+141
View File
@@ -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<Task> {
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()));
}
+231
View File
@@ -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<String>,
/// The texts written to the output, one per label.
values: Vec<String>,
/// 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<String>,
/// The texts written to the output, one per label.
pub(crate) values: Vec<String>,
/// 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<Input> {
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<RenderError> {
self.scan(ctx).1
}
/// Reads the questions of the template and the errors of the scan.
fn scan(&self, ctx: &Ctx<'_>) -> (Vec<Input>, Vec<RenderError>) {
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<Vec<Value>> {
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<PromptArgs> {
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<SuggesterArgs> {
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<String> {
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<bool> {
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<Vec<String>> {
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()) }),
}
}
+2
View File
@@ -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};
+132 -10
View File
@@ -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<u32, Option<String>>);
/// 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<Value, Error> {
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<Value, Error> {
if is_text(left) || is_text(right) {
+156
View File
@@ -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<String> {
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());
}
@@ -0,0 +1,5 @@
//! Copy for the banner of a note.
table! {
remote_off = "ui.banner.remote-off";
}
+1
View File
@@ -1,5 +1,6 @@
//! Copy for the content views.
pub mod banner;
pub mod base_view;
pub mod batch_view;
pub mod blocks;
+2
View File
@@ -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};
@@ -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<PathBuf>,
/// The banners, by vault path.
slots: BTreeMap<String, Slot>,
/// 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<Banners> {
static BANNERS: OnceLock<egui::mutex::Mutex<Banners>> = 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<PathBuf> {
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());
}
}
@@ -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;
@@ -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
}
+289
View File
@@ -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<egui::Shape> {
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<egui::Shape> {
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<egui::Shape> {
let tokens = tokens();
frame(ctx, |ui| {
title_icon::show(ui, &tokens, NOTE);
})
}
/// The texts one frame paints.
fn texts(shapes: &[egui::Shape]) -> Vec<String> {
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<egui::Shape> {
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<Rect> {
let mut bounds: Option<Rect> = 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<Color32> {
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));
}
@@ -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 `<name>.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 `<meta property="og:...">` tags. Metadata and thumbnails cached in `.notescraft/cache/web/`. egui renders rounded clickable card with domain badge.