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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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()) }),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
//! Copy for the content views.
|
||||
|
||||
pub mod banner;
|
||||
pub mod base_view;
|
||||
pub mod batch_view;
|
||||
pub mod blocks;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user