diff --git a/.gitignore b/.gitignore index 23f5f27..98d9fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ # will have compiled files and executables debug target - +md-notes.md # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 9cee2e9..7bc5444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,7 +773,7 @@ dependencies = [ [[package]] name = "ptmp" -version = "0.1.3" +version = "1.1.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index adfee8b..fc74b8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ptmp" -version = "0.1.3" +version = "1.1.2" edition = "2021" authors = ["Michael DeWitt "] description = "Copy a template directory and render Tera placeholders via a TUI." diff --git a/README.Md b/README.Md index c8e262f..fbc30d5 100644 --- a/README.Md +++ b/README.Md @@ -29,13 +29,24 @@ ptmp new \ --destination ~/projects/WNV-modeling ``` +### Set personal defaults + +Run once to save your name, email, ORCID, and other fields. +These become the pre-filled defaults whenever ptmp prompts you. + +```bash +ptmp config +``` + +Defaults are stored in `~/.config/ptmp/defaults.toml`. + ### Emit a single component Write one file into the current working directory, prompting only for the fields that file needs. ```bash ptmp taskfile # Taskfile.yml -ptmp latex # main.tex +ptmp latex # main.tex + references.bib (Nature-style manuscript) ptmp makefile # Makefile ptmp readme # README.md ptmp r # R/example.R @@ -48,6 +59,8 @@ ptmp citation # CITATION.cff ptmp license # LICENSE (MIT) ptmp quarto # report.qmd ptmp slides # slides.qmd +ptmp docx # templates/ (pandoc reference docx + CSL) +ptmp justfile # justfile (pandoc rendering tasks) ``` ## Template rendering diff --git a/src/cli.rs b/src/cli.rs index 4ccd78b..0eb833e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -79,4 +79,16 @@ pub enum Commands { /// Emit a Quarto revealjs slide deck (slides.qmd) into the current directory Slides, + + /// Emit a review Taskfile.yml for academic peer review + Review, + + /// Emit pandoc reference docx and CSL into templates/ + Docx, + + /// Emit a justfile with pandoc rendering tasks + Justfile, + + /// Set personal defaults (author, email, ORCID, etc.) + Config, } diff --git a/src/components.rs b/src/components.rs index eb74fa0..c94e936 100644 --- a/src/components.rs +++ b/src/components.rs @@ -45,21 +45,39 @@ pub static KNOWN_FIELDS: &[FieldDef] = &[ FieldDef { name: "slurm_account", prompt: "SLURM account/group", - default: "", + default: "kortessisGrp", required: true, }, FieldDef { name: "slurm_email", prompt: "Email for SLURM notifications", - default: "", + default: "dewime23@wfu.edu", required: true, }, + FieldDef { + name: "email", + prompt: "Email address", + default: "michael.dewitt@wfusm.edu", + required: false, + }, FieldDef { name: "orcid", - prompt: "ORCID identifier (e.g., 0000-0001-2345-6789)", - default: "", + prompt: "ORCID identifier (e.g., 0000-0001-8940-1967)", + default: "0000-0001-8940-1967", required: false, }, + FieldDef { + name: "pdf_path", + prompt: "Path to the PDF to review", + default: "", + required: true, + }, + FieldDef { + name: "reviewer_name", + prompt: "Reviewer name", + default: "", + required: true, + }, ]; pub struct Component { @@ -84,8 +102,8 @@ pub static COMPONENTS: &[Component] = &[ name: "latex", output_path: "main.tex", template: templates::MAIN_TEX, - fields: &["title", "author", "affiliation"], - extra_text: &[], + fields: &["title", "author", "affiliation", "email"], + extra_text: &[("references.bib", templates::REFERENCES_BIB)], extra_binary: &[], }, Component { @@ -184,6 +202,33 @@ pub static COMPONENTS: &[Component] = &[ extra_text: &[("reveal-theme.scss", templates::REVEAL_THEME)], extra_binary: &[("assets/logo-wide.png", templates::LOGO_WIDE)], }, + Component { + name: "review", + output_path: "Taskfile.yml", + template: templates::REVIEW_TASKFILE, + fields: &["pdf_path", "reviewer_name"], + extra_text: &[], + extra_binary: &[], + }, + Component { + name: "docx", + output_path: "templates/nature.csl", + template: templates::NATURE_CSL, + fields: &["title"], + extra_text: &[], + extra_binary: &[( + "templates/pandoc-reference-arial11-0p5in.docx", + templates::PANDOC_REFERENCE_DOCX, + )], + }, + Component { + name: "justfile", + output_path: "justfile", + template: templates::JUSTFILE, + fields: &["title"], + extra_text: &[], + extra_binary: &[], + }, ]; /// Map a CLI command variant to its registered [`Component`], if applicable. @@ -205,13 +250,28 @@ pub fn component_for_command(cmd: &Commands) -> Option<&'static Component> { Commands::License => "license", Commands::Quarto => "quarto", Commands::Slides => "slides", + Commands::Review => "review", + Commands::Docx => "docx", + Commands::Justfile => "justfile", _ => return None, }; COMPONENTS.iter().find(|c| c.name == name) } /// Prompt only for the fields required by a component. +/// +/// User defaults from `~/.config/ptmp/defaults.toml` override +/// hardcoded defaults so the user can just press Enter. pub fn collect_fields_for_component(component: &Component) -> Result> { + collect_fields_with_defaults(component, &crate::config::load_user_defaults()?) +} + +/// Inner implementation that accepts explicit user defaults +/// (makes unit-testing possible without touching the filesystem). +pub fn collect_fields_with_defaults( + component: &Component, + user_defaults: &HashMap, +) -> Result> { let mut values = HashMap::new(); for &needed in component.fields { let def = KNOWN_FIELDS @@ -219,7 +279,9 @@ pub fn collect_fields_for_component(component: &Component) -> Result PathBuf { + dirs_fallback().join("defaults.toml") +} + +/// Best-effort XDG / macOS config directory. +fn dirs_fallback() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("ptmp") + } else if let Some(home) = home_dir() { + home.join(".config").join("ptmp") + } else { + PathBuf::from(".config").join("ptmp") + } +} + +fn home_dir() -> Option { + std::env::var("HOME").ok().map(PathBuf::from) +} + +/// Load user defaults from `~/.config/ptmp/defaults.toml`. +/// +/// Returns an empty map if the file does not exist. +pub fn load_user_defaults() -> Result> { + load_defaults_from(&config_path()) +} + +/// Load defaults from an arbitrary path (used by tests). +pub fn load_defaults_from(path: &std::path::Path) -> Result> { + if !path.exists() { + return Ok(HashMap::new()); + } + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config: {}", path.display()))?; + let table: toml::Table = content + .parse() + .with_context(|| format!("Failed to parse config: {}", path.display()))?; + let mut map = HashMap::new(); + for (key, val) in table { + if let toml::Value::String(s) = val { + if !s.is_empty() { + map.insert(key, s); + } + } + } + Ok(map) +} + +/// Save user defaults to `~/.config/ptmp/defaults.toml`. +pub fn save_user_defaults(values: &HashMap) -> Result<()> { + save_defaults_to(values, &config_path()) +} + +/// Save defaults to an arbitrary path (used by tests). +pub fn save_defaults_to(values: &HashMap, path: &std::path::Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut table = toml::Table::new(); + let mut keys: Vec<&String> = values.keys().collect(); + keys.sort(); + for key in keys { + table.insert( + key.clone(), + toml::Value::String(values.get(key).cloned().unwrap_or_default()), + ); + } + let content = toml::to_string_pretty(&table).context("Failed to serialise config")?; + fs::write(path, content) + .with_context(|| format!("Failed to write config: {}", path.display()))?; + println!("Saved config to {}", path.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn load_missing_file_returns_empty() { + let dir = tempdir().unwrap(); + let path = dir.path().join("nope.toml"); + let defaults = load_defaults_from(&path).unwrap(); + assert!(defaults.is_empty()); + } + + #[test] + fn round_trip_save_load() { + let dir = tempdir().unwrap(); + let path = dir.path().join("defaults.toml"); + + let mut vals = HashMap::new(); + vals.insert("author".to_string(), "Test Author".to_string()); + vals.insert("email".to_string(), "test@example.com".to_string()); + save_defaults_to(&vals, &path).unwrap(); + + assert!(path.exists()); + + let loaded = load_defaults_from(&path).unwrap(); + assert_eq!( + loaded.get("author").map(|s| s.as_str()), + Some("Test Author") + ); + assert_eq!( + loaded.get("email").map(|s| s.as_str()), + Some("test@example.com") + ); + } +} diff --git a/src/main.rs b/src/main.rs index a8da712..89be46a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ use std::collections::HashMap; mod cli; mod commands; mod components; +mod config; mod manifest; mod render; mod templates; @@ -102,6 +103,9 @@ fn main() -> Result<()> { destination.display() ); } + Commands::Config => { + run_config()?; + } cmd => { let component = component_for_command(cmd).expect("Unhandled command variant"); emit_component(component)?; @@ -110,3 +114,35 @@ fn main() -> Result<()> { Ok(()) } + +/// Interactive config: prompt for every known field and save +/// to `~/.config/ptmp/defaults.toml`. +fn run_config() -> Result<()> { + use components::KNOWN_FIELDS; + use config::{load_user_defaults, save_user_defaults}; + use inquire::Text; + + let existing = load_user_defaults()?; + let mut values = HashMap::new(); + + println!( + "Set your personal defaults (press Enter to keep \ + current value).\n" + ); + + for field in KNOWN_FIELDS { + let current = existing + .get(field.name) + .map(|s| s.as_str()) + .unwrap_or(field.default); + let mut prompt = Text::new(field.prompt); + if !current.is_empty() { + prompt = prompt.with_default(current); + } + let val = prompt.prompt().unwrap_or_default(); + values.insert(field.name.to_string(), val); + } + + save_user_defaults(&values)?; + Ok(()) +} diff --git a/src/render.rs b/src/render.rs index 6eb78c2..7d99ec9 100644 --- a/src/render.rs +++ b/src/render.rs @@ -217,6 +217,42 @@ mod tests { assert_eq!(copied, bytes); } + // --- MAIN_TEX template --- + + #[test] + fn main_tex_renders_with_all_fields() { + let mut vals = HashMap::new(); + vals.insert("title".to_string(), "My Paper".to_string()); + vals.insert("author".to_string(), "Jane Doe".to_string()); + vals.insert("affiliation".to_string(), "Some Lab".to_string()); + vals.insert("email".to_string(), "jane@example.com".to_string()); + let ctx = make_tera_context(vals, "proj"); + + let out = Tera::one_off(crate::templates::MAIN_TEX, &ctx, false).unwrap(); + + assert!(out.contains("\\documentclass[11pt,letterpaper]{article}")); + assert!(out.contains("My Paper")); + assert!(out.contains("\\thanks{Correspondence:")); + assert!(out.contains("\\href{mailto:jane@example.com}{jane@example.com}")); + assert!(out.contains("\\itshape Some Lab")); + assert!(out.contains("\\bibliographystyle{unsrtnat}")); + } + + #[test] + fn main_tex_omits_optional_fields_when_empty() { + let mut vals = HashMap::new(); + vals.insert("title".to_string(), "My Paper".to_string()); + vals.insert("author".to_string(), "Jane Doe".to_string()); + vals.insert("affiliation".to_string(), String::new()); + vals.insert("email".to_string(), String::new()); + let ctx = make_tera_context(vals, "proj"); + + let out = Tera::one_off(crate::templates::MAIN_TEX, &ctx, false).unwrap(); + + assert!(!out.contains("\\thanks{")); + assert!(!out.contains("\\itshape")); + } + // --- copy_template --- #[test] diff --git a/src/templates.rs b/src/templates.rs index b9abcc8..f04e234 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -16,3 +16,8 @@ pub const QUARTO: &str = include_str!("templates/report.qmd"); pub const SLIDES: &str = include_str!("templates/slides.qmd"); pub const REVEAL_THEME: &str = include_str!("templates/reveal-theme.scss"); pub const LOGO_WIDE: &[u8] = include_bytes!("templates/logo-wide.png"); +pub const REVIEW_TASKFILE: &str = include_str!("templates/review-taskfile.yml"); +pub const NATURE_CSL: &str = include_str!("templates/nature.csl"); +pub const PANDOC_REFERENCE_DOCX: &[u8] = + include_bytes!("templates/pandoc-reference-arial11-0p5in.docx"); +pub const JUSTFILE: &str = include_str!("templates/justfile"); diff --git a/src/templates/Taskfile.yml b/src/templates/Taskfile.yml index 51a8ee4..00650b3 100644 --- a/src/templates/Taskfile.yml +++ b/src/templates/Taskfile.yml @@ -90,4 +90,20 @@ tasks: deps: [latex] cmds: - open {{.OUTDIR}}/{{.MAIN}} + + bib: + desc: Format references.bib + cmds: + - bibfmt references.bib + + docx: + desc: Render Markdown to Word via pandoc + cmds: + - mkdir -p {{.OUTDIR}} + - >- + pandoc report.md + --reference-doc=templates/pandoc-reference-arial11-0p5in.docx + --citeproc --csl=templates/nature.csl + --bibliography=references.bib + -o {{.OUTDIR}}/report.docx {% endraw %} diff --git a/src/templates/example.R b/src/templates/example.R index 68940d8..c40ad5c 100644 --- a/src/templates/example.R +++ b/src/templates/example.R @@ -1,4 +1,3 @@ -# Example R script # Project: {{ title }} # Author: {{ author }} # Date: {{ today }} @@ -11,3 +10,5 @@ library(here) # library(officer) message(paste("Project root is:", here::here())) + +# Data import ----------------------------------------------------------------- diff --git a/src/templates/example.slurm b/src/templates/example.slurm index 3f9ba74..2fbdfe6 100644 --- a/src/templates/example.slurm +++ b/src/templates/example.slurm @@ -10,3 +10,7 @@ #SBATCH --time=00-14:00:00 echo "Running {{ title }}" + +# module load apps/python/3.11.8 +# module load apps/julia/1.10.2 +# module load apps/r/4.3.2 \ No newline at end of file diff --git a/src/templates/justfile b/src/templates/justfile new file mode 100644 index 0000000..7572f31 --- /dev/null +++ b/src/templates/justfile @@ -0,0 +1,24 @@ +# Justfile for {{ title }} + +main_file := "report.md" +outdir := "output" +template := "templates/pandoc-reference-arial11-0p5in.docx" +csl := "templates/nature.csl" +bib := "references.bib" + +{% raw %} +default: render + +# Render Markdown to Word via pandoc +render: + mkdir -p {{outdir}} + pandoc {{main_file}} \ + --reference-doc={{template}} \ + --citeproc --csl={{csl}} \ + --bibliography={{bib}} \ + -o {{outdir}}/report.docx + +# Clean generated output +clean: + rm -rf {{outdir}} +{% endraw %} diff --git a/src/templates/main.tex b/src/templates/main.tex index da22d03..764f83e 100644 --- a/src/templates/main.tex +++ b/src/templates/main.tex @@ -1,34 +1,94 @@ -\documentclass[12pt,letterpaper]{article} -\usepackage{setspace} -% Packages -\usepackage[margin=1in]{geometry} -\usepackage{graphicx} % Required for inserting images -\usepackage{comment} +% !TEX program = pdflatex +% Modern single-column manuscript in a Nature-like style. +% Citations render as sorted, superscript numbers ordered by appearance. +\documentclass[11pt,letterpaper]{article} + +% --- Encoding and fonts ---------------------------------------------------- \usepackage[T1]{fontenc} \usepackage[utf8]{inputenc} +% lmodern is universal; for a Times look swap in: newtxtext,newtxmath \usepackage{lmodern} -\usepackage{natbib} +\usepackage{microtype} % subtle protrusion/expansion for cleaner justification + +% --- Page layout ----------------------------------------------------------- +\usepackage[margin=1in]{geometry} +\usepackage{setspace} +\onehalfspacing +\usepackage{parskip} % vertical space between paragraphs, no first-line indent + +% --- Figures, tables, and math --------------------------------------------- +\usepackage{graphicx} +{% raw %}\graphicspath{{figures/}}{% endraw %} +\usepackage[font=small,labelfont=bf]{caption} +\usepackage{booktabs} % \toprule \midrule \bottomrule for clean tables +\usepackage{siunitx} % \num{1.2e3}, \qty{5}{\micro\liter} +\usepackage{amsmath} +\usepackage{amssymb} + +% --- Colour and bibliography ----------------------------------------------- +\usepackage{xcolor} +% Nature-style: numeric, sorted, compressed, superscript citations. +\usepackage[numbers,sort&compress,super]{natbib} +% unsrtnat ships with natbib and always compiles; if you have naturemag.bst, +% swap the next line for \bibliographystyle{naturemag}. +\bibliographystyle{unsrtnat} -\title{ {{ title }} } -\author{ {{ author }} \\ {{ affiliation }} } +% --- Hyperlinks and smart cross-references (load these last) --------------- +\usepackage[colorlinks=true,allcolors=blue!55!black]{hyperref} +\usepackage{cleveref} % \cref{fig:x} -> "Fig. 1", \Cref{...} -> "Figure 1" + +% --- Metadata -------------------------------------------------------------- +\title{\bfseries {{ title }}} +\author{ +{{- author -}} +{%- if email -%} +\thanks{Correspondence: \href{mailto:{{ email }}}{ {{- email -}} }} +{%- endif -%} +{%- if affiliation -%} +\\[0.4em]\normalsize\itshape {{ affiliation }} +{%- endif -%} +} \date{ {{ today }} } \begin{document} \maketitle -\section{Introduction} -Write your introduction here. +\begin{abstract} +\noindent +State the question, what you did, and the main result in a few sentences. +Keep the abstract self-contained and free of citations. +\end{abstract} -\section{Methods} -Describe your methods here. +\section{Introduction} +Motivate the problem and state your contribution. +Cite prior work with \verb|\cite{key}|, which renders as a superscript +number\cite{example2024}. \section{Results} -Present results here. Figures can be stored in `figures/`. +Present the findings. +Reference figures with \verb|\cref{fig:label}| and keep sources in +\texttt{figures/}, one figure per file. \section{Discussion} -Discuss findings here. +Interpret the results, state the limitations, and give the outlook. + +\section*{Methods} +Describe the data, models, and analysis in enough detail to reproduce. +Report uncertainty, not just point estimates. + +% --- Back matter ----------------------------------------------------------- +\section*{Data and code availability} +State where the data and analysis code can be found. + +\section*{Acknowledgements} +Funding sources and thanks. + +\section*{Author contributions} +Summarise each author's contribution by initials. + +\section*{Competing interests} +The authors declare no competing interests. -\bibliographystyle{plain} \bibliography{references} \end{document} diff --git a/src/templates/nature.csl b/src/templates/nature.csl new file mode 100644 index 0000000..8e7c5f4 --- /dev/null +++ b/src/templates/nature.csl @@ -0,0 +1,189 @@ + + \ No newline at end of file diff --git a/src/templates/pandoc-reference-arial11-0p5in.docx b/src/templates/pandoc-reference-arial11-0p5in.docx new file mode 100644 index 0000000..2695afb Binary files /dev/null and b/src/templates/pandoc-reference-arial11-0p5in.docx differ diff --git a/src/templates/references.bib b/src/templates/references.bib index d31bb9b..1ca0389 100644 --- a/src/templates/references.bib +++ b/src/templates/references.bib @@ -1,2 +1,13 @@ -% Bibliography for {{ title }} -% Add BibTeX entries below +% Bibliography. Add BibTeX entries below. +% Format with `bibfmt` before committing. + +@article{example2024, + author = {Doe, Jane and Roe, Richard}, + title = {An example reference}, + journal = {Journal of Examples}, + year = {2024}, + volume = {1}, + number = {1}, + pages = {1--10}, + doi = {10.1000/example}, +} diff --git a/src/templates/review-taskfile.yml b/src/templates/review-taskfile.yml new file mode 100644 index 0000000..c2102ac --- /dev/null +++ b/src/templates/review-taskfile.yml @@ -0,0 +1,53 @@ +# Taskfile for academic peer review +# Reviewer: {{ reviewer_name }} +# https://taskfile.dev +version: "3" + +vars: + PDF: "{{ pdf_path }}" + REVIEWER: "{{ reviewer_name }}" + OUTDIR: . + +{% raw %} +tasks: + default: + desc: Run the full review pipeline + deps: [review] + + review: + desc: Extract annotations and generate review template + cmds: + - >- + academic-review {{.PDF}} + -f markdown + -o {{.OUTDIR}} + + review-all: + desc: Generate review in all formats (md, tex, docx) + cmds: + - >- + academic-review {{.PDF}} + -f markdown latex docx + -o {{.OUTDIR}} + + review-zotero: + desc: Generate review with live Zotero lookup + cmds: + - >- + academic-review {{.PDF}} + -f markdown + -o {{.OUTDIR}} + --no-zotero-stub + + clean: + desc: Remove generated review files + vars: + STEM: + sh: basename "{{.PDF}}" .pdf + cmds: + - rm -f {{.OUTDIR}}/{{.STEM}}_review.md + - rm -f {{.OUTDIR}}/{{.STEM}}_review.tex + - rm -f {{.OUTDIR}}/{{.STEM}}_review.docx + - rm -f {{.OUTDIR}}/{{.STEM}}_citations.md + - rm -f {{.OUTDIR}}/{{.STEM}}_context.json +{% endraw %}