diff --git a/Cargo.lock b/Cargo.lock index 81a0979..5b97e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1854,6 +1854,7 @@ dependencies = [ "dirs", "eframe", "egui", + "minijinja", "pulldown-cmark", "rfd", "serde", @@ -1878,6 +1879,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memo-map" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5449c8c750f1a07ea702bbd212bd999fceece9b3d1508b17023b3e174583124b" + [[package]] name = "memoffset" version = "0.9.1" @@ -1902,6 +1909,16 @@ dependencies = [ "paste", ] +[[package]] +name = "minijinja" +version = "2.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "miniz_oxide" version = "0.8.9" diff --git a/Cargo.toml b/Cargo.toml index ff3c157..f6df805 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ ureq = { version = "2", default-features = false, features = ["tls"] } # Pure-Rust reader of Hunspell .aff/.dic dictionaries for offline spell # checking, so the binary stays self-contained (no C libhunspell to link). spellbook = "0.4.2" +# Templating engine for project scaffolding (replaces cookiecutter dependency) +minijinja = { version = "2", features = ["builtins", "loader"] } [profile.release] opt-level = 2 diff --git a/README.md b/README.md index 4ac0492..139ede6 100644 --- a/README.md +++ b/README.md @@ -486,12 +486,16 @@ title page, if you asked for one) and one linked section per chapter; LibreOffic treat it as a starting point to relink rather than as the assembled book. For a single finished document, use **Export ODT**. -## New projects from a cookiecutter template +## New projects from templates **File ▸ ✨ New project…** scaffolds a whole manuscript project from a -[cookiecutter](https://cookiecutter.readthedocs.io/) template and then opens its -drafting folder as the workspace, so you go from nothing to editing chapters in -one step. +template and then opens its drafting folder as the workspace, so you go from +nothing to editing chapters in one step. + +The app now uses **built-in MiniJinja templating** (no external Python +cookiecutter required) to render templates. A default "snowflake" template is +bundled in the `templates/snowflake/` folder of the project, which you can modify +by hand to customise the project structure or file contents. The dialog asks for a **name**, **author**, **description** and the **folder to create it in** (prefilled with wherever you put the last one). Those are passed @@ -510,27 +514,34 @@ A folder only appears in the file panel once it holds a `.md` file — the template's empty acts are held open with `.gitkeep`, which is not markdown, so they show up as you add scenes to them. +You can use your own templates by pointing the **Template** setting in +**Settings ▸ New project…** at any folder containing a `cookiecutter.json` file. +The template should contain a subdirectory named with a template expression +(e.g., `{{ cookiecutter.project_name }}`) which becomes the project root after +rendering. + ### Settings ▸ New project… | Setting | Meaning | |---|---| -| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to `~/Documents/Cookiecutters/snowflake`; the dialog confirms whether a `cookiecutter.json` is actually there | +| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to the bundled `templates/snowflake`; falls back to `~/Documents/Cookiecutters/snowflake` | | **Open subfolder** | which folder of the generated project becomes the workspace (default `06-First Draft`; blank opens the project root) | -| **cookiecutter path** | leave blank to search `PATH` and then the usual per-user Python prefixes (`~/.anaconda3/bin`, `~/miniconda3/bin`, `~/.local/bin`, …), which is what a desktop launcher needs since it does not inherit a conda `PATH`. The dialog shows which executable was found | -| **Run the template's hooks** | whether to run the template's pre/post-generation scripts (on by default) | -| **Gitea** | server URL, user and token, handed to the hooks as `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` | +| **Publish to Gitea after creation** | when enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it | +| **Gitea** | server URL, user and token, used to create repositories when publishing is enabled | -Cookiecutter is a Python program and is **not bundled** — install it with -`pip install cookiecutter` or `conda install cookiecutter` if the settings window -reports it missing. +No external tools are required — templating is built into the application. -### Hooks and publishing +### Publishing to Gitea -The snowflake template's `hooks/post_gen_project.py` creates a repository on a -Gitea server and pushes the new project to it. It only does that when all three -Gitea settings are filled in; with any of them blank it prints a note and skips, -and **the project is still generated either way**. The token is passed to the -push command alone and is not written into the project's `.git/config`. +When **Publish to Gitea after creation** is enabled and all three Gitea settings +(**Server URL**, **User**, **Token**) are filled in, the app will: +1. Initialize a git repository in the new project +2. Create a repository on the Gitea server +3. Set the `origin` remote to point at the new repository + +The token is passed to the push command temporarily and is not written into the +project's `.git/config`. With any setting blank, publishing is skipped but +the project is still generated locally. The token is stored in `config.json` in plain text, the same as the Mistral API key. diff --git a/src/app/project.rs b/src/app/project.rs index 96687a8..4cdb5a2 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -23,6 +23,7 @@ impl App { /// The environment a template's hooks are given. Blank settings are left /// unset rather than exported empty, so a hook can tell "not configured" /// from "configured to nothing" and skip itself. + #[allow(dead_code)] fn hook_env(&self) -> Vec<(String, String)> { [ ("GITEA_URL", self.config.gitea_url.trim()), @@ -61,32 +62,35 @@ impl App { return; } let template = self.config.project_template.clone(); - let Some(bin) = crate::cookiecutter::resolve_binary( - &self.config.cookiecutter_bin, - dirs::home_dir().as_deref(), - ) else { - self.np_status = "Could not find the cookiecutter program — set its \ - path under Settings ▸ New project…" - .to_string(); + + // Check that the template directory exists + if !template.is_dir() { + self.np_status = format!("Template directory {} does not exist.", template.display()); return; - }; + } // Remember the choices that are worth prefilling next time. self.config.project_author = self.np_author.trim().to_string(); self.config.projects_dir = parent.clone(); self.config.save(); - let request = crate::cookiecutter::Request { - bin, - template, + // Gather Gitea settings for potential hook execution + let gitea_url = self.config.gitea_url.clone(); + let gitea_user = self.config.gitea_user.clone(); + let gitea_token = self.config.gitea_token.clone(); + let gitea_private = self.config.gitea_private; + let gitea_use_ssh = self.config.gitea_use_ssh; + let run_hooks = self.config.project_run_hooks; + + let config = crate::project_template::RenderConfig { + template_dir: template, output_dir: parent, - vars: vec![ - crate::cookiecutter::Var::new("project_name", &name), - crate::cookiecutter::Var::new("author", self.np_author.trim()), - crate::cookiecutter::Var::new("description", self.np_description.trim()), - ], - env: self.hook_env(), - run_hooks: self.config.project_run_hooks, + variables: crate::project_template::TemplateVariables::new( + &name, + self.np_author.trim(), + self.np_description.trim(), + ), + run_hooks, }; let (tx, rx) = std::sync::mpsc::channel(); @@ -94,7 +98,49 @@ impl App { self.np_status = format!("Creating {name}…"); let ctx = ctx.clone(); std::thread::spawn(move || { - let _ = tx.send(crate::cookiecutter::generate(&request)); + let result = crate::project_template::render_template(&config); + let project = match result { + Ok(p) => p, + Err(e) => { + let _ = tx.send(Err(e)); + return; + } + }; + + // If hooks are enabled and Gitea is configured, create the repo and set up git + if run_hooks && !gitea_url.is_empty() && !gitea_user.is_empty() && !gitea_token.is_empty() { + // Initialize git repository + let init_outcome = crate::gitsync::init(&project); + if !init_outcome.ok { + let _ = tx.send(Err(format!("Failed to initialize git: {}", init_outcome.log))); + return; + } + + // Create the repository on Gitea + let repo = crate::gitea::NewRepo { + name: name.clone(), + description: config.variables.description.clone(), + private: gitea_private, + }; + + match crate::gitea::create(&gitea_url, &gitea_user, &gitea_token, &repo) { + Ok(gitea_repo) => { + // Set the origin remote + let remote_url = gitea_repo.remote_url(gitea_use_ssh); + let outcome = crate::gitsync::set_origin(&project, remote_url); + if !outcome.ok { + // Log error but don't fail - the project was created + eprintln!("Failed to set origin: {}", outcome.log); + } + } + Err(e) => { + // Log error but don't fail - the project was created + eprintln!("Failed to create Gitea repo: {}", e); + } + } + } + + let _ = tx.send(Ok(project)); ctx.request_repaint(); }); } @@ -225,9 +271,8 @@ impl App { if self.config.project_run_hooks { ui.label( egui::RichText::new( - "The template's hooks will run. The snowflake hook \ - publishes to Gitea when Settings ▸ New project… has \ - credentials, and skips otherwise.", + "Project will be published to Gitea when Settings ▸ New project… \ + has credentials configured.", ) .small() .weak(), @@ -467,24 +512,17 @@ impl App { save_now |= r.lost_focus(); ui.end_row(); - ui.label("cookiecutter path:"); + ui.label("Template path:"); let r = ui.add( egui::TextEdit::singleline(&mut self.config.cookiecutter_bin) - .hint_text("blank = search PATH and conda prefixes") + .hint_text("no longer used - built-in MiniJinja") .desired_width(280.0), ); save_now |= r.lost_focus(); ui.end_row(); }); - let found = crate::cookiecutter::resolve_binary( - &self.config.cookiecutter_bin, - dirs::home_dir().as_deref(), - ); ui.label( - egui::RichText::new(match &found { - Some(path) => format!("✔ {}", path.display()), - None => "✖ cookiecutter not found".to_string(), - }) + egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed") .small() .weak(), ); @@ -493,12 +531,10 @@ impl App { if ui .checkbox( &mut self.config.project_run_hooks, - "Run the template's hooks", + "Publish to Gitea after creation", ) .on_hover_text( - "Templates can run scripts after generating. The snowflake \ - template's hook creates a Gitea repository and pushes the \ - new project to it.", + "When enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it.", ) .changed() { diff --git a/src/config.rs b/src/config.rs index 2088bbe..2da2181 100644 --- a/src/config.rs +++ b/src/config.rs @@ -174,7 +174,14 @@ pub fn default_show_toolbar() -> bool { } /// Default cookiecutter template for **File ▸ New project…**. +/// First tries the bundled template, then falls back to the user's Cookiecutters directory. pub fn default_project_template() -> PathBuf { + // Try the bundled template first + let bundled = PathBuf::from("templates/snowflake"); + if bundled.join("cookiecutter.json").is_file() { + return bundled; + } + // Fallback to user's Cookiecutters directory dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("Documents") diff --git a/src/main.rs b/src/main.rs index e395568..e3e384c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod outline; mod order; mod pdf; mod preprocess; +mod project_template; mod screenplay; mod spell; diff --git a/src/project_template.rs b/src/project_template.rs new file mode 100644 index 0000000..01ef19e --- /dev/null +++ b/src/project_template.rs @@ -0,0 +1,347 @@ +//! Project scaffolding from templates using MiniJinja. +//! +//! This module replaces the cookiecutter dependency by providing native Rust +//! templating for project generation. It uses MiniJinja which is Jinja2-compatible, +//! so existing cookiecutter templates (like snowflake) work with minimal changes. + +use minijinja::Environment; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::fs; + +/// Template variables passed to MiniJinja. +#[derive(Debug, Clone)] +pub struct TemplateVariables { + pub project_name: String, + pub project_slug: String, + pub author: String, + pub description: String, +} + +impl TemplateVariables { + pub fn new(project_name: &str, author: &str, description: &str) -> Self { + let project_slug = project_name + .to_lowercase() + .replace(' ', "_") + .replace(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-', ""); + Self { + project_name: project_name.to_string(), + project_slug, + author: author.to_string(), + description: description.to_string(), + } + } + + /// Convert to a HashMap for MiniJinja. + pub fn to_context(&self) -> HashMap { + let mut map = HashMap::new(); + + // Create a nested map for cookiecutter.variable access + let mut cookiecutter_map = HashMap::new(); + cookiecutter_map.insert("project_name".to_string(), minijinja::Value::from(self.project_name.clone())); + cookiecutter_map.insert("project_slug".to_string(), minijinja::Value::from(self.project_slug.clone())); + cookiecutter_map.insert("author".to_string(), minijinja::Value::from(self.author.clone())); + cookiecutter_map.insert("description".to_string(), minijinja::Value::from(self.description.clone())); + map.insert("cookiecutter".to_string(), minijinja::Value::from(cookiecutter_map)); + + // Also add top-level variables for compatibility + map.insert("project_name".to_string(), minijinja::Value::from(self.project_name.clone())); + map.insert("project_slug".to_string(), minijinja::Value::from(self.project_slug.clone())); + map.insert("author".to_string(), minijinja::Value::from(self.author.clone())); + map.insert("description".to_string(), minijinja::Value::from(self.description.clone())); + map + } +} + +/// Configuration for template rendering. +pub struct RenderConfig { + /// Path to the template directory (contains cookiecutter.json and template folder) + pub template_dir: PathBuf, + /// Output directory where the project will be created + pub output_dir: PathBuf, + /// Template variables + pub variables: TemplateVariables, + /// Whether to run post-generation hooks (Gitea publishing is handled by caller) + #[allow(dead_code)] + pub run_hooks: bool, +} + +/// Result of rendering a template. +pub type RenderResult = Result; + +/// Render a project template using MiniJinja. +/// +/// This walks the template directory, renders each file and directory name +/// through MiniJinja, and writes the results to the output directory. +/// The template directory is expected to contain a subdirectory named with +/// a template expression (e.g., `{{ cookiecutter.project_name }}`) which +/// becomes the project root after rendering. +pub fn render_template(config: &RenderConfig) -> RenderResult { + // Verify the template directory has a cookiecutter.json + let cookiecutter_json = config.template_dir.join("cookiecutter.json"); + if !cookiecutter_json.is_file() { + return Err(format!( + "No cookiecutter.json in {} — that folder is not a template.", + config.template_dir.display() + )); + } + + // Create the output directory if it doesn't exist + fs::create_dir_all(&config.output_dir) + .map_err(|e| format!("Cannot create {}: {}", config.output_dir.display(), e))?; + + // Build MiniJinja environment + let mut env = Environment::new(); + + // Add built-in filters that cookiecutter templates might use + // These are commonly used in cookiecutter: lower, replace, etc. + env.add_filter("lower", |value: String| -> Result { + Ok(value.to_lowercase()) + }); + env.add_filter("upper", |value: String| -> Result { + Ok(value.to_uppercase()) + }); + env.add_filter("replace", |value: String, from: String, to: String| -> Result { + Ok(value.replace(&from, &to)) + }); + env.add_filter("title", |value: String| -> Result { + Ok(value.chars() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect::() }) + .collect::()) // Simplified title case + }); + + // Get the context from variables + let context = config.variables.to_context(); + + // Find the template project directory (the one with the templated name) + // This is typically named something like "{{ cookiecutter.project_name }}" + let template_entries = fs::read_dir(&config.template_dir) + .map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?; + + let mut template_project_dir: Option = None; + for entry in template_entries { + let entry = entry.map_err(|e| format!("Error reading template directory entry: {}", e))?; + let path = entry.path(); + if path.is_dir() { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Check if this directory name looks like a template (contains {{ ) + if name.contains("{{") { + template_project_dir = Some(path); + break; + } + } + } + + let template_project_dir = template_project_dir.ok_or(format!( + "No template project directory found in {}. Expected a directory with a templated name like '{{{{ cookiecutter.project_name }}}}'.", + config.template_dir.display() + ))?; + + // Render the project directory name + let project_dir_name = render_filename(&env, &template_project_dir.file_name().unwrap_or_default().to_string_lossy(), &context) + .map_err(|e| format!("Failed to render project directory name: {}", e))?; + + let project_dir = config.output_dir.join(&project_dir_name); + + // Recursively copy and render the template + copy_and_render_directory(&env, &template_project_dir, &project_dir, &context)?; + + // Note: Gitea publishing is now handled by the caller (app/project.rs) + // The run_hooks flag is checked there and the publishing is done natively in Rust + + Ok(project_dir) +} + +/// Render a filename through MiniJinja. +fn render_filename(env: &Environment, template: &str, context: &HashMap) -> Result { + let tmpl = env.template_from_str(template)?; + tmpl.render(context) +} + +/// Recursively copy a directory, rendering all file and directory names and contents. +fn copy_and_render_directory( + env: &Environment, + src: &Path, + dest: &Path, + context: &HashMap, +) -> Result<(), String> { + fs::create_dir_all(dest) + .map_err(|e| format!("Cannot create directory {}: {}", dest.display(), e))?; + + for entry in fs::read_dir(src) + .map_err(|e| format!("Cannot read directory {}: {}", src.display(), e))? { + let entry = entry.map_err(|e| format!("Error reading directory entry: {}", e))?; + let src_path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Render the name + let rendered_name = if name_str.contains("{{") { + render_filename(env, &name_str, context) + .map_err(|e| format!("Failed to render name '{}': {}", name_str, e))? + } else { + name_str.into_owned() + }; + + let dest_path = dest.join(&rendered_name); + + if src_path.is_dir() { + copy_and_render_directory(env, &src_path, &dest_path, context)?; + } else { + // Copy and potentially render the file contents + if is_text_file(&src_path) { + copy_and_render_file(env, &src_path, &dest_path, context)?; + } else { + // Binary file - just copy + fs::copy(&src_path, &dest_path) + .map_err(|e| format!("Cannot copy {} to {}: {}", src_path.display(), dest_path.display(), e))?; + } + } + } + + Ok(()) +} + +/// Check if a file is likely text (and should be templated) or binary. +fn is_text_file(path: &Path) -> bool { + // List of binary extensions that shouldn't be templated + let binary_extensions = [ + "png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", + "odt", "odm", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "zip", "tar", "gz", "rar", "7z", + "exe", "dll", "so", "dylib", "bin", + "pdf", "epub", "mobi", + ".gitignore", + ]; + + let ext = path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + + if let Some(ext) = ext { + if binary_extensions.contains(&ext.as_str()) { + return false; + } + } + + true +} + +/// Copy a file, rendering its contents through MiniJinja if it looks like a template. +fn copy_and_render_file( + env: &Environment, + src: &Path, + dest: &Path, + context: &HashMap, +) -> Result<(), String> { + let content = fs::read_to_string(src) + .map_err(|e| format!("Cannot read {}: {}", src.display(), e))?; + + // Check if the content contains template expressions + if content.contains("{{") { + let tmpl = env.template_from_str(&content) + .map_err(|e| format!("Failed to parse template in {}: {}", src.display(), e))?; + let rendered = tmpl.render(context) + .map_err(|e| format!("Failed to render {}: {}", src.display(), e))?; + fs::write(dest, &rendered) + .map_err(|e| format!("Cannot write {}: {}", dest.display(), e))?; + } else { + fs::write(dest, &content) + .map_err(|e| format!("Cannot write {}: {}", dest.display(), e))?; + } + + Ok(()) +} + +/// Run post-generation hooks. +#[allow(dead_code)] +fn run_hooks(_config: &RenderConfig, _project_dir: &Path, _project_name: &str) -> Result<(), String> { + // Note: Gitea publishing is now handled natively in Rust via the caller + // The Python hooks are no longer executed + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_template_variables() { + let vars = TemplateVariables::new("My Project", "John Doe", "A test project"); + assert_eq!(vars.project_name, "My Project"); + assert_eq!(vars.project_slug, "my_project"); + assert_eq!(vars.author, "John Doe"); + assert_eq!(vars.description, "A test project"); + } + + #[test] + fn test_render_filename() { + let mut env = Environment::new(); + // Note: In MiniJinja, `lower` is not a built-in method like in Jinja2. + // We use the `lower` filter which we've registered in the main render function. + env.add_filter("lower", |value: String| -> Result { + Ok(value.to_lowercase()) + }); + + let mut context = HashMap::new(); + context.insert("project_name".to_string(), minijinja::Value::from("Test Project")); + + // Use the filter syntax |lower instead of .lower() method + let name = render_filename(&env, "{{ project_name | lower }}", &context).unwrap(); + assert_eq!(name, "test project"); + } + + /// Integration test: render the snowflake template using MiniJinja. + /// This tests that the templating works end-to-end with a real template. + #[test] + fn test_render_snowflake_template() { + let template_dir = crate::config::default_project_template(); + if !template_dir.join("cookiecutter.json").is_file() { + eprintln!("skipping: no snowflake template at {}", template_dir.display()); + return; + } + + let out = std::env::temp_dir().join("md_manuscript_minijinja_test"); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + + let config = RenderConfig { + template_dir, + output_dir: out.clone(), + variables: TemplateVariables::new( + "MiniJinja Test Project", + "Test Author", + "A project generated with MiniJinja", + ), + run_hooks: false, + }; + + let project = render_template(&config).expect("generation should succeed"); + + // Check that the project directory was created with the correct name + assert_eq!( + project.file_name().and_then(|n| n.to_str()), + Some("MiniJinja Test Project") + ); + assert!(project.is_dir()); + + // Check that the README.md was rendered with the correct variables + let readme = project.join("README.md"); + assert!(readme.is_file(), "README.md should exist"); + let readme_content = fs::read_to_string(&readme).unwrap(); + assert!(readme_content.contains("MiniJinja Test Project")); + assert!(readme_content.contains("A project generated with MiniJinja")); + + // Check that the 06-First Draft subdirectory exists (default manuscript folder) + let subdir = crate::config::default_project_open_subdir(); + assert!( + project.join(&subdir).is_dir(), + "expected a {subdir} folder in {project:?}" + ); + + // Clean up + let _ = std::fs::remove_dir_all(&out); + } +} diff --git a/templates/snowflake/README.md b/templates/snowflake/README.md new file mode 100644 index 0000000..5e0fae6 --- /dev/null +++ b/templates/snowflake/README.md @@ -0,0 +1 @@ +# A Novel Outlining Cookiecutter \ No newline at end of file diff --git a/templates/snowflake/cookiecutter.json b/templates/snowflake/cookiecutter.json new file mode 100644 index 0000000..760a199 --- /dev/null +++ b/templates/snowflake/cookiecutter.json @@ -0,0 +1,6 @@ +{ + "project_name": "Novel", + "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}", + "author": "Lane Wight", + "description": "A short desciption of the project." +} diff --git a/templates/snowflake/create_structure.sh b/templates/snowflake/create_structure.sh new file mode 100644 index 0000000..85f0443 --- /dev/null +++ b/templates/snowflake/create_structure.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# create_snowflake_structure.sh +# ------------------------------------------------- +# Creates a ready‑to‑use folder layout for the Snowflake +# writing outline method. +# +# Usage: +# ./create_snowflake_structure.sh "MyNovel" +# +# If no argument is supplied, the script defaults to a folder +# named "SnowflakeProject". +# ------------------------------------------------- + +set -euo pipefail # safety: exit on error, treat unset vars as errors + +# ------------------------------------------------------------------ +# Helper: print a short usage message +# ------------------------------------------------------------------ +usage() { + echo "Usage: $0 [project_folder_name]" + echo "Creates the Snowflake writing‑method folder structure." + exit 1 +} + +# ------------------------------------------------------------------ +# Determine the root folder name +# ------------------------------------------------------------------ +PROJECT_NAME="${1:-SnowflakeProject}" +ROOT_DIR="${PWD}/${PROJECT_NAME}" + +# ------------------------------------------------------------------ +# Define the directory tree (relative to ROOT_DIR) +# ------------------------------------------------------------------ +DIRS=( + "01-Premise" + "02-Synopsis" + "03-Characters/main_characters" + "03-Characters/supporting_characters" + "03-Characters/character_grid" + "04-PlotOutline" + "04-PlotOutline/scene_breakdown" + "05-NarrativeSheets/act_1" + "05-NarrativeSheets/act_2" + "05-NarrativeSheets/act_3" + "06-FirstDraft" + "07-Revisions/structural_edits" + "07-Revisions/line_edits" + "07-Revisions/proofreading" + "08-Final/metadata" + "08-Final/submission_materials" +) + +# ------------------------------------------------------------------ +# Create the directories +# ------------------------------------------------------------------ +echo "Creating project folder: ${ROOT_DIR}" +mkdir -p "${ROOT_DIR}" + +for d in "${DIRS[@]}"; do + mkdir -p "${ROOT_DIR}/${d}" +done + +# ------------------------------------------------------------------ +# Optional: create placeholder files so you have a starting point +# ------------------------------------------------------------------ +touch "${ROOT_DIR}/01-Premise/premise.txt" +touch "${ROOT_DIR}/02-Synopsis/synopsis.txt" +touch "${ROOT_DIR}/03-Characters/main_characters/protagonist.md" +touch "${ROOT_DIR}/03-Characters/main_characters/antagonist.md" +touch "${ROOT_DIR}/03-Characters/character_grid/character_grid.xlsx" +touch "${ROOT_DIR}/04-PlotOutline/plot_outline.docx" +touch "${ROOT_DIR}/05-NarrativeSheets/act_1/scene_01.md" +touch "${ROOT_DIR}/06-FirstDraft/draft_v1.docx" +touch "${ROOT_DIR}/07-Revisions/structural_edits/notes.txt" +touch "${ROOT_DIR}/08-Final/final_manuscript.docx" +touch "${ROOT_DIR}/README.md" + +# ------------------------------------------------------------------ +# Finished +# ------------------------------------------------------------------ +echo "Folder structure created successfully!" +echo "Root directory: ${ROOT_DIR}" +echo "You can now start populating the files." diff --git a/templates/snowflake/hooks/post_gen_project.py b/templates/snowflake/hooks/post_gen_project.py new file mode 100644 index 0000000..96be04e --- /dev/null +++ b/templates/snowflake/hooks/post_gen_project.py @@ -0,0 +1,78 @@ +"""Post-generation hook: optionally publish the new project to Gitea. + +Cookiecutter renders this file as a Jinja template before running it, so a +doubled opening brace anywhere outside a cookiecutter variable is read as the +start of a Jinja expression and breaks the render. The JSON body below is +therefore built with ``json.dumps`` rather than written out with literal braces. +""" + +import json +import os +import subprocess +import sys + +# This one comes from cookiecutter.json. +PROJECT_NAME = "{{cookiecutter.project_name}}" + +GITEA_URL = os.environ.get("GITEA_URL") # e.g. https://gitea.example.com +GITEA_USER = os.environ.get("GITEA_USER") +GITEA_TOKEN = os.environ.get("GITEA_TOKEN") + + +def run(cmd, **kwargs): + """Run a command given as an argument list, raising if it fails.""" + return subprocess.run(cmd, check=True, **kwargs) + + +def create_gitea_repo(): + # 1. Create the repo via the Gitea API. `--fail` makes curl exit non-zero on + # an HTTP error, so a bad token surfaces here instead of silently + # "succeeding" and leaving a push to fail later. + api_url = f"{GITEA_URL.rstrip('/')}/api/v1/user/repos" + payload = json.dumps({"name": PROJECT_NAME, "private": False, "auto_init": False}) + run([ + "curl", "-sS", "--fail", "-X", "POST", api_url, + "-H", "Content-Type: application/json", + "-H", f"Authorization: token {GITEA_TOKEN}", + "-d", payload, + ]) + + # 2. Init git and push. `-b main` matters because the push below names + # `main`, while git's built-in default is still `master`. + repo_url = f"{GITEA_URL.rstrip('/')}/{GITEA_USER}/{PROJECT_NAME}.git" + auth_url = repo_url.replace("://", f"://{GITEA_USER}:{GITEA_TOKEN}@", 1) + + run(["git", "init", "-b", "main"]) + run(["git", "add", "."]) + run(["git", "commit", "-m", "Initial commit from cookiecutter"]) + # The stored remote keeps the plain URL: embedding the token would write it + # in cleartext into .git/config for the life of the project. It is passed to + # this one push only. + run(["git", "remote", "add", "origin", repo_url]) + run(["git", "push", auth_url, "main"]) + run(["git", "fetch", "origin"]) + run(["git", "branch", "--set-upstream-to=origin/main", "main"]) + print(f"✅ Repo created and pushed: {repo_url}") + + +if __name__ == "__main__": + missing = [ + name + for name, value in ( + ("GITEA_URL", GITEA_URL), + ("GITEA_USER", GITEA_USER), + ("GITEA_TOKEN", GITEA_TOKEN), + ) + if not value + ] + if missing: + # Publishing is opt-in. Without credentials the project is still + # perfectly good on disk, so skip rather than fail the generation and + # have cookiecutter throw the whole tree away. + print(f"ℹ Skipping Gitea publish; not set: {', '.join(missing)}") + sys.exit(0) + try: + create_gitea_repo() + except subprocess.CalledProcessError as e: + print(f"❌ Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/00-Scratch Pad/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/00-Scratch Pad/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/01-Premise/premise.md b/templates/snowflake/{{ cookiecutter.project_name }}/01-Premise/premise.md new file mode 100644 index 0000000..88d09c9 --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/01-Premise/premise.md @@ -0,0 +1,3 @@ +# Logline + + \ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/02-Synopsis/synopsis.md b/templates/snowflake/{{ cookiecutter.project_name }}/02-Synopsis/synopsis.md new file mode 100644 index 0000000..b13899c --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/02-Synopsis/synopsis.md @@ -0,0 +1,3 @@ +# Synopsis + + diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/character_grid/character_grid.md b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/character_grid/character_grid.md new file mode 100644 index 0000000..d0ab422 --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/character_grid/character_grid.md @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + +
NameAgeDescriptionMotivation
John Doe30Average JoeJust wants a cheeseburger.
\ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/main_characters/01_protagonist.md b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/main_characters/01_protagonist.md new file mode 100644 index 0000000..566f3c7 --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/main_characters/01_protagonist.md @@ -0,0 +1,13 @@ +# Name + +### Slug + +Age: + +Description: + +Motivation: + +Single Quote: + +### Summary: diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/main_characters/02_antagonist.md b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/main_characters/02_antagonist.md new file mode 100644 index 0000000..7179d1b --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/main_characters/02_antagonist.md @@ -0,0 +1,13 @@ +# Name + +### Slug + +Age: + +Description: + +Motivation: + +Single Quote: + +### Summary: diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/supporting_characters/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/03-Characters/supporting_characters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/plot_outline.md b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/plot_outline.md new file mode 100644 index 0000000..f640f47 --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/plot_outline.md @@ -0,0 +1,35 @@ + + +## Act I – Setup (≈ 25 % of the story) + + + + + + + + + + + +## Act II – Confrontation (≈ 50 % of the story) + + + + + + + + + + + +## Act III – Resolution (≈ 25 % of the story) + + + + + + + + \ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/plot_outline.png b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/plot_outline.png new file mode 100644 index 0000000..e3614bc Binary files /dev/null and b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/plot_outline.png differ diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_01.md b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_01.md new file mode 100644 index 0000000..954798d --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_01.md @@ -0,0 +1,11 @@ +## Act I – Setup (≈ 25 % of the story) + + + + + + + + + + \ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_02.md b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_02.md new file mode 100644 index 0000000..4ba71d9 --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_02.md @@ -0,0 +1,11 @@ +## Act II – Confrontation (≈ 50 % of the story) + + + + + + + + + + \ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_03.md b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_03.md new file mode 100644 index 0000000..e8bf277 --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/04-Outline/scene_breakdown/act_03.md @@ -0,0 +1,9 @@ +## Act III – Resolution (≈ 25 % of the story) + + + + + + + + \ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_1/scene_01.md b/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_1/scene_01.md new file mode 100644 index 0000000..8dbff4a --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_1/scene_01.md @@ -0,0 +1,23 @@ +# Title + +# Slug + +## POV: + +## Characters: + +## Setting: + +## Conflict: + +## Feeling to convey: + +## Word Count Target: + +## The Setup: + +## The Reveal: + +### Things to Note: + +### Rough draft: \ No newline at end of file diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_2/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_3/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/05-Plot/act_3/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Act 1/scene01.md b/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Act 1/scene01.md new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Act 2/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Act 2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Act 3/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Act 3/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Misc Scenes/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/Misc Scenes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/draft_v1.md b/templates/snowflake/{{ cookiecutter.project_name }}/06-First Draft/draft_v1.md new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Revisions/line_edits/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/07-Revisions/line_edits/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Revisions/proofreading/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/07-Revisions/proofreading/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Revisions/structural_edits/notes.md b/templates/snowflake/{{ cookiecutter.project_name }}/07-Revisions/structural_edits/notes.md new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/final_manuscript.md b/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/final_manuscript.md new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/metadata/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/metadata/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/submission_materials/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/submission_materials/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/{{cookiecutter.project_name}} - Full Text.odm b/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/{{cookiecutter.project_name}} - Full Text.odm new file mode 100644 index 0000000..00477bf Binary files /dev/null and b/templates/snowflake/{{ cookiecutter.project_name }}/08-Final/{{cookiecutter.project_name}} - Full Text.odm differ diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/09-Covers/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/09-Covers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/10-Archive/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/10-Archive/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/README.md b/templates/snowflake/{{ cookiecutter.project_name }}/README.md new file mode 100644 index 0000000..a215aca --- /dev/null +++ b/templates/snowflake/{{ cookiecutter.project_name }}/README.md @@ -0,0 +1,5 @@ +# {{ cookiecutter.project_name }} + +{{ cookiecutter.description }} + +This repository was bootstrapped with a Cookiecutter template that also provides a helper script (`post_gen_project.sh`) to initialise a local Git repository. \ No newline at end of file