diff --git a/AGENTS.md b/AGENTS.md index 29a304b..7fd664e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ The name splits into its two halves: **Eterna** for permanence and **Libre** for ## 1. Project Philosophy & Identity * **Name**: EternaLibre Notes — "Free notes, forever." -* **Mission**: Reclaiming high-performance Rust web development under copyleft terms (GPLv3+). Rejecting heavy client-side JavaScript frameworks and proprietary note silos in favor of software freedom, privacy, and near-zero memory footprint. +* **Mission**: Reclaiming high-performance Rust web development under copyleft terms (GPLv3+). Rejecting heavy client-side JavaScript frameworks and proprietary note silos in favor of software freedom, privacy, and near-zero memory footprint. Optional weather lookup is opt-in and sends the configured location to wttr.in. * **UI Paradigm**: **"Apple Elegance meets Classic Desktop GUI"** * **Apple Notes Polish**: Fluid sidebars, smooth note item previews, refined typography, generous padding, subtle contrast, and graceful theme transitions. * **Traditional Desktop Utility**: Top menu bar (`File`, `Edit`, `View`, `Theme`, `Help`), quick action toolbars, collapsible editor/preview panes, keyboard shortcuts, and a status bar. Panes are not draggable/resizable. @@ -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, drag-and-drop re-filing, and automatic opening of the PDF print dialog are also JavaScript-dependent. +4. **Progressive Enhancement**: Note editing, server-side search, weather-location settings, 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 toolbar 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. + +When no note is open, the editor pane renders a welcome dashboard with up to five recently updated notes from the current sidebar result set. Its optional weather card submits the location and a Celsius/Fahrenheit radio as a plain form; the server stores the location in `.weather_location` and the unit in `.weather_units` under the notes directory and sends the location to wttr.in only when the dashboard or the dedicated `/weather` page is requested. The `format=j1` response keeps both scales so switching units reuses the cache without refetching. Results and failed checks are cached for 15 minutes, and requests time out after four seconds. Without a saved location, no weather request is made. The table shows current conditions as its top row followed by each forecast date wttr.in returns (currently three dates including today); render what is present rather than assuming a five-day horizon. + +A quotes manager under the weather card accepts one quote/author pair per plain-form submission into `.quotes.json` alongside the notes. The first saved quote is server-rendered beneath the welcome tagline; `app.js` rotates the rest on an 8s timer with Prev/Next and a Pause control, pausing on hover/focus and honouring `prefers-reduced-motion`. Without JavaScript the first quote simply stays put. + +`View → Weather` is a `.mobile-only` menu entry (hidden on desktop via a `min-width: 601px` rule, the inverse of `.desktop-only`) linking to the dedicated mobile-first `/weather` page with a `return_to` back to `/` or the open note. The page reuses the cached weather table and settings form with its own element ids. `View → Welcome` follows the same pattern for `/welcome`: header, quote rotator, and recent notes only. + +With no note open the preview pane is not rendered at all — there is nothing to preview — and the layout carries `app-body no-preview` to reclaim the third column. The Preview tab and the View preview toggle are omitted in the same state; opening a note restores all three. 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. @@ -65,6 +73,8 @@ The status bar currently displays `Saved` as a static label; it does not track u * **Language**: Rust (`edition = "2021"`) * **Server Framework**: **Axum 0.7** (async, Tower ecosystem) * **Auth**: `totp-rs` 6 (TOTP), `qrcode` 0.14 (inline SVG QR), `rand` 0.10 (session tokens) +* **Weather**: `reqwest` with rustls for the optional server-side wttr.in `format=j1` request; location in `notes/.weather_location`, units in `notes/.weather_units` +* **Quotes**: local `.quotes.json` in the notes directory; no network involved * **SSR Templating**: **`maud` 0.26** (compiled, type-safe HTML macros) * **Reactivity / Live Swap**: **Alpine.js 3.14** (vendored locally in `static/`, ~15 KB) * **Markdown Engine**: `pulldown-cmark` (with table, tasklist, strikethrough, and footnote extensions) @@ -98,6 +108,8 @@ eternalibre-notes/ │ ├── markdown.rs # Markdown → HTML with code block interception │ ├── syntax.rs # syntect engine + per-theme tmTheme generation │ ├── themes.rs # 40 predefined themes + CSS variable engine +│ ├── weather.rs # Opt-in wttr.in location/units, cached j1 lookup, forecast table data +│ ├── quotes.rs # Local quote/author list stored as `.quotes.json` │ └── storage.rs # Storage re-export module ├── static/ │ ├── alpine.min.js # Vendored Alpine.js @@ -289,7 +301,7 @@ The trap is that the CSS is *correct and present*. You can add a complete mobile `Theme::is_dark` classifies on the local gamma-encoded `relative_luminance` rather than the linearised one in `syntax.rs`. That is safe *only* because the shipped palettes are strongly bimodal: the lightest dark background scores 0.095 and the darkest light one 0.63, so any threshold in that gap agrees. If a future palette lands near 0.5, switch this to the WCAG function rather than nudging the threshold — the two agree on all 40 themes today, so the current answer is not a close call. -**Every outbound link carries `rel="noopener noreferrer"`.** These are the only links that leave the machine, and the app advertises that it never talks to a third party. `about_page_external_links_are_hardened` walks every ` { pub active_folder: Option<&'a str>, } +/// Per-request data that varies with the current app page. +pub struct AppPageContext<'a> { + pub current_note: Option<&'a Note>, + pub recent_notes: &'a [Note], + pub weather: &'a WeatherDisplay, + pub quotes: &'a [Quote], + pub query: Option<&'a str>, + pub message: 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>, + page: &AppPageContext<'_>, base: &Base<'_>, ) -> Markup { let theme = get_theme(theme_name); let css = theme.to_css_block(); + let current_note = page.current_note; let rendered_preview = current_note .map(|n| render_markdown(&n.content, theme)) .unwrap_or_default(); @@ -65,6 +76,10 @@ pub fn render_app( } else { "notes" }; + // The welcome state has nothing to preview, so the preview pane is not + // rendered at all and the layout drops to two columns. + let has_note = current_note.is_some(); + let body_class = if has_note { "" } else { " no-preview" }; html! { (DOCTYPE) @@ -81,16 +96,16 @@ pub fn render_app( (render_menu_bar(base, current_note, folder_ctx.folders)) (render_toolbar( current_note, - query, + page.query, theme_name, folder_ctx.active_folder, base, )) - @if let Some(msg) = message { + @if let Some(msg) = page.message { div class="flash-message" { "Error: " (msg) } } - (render_tab_bar(default_tab)) - main class="app-body" + (render_tab_bar(default_tab, has_note)) + main class={"app-body" (body_class)} x-bind:class="{ 'hide-editor': !editorVisible, 'hide-preview': !previewVisible }" { (render_sidebar( notes, @@ -100,8 +115,16 @@ pub fn render_app( default_tab, base, )) - (render_editor(current_note, folder_ctx.folders, default_tab, base)) - (render_preview(&rendered_preview, default_tab)) + (render_editor( + current_note, + folder_ctx.folders, + page.recent_notes, + page.weather, + page.quotes, + default_tab, + base, + )) + (render_preview(&rendered_preview, default_tab, has_note)) } (render_status_bar(notes.len(), theme_name)) script { @@ -122,7 +145,7 @@ fn escape_js(s: &str) -> String { .replace('\r', "\\r") } -fn render_tab_bar(default_tab: &str) -> Markup { +fn render_tab_bar(default_tab: &str, has_note: bool) -> Markup { html! { nav class="tab-bar" { button type="button" class="tab-button" data-tab-target="notes" @@ -137,11 +160,13 @@ fn render_tab_bar(default_tab: &str) -> Markup { span class="tab-icon" { "\u{270E}" } span { "Write" } } - button type="button" class="tab-button" data-tab-target="preview" - x-on:click="setTab('preview')" - x-bind:class="{ 'active': tab === 'preview' }" { - span class="tab-icon" { "\u{1F4C4}" } - span { "Preview" } + @if has_note { + button type="button" class="tab-button" data-tab-target="preview" + x-on:click="setTab('preview')" + x-bind:class="{ 'active': tab === 'preview' }" { + span class="tab-icon" { "\u{1F4C4}" } + span { "Preview" } + } } script { (PreEscaped(format!( @@ -154,6 +179,9 @@ fn render_tab_bar(default_tab: &str) -> Markup { } fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[String]) -> Markup { + let theme_return = theme_return_path(current_note); + let weather_href = dedicated_page_href(base, "/weather", current_note); + let welcome_href = dedicated_page_href(base, "/welcome", current_note); html! { nav class="menu-bar" { ul class="menu-list" { @@ -280,11 +308,15 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[Stri "Toggle Editor" } } - li class="desktop-only" { - button x-on:click="togglePreview()" - x-bind:class="{ 'active-view': !previewVisible }" - x-text="previewVisible ? 'Hide Preview' : 'Show Preview'" { - "Toggle Preview" + // No preview pane exists without an open note, so the + // toggle would only offer an empty view. + @if current_note.is_some() { + li class="desktop-only" { + button x-on:click="togglePreview()" + x-bind:class="{ 'active-view': !previewVisible }" + x-text="previewVisible ? 'Hide Preview' : 'Show Preview'" { + "Toggle Preview" + } } } // Available at every width. The folder nav is a sidebar @@ -298,6 +330,15 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[Stri "Toggle Folder Nav" } } + // Mobile-only shortcuts to the dedicated pages. Desktop + // already shows the full dashboard, so these entries + // stay out of the way there; see `.mobile-only`. + li class="mobile-only" { + a href=(welcome_href) { "Welcome" } + } + li class="mobile-only" { + a href=(weather_href) { "Weather" } + } } } li class="menu-item" tabindex="0" @@ -309,6 +350,7 @@ fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>, folders: &[Stri li { form action=(base.url("/theme")) method="post" { input type="hidden" name="theme" value=(theme.name); + input type="hidden" name="return_to" value=(theme_return); button type="submit" { (theme.name) } } } @@ -347,6 +389,7 @@ fn render_toolbar( active_folder: Option<&str>, base: &Base<'_>, ) -> Markup { + let theme_return = theme_return_path(current_note); html! { div class="toolbar" { div class="toolbar-group" { @@ -381,6 +424,7 @@ fn render_toolbar( } div class="toolbar-group" { form action=(base.url("/theme")) method="post" class="theme-form" x-on:change="$event.target.form.submit()" { + input type="hidden" name="return_to" value=(theme_return); label { "Theme:" } select name="theme" { @for theme in PREDEFINED_THEMES { @@ -544,6 +588,9 @@ fn move_select_id(note: &Note) -> String { fn render_editor( current_note: Option<&Note>, folders: &[String], + recent_notes: &[Note], + weather: &WeatherDisplay, + quotes: &[Quote], default_tab: &str, base: &Base<'_>, ) -> Markup { @@ -629,25 +676,355 @@ fn render_editor( } } } @else { - div class="empty-state" { - p { "Select a note or create a new one." } + (render_welcome(recent_notes, weather, quotes, base)) + } + } + } +} + +fn theme_return_path(current_note: Option<&Note>) -> String { + current_note + .map(|note| format!("/notes/{}", note.id)) + .unwrap_or_else(|| "/".to_string()) +} + +fn dedicated_page_href(base: &Base<'_>, path: &str, current_note: Option<&Note>) -> String { + let target = theme_return_path(current_note); + let encoded = utf8_percent_encode(&target, NON_ALPHANUMERIC).to_string(); + base.url(&format!("{path}?return_to={encoded}")) +} + +fn render_welcome_heading(quotes: &[Quote]) -> Markup { + html! { + header class="welcome-heading" { + p class="welcome-eyebrow" { "YOUR NOTES, YOUR SPACE" } + h1 id="welcome-title" { "Welcome to EternaLibre Notes" } + p class="welcome-tagline" { "A quiet place for ideas, plans, and everything worth keeping." } + (render_quote_rotator(quotes)) + } + } +} + +fn render_recent_card(recent_notes: &[Note], base: &Base<'_>) -> Markup { + html! { + section class="welcome-card recent-card" aria-labelledby="recent-notes-title" { + h2 id="recent-notes-title" { "Recently updated" } + @if recent_notes.is_empty() { + p class="welcome-muted" { "Your notes will show up here. Create one whenever you’re ready." } + } @else { + ul class="welcome-recent-list" { + @for note in recent_notes.iter().take(5) { + li { + a href=(base.url(&format!("/notes/{}", note.id))) { + span class="welcome-note-title" { (note.title) } + span class="welcome-note-meta" { (relative_time(note.updated_at)) } + @let preview = note.preview(100); + @if !preview.is_empty() { + span class="welcome-note-preview" { (preview) } + } + } + } + } } } } } } -fn render_preview(html_content: &str, default_tab: &str) -> Markup { - let active = if default_tab == "preview" { - " mobile-active" - } else { - "" - }; +fn render_welcome( + recent_notes: &[Note], + weather: &WeatherDisplay, + quotes: &[Quote], + base: &Base<'_>, +) -> Markup { html! { - section class={"preview-pane" (active)} data-tab-pane="preview" - x-bind:class="{ 'mobile-active': tab === 'preview' }" { - div id="preview" class="markdown-body" x-html="previewHtml" { - (PreEscaped(html_content)) + section class="welcome-state" aria-labelledby="welcome-title" { + (render_welcome_heading(quotes)) + div class="welcome-columns" { + (render_recent_card(recent_notes, base)) + section class="welcome-card weather-card" aria-labelledby="weather-title" { + h2 id="weather-title" { "Local weather" } + (render_weather_table(weather)) + (render_weather_form(weather, base, "weather-location")) + } + } + (render_quotes_manager(quotes, base)) + } + } +} + +/// Rotating quote under the welcome tagline. The first quote is server-rendered +/// so the block reads sensibly with JavaScript disabled; `app.js` swaps in the +/// rest on a timer when more than one quote exists. +fn render_quote_rotator(quotes: &[Quote]) -> Markup { + html! { + @if quotes.is_empty() { + } @else { + div class="welcome-quotes" { + blockquote id="welcome-quote" class="welcome-quote" aria-live="polite" { + p id="welcome-quote-text" class="welcome-quote-text" { (quotes[0].text) } + @if !quotes[0].author.trim().is_empty() { + footer { + "— " span id="welcome-quote-author" { (quotes[0].author) } + } + } @else { + footer { + "— " span id="welcome-quote-author" { "Anonymous" } + } + } + } + @if quotes.len() > 1 { + div class="welcome-quote-controls" { + button type="button" id="quote-prev" class="btn btn-small" { "‹ Prev" } + button type="button" id="quote-pause" class="btn btn-small" aria-pressed="false" { "Pause" } + button type="button" id="quote-next" class="btn btn-small" { "Next ›" } + } + } + script { (PreEscaped(welcome_quotes_js(quotes))) } + } + } + } +} + +fn welcome_quotes_js(quotes: &[Quote]) -> String { + // JSON keeps user-supplied quotes intact; escaping ` Markup { + html! { + @if let Some(location) = weather.location.as_deref() { + @if weather.current.is_none() && weather.days.is_empty() { + p class="welcome-muted" { "Weather is temporarily unavailable." } + } @else { + div class="weather-table-wrapper" { + table class="weather-table" { + caption class="weather-caption" { + "Weather for " (location) " (" (weather.units.label()) ")" + } + thead { + tr { + th scope="col" { "Day" } + th scope="col" { "Conditions" } + th scope="col" { "Temp" } + th scope="col" { "High" } + th scope="col" { "Low" } + } + } + tbody { + @if let Some(now) = weather.current.as_ref() { + tr class="weather-now-row" { + th scope="row" { "Now" } + td { (now.desc) } + td { (now.temp(weather.units)) } + td class="weather-na" { "—" } + td class="weather-na" { "—" } + } + } + @for day in &weather.days { + tr { + th scope="row" { (day.short_date()) } + td { (day.desc) } + td { (day.avg(weather.units)) } + td { (day.high(weather.units)) } + td { (day.low(weather.units)) } + } + } + } + } + } + @if let Some(now) = weather.current.as_ref() { + p class="weather-details" { + "Feels like " (now.feels(weather.units)) " · Humidity " (now.humidity) "%" + } + } + } + p class="weather-location" { "Location: " (location) } + } @else { + p class="welcome-muted" { "Set a location to see current weather from wttr.in." } + } + } +} + +fn render_weather_form(weather: &WeatherDisplay, base: &Base<'_>, id: &str) -> Markup { + let units = weather.units; + html! { + form action=(base.url("/settings/weather")) method="post" class="weather-location-form" { + label for=(id) { "Location" } + input + id=(id) + type="text" + name="location" + maxlength="100" + placeholder="City or place name" + value=[weather.location.as_deref()]; + fieldset class="weather-units" { + legend { "Units" } + label { + input + type="radio" + name="units" + value="c" + checked[units == TempUnit::Celsius]; + " Celsius" + } + label { + input + type="radio" + name="units" + value="f" + checked[units == TempUnit::Fahrenheit]; + " Fahrenheit" + } + } + button type="submit" class="btn" { "Save location" } + p class="weather-privacy" { + "The server sends this location to wttr.in to retrieve weather. Leave it blank to clear it." + } + } + } +} + +fn render_quotes_manager(quotes: &[Quote], base: &Base<'_>) -> Markup { + html! { + section class="welcome-card quotes-card" aria-labelledby="quotes-title" { + h2 id="quotes-title" { "Quotes" } + p class="welcome-muted" { + "Add quotes to rotate beneath the welcome message. One submission adds one quote." + } + form action=(base.url("/settings/quotes")) method="post" class="quote-form" { + label for="quote-text" { "Quote" } + textarea + id="quote-text" + name="quote" + maxlength="500" + rows="3" + placeholder="Write something worth re-reading…" + required="required" {} + label for="quote-author" { "Author" } + input + id="quote-author" + type="text" + name="author" + maxlength="100" + placeholder="Who said it?"; + button type="submit" class="btn" { "Add quote" } + } + @if quotes.is_empty() { + p class="welcome-muted" { "No quotes yet. The rotator appears once you add one." } + } @else { + ul class="quote-list" { + @for (index, quote) in quotes.iter().enumerate() { + li class="quote-item" { + blockquote class="quote-item-body" { + p { (quote.text) } + @if !quote.author.trim().is_empty() { + footer { "— " (quote.author) } + } + } + form action=(base.url("/settings/quotes/delete")) method="post" class="quote-delete-form" { + input type="hidden" name="index" value=(index); + button type="submit" class="btn btn-small" { "Remove" } + } + } + } + } + } + } + } +} + +pub fn render_weather_page( + theme_name: &str, + weather: &WeatherDisplay, + return_to: &str, + base: &Base<'_>, +) -> Markup { + let theme = get_theme(theme_name); + html! { + (DOCTYPE) + html lang="en" data-theme=(theme_name) { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"; + title { "Weather - EternaLibre Notes" } + style { (PreEscaped(theme.to_css_block())) } + style { (PreEscaped(include_str!("../static/style.css"))) } + script defer src=(base.url("/static/alpine.min.js")) {} + } + body class="weather-page" x-data="app()" x-on:keydown="handleKeydown($event)" { + (render_menu_bar(base, None, &[])) + main class="weather-page-main" { + p class="welcome-eyebrow" { "MOBILE WEATHER" } + h1 { "Weather" } + section class="welcome-card weather-card" aria-labelledby="weather-page-title" { + h2 id="weather-page-title" { "Local weather" } + (render_weather_table(weather)) + (render_weather_form(weather, base, "weather-page-location")) + } + a class="back-link" href=(base.url(return_to)) { "Back" } + } + (render_status_bar(0, theme_name)) + script { + "window.__INITIAL_PREVIEW__ = ``;" + "window.__BASE_PATH__ = `" (PreEscaped(escape_js(base.prefix()))) "`;" + (PreEscaped(include_str!("../static/app.js"))) + } + } + } + } +} + +pub fn render_welcome_page( + theme_name: &str, + recent_notes: &[Note], + quotes: &[Quote], + return_to: &str, + base: &Base<'_>, +) -> Markup { + let theme = get_theme(theme_name); + html! { + (DOCTYPE) + html lang="en" data-theme=(theme_name) { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"; + title { "Welcome - EternaLibre Notes" } + style { (PreEscaped(theme.to_css_block())) } + style { (PreEscaped(include_str!("../static/style.css"))) } + script defer src=(base.url("/static/alpine.min.js")) {} + } + body class="welcome-page" x-data="app()" x-on:keydown="handleKeydown($event)" { + (render_menu_bar(base, None, &[])) + main class="welcome-page-main welcome-state" { + (render_welcome_heading(quotes)) + (render_recent_card(recent_notes, base)) + a class="back-link" href=(base.url(return_to)) { "Back" } + } + (render_status_bar(recent_notes.len(), theme_name)) + script { + "window.__INITIAL_PREVIEW__ = ``;" + "window.__BASE_PATH__ = `" (PreEscaped(escape_js(base.prefix()))) "`;" + (PreEscaped(include_str!("../static/app.js"))) + } + } + } + } +} + +fn render_preview(html_content: &str, default_tab: &str, has_note: bool) -> Markup { + html! { + @if has_note { + section class={"preview-pane" (if default_tab == "preview" { " mobile-active" } else { "" })} data-tab-pane="preview" + x-bind:class="{ 'mobile-active': tab === 'preview' }" { + div id="preview" class="markdown-body" x-html="previewHtml" { + (PreEscaped(html_content)) + } } } } @@ -817,12 +1194,12 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup { p { "A server-side rendered, copyleft Markdown notes application written in Rust. \ A toolkit for making a private notes cloud: run the server yourself, \ - on your own hardware, and the notes go nowhere else." + on your own hardware, with notes kept in local Markdown files." } p { "The server binds to all interfaces by default, so you can reach your notes \ from a phone or another machine on your network. It is protected by a TOTP \ - code from your authenticator app, and it never talks to a third party." + code from your authenticator app. Optional weather sends only the location you configure to wttr.in." } ul class="about-features" { // Counted from the palette itself, and split by @@ -840,7 +1217,7 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup { } li { "Syntax highlighting for fenced code blocks" } li { "Notes stored as plain Markdown files on disk" } - li { "TOTP-locked, with no accounts or third-party services" } + li { "TOTP-locked, with no accounts or telemetry; weather lookup is optional" } li { "Note editing, search, and theming work without JavaScript" } li { "Export saved notes as Markdown, themed HTML, or PDF via the browser print dialog" } } @@ -875,8 +1252,8 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup { } p class="about-colophon" { (format!( - "Version {}. Licensed under the GNU General Public \ - License v3.0 or later. No telemetry, no accounts, no vendor.", + "Version {}. Licensed under the GNU General Public \ + License v3.0 or later. No telemetry or accounts; weather is opt-in.", env!("CARGO_PKG_VERSION"), )) } diff --git a/src/quotes.rs b/src/quotes.rs new file mode 100644 index 0000000..f05d83f --- /dev/null +++ b/src/quotes.rs @@ -0,0 +1,158 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const MAX_QUOTE_LENGTH: usize = 500; +const MAX_AUTHOR_LENGTH: usize = 100; +const MAX_QUOTES: usize = 50; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Quote { + pub text: String, + pub author: String, +} + +pub struct QuoteStore { + path: PathBuf, +} + +impl QuoteStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// All saved quotes in insertion order. Missing or unreadable storage + /// reads as empty so the welcome page still renders; the parse error is + /// not hidden from writers, only from readers. + pub fn list(&self) -> Vec { + match std::fs::read_to_string(&self.path) { + Ok(body) => { + if body.trim().is_empty() { + Vec::new() + } else { + serde_json::from_str(&body).unwrap_or_default() + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(_) => Vec::new(), + } + } + + pub fn add(&self, text: &str, author: &str) -> Result { + let text = text.trim(); + let author = author.trim(); + if text.is_empty() { + return Err(anyhow!("Quote cannot be blank.")); + } + if text.chars().count() > MAX_QUOTE_LENGTH { + return Err(anyhow!( + "Quote must be at most {MAX_QUOTE_LENGTH} characters." + )); + } + if author.chars().count() > MAX_AUTHOR_LENGTH { + return Err(anyhow!( + "Author must be at most {MAX_AUTHOR_LENGTH} characters." + )); + } + if text.chars().any(char::is_control) || author.chars().any(char::is_control) { + return Err(anyhow!("Quotes cannot contain control characters.")); + } + + let mut quotes = self.list(); + if quotes.len() >= MAX_QUOTES { + return Err(anyhow!("Quote list is full ({MAX_QUOTES} quotes).")); + } + let quote = Quote { + text: text.to_string(), + author: author.to_string(), + }; + quotes.push(quote.clone()); + self.write("es)?; + Ok(quote) + } + + pub fn remove(&self, index: usize) -> Result<()> { + let mut quotes = self.list(); + if index >= quotes.len() { + return Err(anyhow!("Quote not found.")); + } + quotes.remove(index); + if quotes.is_empty() { + match std::fs::remove_file(&self.path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err).context("removing saved quotes"), + } + return Ok(()); + } + self.write("es) + } + + fn write(&self, quotes: &[Quote]) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).context("creating notes directory for quotes")?; + } + let body = serde_json::to_string_pretty(quotes).context("encoding quotes")?; + std::fs::write(&self.path, body).context("saving quotes")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "eternalibre-quotes-{tag}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + p.set_extension("json"); + let _ = std::fs::remove_file(&p); + p + } + + #[test] + fn missing_storage_lists_no_quotes() { + let store = QuoteStore::new(temp_path("missing")); + assert!(store.list().is_empty()); + } + + #[test] + fn add_persists_quote_and_author() { + let path = temp_path("add"); + let store = QuoteStore::new(path.clone()); + store.add("Stay curious.", "Ada").unwrap(); + let quotes = store.list(); + assert_eq!(quotes.len(), 1); + assert_eq!(quotes[0].text, "Stay curious."); + assert_eq!(quotes[0].author, "Ada"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn add_rejects_blank_and_oversized_quotes() { + let store = QuoteStore::new(temp_path("invalid")); + assert!(store.add(" ", "Someone").is_err()); + assert!(store.add(&"x".repeat(MAX_QUOTE_LENGTH + 1), "").is_err()); + assert!(store + .add("Fine", &"y".repeat(MAX_AUTHOR_LENGTH + 1)) + .is_err()); + } + + #[test] + fn remove_deletes_by_index_and_clears_the_file() { + let path = temp_path("remove"); + let store = QuoteStore::new(path.clone()); + store.add("First", "A").unwrap(); + store.add("Second", "B").unwrap(); + store.remove(0).unwrap(); + assert_eq!(store.list().len(), 1); + store.remove(0).unwrap(); + assert!(store.list().is_empty()); + assert!(!path.exists()); + assert!(store.remove(0).is_err()); + } +} diff --git a/src/routes.rs b/src/routes.rs index b084cc6..8cd75c4 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -12,8 +12,10 @@ 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, FolderContext}; +use crate::pages::{self, AppPageContext, Base, FolderContext}; +use crate::quotes::{Quote, QuoteStore}; use crate::themes::get_theme; +use crate::weather::{TempUnit, WeatherDisplay, WeatherService}; #[derive(Clone)] pub struct AppState { @@ -22,6 +24,8 @@ pub struct AppState { /// Empty means mounted at the root, which is the default and the only /// configuration most installs need. pub base_path: Arc, + pub weather: Arc, + pub quotes: Arc, } impl AppState { @@ -82,6 +86,11 @@ mod tests { let root = AppState { auth: Arc::new(AuthState::with_secret_path("/nonexistent-test-secret")), base_path: "".into(), + weather: Arc::new(WeatherService::new( + "/nonexistent-weather-location".into(), + "/nonexistent-weather-units".into(), + )), + quotes: Arc::new(QuoteStore::new("/nonexistent-quotes".into())), }; assert_eq!(root.url("/"), "/"); assert_eq!(root.url("/about"), "/about"); @@ -89,6 +98,8 @@ mod tests { let mounted = AppState { auth: root.auth.clone(), base_path: "/forgejo".into(), + weather: root.weather.clone(), + quotes: root.quotes.clone(), }; assert_eq!(mounted.url("/"), "/forgejo/"); assert_eq!(mounted.url("/about"), "/forgejo/about"); @@ -122,6 +133,11 @@ pub fn router(state: AppState) -> Router { .route("/notes/:id/move", post(move_note_route)) .route("/preview", post(preview_route)) .route("/theme", post(theme_route)) + .route("/settings/weather", post(weather_settings_route)) + .route("/settings/quotes", post(quotes_add_route)) + .route("/settings/quotes/delete", post(quotes_delete_route)) + .route("/weather", get(weather_page_route)) + .route("/welcome", get(welcome_page_route)) .route("/about", get(about_route)) .route("/export", get(export_route)) .route( @@ -338,6 +354,8 @@ async fn index( headers: HeaderMap, ) -> Result { let theme = get_theme_from_cookie(&headers); + let weather = state.weather.display().await; + let quotes = state.quotes.list(); let all_notes = notes::list_notes()?; let folders = notes::list_folders()?; @@ -359,21 +377,22 @@ async fn index( } else { all_notes }; + let recent_notes: Vec = notes.iter().take(5).cloned().collect(); let base = Base::new(&state.base_path); 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, - ); + let page = AppPageContext { + current_note: None, + recent_notes: &recent_notes, + weather: &weather, + quotes: "es, + query: query.q.as_deref(), + message: query.error.as_deref(), + }; + let html = pages::render_app(&theme, ¬es, &folder_ctx, &page, &base); Ok(Html(html.into_string())) } @@ -408,21 +427,24 @@ async fn show_note_route( } else { all_notes }; + let recent_notes: Vec = notes.iter().take(5).cloned().collect(); let base = Base::new(&state.base_path); 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, - ); + let weather = WeatherDisplay::empty(); + let no_quotes: Vec = Vec::new(); + let page = AppPageContext { + current_note: Some(¬e), + recent_notes: &recent_notes, + weather: &weather, + quotes: &no_quotes, + query: query.q.as_deref(), + message: None, + }; + let html = pages::render_app(&theme, ¬es, &folder_ctx, &page, &base); Ok(Html(html.into_string())) } @@ -561,6 +583,8 @@ async fn preview_route( #[derive(Deserialize)] struct ThemeForm { theme: String, + #[serde(default)] + return_to: String, } async fn theme_route( @@ -570,7 +594,8 @@ async fn theme_route( // Validate theme name let _ = get_theme(&form.theme); - let mut response = Redirect::to(&state.url("/")).into_response(); + let return_path = sanitize_return_path(&form.return_to); + let mut response = Redirect::to(&state.url(&return_path)).into_response(); let cookie = format!( "theme={}; Path={}; Max-Age=31536000; SameSite=Lax", form.theme, @@ -582,6 +607,144 @@ async fn theme_route( Ok(response) } +#[derive(Deserialize)] +struct WeatherLocationForm { + location: String, + #[serde(default)] + units: Option, +} + +async fn weather_settings_route( + State(state): State, + Form(form): Form, +) -> Result { + if let Some(units) = form.units { + if TempUnit::parse(&units).is_none() { + return Ok( + error_redirect(&state, "Units must be Celsius or Fahrenheit.").into_response(), + ); + } + if let Err(err) = state.weather.set_units(&units).await { + return Ok(error_redirect(&state, &err.to_string()).into_response()); + } + } + if let Err(err) = state.weather.set_location(&form.location).await { + return Ok(error_redirect(&state, &err.to_string()).into_response()); + } + Ok(Redirect::to(&state.url("/")).into_response()) +} + +#[derive(Deserialize)] +struct QuoteForm { + quote: String, + #[serde(default)] + author: String, +} + +async fn quotes_add_route( + State(state): State, + Form(form): Form, +) -> Result { + if let Err(err) = state.quotes.add(&form.quote, &form.author) { + return Ok(error_redirect(&state, &err.to_string()).into_response()); + } + Ok(Redirect::to(&state.url("/")).into_response()) +} + +#[derive(Deserialize)] +struct QuoteDeleteForm { + index: usize, +} + +async fn quotes_delete_route( + State(state): State, + Form(form): Form, +) -> Result { + if let Err(err) = state.quotes.remove(form.index) { + return Ok(error_redirect(&state, &err.to_string()).into_response()); + } + Ok(Redirect::to(&state.url("/")).into_response()) +} + +#[derive(Deserialize)] +struct WeatherPageQuery { + #[serde(default)] + return_to: Option, +} + +async fn weather_page_route( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result { + let theme = get_theme_from_cookie(&headers); + let weather = state.weather.display().await; + let return_to = sanitize_return_path(query.return_to.as_deref().unwrap_or("/")); + let base = Base::new(&state.base_path); + let html = pages::render_weather_page(&theme, &weather, &return_to, &base); + Ok(Html(html.into_string())) +} + +#[derive(Deserialize)] +struct WelcomePageQuery { + #[serde(default)] + q: Option, + #[serde(default)] + folder: Option, + #[serde(default)] + return_to: Option, +} + +async fn welcome_page_route( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result { + let theme = get_theme_from_cookie(&headers); + let all_notes = notes::list_notes()?; + let active_folder = query.folder.clone().filter(|f| !f.is_empty()); + let notes: Vec = if let Some(ref q) = query.q { + let q = q.to_lowercase(); + all_notes + .into_iter() + .filter(|n| { + 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 recent_notes: Vec = notes.iter().take(5).cloned().collect(); + let quotes = state.quotes.list(); + let return_to = sanitize_return_path(query.return_to.as_deref().unwrap_or("/")); + let base = Base::new(&state.base_path); + let html = pages::render_welcome_page(&theme, &recent_notes, "es, &return_to, &base); + Ok(Html(html.into_string())) +} + +/// Shared guard for client-controlled return targets. Only the app root and +/// canonical `/notes/` paths survive; everything else falls back to `/` +/// so a crafted form or query string cannot turn a redirect or link into an +/// open redirect. +fn sanitize_return_path(raw: &str) -> String { + if raw == "/" { + "/".to_string() + } else if let Some(id) = raw.strip_prefix("/notes/") { + if uuid::Uuid::parse_str(id).is_ok_and(|uuid| uuid.hyphenated().to_string() == id) { + raw.to_string() + } else { + "/".to_string() + } + } else { + "/".to_string() + } +} + async fn about_route( State(state): State, headers: HeaderMap, diff --git a/src/weather.rs b/src/weather.rs new file mode 100644 index 0000000..19553e9 --- /dev/null +++ b/src/weather.rs @@ -0,0 +1,571 @@ +use anyhow::{anyhow, Context, Result}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +const CACHE_TTL: Duration = Duration::from_secs(15 * 60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(4); +const MAX_LOCATION_LENGTH: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum TempUnit { + #[default] + Celsius, + Fahrenheit, +} + +impl TempUnit { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "c" | "celsius" => Some(TempUnit::Celsius), + "f" | "fahrenheit" => Some(TempUnit::Fahrenheit), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + TempUnit::Celsius => "c", + TempUnit::Fahrenheit => "f", + } + } + + pub fn label(&self) -> &'static str { + match self { + TempUnit::Celsius => "°C", + TempUnit::Fahrenheit => "°F", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WeatherCurrent { + pub desc: String, + pub temp_c: String, + pub temp_f: String, + pub feels_c: String, + pub feels_f: String, + pub humidity: String, +} + +impl WeatherCurrent { + pub fn temp(&self, units: TempUnit) -> String { + match units { + TempUnit::Celsius => format!("{}°C", self.temp_c), + TempUnit::Fahrenheit => format!("{}°F", self.temp_f), + } + } + + pub fn feels(&self, units: TempUnit) -> String { + match units { + TempUnit::Celsius => format!("{}°C", self.feels_c), + TempUnit::Fahrenheit => format!("{}°F", self.feels_f), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WeatherDay { + pub date: String, + pub desc: String, + pub avg_c: String, + pub avg_f: String, + pub max_c: String, + pub max_f: String, + pub min_c: String, + pub min_f: String, +} + +impl WeatherDay { + pub fn avg(&self, units: TempUnit) -> String { + match units { + TempUnit::Celsius => format!("{}°C", self.avg_c), + TempUnit::Fahrenheit => format!("{}°F", self.avg_f), + } + } + + pub fn high(&self, units: TempUnit) -> String { + match units { + TempUnit::Celsius => format!("{}°C", self.max_c), + TempUnit::Fahrenheit => format!("{}°F", self.max_f), + } + } + + pub fn low(&self, units: TempUnit) -> String { + match units { + TempUnit::Celsius => format!("{}°C", self.min_c), + TempUnit::Fahrenheit => format!("{}°F", self.min_f), + } + } + + /// Human-friendly day label: `2026-10-12` becomes `Oct 12`. + /// Falls back to the raw value when it is not a calendar date. + pub fn short_date(&self) -> String { + if let Ok(date) = chrono::NaiveDate::parse_from_str(&self.date, "%Y-%m-%d") { + date.format("%b %d").to_string() + } else { + self.date.clone() + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WeatherDisplay { + pub location: Option, + pub units: TempUnit, + pub current: Option, + pub days: Vec, +} + +impl WeatherDisplay { + pub fn empty() -> Self { + Self { + location: None, + units: TempUnit::Celsius, + current: None, + days: Vec::new(), + } + } +} + +struct CachedWeather { + location: String, + current: Option, + days: Vec, + checked_at: Instant, +} + +pub struct WeatherService { + location_path: PathBuf, + units_path: PathBuf, + endpoint: String, + client: reqwest::Client, + cache: Mutex>, +} + +impl WeatherService { + pub fn new(location_path: PathBuf, units_path: PathBuf) -> Self { + Self::with_endpoint(location_path, units_path, "https://wttr.in") + } + + fn with_endpoint( + location_path: PathBuf, + units_path: PathBuf, + endpoint: impl Into, + ) -> Self { + Self { + location_path, + units_path, + endpoint: endpoint.into(), + client: reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .user_agent(concat!("EternaLibre-Notes/", env!("CARGO_PKG_VERSION"))) + .build() + .expect("reqwest client configuration is valid"), + cache: Mutex::new(None), + } + } + + pub async fn display(&self) -> WeatherDisplay { + let units = self.read_units(); + let location = match self.read_location() { + Ok(Some(location)) => location, + _ => { + return WeatherDisplay { + location: None, + units, + current: None, + days: Vec::new(), + } + } + }; + + let mut cache = self.cache.lock().await; + if let Some(cached) = cache.as_ref() { + if cached.location == location && cached.checked_at.elapsed() < CACHE_TTL { + return WeatherDisplay { + location: Some(location), + units, + current: cached.current.clone(), + days: cached.days.clone(), + }; + } + } + + match self.fetch(&location).await { + Ok((current, days)) => { + *cache = Some(CachedWeather { + location: location.clone(), + current: current.clone(), + days: days.clone(), + checked_at: Instant::now(), + }); + WeatherDisplay { + location: Some(location), + units, + current, + days, + } + } + Err(_) => { + // Retain a same-location report through an outage and cache + // the failed check too, so every page load does not wait for + // another timeout while wttr.in is unavailable. + let (current, days) = cache + .as_ref() + .filter(|cached| cached.location == location) + .map(|cached| (cached.current.clone(), cached.days.clone())) + .unwrap_or((None, Vec::new())); + *cache = Some(CachedWeather { + location: location.clone(), + current: current.clone(), + days: days.clone(), + checked_at: Instant::now(), + }); + WeatherDisplay { + location: Some(location), + units, + current, + days, + } + } + } + } + + pub async fn set_location(&self, value: &str) -> Result<()> { + let location = value.trim(); + if location.chars().count() > MAX_LOCATION_LENGTH { + return Err(anyhow!( + "Location must be at most {MAX_LOCATION_LENGTH} characters." + )); + } + if location.chars().any(char::is_control) { + return Err(anyhow!("Location cannot contain control characters.")); + } + + if location.is_empty() { + match tokio::fs::remove_file(&self.location_path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err).context("removing saved weather location"), + } + } else { + if let Some(parent) = self.location_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("creating notes directory for weather setting")?; + } + tokio::fs::write(&self.location_path, location) + .await + .context("saving weather location")?; + } + + *self.cache.lock().await = None; + Ok(()) + } + + pub async fn set_units(&self, value: &str) -> Result<()> { + let units = TempUnit::parse(value) + .ok_or_else(|| anyhow!("Units must be Celsius or Fahrenheit."))?; + if let Some(parent) = self.units_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("creating notes directory for weather setting")?; + } + tokio::fs::write(&self.units_path, units.as_str()) + .await + .context("saving weather units")?; + // No refetch needed: the cached report keeps both scales and the + // display layer picks which one to render. + Ok(()) + } + + pub fn read_units(&self) -> TempUnit { + match std::fs::read_to_string(&self.units_path) { + Ok(value) => TempUnit::parse(&value).unwrap_or_default(), + Err(_) => TempUnit::default(), + } + } + + fn read_location(&self) -> Result> { + let value = match std::fs::read_to_string(&self.location_path) { + Ok(value) => value, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).context("reading saved weather location"), + }; + let location = value.trim(); + if location.is_empty() { + Ok(None) + } else if location.chars().count() > MAX_LOCATION_LENGTH + || location.chars().any(char::is_control) + { + Err(anyhow!("saved weather location is invalid")) + } else { + Ok(Some(location.to_string())) + } + } + + async fn fetch(&self, location: &str) -> Result<(Option, Vec)> { + let encoded = utf8_percent_encode(location, NON_ALPHANUMERIC); + let url = format!( + "{}/{encoded}?format=j1", + self.endpoint.trim_end_matches('/') + ); + let response = self.client.get(url).send().await?.error_for_status()?; + let body = response.text().await?; + let parsed: WttrResponse = + serde_json::from_str(&body).context("parsing wttr.in response")?; + Ok((parsed.current(), parsed.days())) + } +} + +#[derive(Debug, Deserialize)] +struct WttrResponse { + #[serde(default)] + current_condition: Vec, + #[serde(default)] + weather: Vec, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct WttrCurrent { + #[serde(default)] + temp_C: String, + #[serde(default)] + temp_F: String, + #[serde(default)] + FeelsLikeC: String, + #[serde(default)] + FeelsLikeF: String, + #[serde(default)] + humidity: String, + #[serde(default)] + weatherDesc: Vec, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct WttrDay { + #[serde(default)] + date: String, + #[serde(default)] + avgtempC: String, + #[serde(default)] + avgtempF: String, + #[serde(default)] + maxtempC: String, + #[serde(default)] + maxtempF: String, + #[serde(default)] + mintempC: String, + #[serde(default)] + mintempF: String, + #[serde(default)] + hourly: Vec, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct WttrHourly { + #[serde(default)] + weatherDesc: Vec, +} + +#[derive(Debug, Deserialize)] +struct WttrValue { + #[serde(default)] + value: String, +} + +fn clean(value: &str, max: usize) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "—".to_string() + } else if trimmed.chars().count() > max { + trimmed.chars().take(max).collect() + } else { + trimmed.to_string() + } +} + +impl WttrResponse { + fn current(&self) -> Option { + let now = self.current_condition.first()?; + Some(WeatherCurrent { + desc: clean( + now.weatherDesc + .first() + .map(|d| d.value.as_str()) + .unwrap_or(""), + 80, + ), + temp_c: clean(&now.temp_C, 12), + temp_f: clean(&now.temp_F, 12), + feels_c: clean(&now.FeelsLikeC, 12), + feels_f: clean(&now.FeelsLikeF, 12), + humidity: clean(&now.humidity, 12), + }) + } + + /// Every forecast date wttr.in returns, in order. The service currently + /// sends three dates including today, so callers must render what is + /// present rather than assuming a five-day horizon. + fn days(&self) -> Vec { + self.weather + .iter() + .map(|day| { + let desc = day + .hourly + .iter() + .filter_map(|h| h.weatherDesc.first()) + .map(|d| d.value.trim()) + .find(|d| !d.is_empty()) + .unwrap_or(""); + WeatherDay { + date: clean(&day.date, 10), + desc: clean(desc, 80), + avg_c: clean(&day.avgtempC, 12), + avg_f: clean(&day.avgtempF, 12), + max_c: clean(&day.maxtempC, 12), + max_f: clean(&day.maxtempF, 12), + min_c: clean(&day.mintempC, 12), + min_f: clean(&day.mintempF, 12), + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn temp_path(tag: &str, ext: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "eternalibre-weather-{tag}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + p.set_extension(ext); + p + } + + fn service(tag: &str, endpoint: &str) -> (WeatherService, PathBuf, PathBuf) { + let location = temp_path(tag, "location"); + let units = temp_path(tag, "units"); + let _ = std::fs::remove_file(&location); + let _ = std::fs::remove_file(&units); + ( + WeatherService::with_endpoint(location.clone(), units.clone(), endpoint), + location, + units, + ) + } + + async fn mock_server(response: String) -> (String, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let length = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..length]).to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.len(), + response + ); + stream.write_all(response.as_bytes()).await.unwrap(); + request + }); + (format!("http://{addr}"), task) + } + + const J1: &str = r#"{ + "current_condition": [{ + "temp_C": "15", "temp_F": "59", + "FeelsLikeC": "13", "FeelsLikeF": "55", + "humidity": "51", + "weatherDesc": [{"value": "Overcast"}] + }], + "weather": [ + {"date": "2026-10-11", "avgtempC": "12", "avgtempF": "54", + "maxtempC": "15", "maxtempF": "59", "mintempC": "9", "mintempF": "48", + "hourly": [{"weatherDesc": [{"value": "Overcast"}]}]}, + {"date": "2026-10-12", "avgtempC": "14", "avgtempF": "57", + "maxtempC": "17", "maxtempF": "62", "mintempC": "13", "mintempF": "55", + "hourly": [{"weatherDesc": [{"value": "Sunny"}]}]} + ] + }"#; + + #[tokio::test] + async fn location_is_stored_and_blank_clears_it() { + let (svc, location, _units) = service("location", "http://127.0.0.1:1"); + svc.set_location(" Reykjavík ").await.unwrap(); + assert_eq!(std::fs::read_to_string(&location).unwrap(), "Reykjavík"); + assert_eq!(svc.display().await.location.as_deref(), Some("Reykjavík")); + svc.set_location(" ").await.unwrap(); + assert!(!location.exists()); + } + + #[tokio::test] + async fn location_rejects_control_characters_and_long_values() { + let (svc, _, _) = service("invalid", "http://127.0.0.1:1"); + assert!(svc.set_location("London\nInjected").await.is_err()); + assert!(svc + .set_location(&"x".repeat(MAX_LOCATION_LENGTH + 1)) + .await + .is_err()); + } + + #[tokio::test] + async fn units_default_to_celsius_and_persist() { + let (svc, _, units) = service("units", "http://127.0.0.1:1"); + assert_eq!(svc.read_units(), TempUnit::Celsius); + svc.set_units("f").await.unwrap(); + assert_eq!(std::fs::read_to_string(&units).unwrap(), "f"); + assert_eq!(svc.read_units(), TempUnit::Fahrenheit); + assert!(svc.set_units("kelvin").await.is_err()); + } + + #[tokio::test] + async fn weather_fetch_is_cached_and_location_is_encoded() { + let (endpoint, server) = mock_server(J1.to_string()).await; + let (svc, location, _) = service("cache", &endpoint); + svc.set_location("London, UK").await.unwrap(); + + let first = svc.display().await; + assert_eq!(first.location.as_deref(), Some("London, UK")); + assert_eq!(first.units, TempUnit::Celsius); + let current = first.current.as_ref().expect("current conditions"); + assert_eq!(current.temp(TempUnit::Celsius), "15°C"); + assert_eq!(current.temp(TempUnit::Fahrenheit), "59°F"); + assert_eq!(first.days.len(), 2); + assert_eq!(first.days[0].short_date(), "Oct 11"); + assert_eq!(first.days[1].high(TempUnit::Celsius), "17°C"); + let request = server.await.unwrap(); + assert!(request.starts_with("GET /London%2C%20UK?format=j1 ")); + let second = svc.display().await; + assert_eq!(second, first); + let _ = std::fs::remove_file(location); + } + + #[tokio::test] + async fn switching_units_reuses_the_cached_report() { + let (endpoint, server) = mock_server(J1.to_string()).await; + let (svc, _, _) = service("units-cache", &endpoint); + svc.set_location("London").await.unwrap(); + let celsius = svc.display().await; + server.await.unwrap(); + svc.set_units("f").await.unwrap(); + let fahrenheit = svc.display().await; + assert_eq!(fahrenheit.units, TempUnit::Fahrenheit); + assert_eq!(fahrenheit.current, celsius.current); + assert_eq!(fahrenheit.days, celsius.days); + } +} diff --git a/static/app.js b/static/app.js index aebd8bf..2339f06 100644 --- a/static/app.js +++ b/static/app.js @@ -42,6 +42,7 @@ function app() { } this.initNoteDragging(); + this.initQuoteRotation(); }, /// Wires up dragging a sidebar note onto a folder nav item to re-file it. @@ -146,6 +147,10 @@ function app() { if (this._onDocClick) { document.removeEventListener('click', this._onDocClick); } + if (this._quoteTimer) { + clearInterval(this._quoteTimer); + this._quoteTimer = null; + } if (this._onDragStart) { document.removeEventListener('dragstart', this._onDragStart); document.removeEventListener('dragover', this._onDragOver); @@ -353,6 +358,103 @@ function app() { if (editor) editor.focus(); }, + /// Rotates the welcome quote beneath the tagline. + /// + /// The first quote is server-rendered, so without JS the block simply + /// stays put. Rotation is a progressive enhancement for sighted mouse + /// and touch users: it pauses on hover/focus, honours + /// `prefers-reduced-motion`, and exposes an explicit Pause control. + initQuoteRotation() { + const quotes = window.__WELCOME_QUOTES__ || []; + const textEl = document.getElementById('welcome-quote-text'); + const authorEl = document.getElementById('welcome-quote-author'); + if (!quotes.length || quotes.length < 2 || !textEl || !authorEl) return; + + this._quotes = quotes; + this._quoteIndex = 0; + this._quotesPaused = false; + + const show = (index) => { + const quote = this._quotes[index]; + if (!quote) return; + textEl.textContent = quote.text || ''; + authorEl.textContent = + quote.author && quote.author.trim() ? quote.author : 'Anonymous'; + }; + + const next = () => { + this._quoteIndex = (this._quoteIndex + 1) % this._quotes.length; + show(this._quoteIndex); + }; + + const prev = () => { + this._quoteIndex = + (this._quoteIndex - 1 + this._quotes.length) % this._quotes.length; + show(this._quoteIndex); + }; + + const pauseBtn = document.getElementById('quote-pause'); + const nextBtn = document.getElementById('quote-next'); + const prevBtn = document.getElementById('quote-prev'); + const quoteEl = document.getElementById('welcome-quote'); + + const stop = () => { + if (this._quoteTimer) { + clearInterval(this._quoteTimer); + this._quoteTimer = null; + } + }; + + const start = () => { + stop(); + // Auto-rotation is a motion affordance: when the OS asks for reduced + // motion, leave the first server-rendered quote in place instead. + const reduced = + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!reduced && !this._quotesPaused) { + this._quoteTimer = setInterval(next, 8000); + } + }; + + if (nextBtn) { + nextBtn.addEventListener('click', () => { + next(); + start(); + }); + } + if (prevBtn) { + prevBtn.addEventListener('click', () => { + prev(); + start(); + }); + } + if (pauseBtn) { + pauseBtn.addEventListener('click', () => { + this._quotesPaused = !this._quotesPaused; + pauseBtn.textContent = this._quotesPaused ? 'Play' : 'Pause'; + pauseBtn.setAttribute('aria-pressed', String(this._quotesPaused)); + if (this._quotesPaused) { + stop(); + } else { + start(); + } + }); + } + if (quoteEl) { + quoteEl.addEventListener('mouseenter', stop); + quoteEl.addEventListener('mouseleave', () => { + if (!this._quotesPaused) start(); + }); + quoteEl.addEventListener('focusin', stop); + quoteEl.addEventListener('focusout', () => { + if (!this._quotesPaused) start(); + }); + } + + start(); + }, + saveCurrentNote() { const form = document.getElementById('note-form'); if (form) form.requestSubmit(); diff --git a/static/style.css b/static/style.css index 9d1b447..1273004 100644 --- a/static/style.css +++ b/static/style.css @@ -248,6 +248,7 @@ button, input, select, textarea { .btn:hover { background-color: var(--ansi-12); border-color: var(--app-fg); + color: var(--ansi-0); } .btn-primary { @@ -314,6 +315,12 @@ button, input, select, textarea { overflow: hidden; } +/* The welcome state renders no preview pane, so the editor takes the full + remaining width instead of sitting in a half-width track. */ +.app-body.no-preview { + grid-template-columns: 280px 1fr; +} + /* When a pane is collapsed via the View menu, the remaining pane reclaims the space. Each state sets its own column count so a lone pane always spans the full remaining width instead of sitting in a half-width track. @@ -323,22 +330,26 @@ button, input, select, textarea { on specificity, leaving the Preview tab permanently empty. */ @media (min-width: 901px) { .app-body.hide-preview, - .app-body.hide-editor { + .app-body.hide-editor, + .app-body.no-preview { grid-template-columns: 280px 1fr; } - .app-body.hide-editor.hide-preview { + .app-body.hide-editor.hide-preview, + .app-body.no-preview.hide-editor { grid-template-columns: 280px; } } @media (min-width: 601px) and (max-width: 900px) { .app-body.hide-preview, - .app-body.hide-editor { + .app-body.hide-editor, + .app-body.no-preview { grid-template-columns: 240px 1fr; } - .app-body.hide-editor.hide-preview { + .app-body.hide-editor.hide-preview, + .app-body.no-preview.hide-editor { grid-template-columns: 240px; } } @@ -757,15 +768,462 @@ button, input, select, textarea { justify-content: flex-end; } -.empty-state { +.welcome-state { flex: 1; + min-height: 0; + overflow-y: auto; + padding: clamp(24px, 5vw, 52px); + scrollbar-gutter: stable; +} + +.welcome-heading { + max-width: 760px; + margin: 0 auto 28px; +} + +.welcome-eyebrow { + margin: 0 0 10px; + color: var(--ansi-7); + font-size: 11px; + font-weight: 700; + letter-spacing: 1.2px; +} + +.welcome-heading h1 { + margin: 0; + font-size: clamp(24px, 3vw, 36px); + line-height: 1.15; + letter-spacing: -0.6px; + text-wrap: balance; +} + +.welcome-tagline { + margin: 12px 0 0; + max-width: 560px; + font-size: 15px; + line-height: 1.6; + opacity: 0.72; +} + +.welcome-columns { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + max-width: 760px; + margin: 0 auto; +} + +.welcome-card { + min-width: 0; + padding: 18px; + border: 1px solid var(--ui-border); + border-radius: 10px; + background: color-mix(in srgb, var(--app-fg) 3%, var(--app-bg)); + box-shadow: 0 4px 18px color-mix(in srgb, var(--app-fg) 5%, transparent); +} + +.welcome-card h2 { + margin: 0 0 14px; + font-size: 14px; + font-weight: 650; +} + +.welcome-muted { + margin: 0; + font-size: 13px; + line-height: 1.55; + opacity: 0.68; +} + +.welcome-recent-list { + list-style: none; + margin: 0; + padding: 0; +} + +.welcome-recent-list li + li { + border-top: 1px solid var(--ui-border); +} + +.welcome-recent-list a { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 8px; + margin: 0 -8px; + border-radius: 6px; + color: var(--app-fg); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.welcome-recent-list a:hover { + background-color: var(--ui-hover); + text-decoration: none; +} + +.welcome-note-title { + overflow-wrap: anywhere; + font-size: 14px; + font-weight: 600; +} + +.welcome-note-meta, +.welcome-note-preview, +.weather-location, +.weather-privacy { + font-size: 12px; + line-height: 1.45; + opacity: 0.65; +} + +.welcome-note-preview { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.weather-summary { + margin: 0 0 6px; + font-size: 20px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.weather-location { + margin: 0 0 18px; +} + +.weather-location-form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 14px; +} + +.weather-location-form label { + font-size: 12px; + font-weight: 600; +} + +.weather-location-form input { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 8px 10px; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: var(--app-bg); + outline: none; +} + +.weather-location-form input:focus { + border-color: var(--ansi-4); +} + +.weather-location-form .btn { + min-height: 40px; +} + +.weather-privacy { + margin: 2px 0 0; +} + +.weather-table-wrapper { + margin: 0 0 8px; + overflow-x: auto; + border: 1px solid var(--ui-border); + border-radius: 8px; +} + +.weather-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + line-height: 1.45; +} + +.weather-caption { + padding: 8px 10px; + font-weight: 600; + text-align: left; +} + +.weather-table th, +.weather-table td { + padding: 8px 10px; + border-top: 1px solid var(--ui-border); + text-align: left; + white-space: nowrap; +} + +.weather-table thead th { + border-top: none; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.65; +} + +.weather-now-row th, +.weather-now-row td { + background: color-mix(in srgb, var(--ansi-4) 10%, transparent); +} + +.weather-na { + opacity: 0.45; +} + +.weather-details { + margin: 0 0 12px; + font-size: 12px; + opacity: 0.7; +} + +.weather-units { display: flex; align-items: center; - justify-content: center; - opacity: 0.5; + gap: 14px; + margin: 0; + padding: 0; + border: none; +} + +.weather-units legend { + padding: 0; + font-size: 12px; + font-weight: 600; +} + +.weather-units label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 400; + min-height: 40px; +} + +.weather-units input { + width: auto; + min-height: auto; + accent-color: var(--ansi-4); +} + +.welcome-quotes { + margin-top: 16px; + max-width: 560px; +} + +.welcome-quote { + margin: 0; + padding: 12px 16px; + border-left: 4px solid var(--ansi-4); + background: color-mix(in srgb, var(--app-fg) 4%, var(--app-bg)); + border-radius: 0 8px 8px 0; +} + +.welcome-quote-text { + margin: 0 0 6px; + font-size: 16px; + font-style: italic; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.welcome-quote footer { + font-size: 13px; + opacity: 0.7; +} + +.welcome-quote-controls { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.btn-small { + min-height: 36px; + padding: 6px 12px; + font-size: 12px; +} + +.quotes-card { + max-width: 760px; + margin: 16px auto 0; +} + +.quote-form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 12px; +} + +.quote-form label { + font-size: 12px; + font-weight: 600; +} + +.quote-form textarea, +.quote-form input { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 8px 10px; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: var(--app-bg); + outline: none; font-size: 14px; } +.quote-form textarea { + min-height: 76px; + resize: vertical; + line-height: 1.5; +} + +.quote-form textarea:focus, +.quote-form input:focus { + border-color: var(--ansi-4); +} + +.quote-list { + list-style: none; + margin: 14px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.quote-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--ui-border); + border-radius: 8px; +} + +.quote-item-body { + margin: 0; + flex: 1; + min-width: 0; +} + +.quote-item-body p { + margin: 0 0 4px; + font-size: 14px; + font-style: italic; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.quote-item-body footer { + font-size: 12px; + opacity: 0.65; +} + +.quote-delete-form { + flex-shrink: 0; +} + +/* Mobile-only menu entries, hidden on desktop. Inverse of `.desktop-only`, + which hides on mobile. Keep the two breakpoints in sync. */ +.mobile-only { + display: none; +} + +/* Dedicated weather page. Separate document like `/about`: same menu bar and + status bar, own main column, own back link. */ +.weather-page { + display: flex; + flex-direction: column; + height: 100dvh; +} + +.weather-page-main { + flex: 1; + min-height: 0; + overflow-y: auto; + max-width: 720px; + width: 100%; + margin: 0 auto; + padding: 32px 24px; +} + +.weather-page-main h1 { + margin: 0 0 16px; + font-size: 26px; + line-height: 1.2; + text-wrap: balance; +} + +.weather-page-main .weather-card { + margin-bottom: 20px; +} + +.weather-page .back-link { + display: inline-block; + padding: 8px 14px; + border: 1px solid var(--ui-border); + border-radius: 6px; + font-size: 14px; + font-weight: 500; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.weather-page .back-link:hover { + background-color: var(--ui-hover); + text-decoration: none; +} + +/* Simplified welcome page backing the mobile-only `View → Welcome` entry. + Same separate-document pattern as `/weather`: header, quote rotator, and + recent notes only — no weather card, no quotes manager. */ +.welcome-page { + display: flex; + flex-direction: column; + height: 100dvh; +} + +.welcome-page-main { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.welcome-page-main .welcome-heading { + max-width: 760px; +} + +.welcome-page-main .recent-card { + max-width: 760px; + margin: 0 auto 20px; +} + +.welcome-page-main .back-link { + display: block; + max-width: 760px; + margin: 0 auto; + padding: 8px 14px; + border: 1px solid var(--ui-border); + border-radius: 6px; + font-size: 14px; + font-weight: 500; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.welcome-page-main .back-link:hover { + background-color: var(--ui-hover); + text-decoration: none; +} + /* Preview Pane */ .preview-pane { display: flex; @@ -1503,4 +1961,88 @@ button, input, select, textarea { text-align: center; font-size: 16px; } + + .welcome-state { + padding: 24px 18px; + } + + .welcome-heading { + margin-bottom: 20px; + } + + .welcome-heading h1 { + font-size: 27px; + } + + .welcome-columns { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + } + + .welcome-card { + padding: 16px; + } + + .weather-location-form .btn { + width: 100%; + min-height: 44px; + } + + /* Mobile-only View entries. Shown here; `.mobile-only` is `display: none` + at wider widths so desktop keeps the full dashboard instead. */ + .mobile-only { + display: list-item; + } + + .weather-table { + font-size: 12px; + } + + .weather-table th, + .weather-table td { + padding: 10px 8px; + } + + .welcome-quote-controls .btn-small { + min-height: 44px; + flex: 1; + } + + .quote-item { + flex-direction: column; + } + + .quote-delete-form .btn { + width: 100%; + min-height: 44px; + } + + .weather-page-main { + padding: 20px 16px; + } + + .weather-page-main h1 { + font-size: 22px; + } + + .weather-page .back-link { + display: block; + min-height: 44px; + padding: 12px 16px; + text-align: center; + font-size: 16px; + } + + .welcome-page-main .back-link { + min-height: 44px; + padding: 12px 16px; + text-align: center; + font-size: 16px; + } +} + +@media (min-width: 901px) { + .app-body.hide-preview .welcome-columns { + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); + } } diff --git a/tests/auth_gating.rs b/tests/auth_gating.rs index 3a70f1f..9098294 100644 --- a/tests/auth_gating.rs +++ b/tests/auth_gating.rs @@ -7,7 +7,9 @@ use axum::body::Body; use axum::http::{header, Request, StatusCode}; use eternalibre_notes::auth::{AuthError, AuthState}; +use eternalibre_notes::quotes::QuoteStore; use eternalibre_notes::routes::{self, AppState}; +use eternalibre_notes::weather::WeatherService; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, OnceLock}; @@ -78,6 +80,11 @@ impl Fixture { app: routes::app(AppState { auth: auth.clone(), base_path: base_path.into(), + weather: Arc::new(WeatherService::new( + secret_file.with_extension("weather-location"), + secret_file.with_extension("weather-units"), + )), + quotes: Arc::new(QuoteStore::new(secret_file.with_extension("quotes"))), }), auth, secret_file, @@ -200,6 +207,10 @@ impl Fixture { /// POST carrying the session cookie. async fn post_authed(&self, uri: &str, body: &str) -> (StatusCode, String, Option) { + respond(self.post_authed_response(uri, body).await).await + } + + async fn post_authed_response(&self, uri: &str, body: &str) -> axum::response::Response { let res = self .app .clone() @@ -214,7 +225,7 @@ impl Fixture { ) .await .unwrap(); - respond(res).await + res } /// Creates a note and returns its id. @@ -628,6 +639,30 @@ async fn write_routes_require_authentication() { // Theme changes are gated too. let (status, _, _) = f.post_form("/theme", "theme=Dracula").await; assert_eq!(status, StatusCode::SEE_OTHER); + + // The optional third-party weather location is gated as well. + let (status, _, _) = f.post_form("/settings/weather", "location=Somewhere").await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert!( + !f.secret_file.with_extension("weather-location").exists(), + "anonymous weather settings request must not persist a location" + ); + + // Quote settings and the mobile weather page sit behind the same gate. + let (status, _, _) = f + .post_form("/settings/quotes", "quote=Hello&author=Someone") + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert!( + !f.secret_file.with_extension("quotes").exists(), + "anonymous quote request must not persist quotes" + ); + let (status, _, _) = f.post_form("/settings/quotes/delete", "index=0").await; + assert_eq!(status, StatusCode::SEE_OTHER); + let (status, _) = f.get("/weather").await; + assert_eq!(status, StatusCode::SEE_OTHER); + let (status, _) = f.get("/welcome").await; + assert_eq!(status, StatusCode::SEE_OTHER); } #[tokio::test] @@ -657,7 +692,7 @@ async fn delete_form_has_confirmation_prompt() { // Find the delete form by its note-specific action let form_start = body - .find(&format!("/notes/{id}\"")) + .find(&format!("action=\"/notes/{id}\"")) .expect("delete form should exist"); let form_end = body[form_start..] .find("") @@ -1753,6 +1788,334 @@ async fn theme_cookie_alongside_session_still_works() { assert!(String::from_utf8_lossy(&bytes).contains("data-theme=\"Dracula\"")); } +#[tokio::test] +async fn theme_change_preserves_the_open_note_and_rejects_external_return_targets() { + let f = Fixture::with_base_path("theme-return", "/forgejo") + .login_over_http() + .await; + let id = f.create_note().await; + let body = format!("theme=Dracula&return_to=%2Fnotes%2F{id}"); + let response = f.post_authed_response("/theme", &body).await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()); + assert_eq!(location, Some(f.expect(&format!("/notes/{id}")).as_str())); + + let response = f + .post_authed_response("/theme", "theme=Light&return_to=%2F") + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some(f.expect("/").as_str()) + ); + + let response = f + .post_authed_response("/theme", "theme=Light&return_to=https%3A%2F%2Fevil.example") + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()); + assert_eq!(location, Some(f.expect("/").as_str())); +} + +#[tokio::test] +async fn welcome_dashboard_and_weather_location_are_server_rendered() { + let f = Fixture::provisioned("welcome-dashboard"); + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains(r#"class="welcome-state""#)); + assert!(body.contains("Welcome to EternaLibre Notes")); + assert!(body.contains(r#"action="/settings/weather" method="post""#)); + assert!(body.contains("wttr.in")); + assert!(body.contains(r#"class="welcome-recent-list""#)); +} + +#[tokio::test] +async fn weather_location_setting_is_persisted_and_can_be_cleared() { + let f = Fixture::provisioned("weather-setting"); + let setting_path = f.secret_file.with_extension("weather-location"); + let (status, _, location) = f + .post_authed("/settings/weather", "location=San+Francisco") + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(location.as_deref(), Some("/")); + assert_eq!( + std::fs::read_to_string(&setting_path).unwrap(), + "San Francisco" + ); + + let (status, _, _) = f.post_authed("/settings/weather", "location=").await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert!( + !setting_path.exists(), + "blank location should clear the setting" + ); +} + +#[tokio::test] +async fn weather_units_are_saved_and_offered_as_radios() { + let f = Fixture::provisioned("weather-units"); + let units_path = f.secret_file.with_extension("weather-units"); + let (status, _, _) = f + .post_authed("/settings/weather", "location=Oslo&units=f") + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(std::fs::read_to_string(&units_path).unwrap(), "f"); + + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains(r#"name="units" value="f""#), + "weather form should offer a Fahrenheit radio" + ); + assert!( + body.contains(r#"name="units" value="c""#), + "weather form should offer a Celsius radio" + ); + + let (status, _, _) = f + .post_authed("/settings/weather", "location=Oslo&units=kelvin") + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!( + std::fs::read_to_string(&units_path).unwrap(), + "f", + "an invalid unit must not overwrite the saved preference" + ); +} + +#[tokio::test] +async fn weather_table_renders_current_first_then_forecast_days() { + use eternalibre_notes::pages::{render_app, AppPageContext, Base, FolderContext}; + use eternalibre_notes::weather::{WeatherCurrent, WeatherDay, WeatherDisplay}; + + let weather = WeatherDisplay { + location: Some("London".to_string()), + units: eternalibre_notes::weather::TempUnit::Fahrenheit, + current: Some(WeatherCurrent { + desc: "Overcast".to_string(), + temp_c: "15".to_string(), + temp_f: "59".to_string(), + feels_c: "13".to_string(), + feels_f: "55".to_string(), + humidity: "51".to_string(), + }), + days: vec![WeatherDay { + date: "2026-10-12".to_string(), + desc: "Sunny".to_string(), + avg_c: "14".to_string(), + avg_f: "57".to_string(), + max_c: "17".to_string(), + max_f: "62".to_string(), + min_c: "13".to_string(), + min_f: "55".to_string(), + }], + }; + let folders = Vec::new(); + let folder_ctx = FolderContext { + folders: &folders, + active_folder: None, + }; + let no_quotes: Vec = Vec::new(); + let base = Base::new(""); + let page = AppPageContext { + current_note: None, + recent_notes: &[], + weather: &weather, + quotes: &no_quotes, + query: None, + message: None, + }; + let html = render_app("Dark", &[], &folder_ctx, &page, &base).into_string(); + assert!( + html.contains(r#"class="weather-table""#), + "forecast should render as a table" + ); + let now = html.find("Now").expect("current row should lead the table"); + let forecast = html.find("Oct 12").expect("forecast day should follow"); + assert!(now < forecast, "current weather must be the top table row"); + assert!( + html.contains("59°F"), + "active Fahrenheit unit should render" + ); + assert!( + html.contains("62°F"), + "forecast high should follow the unit" + ); +} + +#[tokio::test] +async fn quotes_can_be_added_listed_and_removed() { + let f = Fixture::provisioned("quotes-crud"); + let quotes_path = f.secret_file.with_extension("quotes"); + let (status, _, _) = f + .post_authed("/settings/quotes", "quote=Stay+curious.&author=Ada") + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + let saved = std::fs::read_to_string("es_path).unwrap(); + assert!(saved.contains("Stay curious.")); + assert!(saved.contains("Ada")); + + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains(r#"id="welcome-quote""#)); + assert!(body.contains("Stay curious.")); + assert!(body.contains("window.__WELCOME_QUOTES__")); + + let (status, _, _) = f.post_authed("/settings/quotes/delete", "index=0").await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert!( + !quotes_path.exists(), + "removing the last quote should clear its file" + ); +} + +#[tokio::test] +async fn mobile_weather_entry_links_to_a_dedicated_page() { + let f = Fixture::provisioned("mobile-weather"); + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains(r#"class="mobile-only""#), + "View menu should carry a mobile-only weather entry" + ); + assert!( + body.contains("/weather?return_to=%2F"), + "weather entry should link back to the dashboard" + ); + + let (status, body) = f.get_authed("/weather?return_to=%2F").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains(r#"class="weather-page-main""#)); + assert!(body.contains(r#"class="weather-table-wrapper""#) || body.contains("wttr.in")); + assert!(body.contains(r#"class="back-link""#)); +} + +/// The welcome state has nothing to preview, so the preview pane, its mobile +/// tab, and its View toggle must not render. Opening a note restores all +/// three. Anchored on template-only attributes (`class="preview-pane"`, +/// `data-tab-target="preview"`) rather than bare words that also match the +/// inlined stylesheet. +#[tokio::test] +async fn welcome_state_omits_the_preview_pane() { + let f = Fixture::provisioned("welcome-no-preview"); + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains(r#"class="app-body no-preview""#), + "welcome layout should drop to two columns" + ); + assert!( + !body.contains(r#"class="preview-pane""#), + "no note means nothing to preview" + ); + assert!( + !body.contains(r#"data-tab-target="preview""#), + "the Preview tab would only offer an empty view" + ); + assert!( + !body.contains(r#"x-on:click="togglePreview()""#), + "the View menu should not offer a preview toggle with no pane" + ); + + let id = f.create_note().await; + let (status, body) = f.get_authed(&format!("/notes/{id}")).await; + assert_eq!(status, StatusCode::OK); + assert!( + !body.contains(r#"class="app-body no-preview""#), + "an open note restores the three-column layout" + ); + assert!(body.contains(r#"class="preview-pane"#)); + assert!(body.contains(r#"data-tab-target="preview""#)); + assert!(body.contains(r#"x-on:click="togglePreview()""#)); +} + +#[tokio::test] +async fn mobile_welcome_entry_links_to_a_simplified_page() { + let f = Fixture::provisioned("mobile-welcome"); + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("/welcome?return_to=%2F"), + "View menu should link to the simplified welcome page" + ); + + let (status, body) = f.get_authed("/welcome?return_to=%2F").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains(r#"class="welcome-page-main"#)); + assert!(body.contains("Welcome to EternaLibre Notes")); + assert!(body.contains(r#"class="welcome-recent-list""#)); + assert!( + !body.contains(r#"class="weather-table""#), + "the simplified page carries no weather table" + ); + assert!( + !body.contains(r#"name="units""#), + "units belong to the full weather settings, not this page" + ); + assert!( + !body.contains(r#"action="/settings/quotes""#), + "quote management stays on the dashboard" + ); + assert!(body.contains(r#"class="back-link""#)); +} + +#[tokio::test] +async fn welcome_lists_recent_notes_and_theme_forms_carry_a_safe_return_target() { + use chrono::Utc; + use eternalibre_notes::notes::Note; + use eternalibre_notes::pages::{render_app, AppPageContext, Base, FolderContext}; + use eternalibre_notes::weather::WeatherDisplay; + + let mut note = Note::new("A recent thought", "A little preview."); + note.id = "550e8400-e29b-41d4-a716-446655440000".to_string(); + note.updated_at = Utc::now(); + let folders = Vec::new(); + let folder_ctx = FolderContext { + folders: &folders, + active_folder: None, + }; + let weather = WeatherDisplay::empty(); + let no_quotes: Vec = Vec::new(); + let base = Base::new(""); + let welcome_page = AppPageContext { + current_note: None, + recent_notes: &[note.clone()], + weather: &weather, + quotes: &no_quotes, + query: None, + message: None, + }; + let html = render_app("Dark", &[], &folder_ctx, &welcome_page, &base).into_string(); + let note_url = format!("href=\"/notes/{}\"", note.id); + assert!(html.contains(¬e_url)); + assert!(html.contains("A recent thought")); + + let note_context = AppPageContext { + current_note: Some(¬e), + recent_notes: &[], + weather: &weather, + quotes: &no_quotes, + query: None, + message: None, + }; + let note_page = render_app("Dark", &[], &folder_ctx, ¬e_context, &base).into_string(); + assert_eq!( + note_page + .matches(r#"name="return_to" value="/notes/550e8400-e29b-41d4-a716-446655440000""#) + .count(), + eternalibre_notes::themes::PREDEFINED_THEMES.len() + 1 + ); +} + // ----------------------------------------------------------- about page ---- /// The about page's mobile styling hangs entirely off this one class. @@ -1811,7 +2174,7 @@ async fn about_page_inlines_the_stylesheet() { #[tokio::test] async fn every_screen_declares_a_viewport() { let f = Fixture::fresh("viewport").login_over_http().await; - for path in ["/", "/about"] { + for path in ["/", "/about", "/weather", "/welcome"] { let (status, body) = f.get_authed(path).await; assert_eq!(status, StatusCode::OK, "{path}"); assert!( @@ -1924,9 +2287,9 @@ async fn about_page_theme_counts_come_from_the_palette() { ); } -/// Outbound links must carry `rel="noopener noreferrer"`. The app advertises -/// that it never talks to a third party, and these are the only links that -/// leave the machine, so they should not leak a referrer when followed. +/// Outbound links must carry `rel="noopener noreferrer"` so they do not leak +/// a referrer when followed. The optional weather request is server-side and +/// is covered separately from these browser navigation links. #[tokio::test] async fn about_page_external_links_are_hardened() { let f = Fixture::fresh("about-links").login_over_http().await; @@ -2085,6 +2448,11 @@ async fn alpine_is_served_app_absolute_even_with_a_base_path() { let app = routes::app(AppState { auth, base_path: "/forgejo".into(), + weather: Arc::new(WeatherService::new( + secret_file.with_extension("weather-location"), + secret_file.with_extension("weather-units"), + )), + quotes: Arc::new(QuoteStore::new(secret_file.with_extension("quotes"))), }); let (status, body) = fetch(&app, "/static/alpine.min.js").await; @@ -2171,6 +2539,11 @@ async fn alpine_is_available_without_a_static_directory() { let app = routes::app(AppState { auth, base_path: "".into(), + weather: Arc::new(WeatherService::new( + secret_file.with_extension("weather-location"), + secret_file.with_extension("weather-units"), + )), + quotes: Arc::new(QuoteStore::new(secret_file.with_extension("quotes"))), }); // Nothing is read from disk at request time; the bundle is linked in.