Start of changes for 0.2, adding folders to organize notes
This commit is contained in:
parent
2bf88525a4
commit
8f7a5c03e0
8 changed files with 754 additions and 34 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -386,7 +386,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "eternalibre-notes"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
17
README.md
17
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.
|
||||
|
|
|
|||
232
src/notes.rs
232
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>, 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(¬e_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(¬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<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(¬e)?;
|
||||
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(¬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<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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
src/pages.rs
118
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<String, Vec<&Note>> =
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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, ¬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<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, ¬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<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>,
|
||||
|
|
|
|||
124
static/style.css
124
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue