diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f6debd7..292b333 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -9,7 +9,7 @@ use notecraft_store::page::Version; use notecraft_store::recycle::Recycled; use notecraft_store::search::Hit; use notecraft_store::tags::Tagged; -use notecraft_store::{StoreError, assets, check, ops, page, recycle, search, tags, tree}; +use notecraft_store::{StoreError, assets, check, ops, page, page_templates, recycle, search, tags, tree}; use std::collections::BTreeMap; use std::path::PathBuf; @@ -100,6 +100,29 @@ fn node_copy(s: &mut Session, p: &Params) -> Result { Ok(Output::Path(ops::copy_node(&s.resolve(req(p, "path")?)?, &s.resolve(req(p, "to")?)?)?)) } +fn page_templates_list(s: &mut Session, _p: &Params) -> Result { + Ok(Output::Text(page_templates::describe(&page_templates::list(s.root())))) +} + +fn page_from_template(s: &mut Session, p: &Params) -> Result { + let parent = s.resolve(req(p, "parent")?)?; + let id = req(p, "template")?; + let template = + page_templates::find(s.root(), id).ok_or_else(|| EngineError::BadParam(format!("there is no template {id}; page.templates lists them")))?; + Ok(Output::Path(page_templates::new_page(&template, &parent, &s.settings)?)) +} + +fn page_save_template(s: &mut Session, p: &Params) -> Result { + let page = s.resolve(req(p, "path")?)?; + Ok(Output::Path(page_templates::save(s.root(), &page, req(p, "name")?)?)) +} + +fn page_remove_template(s: &mut Session, p: &Params) -> Result { + let id = req(p, "template")?; + page_templates::remove(s.root(), id)?; + Ok(Output::Text(format!("removed {id}"))) +} + fn node_move_up(s: &mut Session, p: &Params) -> Result { Ok(Output::Path(ops::move_page(&s.resolve(req(p, "path")?)?, true)?)) } @@ -423,6 +446,36 @@ to (required): where the copy goes; it is put last there, and every node in it g changes_tree: true, run: node_copy, }, + CommandSpec { + id: "page.templates", + label: "List the page templates, built in and saved: one line each, with the id, the category and the name, separated by tabs", + params: "", + changes_tree: false, + run: page_templates_list, + }, + CommandSpec { + id: "page.from_template", + label: "A new page started from a template", + params: "parent (required): the section (or page, for a subpage) the new page goes in, last +template (required): the template's id, as page.templates lists it (builtin:meeting-notes, saved:lab-report)", + changes_tree: true, + run: page_from_template, + }, + CommandSpec { + id: "page.save_template", + label: "Keep a page as a template", + params: "path (required): the page; its words, boxes, ink, files, paper and colour are kept, not its subpages +name (required): the template's name", + changes_tree: false, + run: page_save_template, + }, + CommandSpec { + id: "page.remove_template", + label: "Take away a page saved as a template", + params: "template (required): the saved template's id (saved:...). Built-in templates cannot be removed", + changes_tree: false, + run: page_remove_template, + }, CommandSpec { id: "node.move_up", label: "Move a page up among its siblings", diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index fac131d..2b7f355 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -16,6 +16,7 @@ pub mod error; pub mod links; pub mod ops; pub mod page; +pub mod page_templates; pub mod recycle; pub mod search; pub mod settings; diff --git a/crates/store/src/ops.rs b/crates/store/src/ops.rs index 9d91bb8..5c841c9 100644 --- a/crates/store/src/ops.rs +++ b/crates/store/src/ops.rs @@ -51,7 +51,7 @@ fn clean_title(title: &str) -> Result { /// A folder path under `parent` that does not exist yet, ignoring case, so two /// pages titled "Notes" and "notes" never collide on Windows or macOS. -fn unique_child(parent: &Path, base: &str) -> PathBuf { +pub(crate) fn unique_child(parent: &Path, base: &str) -> PathBuf { let taken: HashSet = match fs::read_dir(parent) { Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_lowercase()).collect(), Err(_) => HashSet::new(), diff --git a/crates/store/src/page_templates.rs b/crates/store/src/page_templates.rs new file mode 100644 index 0000000..7bc33ad --- /dev/null +++ b/crates/store/src/page_templates.rs @@ -0,0 +1,408 @@ +//! Page templates (issue #173): pages to start from, as OneNote's Insert > Page Templates offers. +//! +//! Two kinds: +//! +//! - **Built in**: a few starting pages kept here as Markdown, with the paper they are drawn on. +//! A new page from one is an ordinary new page (its own id, order and date) whose words are the +//! template's, written in whichever format the section uses. +//! - **Saved**: a page someone kept as a template. It is a copy of that page's folder (its words, +//! boxes, ink, pictures and other files, its paper and its colour; not its subpages) under +//! `.notecraft/templates/` in the notebooks folder. A dot folder is not a notebook, so the tree +//! never shows it. A new page from one is a copy of that folder with ids of its own, the way +//! Copy makes one. +//! +//! Either way the new page goes last in the section and has no title yet, as Add Page does: the +//! template gives the page its contents, not its name. Nothing is ever written over a page that +//! is already there. + +use crate::error::{Result, StoreError}; +use crate::ops; +use crate::page::{set_appearance, write_atomic}; +use crate::settings::Settings; +use crate::tree::{self, page_file, read_node}; +use notecraft_formats::{frontmatter, html, markdown, render}; +use notecraft_model::{NodeKind, PageFormat, kv, names, time::iso_now}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A starting page that ships with NoteCraft. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BuiltIn { + /// What `template` names it by: `builtin:`. + pub id: &'static str, + pub name: &'static str, + pub category: Category, + /// The paper it is drawn on (see `page.paper`), if it has one. + pub paper: Option<&'static str>, + /// Its words, as Markdown. Empty for a template that is only paper. + pub body: &'static str, +} + +/// The groups OneNote's gallery sorts its templates into, and one for saved ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Category { + Academic, + Blank, + Business, + Planners, + /// Pages saved as templates. + Mine, +} + +impl Category { + pub const ALL: [Category; 5] = [Category::Academic, Category::Blank, Category::Business, Category::Planners, Category::Mine]; + + pub fn id(self) -> &'static str { + match self { + Category::Academic => "academic", + Category::Blank => "blank", + Category::Business => "business", + Category::Planners => "planners", + Category::Mine => "mine", + } + } + + /// The English name, which the window translates. + pub fn label(self) -> &'static str { + match self { + Category::Academic => "Academic", + Category::Blank => "Blank", + Category::Business => "Business", + Category::Planners => "Planners", + Category::Mine => "My Templates", + } + } +} + +pub const BUILT_IN: &[BuiltIn] = &[ + BuiltIn { + id: "lecture-notes", + name: "Lecture Notes", + category: Category::Academic, + paper: Some("college"), + body: "## Course and lecture\n\n## Key ideas\n\n- \n\n## Notes\n\n## Questions to follow up\n\n- [ ] \n\n## Summary\n", + }, + BuiltIn { + id: "study-guide", + name: "Study Guide", + category: Category::Academic, + paper: None, + body: "## Topic\n\n## Terms to know\n\n| Term | Meaning |\n| --- | --- |\n| | |\n\n## Main points\n\n1. \n\n## Practice\n\n- [ ] \n", + }, + BuiltIn { id: "college-ruled", name: "College Ruled", category: Category::Blank, paper: Some("college"), body: "" }, + BuiltIn { id: "grid", name: "Grid", category: Category::Blank, paper: Some("grid-medium"), body: "" }, + BuiltIn { + id: "meeting-notes", + name: "Meeting Notes", + category: Category::Business, + paper: None, + body: "## Attendees\n\n- \n\n## Agenda\n\n1. \n\n## Notes\n\n## Decisions\n\n- \n\n## Action items\n\n- [ ] \n", + }, + BuiltIn { + id: "project-overview", + name: "Project Overview", + category: Category::Business, + paper: None, + body: "## Goal\n\n## Scope\n\n## Milestones\n\n| Milestone | Owner | Due |\n| --- | --- | --- |\n| | | |\n\n## Risks\n\n- \n\n## Next steps\n\n- [ ] \n", + }, + BuiltIn { + id: "to-do-list", + name: "To Do List", + category: Category::Planners, + paper: None, + body: "## Today\n\n- [ ] \n\n## This week\n\n- [ ] \n\n## Later\n\n- [ ] \n", + }, + BuiltIn { + id: "weekly-planner", + name: "Weekly Planner", + category: Category::Planners, + paper: None, + body: "## Monday\n\n- [ ] \n\n## Tuesday\n\n- [ ] \n\n## Wednesday\n\n- [ ] \n\n## Thursday\n\n- [ ] \n\n## Friday\n\n- [ ] \n\n## Weekend\n\n- [ ] \n", + }, +]; + +/// A template that can be picked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Template { + /// `builtin:` or `saved:`: what `new_page` and the engine take. + pub id: String, + pub name: String, + pub category: Category, + pub source: Source, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Source { + BuiltIn(BuiltIn), + /// The template's folder. + Saved(PathBuf), +} + +/// Where saved templates are kept: `.notecraft/templates` in the notebooks folder. +pub fn folder(root: &Path) -> PathBuf { + root.join(".notecraft").join("templates") +} + +/// Every template: the built-in ones, then the saved ones by name. +pub fn list(root: &Path) -> Vec