//! The data-export portal at `/dashboard/export`, described. //! //! The no-lock-in guarantee's own screen: six exports, five of which hand back //! a file and one of which is assembled in the background and mailed. It //! replaces `templates/dashboards/dashboard-export.html`, //! `dashboard::forms::export_portal`, `ExportPortalTemplate` and //! `static/dashboard-export-inline.js`. //! //! # The five direct exports were a sixth hand-written export control //! //! [`super::export_act`] is the described control for "post this route and keep //! the answer as a file", and five sites in four templates already use it. //! `project_overview`'s module header calls this page's hand-written buttons the //! sixth. They were: `hx-post` + `hx-target` + `hx-swap` + `hx-indicator`, plus //! a ` ...` and an empty status `
`, per //! card, six times over. All of it says what [`Action::saving`] says in one //! word, so the cards go through `export_act::control` and the six spellings go //! with them (`736f45a5`). //! //! **This changes what the reader gets, and the change is a fix.** The shipped //! buttons posted with htmx, so `is_htmx_request` held and the API took its //! htmx branch: a `data:` URI built from the whole body, which //! `routes::api::exports` truncates with the line "Export truncated. Enable //! JavaScript to download the full file." A `data-saves` control cancels the //! htmx request and reissues a plain `fetch` (`htmx-glue.ts`), which carries no //! `HX-Request`, so the API streams the real file instead. The five sites //! already described made this same trade; these six are the last that had not. //! //! # Content Files is not one of them, and that is why it keeps a region //! //! `/api/export/content` does not answer with a file. It queues a background //! job, uploads a ZIP to S3 and mails a link, answering the request with a //! status panel ([`export_pending_html`](crate::routes::api::exports)). So it //! is the one card whose act still targets a region: [`Action::replacing`], //! pointed at [`CONTENT_STATUS`], which is the documented use for a route the //! description layer does not serve. //! //! # Export All is not carried over //! //! `data-action="exportAll"` ran `window.exportAll`, which selected //! `.export-card button.secondary` while every button rendered `btn-secondary`. //! The selector matched nothing, so the handler returned at its first guard and //! the button did nothing at all -- no label change, no disabled state, no //! error. Measured 2026-08-31 and filed as a problem; it shipped that way //! rather than drifting (`git show b9dd22a6`). Describing a control that has //! never worked would be inventing a feature inside a conversion, and fixing it //! is a product call about what "all" means when one of the six is asynchronous. //! The problem holds that question. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::{Document, Request, Response, RouteError}; use quasi_webview::Webview; /// The address, registered whole. See [`super::document_mount`]. pub const PATH: &str = "/dashboard/export"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "export"; /// The card set. const CARDS_REGION: &str = "export-cards"; /// Where the content export's status panel lands. /// /// The id the shipped template gave the empty `
`, so /// the panel the API already returns arrives where it always did. pub const CONTENT_STATUS: &str = "content-status"; const MEASURE: layout::Measure = layout::Measure::Wide; /// What the screen needs from the database. pub struct Page { /// Whether the reader has any exportable files at all. pub has_content: bool, /// The size line the Content Files card carries, already formatted. pub content_size: String, } /// The two facts the content card needs, read the way the Askama handler read /// them so the card says the same thing it said before. pub async fn load(db: &sqlx::PgPool, user: crate::db::UserId) -> crate::error::Result { let items = crate::db::items::get_items_by_user(db, user).await?; let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content); let known_size = crate::db::creator_tiers::get_user_content_size(db, user).await?; let has_content = has_item_content || known_size > 0; let content_size = if !has_content { "No files".to_string() } else if has_item_content && known_size > 0 { format!( "{} + audio/cover files", crate::helpers::format_file_size(known_size) ) } else if known_size > 0 { crate::helpers::format_file_size(known_size) } else { "Audio/cover files".to_string() }; Ok(Page { has_content, content_size, }) } /// The one read this page makes, for the mount that serves it from a residual. pub(crate) fn reading(viewer: &super::Viewer) -> Result { viewer .block_on(load(&viewer.app.db, viewer.reader()?.id)) .map_err(|_| RouteError::internal("your exports could not be read")) } pub fn screen(viewer: &super::Viewer, _request: Request) -> Result { Ok(page_screen(&reading(viewer)?).into()) } declare! { /// The whole document: the title, the measure, the body. pub(crate) shape page_screen(page: &Page) -> Screen; screen single "Export Your Data - Makenotwork" { measured MEASURE; // `padded-page export-page`, which is what // `dashboards/dashboard-export.html:4` rendered. Composed rather than // written out: `Document::classed` replaces, so a screen naming only its // own token would drop its measure (`2790e5c4`). documented Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])); summarised "Download your content, projects, and transaction history."; include page_region(page); } } declare! { /// The page's one region, split out so it can be staged. /// /// Everything on it but the content card is words, so the residual is one /// literal around two branches: whether this reader has files at all. #[staged] pub(crate) shape page_region(page: &Page) -> Slot; region PAGE_REGION as Pane { link "Back to Dashboard" to get "/dashboard" navigating; page "Export Your Data"; text "Download your content, projects, and transaction history."; include cards(page); section "About Your Data"; text "Your data belongs to you. These exports contain everything we store \ about your account and content. If you're planning to delete your \ account, we recommend downloading your data first."; } } declare! { /// The card set: five direct exports, then the content archive when there is /// one. /// /// The five go through [`super::export_act::control`], which is this /// server's one sentence about posting a route and keeping the answer as a /// file. What a caller must not restate is that sentence, so the control /// arrives built and the row places it. #[staged] shape cards(page: &Page) -> Slot; region CARDS_REGION as Group { list { for export in copy "content/export-portal.toml" as direct { row export.title { secondary export.description; meta export.meta; include super::export_act::control("Download", export.route, export.filename); } } include content_row(&page.content_size) when page.has_content; } // The status panel's home. Empty until the export is asked for, which is // what the shipped `
` was. region CONTENT_STATUS as Group when page.has_content {} } } declare! { /// The asynchronous one. See the module header for why it targets a region. #[staged] shape content_row(size: &str) -> Row; row "Content Files" { secondary "All your uploaded audio files, cover images, version downloads, and dynamic clips."; meta "ZIP archive ({size})"; act "Download" to post "/api/export/content" replacing CONTENT_STATUS awaiting; } } #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } #[cfg(test)] mod tests { use super::*; fn page(has_content: bool) -> Page { Page { has_content, content_size: "12.3 MB".to_string(), } } fn html(has_content: bool) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(&page(has_content))) } /// The done-condition of `2790e5c4` for this screen: the class the template /// carried, composed from the measure rather than written out. #[test] fn the_document_carries_the_class_the_template_carried() { let screen = page_screen(&page(true)); assert_eq!( screen.document.body_class.as_deref(), Some("padded-page export-page") ); assert!( html(true).contains("padded-page export-page"), "{}", html(true) ); } /// The five direct exports, read the way the macro reads them. /// /// `policy`'s rule: the file is asked what the page should say rather than /// a second copy of it being kept here. It was a `const DIRECT` in this /// module and these tests iterated it, which checked the page against the /// same array the page was built from; now they check it against the file. fn direct() -> Vec { let copy: toml::Table = include_str!("../../content/export-portal.toml") .parse() .expect("the export copy is TOML"); copy["direct"] .as_array() .expect("a list of direct exports") .iter() .map(|export| export.as_table().expect("a table").clone()) .collect() } /// What a card says it is, out of the copy. fn says(export: &toml::Table, key: &str) -> String { export[key].as_str().expect("a string").to_owned() } /// Each direct export says where it reads from and what the file is called, /// and nothing says it twice. #[test] fn every_direct_export_names_its_route_and_its_filename() { let html = html(true); assert_eq!(direct().len(), 5, "five exports answer with a file"); for export in direct() { let route = says(&export, "route"); let filename = says(&export, "filename"); assert!( html.contains(&format!(r#"hx-post="{route}""#)), "{route} missing from {html}" ); assert!( html.contains(&format!(r#"data-saves="{filename}""#)), "{filename} missing from {html}" ); } } /// The filename a control renames the download to is the one the endpoint /// already sets, so the same export cannot arrive under two names. #[test] fn the_saved_filenames_are_the_ones_the_endpoints_set() { let api = include_str!("../routes/api/exports/mod.rs"); for export in direct() { let filename = says(&export, "filename"); assert!( api.contains(&filename), "{filename} is not a filename `routes::api::exports` sets" ); } } /// The content export is the one that does not hand back a file, so it is /// the one that still targets a region. #[test] fn the_content_export_fills_a_region_rather_than_saving_a_file() { let html = html(true); assert!(html.contains(r#"hx-post="/api/export/content""#), "{html}"); assert!(html.contains(CONTENT_STATUS), "{html}"); assert!( !html.contains(r#"data-saves="makenot-work-content"#), "the content export is queued and mailed, so it saves nothing: {html}" ); } /// `has_content` is false for a reader with no files, and the card and its /// status region both go with it. #[test] fn a_reader_with_no_files_is_offered_no_content_export() { let html = html(false); assert!(!html.contains("/api/export/content"), "{html}"); assert!(!html.contains("Content Files"), "{html}"); assert!(!html.contains(CONTENT_STATUS), "{html}"); } /// `736f45a5`: the wait is said once, by the description, rather than spelled /// as an indicator element per card. #[test] fn no_card_spells_a_spinner() { let html = html(true); assert!(html.contains("data-awaiting="), "{html}"); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }