diff --git a/Cargo.lock b/Cargo.lock index 7695c1f..c366aa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,7 +386,7 @@ dependencies = [ [[package]] name = "eternalibre-notes" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index d7efb2c..a8a003a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eternalibre-notes" -version = "0.1.1" +version = "0.2.0" edition = "2021" license = "GPL-3.0-or-later" description = "EternaLibre Notes - a server-side rendered, copyleft Markdown notes application" diff --git a/README.md b/README.md index 12b4cff..42ee936 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ authenticator app. No telemetry, no accounts, no third-party services. - **Works without JavaScript** — note editing, search, theming, and the login form are plain HTML; the menus, mobile tabs, and live preview are the JS-dependent parts - **Alpine.js enhancements** — live Markdown preview, instant search, keyboard shortcuts - **File-based storage** — notes are persisted as Markdown files in `notes/` +- **Virtual folders** — organize notes into folders without real directories; folders are encoded as `[Folder]uuid.md` filename prefixes - **Per-note exports** — download Markdown or self-contained themed HTML, or print/save the themed note as PDF - **40 built-in themes** — including KDE Breeze, Blueberry Professional, classic desktop homages, distro-inspired palettes, warm neutrals, and more - **Markdown extras** — tables, tasklists, strikethrough, and footnotes via `pulldown-cmark` @@ -44,6 +45,22 @@ Then open http://127.0.0.1:3000 (or your chosen address). Notes are stored in the `notes/` directory as `.md` files with YAML frontmatter. +## Folders + +Notes can be organized into virtual folders without creating real directories on disk. A folder is encoded as a filename prefix: + +``` +[Project Notes]550e8400-e29b-41d4-a716-446655440000.md +``` + +Root notes (no folder) keep the plain `uuid.md` format. Folders exist only when they contain at least one note — there is no separate folder metadata to manage. + +**Creating a folder:** click `+ New Folder` in the sidebar (or File → New Folder in the menu), type a name, and the app creates a new note in that folder and opens it. + +**Filtering:** click a folder name in the sidebar to see only that folder's notes. Click **All Notes** to see everything grouped by folder. When a folder is active, the **+ New Note** button creates notes in that folder. + +**Validation:** folder names reject path traversal (`..`), shell metacharacters (`;`, `|`, `&`, `$`, backtick, parens, angle brackets), control characters, leading dots, and names over 100 characters. Invalid names show a red flash message at the top of the page. + ## Authentication EternaLibre Notes is locked with TOTP. Because the server binds `0.0.0.0` by default, a code is required before any note can be read. diff --git a/src/notes.rs b/src/notes.rs index fbefc0b..c9834c4 100644 --- a/src/notes.rs +++ b/src/notes.rs @@ -1,6 +1,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; @@ -12,8 +13,62 @@ pub fn notes_dir() -> PathBuf { PathBuf::from(std::env::var("ETERNALIBRE_NOTES_DIR").unwrap_or_else(|_| "notes".to_string())) } -fn note_path(id: &str) -> PathBuf { - notes_dir().join(format!("{id}.md")) +/// Builds the on-disk filename for a note. +/// +/// Root notes are `{id}.md`. Notes in a folder are `[Folder Name]{id}.md` — +/// the folder is encoded in the filename rather than a real directory, so a +/// single notes directory can hold many virtual folders without nesting. +fn note_filename(id: &str, folder: Option<&str>) -> String { + match folder { + Some(f) if !f.is_empty() => format!("[{f}]{id}.md"), + _ => format!("{id}.md"), + } +} + +/// Parses a note filename stem into its folder and id. +/// +/// `[Folder]uuid` → `(Some("Folder"), "uuid")`. A bare `uuid` → `(None, "uuid")`. +/// A `[` without a closing `]` is treated as a literal id rather than a +/// malformed folder, so a note that happens to start with `[` still loads. +fn parse_stem(stem: &str) -> (Option, String) { + if let Some(rest) = stem.strip_prefix('[') { + if let Some(end) = rest.find(']') { + let folder = &rest[..end]; + let id = &rest[end + 1..]; + if !folder.is_empty() && !id.is_empty() { + return (Some(folder.to_string()), id.to_string()); + } + } + } + (None, stem.to_string()) +} + +/// Finds the on-disk path for a note by id, scanning the notes directory. +/// +/// The id is the UUID portion of the filename; the folder prefix is ignored +/// during the search so a note can be located regardless of which folder it +/// lives in. +fn find_note_path(id: &str) -> Result { + let dir = notes_dir(); + if !dir.exists() { + return Err(not_found(id)); + } + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") { + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let (_, note_id) = parse_stem(stem); + if note_id == id { + return Ok(path); + } + } + } + Err(not_found(id)) +} + +fn not_found(id: &str) -> anyhow::Error { + std::io::Error::new(std::io::ErrorKind::NotFound, format!("note {id} not found")).into() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -23,6 +78,9 @@ pub struct Note { pub content: String, pub created_at: DateTime, pub updated_at: DateTime, + /// Virtual folder this note belongs to, derived from the filename prefix. + /// `None` means the note lives at the root of the notes directory. + pub folder: Option, } impl Note { @@ -34,6 +92,7 @@ impl Note { content: content.into(), created_at: now, updated_at: now, + folder: None, } } @@ -75,17 +134,29 @@ pub fn list_notes() -> Result> { Ok(notes) } +/// Returns the sorted set of folder names currently in use. +/// +/// Folders are virtual — they exist only when at least one note carries the +/// `[Folder]` filename prefix. An empty folder has no representation on disk. +pub fn list_folders() -> Result> { + let mut folders = BTreeSet::new(); + for note in list_notes()? { + if let Some(f) = note.folder { + folders.insert(f); + } + } + Ok(folders.into_iter().collect()) +} + pub fn load_note(id: &str) -> Result { - load_note_from_path(¬e_path(id)) + let path = find_note_path(id)?; + load_note_from_path(&path) } fn load_note_from_path(path: &Path) -> Result { let content = fs::read_to_string(path)?; - let id = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let (folder, id) = parse_stem(stem); // Parse frontmatter if present let (title, body, created_at, updated_at) = parse_frontmatter(&content); @@ -96,6 +167,7 @@ fn load_note_from_path(path: &Path) -> Result { content: body, created_at, updated_at, + folder, }) } @@ -146,17 +218,24 @@ pub fn save_note(note: &Note) -> Result<()> { note.updated_at.to_rfc3339(), note.content ); - fs::write(note_path(¬e.id), frontmatter)?; + let filename = note_filename(¬e.id, note.folder.as_deref()); + fs::write(notes_dir().join(filename), frontmatter)?; Ok(()) } pub fn delete_note(id: &str) -> Result<()> { - fs::remove_file(note_path(id))?; + let path = find_note_path(id)?; + fs::remove_file(path)?; Ok(()) } -pub fn create_note(title: impl Into, content: impl Into) -> Result { - let note = Note::new(title, content); +pub fn create_note( + title: impl Into, + content: impl Into, + folder: Option, +) -> Result { + let mut note = Note::new(title, content); + note.folder = folder; save_note(¬e)?; Ok(note) } @@ -170,6 +249,48 @@ pub fn update_note(id: &str, title: impl Into, content: impl Into Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + anyhow::bail!("folder name cannot be empty"); + } + if trimmed.len() > 100 { + anyhow::bail!("folder name is too long (max 100 characters)"); + } + if trimmed.starts_with('.') { + anyhow::bail!("folder name cannot start with a dot"); + } + if trimmed.contains("..") { + anyhow::bail!("folder name cannot contain '..'"); + } + if trimmed.contains(']') { + anyhow::bail!("folder name cannot contain ']'"); + } + if trimmed.contains('/') || trimmed.contains('\\') { + anyhow::bail!("folder name cannot contain '/' or '\\'"); + } + if trimmed + .chars() + .any(|c| matches!(c, ';' | '|' | '&' | '$' | '`' | '(' | ')' | '<' | '>')) + { + anyhow::bail!("folder name cannot contain shell metacharacters"); + } + if trimmed.chars().any(|c| c.is_control()) { + anyhow::bail!("folder name cannot contain control characters"); + } + Ok(trimmed.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -188,10 +309,11 @@ mod tests { #[test] fn crud_lifecycle() { std::fs::create_dir_all(notes_dir()).unwrap(); - let note = create_note("CRUD Test", "Initial content").unwrap(); + let note = create_note("CRUD Test", "Initial content", None).unwrap(); let loaded = load_note(¬e.id).unwrap(); assert_eq!(loaded.title, "CRUD Test"); assert_eq!(loaded.content, "Initial content"); + assert_eq!(loaded.folder, None); update_note(¬e.id, "Updated Title", "Updated content").unwrap(); let updated = load_note(¬e.id).unwrap(); @@ -205,10 +327,92 @@ mod tests { #[test] fn derives_title_from_heading() { std::fs::create_dir_all(notes_dir()).unwrap(); - let note = create_note("Fallback", "# Real Title\n\nSome content.").unwrap(); + let note = create_note("Fallback", "# Real Title\n\nSome content.", None).unwrap(); let loaded = load_note(¬e.id).unwrap(); // The stored title is what was passed to create_note, but the file content has a heading. assert_eq!(loaded.title, "Fallback"); delete_note(¬e.id).unwrap(); } + + #[test] + fn folder_prefix_round_trip() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let note = create_note("Folder Note", "Body", Some("Project Notes".to_string())).unwrap(); + let loaded = load_note(¬e.id).unwrap(); + assert_eq!(loaded.folder.as_deref(), Some("Project Notes")); + assert_eq!(loaded.title, "Folder Note"); + delete_note(¬e.id).unwrap(); + } + + #[test] + fn list_folders_returns_sorted_unique() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let a = create_note("A", "a", Some("Zebra".to_string())).unwrap(); + let b = create_note("B", "b", Some("Apple".to_string())).unwrap(); + let c = create_note("C", "c", Some("Apple".to_string())).unwrap(); + let d = create_note("D", "d", None).unwrap(); + let folders = list_folders().unwrap(); + assert_eq!(folders, vec!["Apple".to_string(), "Zebra".to_string()]); + for note in &[a, b, c, d] { + delete_note(¬e.id).unwrap(); + } + } + + #[test] + fn validate_folder_name_rejects_bad_names() { + assert!(validate_folder_name("").is_err()); + assert!(validate_folder_name(" ").is_err()); + assert!(validate_folder_name("has]bracket").is_err()); + assert!(validate_folder_name("has/slash").is_err()); + assert!(validate_folder_name("has\\backslash").is_err()); + assert!(validate_folder_name(&"x".repeat(101)).is_err()); + assert!(validate_folder_name("Good Name").is_ok()); + assert!(validate_folder_name(" Trimmed ").is_ok()); + assert_eq!(validate_folder_name(" Trimmed ").unwrap(), "Trimmed"); + } + + #[test] + fn validate_folder_name_rejects_path_traversal() { + assert!(validate_folder_name("..").is_err()); + assert!(validate_folder_name(".").is_err()); + assert!(validate_folder_name("foo..bar").is_err()); + assert!(validate_folder_name("../etc").is_err()); + assert!(validate_folder_name("foo/../bar").is_err()); + assert!(validate_folder_name(".hidden").is_err()); + } + + #[test] + fn validate_folder_name_rejects_shell_metacharacters() { + assert!(validate_folder_name("foo;bar").is_err()); + assert!(validate_folder_name("foo|bar").is_err()); + assert!(validate_folder_name("foo&bar").is_err()); + assert!(validate_folder_name("foo$bar").is_err()); + assert!(validate_folder_name("foo`bar").is_err()); + assert!(validate_folder_name("foo(bar)").is_err()); + assert!(validate_folder_name("foo").is_err()); + assert!(validate_folder_name("$(whoami)").is_err()); + assert!(validate_folder_name("`id`").is_err()); + } + + #[test] + fn validate_folder_name_rejects_control_characters() { + assert!(validate_folder_name("foo\nbar").is_err()); + assert!(validate_folder_name("foo\tbar").is_err()); + assert!(validate_folder_name("foo\rbar").is_err()); + assert!(validate_folder_name("foo\u{0}bar").is_err()); + assert!(validate_folder_name("foo\u{1b}bar").is_err()); + } + + #[test] + fn parse_stem_handles_edge_cases() { + assert_eq!(parse_stem("uuid"), (None, "uuid".to_string())); + assert_eq!( + parse_stem("[Folder]uuid"), + (Some("Folder".to_string()), "uuid".to_string()) + ); + // No closing bracket — treated as literal id + assert_eq!(parse_stem("[Folder"), (None, "[Folder".to_string())); + // Empty folder — treated as literal id + assert_eq!(parse_stem("[]uuid"), (None, "[]uuid".to_string())); + } } diff --git a/src/pages.rs b/src/pages.rs index b3a5c60..7214b2a 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -3,6 +3,7 @@ use crate::notes::Note; use crate::themes::{get_theme, PREDEFINED_THEMES}; use chrono::{DateTime, Utc}; use maud::{html, Markup, PreEscaped, DOCTYPE}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; /// Base path prefix for generated URLs. /// @@ -34,9 +35,18 @@ impl<'a> Base<'a> { } } +/// Folder state passed to the app template. +pub struct FolderContext<'a> { + /// All existing folder names, sorted. + pub folders: &'a [String], + /// The folder currently being viewed, if any. + pub active_folder: Option<&'a str>, +} + pub fn render_app( theme_name: &str, notes: &[Note], + folder_ctx: &FolderContext<'_>, current_note: Option<&Note>, query: Option<&str>, message: Option<&str>, @@ -69,14 +79,27 @@ pub fn render_app( } body x-data="app()" x-on:keydown="handleKeydown($event)" { (render_menu_bar(base, current_note)) - (render_toolbar(current_note, query, theme_name, base)) + (render_toolbar( + current_note, + query, + theme_name, + folder_ctx.active_folder, + base, + )) @if let Some(msg) = message { - div class="flash-message" { (msg) } + div class="flash-message" { "Error: " (msg) } } (render_tab_bar(default_tab)) main class="app-body" x-bind:class="{ 'hide-editor': !editorVisible, 'hide-preview': !previewVisible }" { - (render_sidebar(notes, current_note, default_tab, base)) + (render_sidebar( + notes, + folder_ctx.folders, + folder_ctx.active_folder, + current_note, + default_tab, + base, + )) (render_editor(current_note, default_tab, base)) (render_preview(&rendered_preview, default_tab)) } @@ -261,12 +284,16 @@ fn render_toolbar( current_note: Option<&Note>, query: Option<&str>, theme_name: &str, + active_folder: Option<&str>, base: &Base<'_>, ) -> Markup { html! { div class="toolbar" { div class="toolbar-group" { form action=(base.url("/notes")) method="post" { + @if let Some(folder) = active_folder { + input type="hidden" name="folder" value=(folder); + } button type="submit" class="btn btn-primary" { "+ New Note" } } @if let Some(note) = current_note { @@ -302,6 +329,8 @@ fn render_toolbar( fn render_sidebar( notes: &[Note], + folders: &[String], + active_folder: Option<&str>, current_note: Option<&Note>, default_tab: &str, base: &Base<'_>, @@ -311,31 +340,91 @@ fn render_sidebar( } else { "" }; + + // Group notes by folder for the "All Notes" view. + let mut unfiled: Vec<&Note> = Vec::new(); + let mut by_folder: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for note in notes { + match ¬e.folder { + Some(f) => by_folder.entry(f.clone()).or_default().push(note), + None => unfiled.push(note), + } + } + html! { aside class={"sidebar" (active)} data-tab-pane="notes" x-bind:class="{ 'mobile-active': tab === 'notes' }" { div class="sidebar-header" { "Notes" } + form action=(base.url("/folders")) method="post" class="new-folder-form" { + input type="text" name="name" placeholder="New folder name..."; + button type="submit" { "+ New Folder" } + } + ul class="folder-nav" { + li class={"nav-item" (if active_folder.is_none() { " active" } else { "" })} { + a href=(base.url("/")) { + span class="nav-label" { "All Notes" } + span class="nav-count" { (notes.len()) } + } + } + @for folder in folders { + li class={"nav-item" (if active_folder == Some(folder.as_str()) { " active" } else { "" })} { + a href=(folder_url(base, folder)) { + span class="nav-label" { (folder) } + span class="nav-count" { (by_folder.get(folder).map(|v| v.len()).unwrap_or(0)) } + } + } + } + } ul class="note-list" { @if notes.is_empty() { li class="note-item empty" { "No notes yet." } - } @else { - @for note in notes { - li class={"note-item " (if current_note.map(|n| n.id == note.id).unwrap_or(false) { "active" } else { "" })} - data-title=(note.title.to_lowercase()) - data-content=(note.content.to_lowercase()) { - a href=(base.url(&format!("/notes/{}", note.id))) { - div class="note-title" { (note.title) } - div class="note-meta" { (relative_time(note.updated_at)) } - div class="note-preview" { (note.preview(80)) } - } + } @else if active_folder.is_none() { + // Grouped view: unfiled first, then folders alphabetically. + @if !unfiled.is_empty() { + li class="folder-header" { "Unfiled" } + @for note in &unfiled { + (render_note_item(note, current_note, base)) } } + @for (folder, folder_notes) in &by_folder { + li class="folder-header" { (folder) } + @for note in folder_notes { + (render_note_item(note, current_note, base)) + } + } + } @else { + // Flat view of the active folder. + @for note in notes { + (render_note_item(note, current_note, base)) + } } } } } } +fn render_note_item(note: &Note, current_note: Option<&Note>, base: &Base<'_>) -> Markup { + let is_active = current_note.map(|n| n.id == note.id).unwrap_or(false); + html! { + li class={"note-item " (if is_active { "active" } else { "" })} + data-title=(note.title.to_lowercase()) + data-content=(note.content.to_lowercase()) { + a href=(base.url(&format!("/notes/{}", note.id))) { + div class="note-title" { (note.title) } + div class="note-meta" { (relative_time(note.updated_at)) } + div class="note-preview" { (note.preview(80)) } + } + } + } +} + +/// Builds a URL that filters the sidebar to a single folder. +fn folder_url(base: &Base<'_>, folder: &str) -> String { + let encoded = utf8_percent_encode(folder, NON_ALPHANUMERIC).to_string(); + base.url(&format!("/?folder={encoded}")) +} + fn render_editor(current_note: Option<&Note>, default_tab: &str, base: &Base<'_>) -> Markup { let active = if default_tab == "write" { " mobile-active" @@ -360,6 +449,9 @@ fn render_editor(current_note: Option<&Note>, default_tab: &str, base: &Base<'_> class="note-title-input" placeholder="Note title" value=(note.title); + @if let Some(folder) = ¬e.folder { + div class="note-folder" { "Folder: " (folder) } + } textarea id="editor" name="content" diff --git a/src/routes.rs b/src/routes.rs index 00c361f..d6bb287 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use crate::auth::{AuthError, AuthState, SESSION_COOKIE}; use crate::markdown::render_markdown; use crate::notes::{self, Note}; -use crate::pages::{self, Base}; +use crate::pages::{self, Base, FolderContext}; use crate::themes::get_theme; #[derive(Clone)] @@ -114,6 +114,7 @@ pub fn router(state: AppState) -> Router { let protected = Router::new() .route("/", get(index)) .route("/notes", post(create_note_route)) + .route("/folders", post(create_folder_route)) .route( "/notes/:id", get(show_note_route).post(update_or_delete_note_route), @@ -326,6 +327,8 @@ fn cookie_path(base_path: &str) -> &str { #[derive(Deserialize)] struct IndexQuery { q: Option, + folder: Option, + error: Option, } async fn index( @@ -335,6 +338,9 @@ async fn index( ) -> Result { let theme = get_theme_from_cookie(&headers); let all_notes = notes::list_notes()?; + let folders = notes::list_folders()?; + + let active_folder = query.folder.clone().filter(|f| !f.is_empty()); let notes: Vec = if let Some(ref q) = query.q { let q = q.to_lowercase(); @@ -344,12 +350,29 @@ async fn index( n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q) }) .collect() + } else if let Some(ref f) = active_folder { + all_notes + .into_iter() + .filter(|n| n.folder.as_deref() == Some(f.as_str())) + .collect() } else { all_notes }; let base = Base::new(&state.base_path); - let html = pages::render_app(&theme, ¬es, None, query.q.as_deref(), None, &base); + let folder_ctx = FolderContext { + folders: &folders, + active_folder: active_folder.as_deref(), + }; + let html = pages::render_app( + &theme, + ¬es, + &folder_ctx, + None, + query.q.as_deref(), + query.error.as_deref(), + &base, + ); Ok(Html(html.into_string())) } @@ -362,6 +385,11 @@ async fn show_note_route( let theme = get_theme_from_cookie(&headers); let note = notes::load_note(&id)?; let all_notes = notes::list_notes()?; + let folders = notes::list_folders()?; + + // The sidebar follows the note's folder, so opening a note in a folder + // keeps the user in that folder's context. + let active_folder = note.folder.clone(); let notes: Vec = if let Some(ref q) = query.q { let q = q.to_lowercase(); @@ -371,12 +399,29 @@ async fn show_note_route( n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q) }) .collect() + } else if let Some(ref f) = active_folder { + all_notes + .into_iter() + .filter(|n| n.folder.as_deref() == Some(f.as_str())) + .collect() } else { all_notes }; let base = Base::new(&state.base_path); - let html = pages::render_app(&theme, ¬es, Some(¬e), query.q.as_deref(), None, &base); + let folder_ctx = FolderContext { + folders: &folders, + active_folder: active_folder.as_deref(), + }; + let html = pages::render_app( + &theme, + ¬es, + &folder_ctx, + Some(¬e), + query.q.as_deref(), + None, + &base, + ); Ok(Html(html.into_string())) } @@ -388,11 +433,54 @@ struct NoteForm { _method: String, } -async fn create_note_route(State(state): State) -> Result { - let note = notes::create_note("Untitled", "")?; +#[derive(Deserialize)] +struct CreateNoteForm { + folder: Option, +} + +async fn create_note_route( + State(state): State, + Form(form): Form, +) -> Result { + let note = notes::create_note("Untitled", "", form.folder)?; Ok(Redirect::to(&state.url(&format!("/notes/{}", note.id)))) } +#[derive(Deserialize)] +struct FolderForm { + name: String, +} + +/// Creates a folder by creating the first note in it. +/// +/// Folders are virtual — they exist only when a note carries the `[Folder]` +/// filename prefix — so "creating a folder" means creating a note in it. The +/// user is redirected to that new note, where they can rename it and start +/// writing. +async fn create_folder_route( + State(state): State, + Form(form): Form, +) -> Result { + let folder = match notes::validate_folder_name(&form.name) { + Ok(f) => f, + Err(e) => { + // Redirect back with the error in the query string so the + // server-rendered flash message can display it. This keeps the + // no-JS guarantee: the form is a plain HTML form, and the error + // appears after a normal page reload. + let error_msg = e.to_string(); + let encoded = percent_encoding::utf8_percent_encode( + &error_msg, + percent_encoding::NON_ALPHANUMERIC, + ); + let redirect_url = state.url(&format!("/?error={encoded}")); + return Ok(Redirect::to(&redirect_url).into_response()); + } + }; + let note = notes::create_note("Untitled", "", Some(folder))?; + Ok(Redirect::to(&state.url(&format!("/notes/{}", note.id))).into_response()) +} + async fn update_or_delete_note_route( State(state): State, Path(id): Path, diff --git a/static/style.css b/static/style.css index 2d2f95c..f66a367 100644 --- a/static/style.css +++ b/static/style.css @@ -293,7 +293,7 @@ button, input, select, textarea { /* Flash Messages */ .flash-message { padding: 8px 12px; - background-color: var(--ansi-2); + background-color: var(--ansi-1); color: var(--app-bg); font-size: 13px; font-weight: 500; @@ -430,6 +430,109 @@ button, input, select, textarea { font-size: 13px; } +/* New Folder Form */ +.new-folder-form { + display: flex; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--ui-border); +} + +.new-folder-form input { + flex: 1; + min-width: 0; + padding: 6px 10px; + background-color: var(--app-bg); + border: 1px solid var(--ui-border); + border-radius: 6px; + color: var(--app-fg); + font-size: 13px; + outline: none; +} + +.new-folder-form input:focus { + border-color: var(--ansi-4); +} + +.new-folder-form button { + padding: 6px 10px; + background-color: var(--ui-hover); + border: 1px solid var(--ui-border); + border-radius: 6px; + color: var(--app-fg); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s ease; +} + +.new-folder-form button:hover { + background-color: var(--ansi-12); + color: var(--ansi-0); +} + +/* Folder Navigation */ +.folder-nav { + list-style: none; + margin: 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; + border-bottom: 1px solid var(--ui-border); +} + +.nav-item a { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 6px; + color: var(--app-fg); + font-size: 13px; + transition: background-color 0.15s ease; +} + +.nav-item a:hover { + background-color: var(--ui-hover); + text-decoration: none; +} + +.nav-item.active a { + background-color: var(--ui-hover); + font-weight: 600; +} + +.nav-count { + font-size: 11px; + opacity: 0.6; + background-color: var(--ui-hover); + padding: 2px 6px; + border-radius: 10px; +} + +.nav-item.active .nav-count { + opacity: 0.8; +} + +/* Folder Group Headers */ +.folder-header { + padding: 8px 12px 4px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.5; +} + +/* Note Folder Display */ +.note-folder { + padding: 8px 16px; + font-size: 12px; + opacity: 0.6; + border-bottom: 1px solid var(--ui-border); +} + /* Editor Pane */ .editor-pane { display: flex; @@ -1046,6 +1149,25 @@ button, input, select, textarea { padding: 12px 16px; } + .new-folder-form { + padding: 8px; + } + + .new-folder-form input, + .new-folder-form button { + min-height: 44px; + font-size: 16px; /* prevents iOS zoom-on-focus */ + } + + .folder-nav { + padding: 8px; + } + + .nav-item a { + min-height: 44px; + padding: 10px 12px; + } + .note-list { padding: 8px; } diff --git a/tests/auth_gating.rs b/tests/auth_gating.rs index 254c871..57635e0 100644 --- a/tests/auth_gating.rs +++ b/tests/auth_gating.rs @@ -643,6 +643,203 @@ async fn deleting_a_note_requires_authentication() { assert_eq!(status, StatusCode::OK); } +// ---------------------------------------------------------------- folders ---- + +#[tokio::test] +async fn folder_creation_creates_a_note_in_the_folder() { + let f = Fixture::provisioned("folder-create"); + let (status, _, loc) = f.post_authed("/folders", "name=Project+Notes").await; + assert_eq!(status, StatusCode::SEE_OTHER); + let loc = loc.expect("folder creation redirects"); + assert!(loc.starts_with("/notes/"), "got: {loc}"); + + // The new note should appear in the folder view. + let (status, body) = f.get_authed("/?folder=Project%20Notes").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("Project Notes"), + "folder nav should list the folder" + ); + assert!( + body.contains("Untitled"), + "the new note should be in the folder" + ); +} + +#[tokio::test] +async fn folder_filtering_shows_only_folder_notes() { + let f = Fixture::provisioned("folder-filter"); + let root_id = f.create_note().await; + let (_, _, loc) = f.post_authed("/folders", "name=Work").await; + let work_loc = loc.expect("folder creation redirects"); + let work_id = work_loc.rsplit('/').next().unwrap().to_string(); + + // Root view shows both. + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains(&root_id), + "root note should appear in All Notes" + ); + assert!( + body.contains(&work_id), + "folder note should appear in All Notes" + ); + + // Folder view shows only the folder note. + let (status, body) = f.get_authed("/?folder=Work").await; + assert_eq!(status, StatusCode::OK); + assert!( + !body.contains(&root_id), + "root note should not appear in folder view" + ); + assert!( + body.contains(&work_id), + "folder note should appear in folder view" + ); +} + +#[tokio::test] +async fn new_note_in_folder_creates_with_prefix() { + let f = Fixture::provisioned("folder-new-note"); + let (status, _, loc) = f.post_authed("/notes", "folder=Personal").await; + assert_eq!(status, StatusCode::SEE_OTHER); + let loc = loc.expect("create redirects"); + let id = loc.rsplit('/').next().unwrap().to_string(); + + // The file on disk should have the [Personal] prefix. + let dir = shared_notes_dir(); + let entries: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + let found = entries + .iter() + .any(|name| name.starts_with("[Personal]") && name.contains(&id)); + assert!( + found, + "expected a [Personal]-prefixed file for {id}, got: {entries:?}" + ); +} + +#[tokio::test] +async fn folder_route_requires_authentication() { + let f = Fixture::provisioned("folder-gate"); + let (status, _, _) = f.post_form("/folders", "name=Secret").await; + assert_eq!(status, StatusCode::SEE_OTHER); + + // No note should have been created. + let (status, body) = f.get("/").await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert!(!body.contains("Secret")); +} + +#[tokio::test] +async fn invalid_folder_name_is_rejected() { + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + + let f = Fixture::provisioned("folder-invalid"); + for name in [ + "", + " ", + "has]bracket", + "has/slash", + "has\\backslash", + "..", + ".hidden", + "foo;bar", + "foo|bar", + "foo&bar", + "foo$bar", + "foo`bar", + "foo(bar)", + "foo", + ] { + let encoded = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let body = format!("name={encoded}"); + let (status, _, loc) = f.post_authed("/folders", &body).await; + // Should redirect back to / with an error, not to a new note. + assert_eq!( + status, + StatusCode::SEE_OTHER, + "folder name {name:?} should redirect back" + ); + let loc = loc.expect("should have a Location header"); + assert!( + loc.starts_with("/?error="), + "expected redirect to /?error=..., got: {loc}" + ); + } +} + +#[tokio::test] +async fn folder_creation_error_shows_flash_message() { + let f = Fixture::provisioned("folder-flash"); + let (_, _, loc) = f.post_authed("/folders", "name=has%2Fslash").await; + let loc = loc.expect("should redirect"); + assert!(loc.starts_with("/?error="), "got: {loc}"); + + // Follow the redirect and verify the flash message appears. + let (status, body) = f + .get_authed("/?error=folder+name+cannot+contain+%27%2F%27+or+%27%5C%27") + .await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("flash-message"), + "page should contain the flash-message element" + ); + assert!( + body.contains("folder name cannot contain"), + "flash message should show the validation error" + ); +} + +#[tokio::test] +async fn sidebar_shows_folder_navigation() { + let f = Fixture::provisioned("folder-nav"); + f.post_authed("/folders", "name=Alpha").await; + f.post_authed("/folders", "name=Beta").await; + + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains("All Notes"), "should show All Notes"); + assert!(body.contains("Alpha"), "should show Alpha folder"); + assert!(body.contains("Beta"), "should show Beta folder"); + assert!(body.contains("nav-count"), "should show note counts"); +} + +#[tokio::test] +async fn note_in_folder_shows_folder_in_editor() { + let f = Fixture::provisioned("folder-editor"); + let (_, _, loc) = f.post_authed("/folders", "name=Projects").await; + let loc = loc.expect("folder creation redirects"); + let id = loc.rsplit('/').next().unwrap().to_string(); + + let (status, body) = f.get_authed(&format!("/notes/{id}")).await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("Projects"), + "editor should show the note's folder" + ); + assert!( + body.contains("note-folder"), + "should use the note-folder class" + ); +} + +#[tokio::test] +async fn folder_creation_with_special_characters() { + let f = Fixture::provisioned("folder-special"); + // Spaces and unicode are fine. + let (status, _, loc) = f + .post_authed("/folders", "name=My+%E2%9C%93+Projects") + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + let loc = loc.expect("folder creation redirects"); + assert!(loc.starts_with("/notes/")); +} + // ------------------------------------------------------------------ auth ---- /// There is deliberately no `/setup` route. Enrolment happens in the