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]]
|
[[package]]
|
||||||
name = "eternalibre-notes"
|
name = "eternalibre-notes"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "eternalibre-notes"
|
name = "eternalibre-notes"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
description = "EternaLibre Notes - a server-side rendered, copyleft Markdown notes application"
|
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
|
- **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
|
- **Alpine.js enhancements** — live Markdown preview, instant search, keyboard shortcuts
|
||||||
- **File-based storage** — notes are persisted as Markdown files in `notes/`
|
- **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
|
- **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
|
- **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`
|
- **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.
|
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
|
## 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.
|
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 anyhow::Result;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
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()))
|
PathBuf::from(std::env::var("ETERNALIBRE_NOTES_DIR").unwrap_or_else(|_| "notes".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn note_path(id: &str) -> PathBuf {
|
/// Builds the on-disk filename for a note.
|
||||||
notes_dir().join(format!("{id}.md"))
|
///
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -23,6 +78,9 @@ pub struct Note {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_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 {
|
impl Note {
|
||||||
|
|
@ -34,6 +92,7 @@ impl Note {
|
||||||
content: content.into(),
|
content: content.into(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
folder: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,17 +134,29 @@ pub fn list_notes() -> Result<Vec<Note>> {
|
||||||
Ok(notes)
|
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> {
|
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> {
|
fn load_note_from_path(path: &Path) -> Result<Note> {
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let id = path
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
.file_stem()
|
let (folder, id) = parse_stem(stem);
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Parse frontmatter if present
|
// Parse frontmatter if present
|
||||||
let (title, body, created_at, updated_at) = parse_frontmatter(&content);
|
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,
|
content: body,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
|
folder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,17 +218,24 @@ pub fn save_note(note: &Note) -> Result<()> {
|
||||||
note.updated_at.to_rfc3339(),
|
note.updated_at.to_rfc3339(),
|
||||||
note.content
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_note(id: &str) -> Result<()> {
|
pub fn delete_note(id: &str) -> Result<()> {
|
||||||
fs::remove_file(note_path(id))?;
|
let path = find_note_path(id)?;
|
||||||
|
fs::remove_file(path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_note(title: impl Into<String>, content: impl Into<String>) -> Result<Note> {
|
pub fn create_note(
|
||||||
let note = Note::new(title, content);
|
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)?;
|
save_note(¬e)?;
|
||||||
Ok(note)
|
Ok(note)
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +249,48 @@ pub fn update_note(id: &str, title: impl Into<String>, content: impl Into<String
|
||||||
Ok(note)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -188,10 +309,11 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn crud_lifecycle() {
|
fn crud_lifecycle() {
|
||||||
std::fs::create_dir_all(notes_dir()).unwrap();
|
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();
|
let loaded = load_note(¬e.id).unwrap();
|
||||||
assert_eq!(loaded.title, "CRUD Test");
|
assert_eq!(loaded.title, "CRUD Test");
|
||||||
assert_eq!(loaded.content, "Initial content");
|
assert_eq!(loaded.content, "Initial content");
|
||||||
|
assert_eq!(loaded.folder, None);
|
||||||
|
|
||||||
update_note(¬e.id, "Updated Title", "Updated content").unwrap();
|
update_note(¬e.id, "Updated Title", "Updated content").unwrap();
|
||||||
let updated = load_note(¬e.id).unwrap();
|
let updated = load_note(¬e.id).unwrap();
|
||||||
|
|
@ -205,10 +327,92 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn derives_title_from_heading() {
|
fn derives_title_from_heading() {
|
||||||
std::fs::create_dir_all(notes_dir()).unwrap();
|
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();
|
let loaded = load_note(¬e.id).unwrap();
|
||||||
// The stored title is what was passed to create_note, but the file content has a heading.
|
// The stored title is what was passed to create_note, but the file content has a heading.
|
||||||
assert_eq!(loaded.title, "Fallback");
|
assert_eq!(loaded.title, "Fallback");
|
||||||
delete_note(¬e.id).unwrap();
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
src/pages.rs
106
src/pages.rs
|
|
@ -3,6 +3,7 @@ use crate::notes::Note;
|
||||||
use crate::themes::{get_theme, PREDEFINED_THEMES};
|
use crate::themes::{get_theme, PREDEFINED_THEMES};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||||
|
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
|
|
||||||
/// Base path prefix for generated URLs.
|
/// 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(
|
pub fn render_app(
|
||||||
theme_name: &str,
|
theme_name: &str,
|
||||||
notes: &[Note],
|
notes: &[Note],
|
||||||
|
folder_ctx: &FolderContext<'_>,
|
||||||
current_note: Option<&Note>,
|
current_note: Option<&Note>,
|
||||||
query: Option<&str>,
|
query: Option<&str>,
|
||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
|
|
@ -69,14 +79,27 @@ pub fn render_app(
|
||||||
}
|
}
|
||||||
body x-data="app()" x-on:keydown="handleKeydown($event)" {
|
body x-data="app()" x-on:keydown="handleKeydown($event)" {
|
||||||
(render_menu_bar(base, current_note))
|
(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 {
|
@if let Some(msg) = message {
|
||||||
div class="flash-message" { (msg) }
|
div class="flash-message" { "Error: " (msg) }
|
||||||
}
|
}
|
||||||
(render_tab_bar(default_tab))
|
(render_tab_bar(default_tab))
|
||||||
main class="app-body"
|
main class="app-body"
|
||||||
x-bind:class="{ 'hide-editor': !editorVisible, 'hide-preview': !previewVisible }" {
|
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_editor(current_note, default_tab, base))
|
||||||
(render_preview(&rendered_preview, default_tab))
|
(render_preview(&rendered_preview, default_tab))
|
||||||
}
|
}
|
||||||
|
|
@ -261,12 +284,16 @@ fn render_toolbar(
|
||||||
current_note: Option<&Note>,
|
current_note: Option<&Note>,
|
||||||
query: Option<&str>,
|
query: Option<&str>,
|
||||||
theme_name: &str,
|
theme_name: &str,
|
||||||
|
active_folder: Option<&str>,
|
||||||
base: &Base<'_>,
|
base: &Base<'_>,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
div class="toolbar" {
|
div class="toolbar" {
|
||||||
div class="toolbar-group" {
|
div class="toolbar-group" {
|
||||||
form action=(base.url("/notes")) method="post" {
|
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" }
|
button type="submit" class="btn btn-primary" { "+ New Note" }
|
||||||
}
|
}
|
||||||
@if let Some(note) = current_note {
|
@if let Some(note) = current_note {
|
||||||
|
|
@ -302,6 +329,8 @@ fn render_toolbar(
|
||||||
|
|
||||||
fn render_sidebar(
|
fn render_sidebar(
|
||||||
notes: &[Note],
|
notes: &[Note],
|
||||||
|
folders: &[String],
|
||||||
|
active_folder: Option<&str>,
|
||||||
current_note: Option<&Note>,
|
current_note: Option<&Note>,
|
||||||
default_tab: &str,
|
default_tab: &str,
|
||||||
base: &Base<'_>,
|
base: &Base<'_>,
|
||||||
|
|
@ -311,16 +340,74 @@ fn render_sidebar(
|
||||||
} else {
|
} 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! {
|
html! {
|
||||||
aside class={"sidebar" (active)} data-tab-pane="notes"
|
aside class={"sidebar" (active)} data-tab-pane="notes"
|
||||||
x-bind:class="{ 'mobile-active': tab === 'notes' }" {
|
x-bind:class="{ 'mobile-active': tab === 'notes' }" {
|
||||||
div class="sidebar-header" { "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" {
|
ul class="note-list" {
|
||||||
@if notes.is_empty() {
|
@if notes.is_empty() {
|
||||||
li class="note-item empty" { "No notes yet." }
|
li class="note-item empty" { "No notes yet." }
|
||||||
|
} @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 {
|
} @else {
|
||||||
|
// Flat view of the active folder.
|
||||||
@for note in notes {
|
@for note in notes {
|
||||||
li class={"note-item " (if current_note.map(|n| n.id == note.id).unwrap_or(false) { "active" } else { "" })}
|
(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-title=(note.title.to_lowercase())
|
||||||
data-content=(note.content.to_lowercase()) {
|
data-content=(note.content.to_lowercase()) {
|
||||||
a href=(base.url(&format!("/notes/{}", note.id))) {
|
a href=(base.url(&format!("/notes/{}", note.id))) {
|
||||||
|
|
@ -331,9 +418,11 @@ fn render_sidebar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
/// 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 {
|
fn render_editor(current_note: Option<&Note>, default_tab: &str, base: &Base<'_>) -> Markup {
|
||||||
|
|
@ -360,6 +449,9 @@ fn render_editor(current_note: Option<&Note>, default_tab: &str, base: &Base<'_>
|
||||||
class="note-title-input"
|
class="note-title-input"
|
||||||
placeholder="Note title"
|
placeholder="Note title"
|
||||||
value=(note.title);
|
value=(note.title);
|
||||||
|
@if let Some(folder) = ¬e.folder {
|
||||||
|
div class="note-folder" { "Folder: " (folder) }
|
||||||
|
}
|
||||||
textarea
|
textarea
|
||||||
id="editor"
|
id="editor"
|
||||||
name="content"
|
name="content"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use std::sync::Arc;
|
||||||
use crate::auth::{AuthError, AuthState, SESSION_COOKIE};
|
use crate::auth::{AuthError, AuthState, SESSION_COOKIE};
|
||||||
use crate::markdown::render_markdown;
|
use crate::markdown::render_markdown;
|
||||||
use crate::notes::{self, Note};
|
use crate::notes::{self, Note};
|
||||||
use crate::pages::{self, Base};
|
use crate::pages::{self, Base, FolderContext};
|
||||||
use crate::themes::get_theme;
|
use crate::themes::get_theme;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -114,6 +114,7 @@ pub fn router(state: AppState) -> Router {
|
||||||
let protected = Router::new()
|
let protected = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/notes", post(create_note_route))
|
.route("/notes", post(create_note_route))
|
||||||
|
.route("/folders", post(create_folder_route))
|
||||||
.route(
|
.route(
|
||||||
"/notes/:id",
|
"/notes/:id",
|
||||||
get(show_note_route).post(update_or_delete_note_route),
|
get(show_note_route).post(update_or_delete_note_route),
|
||||||
|
|
@ -326,6 +327,8 @@ fn cookie_path(base_path: &str) -> &str {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct IndexQuery {
|
struct IndexQuery {
|
||||||
q: Option<String>,
|
q: Option<String>,
|
||||||
|
folder: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index(
|
async fn index(
|
||||||
|
|
@ -335,6 +338,9 @@ async fn index(
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let theme = get_theme_from_cookie(&headers);
|
let theme = get_theme_from_cookie(&headers);
|
||||||
let all_notes = notes::list_notes()?;
|
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 notes: Vec<Note> = if let Some(ref q) = query.q {
|
||||||
let q = q.to_lowercase();
|
let q = q.to_lowercase();
|
||||||
|
|
@ -344,12 +350,29 @@ async fn index(
|
||||||
n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q)
|
n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q)
|
||||||
})
|
})
|
||||||
.collect()
|
.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 {
|
} else {
|
||||||
all_notes
|
all_notes
|
||||||
};
|
};
|
||||||
|
|
||||||
let base = Base::new(&state.base_path);
|
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()))
|
Ok(Html(html.into_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,6 +385,11 @@ async fn show_note_route(
|
||||||
let theme = get_theme_from_cookie(&headers);
|
let theme = get_theme_from_cookie(&headers);
|
||||||
let note = notes::load_note(&id)?;
|
let note = notes::load_note(&id)?;
|
||||||
let all_notes = notes::list_notes()?;
|
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 notes: Vec<Note> = if let Some(ref q) = query.q {
|
||||||
let q = q.to_lowercase();
|
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)
|
n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q)
|
||||||
})
|
})
|
||||||
.collect()
|
.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 {
|
} else {
|
||||||
all_notes
|
all_notes
|
||||||
};
|
};
|
||||||
|
|
||||||
let base = Base::new(&state.base_path);
|
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()))
|
Ok(Html(html.into_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,11 +433,54 @@ struct NoteForm {
|
||||||
_method: String,
|
_method: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_note_route(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
#[derive(Deserialize)]
|
||||||
let note = notes::create_note("Untitled", "")?;
|
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))))
|
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(
|
async fn update_or_delete_note_route(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
|
|
||||||
124
static/style.css
124
static/style.css
|
|
@ -293,7 +293,7 @@ button, input, select, textarea {
|
||||||
/* Flash Messages */
|
/* Flash Messages */
|
||||||
.flash-message {
|
.flash-message {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background-color: var(--ansi-2);
|
background-color: var(--ansi-1);
|
||||||
color: var(--app-bg);
|
color: var(--app-bg);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -430,6 +430,109 @@ button, input, select, textarea {
|
||||||
font-size: 13px;
|
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 */
|
||||||
.editor-pane {
|
.editor-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1046,6 +1149,25 @@ button, input, select, textarea {
|
||||||
padding: 12px 16px;
|
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 {
|
.note-list {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -643,6 +643,203 @@ async fn deleting_a_note_requires_authentication() {
|
||||||
assert_eq!(status, StatusCode::OK);
|
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 ----
|
// ------------------------------------------------------------------ auth ----
|
||||||
|
|
||||||
/// There is deliberately no `/setup` route. Enrolment happens in the
|
/// There is deliberately no `/setup` route. Enrolment happens in the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue