Start of changes for 0.2, adding folders to organize notes

This commit is contained in:
mrkmntal 2026-10-02 14:41:37 -04:00
commit 8f7a5c03e0
8 changed files with 754 additions and 34 deletions

2
Cargo.lock generated
View file

@ -386,7 +386,7 @@ dependencies = [
[[package]]
name = "eternalibre-notes"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"anyhow",
"axum",

View file

@ -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"

View file

@ -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.

View file

@ -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>, 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<PathBuf> {
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<Utc>,
pub updated_at: DateTime<Utc>,
/// 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<String>,
}
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<Vec<Note>> {
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<Vec<String>> {
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<Note> {
load_note_from_path(&note_path(id))
let path = find_note_path(id)?;
load_note_from_path(&path)
}
fn load_note_from_path(path: &Path) -> Result<Note> {
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<Note> {
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(&note.id), frontmatter)?;
let filename = note_filename(&note.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<String>, content: impl Into<String>) -> Result<Note> {
let note = Note::new(title, content);
pub fn create_note(
title: impl Into<String>,
content: impl Into<String>,
folder: Option<String>,
) -> Result<Note> {
let mut note = Note::new(title, content);
note.folder = folder;
save_note(&note)?;
Ok(note)
}
@ -170,6 +249,48 @@ pub fn update_note(id: &str, title: impl Into<String>, content: impl Into<String
Ok(note)
}
/// Validates a user-supplied folder name.
///
/// The name is embedded in the filename as `[Name]`, so `]` would corrupt the
/// encoding. `/` and `\` are path separators and would escape the notes
/// directory. Empty names and over-long names are rejected for sanity.
///
/// Defense-in-depth against adversarial input: path traversal (`..`), shell
/// metacharacters (`;`, `|`, `&`, `$`, backtick, parens, angle brackets),
/// control characters, and leading dots (hidden files) are all rejected even
/// though the name only ever reaches a filename — not a shell or eval context.
pub fn validate_folder_name(name: &str) -> Result<String> {
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(&note.id).unwrap();
assert_eq!(loaded.title, "CRUD Test");
assert_eq!(loaded.content, "Initial content");
assert_eq!(loaded.folder, None);
update_note(&note.id, "Updated Title", "Updated content").unwrap();
let updated = load_note(&note.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(&note.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(&note.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(&note.id).unwrap();
assert_eq!(loaded.folder.as_deref(), Some("Project Notes"));
assert_eq!(loaded.title, "Folder Note");
delete_note(&note.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(&note.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<bar>").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()));
}
}

View file

@ -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<String, Vec<&Note>> =
std::collections::BTreeMap::new();
for note in notes {
match &note.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) = &note.folder {
div class="note-folder" { "Folder: " (folder) }
}
textarea
id="editor"
name="content"

View file

@ -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<String>,
folder: Option<String>,
error: Option<String>,
}
async fn index(
@ -335,6 +338,9 @@ async fn index(
) -> Result<impl IntoResponse, AppError> {
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<Note> = 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, &notes, 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,
&notes,
&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<Note> = 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, &notes, Some(&note), query.q.as_deref(), None, &base);
let folder_ctx = FolderContext {
folders: &folders,
active_folder: active_folder.as_deref(),
};
let html = pages::render_app(
&theme,
&notes,
&folder_ctx,
Some(&note),
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<AppState>) -> Result<impl IntoResponse, AppError> {
let note = notes::create_note("Untitled", "")?;
#[derive(Deserialize)]
struct CreateNoteForm {
folder: Option<String>,
}
async fn create_note_route(
State(state): State<AppState>,
Form(form): Form<CreateNoteForm>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
Form(form): Form<FolderForm>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
Path(id): Path<String>,

View file

@ -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;
}

View file

@ -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<bar>",
] {
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