diff --git a/AGENTS.md b/AGENTS.md index 2711343..e2b23bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,15 @@ The application layout follows a classic 3-column / top-menu layout rendered cle 1. **Fluid Typography**: Inter/SF Pro or high-quality system sans-serif for the interface; Monospace for Markdown source; clean sans for HTML preview. 2. **Subtle Elevation & Borders**: Soft 1px borders using `--ui-border` / `--app-bg` contrast shades rather than heavy drop shadows. 3. **Apple-style Note List**: Sidebar lists show note title, relative timestamp, and a single line preview snippet in muted text. -4. **Progressive Enhancement**: Note editing, server-side search, and the login form work with JavaScript disabled via HTML forms and page reloads. The visible theme selector currently submits through Alpine's change handler, so theme changes from the UI require JavaScript. Menu dropdowns, mobile tab switching, live preview, and automatic opening of the PDF print dialog are also JavaScript-dependent. +4. **Progressive Enhancement**: Note editing, server-side search, and the login form work with JavaScript disabled via HTML forms and page reloads. The visible theme selector currently submits through Alpine's change handler, so theme changes from the UI require JavaScript. Menu dropdowns, mobile tab switching, live preview, drag-and-drop re-filing, and automatic opening of the PDF print dialog are also JavaScript-dependent. + +Re-filing a note has **three entry points, and they are not equivalent**. `File > Move to Folder…` is a submenu whose entries are plain forms posting to `POST /notes/:id/move`; the *move* is server-rendered and JS-free, but the menu will not open without JS, so it needs JS on every device. `.move-folder-bar` in the editor pane is also a plain form and works with JS off, but it is `display: none` above 600px, so it is mobile-only. Both are present on a phone. + +Drag-and-drop is the third path and **does work on touch** — this was previously documented here as impossible, which was wrong. On a touch screen the gesture is a *long press* rather than an immediate drag, verified working on iOS 18.7 / Firefox 156. It is not universal: it requires JS, and in engines without touch support for HTML5 drag the same gesture scrolls the sidebar instead. So a phone's three paths are all live; do not assume drag is desktop-only when adding UI around it. + +Two touch-specific details follow from the long press. `.note-item` sets `-webkit-touch-callout: none` so Safari's long-press menu does not compete with the gesture, while leaving `user-select` alone so preview text stays copyable. And the `dragstart` text-selection guard in `app.js` is gated on `matchMedia('(hover: hover)')` — a long press creates a selection *before* the drag starts, so an ungated guard cancels the gesture itself. `.folder-nav` is `position: sticky` inside the scrolling `.sidebar`, because nothing auto-scrolls a nested overflow container mid-drag and the folders are the drop targets. + +Neither entry point may carry `.desktop-only`. That class hides at `max-width: 600px`, and the bar is already hidden above 600px by a `min-width` query; adding the class composes to hiding it at *every* width and phones lose re-filing entirely. That shipped once. No Rust test can observe `display: none`, so `move_entry_points_are_not_desktop_only` pins the markup contract instead. All forms post the same `folder` field, where an empty value means the root. See §11 for the rename rule that makes a move destructive if done any other way, and for why user-supplied folder names force the flyout's `max-width`/wrapping. The status bar currently displays `Saved` as a static label; it does not track unsaved editor changes. The toolbar has no separate Preview button: Preview is a pane and a mobile tab. @@ -447,3 +455,8 @@ Binding to `0.0.0.0` by default makes the app reachable from other devices on th 5. **Respect the stacking context rule**: See section 7 before touching overflow or transform on menu-related selectors. 6. **Remember the Maud attribute caveat**: See section 8 before adding any Alpine modifier to a template. 7. **Test before claiming done**: Run `cargo fmt -- --check`, `cargo clippy -- -D warnings`, `cargo test`, and `cargo build --release`. If a browser is unavailable, say so rather than implying the UI was visually verified. +8. **Never move a note by re-saving it**: `save_note` writes the filename implied by `note.folder` and never removes the file it replaced. Re-filing a note through `save_note` therefore leaves the original behind, and `list_notes` then returns the same id twice — the note appears twice in the sidebar, each copy ordered by whatever `updated_at` its file happened to carry. `move_note` exists for this and uses `fs::rename`. Any new code that changes a note's folder must go through it. +9. **Anchor markup assertions on markup**: `style.css` and `app.js` are inlined into every rendered page, so a test searching the response body for a bare class name or a label string can match the stylesheet first and pass without inspecting anything. Search for an attribute or a binding that only the template emits (`class="move-folder-bar"`, `toggleSubmenu('noteMove'`). `the_move_bar_survives_for_mobile` and `the_move_menu_posts_the_move_route` each shipped a version of this bug during development, and a CSS comment mentioning a menu name was enough to redirect one search. +10. **Integration tests share one notes directory**: `ETERNALIBRE_NOTES_DIR` is set once per test binary, so every test sees every other test's notes and folders. Never assert an exact folder or note count across the whole app; scope to a region, or derive the expected value from the markup under test. +11. **Responsive bugs are invisible to the test suite**: nothing in a Rust test can observe `display: none`, a media query that fails to match, or a flyout that renders off-screen. A test asserting a control's *class* passes while the control is invisible, which is how the move bar ended up hidden at every width. For anything breakpoint-dependent, pin the markup contract that the CSS depends on — such as the absence of `.desktop-only` — and state in a browser at both widths before claiming it works. +12. **Do not encode a capability assumption in a guard without saying where it came from**: the `dragstart` selection guard in `app.js` was written assuming drag was desktop-only, and it silently broke long-press re-filing on touch the moment that assumption was falsified. When a guard exists to protect a behaviour on one platform, note the assumption in a comment at the guard itself. Prefer gating on a capability query (`matchMedia('(hover: hover)')`) over inferring the platform from context, and scope the check to the element it is protecting rather than the whole page. diff --git a/README.md b/README.md index 42ee936..e31a177 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,24 @@ binds to all interfaces by default, so you can reach your notes from a phone or another machine on your network, protected by a TOTP code from your authenticator app. No telemetry, no accounts, no third-party services. +## What's new in 0.2 + +- **Virtual folders** — organize notes into folders without creating real directories. A folder is encoded as a `[Folder]uuid.md` filename prefix, so a single notes directory can hold many of them +- **Move notes between folders** — drag a note onto a folder in the sidebar, use `File → Move to Folder…`, or use the Move control in the editor on a phone +- **Re-filing is a rename** — `[Work]uuid.md` becomes `[Home]uuid.md`. The note's id never changes, so links to it keep working, and its modified time is not bumped +- **Long-press drag on touch** — the re-file gesture on a phone +- **Quieter folder counts** — badges show across every folder in the All Notes view, and only on the folder you are inside otherwise +- **Pinned folder list** — folders stay visible and droppable however long the note list grows + ## Features - **TOTP-locked by default** — authenticator app required on first launch and after every restart - **Server-side rendered** with Axum and Maud — fast, type-safe HTML templates -- **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, and the login form are plain HTML; the menus, mobile tabs, live preview, and drag-and-drop re-filing 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 +- **Move notes between folders** — drag a note onto a folder, use `File → Move to Folder…`, or the Move control in the editor on a phone - **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` @@ -45,7 +55,7 @@ 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 +## Virtual Folders Notes can be organized into virtual folders without creating real directories on disk. A folder is encoded as a filename prefix: @@ -55,10 +65,14 @@ Notes can be organized into virtual folders without creating real directories on 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. +**Creating a folder:** click `+ New Folder` in the sidebar, 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. +**Moving a note between folders:** drag it from the sidebar onto a folder, pick `File → Move to Folder…`, or use the Move control under the editor on a phone. Dropping a note on **All Notes** — or choosing **Unfiled** — takes it back out of its folder. + +A move renames the file: `[Work]uuid.md` becomes `[Home]uuid.md`. The note's id is left untouched, so links to it keep working, and its modified time is not bumped, since re-filing is not an edit. + **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 @@ -103,7 +117,7 @@ Options: --host Host address to bind to [default: 0.0.0.0] -p, --port Port to listen on [default: 3000] --notes-dir Directory holding note files [default: notes] - --base-path Path prefix when behind a reverse proxy subdirectory + --base-path Path prefix when behind a reverse proxy subdirectory. Example: --base-path /forgejo when the app answers at https://host/forgejo/. Leave empty when mounted at the site root [default: ""] -h, --help Print help ``` @@ -135,8 +149,8 @@ cargo fmt | `src/syntax.rs` | syntect integration and per-theme syntax color scheme generation | | `src/themes.rs` | 40 predefined themes and CSS variable engine | | `static/style.css` | Full application stylesheet | -| `static/app.js` | Alpine.js component: preview, search, menus, tabs | -| `tests/auth_gating.rs` | End-to-end tests for the TOTP auth gate | +| `static/app.js` | Alpine.js component: preview, search, menus, tabs, drag-and-drop re-filing | +| `tests/auth_gating.rs` | End-to-end tests: auth gate, folders, note moves, nav counts, base paths, about page | ## License diff --git a/src/notes.rs b/src/notes.rs index c9834c4..cb3c652 100644 --- a/src/notes.rs +++ b/src/notes.rs @@ -249,6 +249,53 @@ pub fn update_note(id: &str, title: impl Into, content: impl Into) -> Result { + let path = find_note_path(id)?; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let (current_folder, note_id) = parse_stem(stem); + + // A blank name means "no folder", which is how the sidebar's All Notes + // item and the move form's Unfiled option express the root. Route-level + // callers reject a blank-but-nonempty field before reaching this, so this + // is the defensive reading of the same input, not a second opinion. + let target = match folder { + Some(f) if !f.trim().is_empty() => Some(validate_folder_name(f)?), + _ => None, + }; + + // Dropping a note back where it already lives is a no-op rather than a + // rename onto itself. Nothing is lost by short-circuiting, and it keeps + // the operation idempotent: dragging the same note onto the same folder + // twice cannot fail the second time. + if current_folder == target { + return load_note(id); + } + + let dest = notes_dir().join(note_filename(¬e_id, target.as_deref())); + if dest.exists() { + // Only reachable if two files share a parsed id, e.g. a hand-copied + // note. Renaming would silently clobber one of them. + anyhow::bail!( + "refusing to move note {id}: {} already exists", + dest.display() + ); + } + + fs::rename(&path, &dest)?; + // Re-read rather than patching the in-memory copy, so the returned note + // reflects what actually landed on disk. + load_note(¬e_id) +} + /// Validates a user-supplied folder name. /// /// The name is embedded in the filename as `[Name]`, so `]` would corrupt the @@ -344,6 +391,123 @@ mod tests { delete_note(¬e.id).unwrap(); } + /// Every on-disk `.md` file whose filename parses to `id`. + /// + /// More than one is the signature of the duplicate-write bug: a "move" + /// that re-saves under a new prefix without removing the old file leaves + /// two files that both claim the same id. + fn files_for_id(id: &str) -> Vec { + let Ok(entries) = fs::read_dir(notes_dir()) else { + return Vec::new(); + }; + let mut found: Vec = entries + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")) + .filter(|p| { + let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + parse_stem(stem).1 == id + }) + .map(|p| { + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }) + .collect(); + found.sort(); + found + } + + #[test] + fn move_note_renames_the_file_and_keeps_content() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let note = create_note("Movable", "Body stays put", None).unwrap(); + assert_eq!(files_for_id(¬e.id), vec![format!("{}.md", note.id)]); + + let moved = move_note(¬e.id, Some("Work")).unwrap(); + assert_eq!(moved.folder.as_deref(), Some("Work")); + assert_eq!(moved.title, "Movable"); + assert_eq!(moved.content, "Body stays put"); + assert_eq!(moved.id, note.id); + + // The file was renamed, not copied: exactly one file claims the id and + // it now carries the prefix. + assert_eq!( + files_for_id(¬e.id), + vec![format!("[Work]{}.md", note.id)] + ); + + // And a fresh read agrees. + assert_eq!(load_note(¬e.id).unwrap().folder.as_deref(), Some("Work")); + delete_note(¬e.id).unwrap(); + } + + #[test] + fn move_note_to_root_strips_the_prefix() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let note = create_note("Filed", "b", Some("Work".to_string())).unwrap(); + let moved = move_note(¬e.id, None).unwrap(); + assert_eq!(moved.folder, None); + assert_eq!(files_for_id(¬e.id), vec![format!("{}.md", note.id)]); + + // An empty string means the same thing as None. + let refiled = create_note("Filed again", "b", Some("Work".to_string())).unwrap(); + assert_eq!(move_note(&refiled.id, Some(" ")).unwrap().folder, None); + assert_eq!( + files_for_id(&refiled.id), + vec![format!("{}.md", refiled.id)] + ); + + delete_note(¬e.id).unwrap(); + delete_note(&refiled.id).unwrap(); + } + + #[test] + fn move_note_to_the_same_folder_is_a_no_op() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let note = create_note("Steady", "b", Some("Work".to_string())).unwrap(); + // Dropping a note onto the folder it is already in must not fork it. + move_note(¬e.id, Some("Work")).unwrap(); + move_note(¬e.id, Some("Work")).unwrap(); + assert_eq!( + files_for_id(¬e.id), + vec![format!("[Work]{}.md", note.id)] + ); + + // Same for an unfiled note dropped on the root. + let root = create_note("Rooted", "b", None).unwrap(); + move_note(&root.id, None).unwrap(); + assert_eq!(files_for_id(&root.id), vec![format!("{}.md", root.id)]); + + delete_note(¬e.id).unwrap(); + delete_note(&root.id).unwrap(); + } + + #[test] + fn move_note_leaves_the_file_alone_when_the_name_is_rejected() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let note = create_note("Careful", "b", None).unwrap(); + assert!(move_note(¬e.id, Some("bad]name")).is_err()); + assert!(move_note(¬e.id, Some("../escape")).is_err()); + // A rejected move must not have moved or deleted anything. + assert_eq!(files_for_id(¬e.id), vec![format!("{}.md", note.id)]); + assert_eq!(load_note(¬e.id).unwrap().folder, None); + delete_note(¬e.id).unwrap(); + } + + #[test] + fn move_note_does_not_disturb_the_recency_sort() { + std::fs::create_dir_all(notes_dir()).unwrap(); + let note = create_note("Untouched", "b", None).unwrap(); + let before = load_note(¬e.id).unwrap().updated_at; + let moved = move_note(¬e.id, Some("Work")).unwrap(); + // A move is not an edit. Bumping this would resurface the note at the + // top of the recency-sorted sidebar every time it is re-filed. + assert_eq!(moved.updated_at, before); + delete_note(¬e.id).unwrap(); + } + #[test] fn list_folders_returns_sorted_unique() { std::fs::create_dir_all(notes_dir()).unwrap(); diff --git a/src/pages.rs b/src/pages.rs index 7214b2a..462f7ed 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -78,7 +78,7 @@ pub fn render_app( script defer src=(base.url("/static/alpine.min.js")) {} } body x-data="app()" x-on:keydown="handleKeydown($event)" { - (render_menu_bar(base, current_note)) + (render_menu_bar(base, current_note, folder_ctx.folders)) (render_toolbar( current_note, query, @@ -100,7 +100,7 @@ pub fn render_app( default_tab, base, )) - (render_editor(current_note, default_tab, base)) + (render_editor(current_note, folder_ctx.folders, default_tab, base)) (render_preview(&rendered_preview, default_tab)) } (render_status_bar(notes.len(), theme_name)) @@ -153,7 +153,7 @@ fn render_tab_bar(default_tab: &str) -> Markup { } } -fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>) -> Markup { +fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[String]) -> Markup { html! { nav class="menu-bar" { ul class="menu-list" { @@ -202,6 +202,55 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>) -> Markup { aria-disabled="true" { "Export Note As…" } } } + // Available at every width, including mobile. The + // editor pane's `.move-folder-bar` is the other mobile + // entry point and is `display: none` above 600px; both + // post to the same route. + li class="menu-submenu" { + @if let Some(note) = current_note { + button type="button" class="menu-submenu-trigger" + x-on:click="toggleSubmenu('noteMove', $event)" + x-bind:aria-expanded="openSubmenu === 'noteMove'" { + "Move to Folder…" + span class="submenu-chevron" aria-hidden="true" { "›" } + } + ul class="menu-submenu-list" + x-bind:class="{ 'open': openSubmenu === 'noteMove' }" { + // One plain form per destination, so a + // click is a real POST to /notes/:id/move + // and no JavaScript runs the move. Matches + // how the Theme menu works. + li { + form + action=(base.url(&format!("/notes/{}/move", note.id))) + method="post" { + input type="hidden" name="folder" value=""; + button type="submit" { "(Unfiled)" } + } + } + @for folder in folders { + li { + form + action=(base.url(&format!( + "/notes/{}/move", + note.id + ))) + method="post" { + input type="hidden" name="folder" value=(folder); + // `title` because folder names run to 100 + // characters and the flyout is capped + // to the viewport width, so a long name + // can wrap to several lines. + button type="submit" title=(folder) { (folder) } + } + } + } + } + } @else { + button type="button" class="menu-submenu-trigger" disabled + aria-disabled="true" { "Move to Folder…" } + } + } } } li class="menu-item" tabindex="0" @@ -327,6 +376,24 @@ fn render_toolbar( } } +/// Whether a folder nav item should carry a count badge. +/// +/// `this_folder` is `None` for the "All Notes" item, which is itself scoped to +/// the root rather than to any one folder. +/// +/// Badges are a whole-sidebar summary, which is only meaningful in the All +/// Notes view where every folder is visible at once. Inside a folder the +/// sidebar lists just that folder's notes, so a count beside any *other* +/// folder would be zero — and zero is worse than absent, because it reads as +/// "this folder is empty" rather than "you are not looking at it". So a folder +/// view shows exactly one badge, on the folder you are in. +fn show_nav_count(active_folder: Option<&str>, this_folder: Option<&str>) -> bool { + match active_folder { + None => true, + Some(active) => Some(active) == this_folder, + } +} + fn render_sidebar( notes: &[Note], folders: &[String], @@ -361,17 +428,26 @@ fn render_sidebar( button type="submit" { "+ New Folder" } } ul class="folder-nav" { - li class={"nav-item" (if active_folder.is_none() { " active" } else { "" })} { + // An empty drop folder is the root: dropping a note on "All + // Notes" un-files it, which is the only way back out of a + // folder once every note has left it. + li class={"nav-item" (if active_folder.is_none() { " active" } else { "" })} + data-drop-folder="" { a href=(base.url("/")) { span class="nav-label" { "All Notes" } - span class="nav-count" { (notes.len()) } + @if show_nav_count(active_folder, None) { + span class="nav-count" { (notes.len()) } + } } } @for folder in folders { - li class={"nav-item" (if active_folder == Some(folder.as_str()) { " active" } else { "" })} { + li class={"nav-item" (if active_folder == Some(folder.as_str()) { " active" } else { "" })} + data-drop-folder=(folder) { 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)) } + @if show_nav_count(active_folder, Some(folder)) { + span class="nav-count" { (by_folder.get(folder).map(|v| v.len()).unwrap_or(0)) } + } } } } @@ -408,9 +484,14 @@ fn render_note_item(note: &Note, current_note: Option<&Note>, base: &Base<'_>) - let is_active = current_note.map(|n| n.id == note.id).unwrap_or(false); html! { li class={"note-item " (if is_active { "active" } else { "" })} + draggable="true" + data-note-id=(note.id) data-title=(note.title.to_lowercase()) data-content=(note.content.to_lowercase()) { - a href=(base.url(&format!("/notes/{}", note.id))) { + // `draggable="false"` matters: anchors are natively draggable, so + // without this the browser starts a link drag (URL ghost, no + // dataTransfer payload) and no folder ever lights up. + a href=(base.url(&format!("/notes/{}", note.id))) draggable="false" { div class="note-title" { (note.title) } div class="note-meta" { (relative_time(note.updated_at)) } div class="note-preview" { (note.preview(80)) } @@ -425,7 +506,20 @@ fn folder_url(base: &Base<'_>, folder: &str) -> String { base.url(&format!("/?folder={encoded}")) } -fn render_editor(current_note: Option<&Note>, default_tab: &str, base: &Base<'_>) -> Markup { +/// DOM id for a note's move-folder select, so the `