//! Tiny hash-based router. //! //! - `#/` — root picker / single-root browser //! - `#/files/{root_id}/{segment/...}` — a user's folder //! - `#/share/{token}/{segment/...}` — a public share (no login) //! - `#/shares`, `#/users`, `#/settings/{tab}`, `#/uploads` — the signed-in //! shell's manage views //! - `#/calendar/month/{yyyy-mm}`, `#/calendar/agenda`, //! `#/calendar/invitations`, `#/contacts?q=` — calendars and address //! books //! //! The URL hash is the single source of truth; the `Location` signal in the //! app mirrors it. A folder listing carries `?page=&size=&sort=&desc=`, and //! an open file `open=`. use leptos::prelude::*; use wasm_bindgen::JsValue; use crate::util::{FILE_PAGE, PAGE_SIZES, SortKey, SortSpec, load_page_size}; /// Which section of the signed-in shell a location points at. `Files` is the /// browser; the rest are the manage views, each with its own URL. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Section { Files, /// The search view (`#/search`); query params live in the hash query. Search, Shares, Users, Settings, /// This session's upload jobs (`#/uploads`). Uploads, Calendar, Contacts, } impl Section { /// The URL segment for this section, or `None` for `Files` (which has no /// segment of its own — the browser is the default view). const fn segment(self) -> Option<&'static str> { match self { Section::Files => None, Section::Search => Some("search"), Section::Shares => Some("shares"), Section::Users => Some("users"), Section::Settings => Some("settings"), Section::Uploads => Some("uploads"), Section::Calendar => Some("calendar"), Section::Contacts => Some("contacts"), } } /// The inverse of [`segment`]: the section a URL segment names. `files` /// has no segment, so it is never returned here. fn from_segment(seg: &str) -> Option { [ Section::Search, Section::Shares, Section::Users, Section::Settings, Section::Uploads, Section::Calendar, Section::Contacts, ] .into_iter() .find(|s| s.segment() == Some(seg)) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Location { /// None = the top-level "folders" view (root picker). pub root_id: Option, /// Path segments relative to the current root (or share target). On the /// settings view, the tab. pub path: Vec, /// Present when the location is a public share (`#/share/{token}/...`). pub share_token: Option, /// Which shell section the location points at. pub section: Section, /// The listing page, 0-based. The URL counts from 1. pub page: usize, pub size: usize, pub sort: SortSpec, /// The open file: its name in the folder, or on the search view its /// path in the searched root. pub open: Option, /// The search view's own params. Only compared: a new search is a new /// location. On the contacts view, the search text (`q`). pub search: String, } impl Location { pub const fn root() -> Self { Self { root_id: None, path: Vec::new(), share_token: None, section: Section::Files, page: 0, size: FILE_PAGE, sort: SortSpec { key: SortKey::Name, asc: true, }, open: None, search: String::new(), } } /// A folder in the browser, in the user's saved order and page size. pub fn folder(root_id: Option, share_token: Option, path: Vec) -> Self { Self { root_id, path, share_token, sort: SortSpec::load(), size: load_page_size(), ..Self::root() } } /// A folder listing, which always carries its order and page size. fn is_folder(&self) -> bool { self.section == Section::Files && (self.root_id.is_some() || self.share_token.is_some()) } /// What names the folder or view, without the listing params. pub fn folder_key(&self) -> (Option, Vec, Option, Section) { ( self.root_id, self.path.clone(), self.share_token.clone(), self.section, ) } /// A folder's order and page size are always written, so a link keeps /// its position when a default changes. Then the open file. fn query_pairs(&self) -> Vec<(&'static str, String)> { let mut out = Vec::new(); if self.is_folder() { if self.page != 0 { out.push(("page", (self.page + 1).to_string())); } out.push(("sort", self.sort.key.as_str().to_string())); if !self.sort.asc { out.push(("desc", "1".to_string())); } out.push(("size", self.size.to_string())); } if self.section == Section::Contacts && !self.search.is_empty() { out.push(("q", self.search.clone())); } if let Some(o) = &self.open { out.push(("open", o.clone())); } out } /// The inverse of [`Self::query_pairs`]. Bad values are ignored: they /// come from hand-typed URLs. fn apply_pair(&mut self, key: &str, value: String) { match key { "page" => { if let Ok(n) = value.parse::() { self.page = n.saturating_sub(1); } } "size" => { if let Some(n) = value.parse().ok().filter(|n| PAGE_SIZES.contains(n)) { self.size = n; } } "sort" => { if let Some(key) = SortKey::parse(&value) { self.sort = SortSpec { key, asc: true }; } } "desc" => self.sort.asc = value != "1", "open" if !value.is_empty() => self.open = Some(value), _ => {} } } } /// Query keys the router owns, in the order they apply: `desc` after /// `sort`. The search view reads its own keys. const QUERY_KEYS: [&str; 5] = ["page", "sort", "desc", "size", "open"]; /// Percent-encode one path segment for the URL hash. Without this a name /// containing `#`, `%` or `/` would break the round trip. fn encode_segment(seg: &str) -> String { js_sys::encode_uri_component(seg).into() } fn decode_segment(seg: &str) -> String { // A hand-typed hash can hold invalid escapes; keep it verbatim then. js_sys::decode_uri_component(seg) .map(String::from) .unwrap_or_else(|_| seg.to_string()) } pub fn current_hash() -> String { web_sys::window() .and_then(|w| w.location().hash().ok()) .unwrap_or_default() } pub fn parse_location() -> Location { parse_hash(¤t_hash()) } pub fn parse_hash(hash: &str) -> Location { let h = hash.trim_start_matches('#'); let h = h.trim_start_matches('/'); let (h, query) = h.split_once('?').unwrap_or((h, "")); let mut parts = h.split('/').filter(|s| !s.is_empty()); let mut loc = match parts.next() { Some("files") => match parts.next().and_then(|s| s.parse().ok()) { Some(root_id) => Location { root_id: Some(root_id), path: parts.map(decode_segment).collect(), ..Location::root() }, None => Location::root(), }, // `#/share/{token}/...` — public share page. Some("share") => match parts.next() { Some(token) => Location { path: parts.map(decode_segment).collect(), share_token: Some(token.to_string()), ..Location::root() }, // `#/share` without a token is not a share; treat it as home. None => Location::root(), }, // `#/search`, `#/shares`, `#/users`, `#/settings/{tab}` — the manage // views. None carries a root. seg => match seg.and_then(Section::from_segment) { Some(section) => Location { section, path: parts.map(decode_segment).collect(), ..Location::root() }, None => Location::root(), }, }; let Ok(params) = web_sys::UrlSearchParams::new_with_str(query) else { return loc; }; // A folder URL without them (typed, or from before they existed) // gets the saved ones. if loc.is_folder() { loc.sort = SortSpec::load(); loc.size = load_page_size(); } for k in QUERY_KEYS { if let Some(v) = params.get(k) { loc.apply_pair(k, v); } } if loc.section == Section::Contacts { loc.search = params.get("q").unwrap_or_default(); } if loc.section == Section::Search { let _ = params.sort(); for k in QUERY_KEYS { params.delete(k); } loc.search = params.to_string().into(); } loc } pub fn location_to_hash(loc: &Location) -> String { let mut s = if let Some(token) = &loc.share_token { format!("#/share/{token}") } else if let Some(segment) = loc.section.segment() { format!("#/{segment}") } else if let Some(id) = loc.root_id { format!("#/files/{id}") } else { "#/".to_string() }; for seg in &loc.path { s.push('/'); s.push_str(&encode_segment(seg)); } let pairs = loc.query_pairs(); if let (false, Ok(params)) = (pairs.is_empty(), web_sys::UrlSearchParams::new()) { for (k, v) in pairs { params.append(k, &v); } s.push('?'); s.push_str(&String::from(params.to_string())); } s } thread_local! { /// The server's `--public-url`, set from every `/me` response. `None` /// means "use the address the browser is connected to". static PUBLIC_URL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } pub fn set_public_url(url: Option) { PUBLIC_URL.with(|p| *p.borrow_mut() = url); } fn public_url() -> Option { PUBLIC_URL.with(|p| p.borrow().clone()) } /// The absolute URL of a server route: under the public URL when the /// server has one, else the browser's origin. Origin-relative, not /// page-relative: server routes do not live under the app's own path. pub fn server_url(path: &str) -> String { let origin = public_url().unwrap_or_else(|| { web_sys::window() .and_then(|w| w.location().origin().ok()) .unwrap_or_default() }); format!("{}{path}", origin.trim_end_matches('/')) } /// Build the WebDAV mount URL for a share token. Only folder shares have /// one; a file share has no collection to mount. pub fn dav_share_url(token: &str) -> String { server_url(&format!("{}/{token}", api_types::DAV_SHARE)) } /// Build the share link for a token: under the public URL when the server /// has one, else relative to the current page. pub fn share_url(token: &str) -> String { if let Some(p) = public_url() { return format!("{p}/#/share/{token}"); } let base = web_sys::window() .and_then(|w| w.location().href().ok()) .unwrap_or_default(); // Cut off any existing hash, then append the share hash. let cut = base.find('#').unwrap_or(base.len()); format!("{}#/share/{token}", &base[..cut]) } thread_local! { static SET_LOC: std::cell::Cell>> = const { std::cell::Cell::new(None) }; /// Set by [`close_open`] until its Back arrives, so a second close does /// not go Back twice. static CLOSING: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Make `set_loc` follow the URL: now, on Back / Forward, and on every /// change made through this module. `popstate` also fires for a changed /// hash (a link, a typed URL). pub fn install(set_loc: WriteSignal) { SET_LOC.with(|s| s.set(Some(set_loc))); sync(); crate::util::owned_window_listener(leptos::ev::popstate, |_| sync()); // Back into a page from before a reload can restore it from the // browser's page cache, without a popstate. crate::util::owned_window_listener(leptos::ev::pageshow, |_| sync()); } fn sync() { CLOSING.with(|c| c.set(false)); let Some(set_loc) = SET_LOC.with(std::cell::Cell::get) else { return; }; let hash = current_hash(); let next = parse_hash(&hash); // Spell out a folder URL's position, so a copied link reproduces it. let canon = location_to_hash(&next); if next.is_folder() && canon != hash { replace_state(&canon); } set_loc.maybe_update(|cur| { let changed = *cur != next; if changed { *cur = next; } changed }); } /// History state of an entry that [`set_open`] pushed. Closing that file /// goes Back instead of adding an entry. const OPEN_MARK: &str = "dovenest-open"; /// History state of the entry a file was opened from. const ORIGIN_MARK: &str = "dovenest-origin"; fn history() -> Option { web_sys::window()?.history().ok() } fn state_is(mark: &str) -> bool { history() .and_then(|h| h.state().ok()) .and_then(|s| s.as_string()) .is_some_and(|s| s == mark) } /// Keeps the state: a replaced entry stays marked. fn replace_state(url: &str) { if let Some(h) = history() { let state = h.state().unwrap_or(JsValue::NULL); let _ = h.replace_state_with_url(&state, "", Some(url)); } } fn push_state(url: &str, state: &JsValue) { if url == current_hash() { replace_state(url); } else if let Some(h) = history() { let _ = h.push_state_with_url(state, "", Some(url)); } } /// Go to a new location, with a history entry. pub fn navigate(loc: &Location) { push_state(&location_to_hash(loc), &JsValue::NULL); sync(); } /// Correct the current location, without a history entry. pub fn replace(loc: &Location) { replace_url(&location_to_hash(loc)); } pub fn replace_url(url: &str) { replace_state(url); sync(); } /// `hash` with `open` set or removed. Edits the raw hash, so the search /// view's own params survive. pub fn hash_with_open(hash: &str, open: Option<&str>) -> String { let (path, query) = hash.split_once('?').unwrap_or((hash, "")); let Ok(params) = web_sys::UrlSearchParams::new_with_str(query) else { return hash.to_string(); }; match open { Some(o) => params.set("open", o), None => params.delete("open"), } let qs = String::from(params.to_string()); if qs.is_empty() { path.to_string() } else { format!("{path}?{qs}") } } /// Put the open file into the URL. Opening one adds a history entry, so Back /// closes it. Switching to another file replaces the entry. pub fn set_open(open: &str) { let url = hash_with_open(¤t_hash(), Some(open)); if parse_location().open.is_some() { replace_url(&url); return; } if let Some(h) = history() { let _ = h.replace_state_with_url(&JsValue::from_str(ORIGIN_MARK), "", None); } push_open_url(&url); } /// Take the open file out of the URL: Back to the entry it was opened /// from, else a replace (the page was loaded with the file open). pub fn close_open() { if CLOSING.with(std::cell::Cell::get) { return; } match history() { Some(h) if state_is(OPEN_MARK) => { CLOSING.with(|c| c.set(true)); let _ = h.back(); } _ => clear_open(), } } /// Whether Back has just returned to the entry a file was opened from. pub fn at_open_origin() -> bool { state_is(ORIGIN_MARK) } /// Take the open file out of the URL, without a history entry. pub fn clear_open() { replace_url(&hash_with_open(¤t_hash(), None)); } /// Add an entry for `url`, which has an open file. Also undoes a Back that /// must not close the file. pub fn push_open_url(url: &str) { push_state(url, &JsValue::from_str(OPEN_MARK)); sync(); } #[cfg(test)] mod tests { use super::*; #[test] fn query_pairs_round_trip() { let loc = Location { root_id: Some(1), page: 2, size: 200, sort: SortSpec { key: SortKey::Size, asc: false, }, open: Some("a b.jpg".into()), ..Location::root() }; let mut back = Location::root(); for (k, v) in loc.query_pairs() { back.apply_pair(k, v); } assert_eq!( Location { root_id: None, ..back }, Location { root_id: None, ..loc } ); // A folder always names its order and size; other views do not. let folder = Location { root_id: Some(1), ..Location::root() }; assert_eq!( folder.query_pairs(), [("sort", "name".to_string()), ("size", "100".to_string())] ); assert!(Location::root().query_pairs().is_empty()); // Hand-typed values that make no sense keep the defaults. let mut bad = Location::root(); bad.apply_pair("size", "37".into()); bad.apply_pair("page", "x".into()); bad.apply_pair("sort", "color".into()); bad.apply_pair("open", String::new()); assert_eq!(bad, Location::root()); } }