//! 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_router::screen::Act; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Row, Screen as Described, Slot, }; 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; /// One export that hands back a file. /// /// `filename` is what the reader's disk ends up with, and it is the name the /// endpoint itself sets in `Content-Disposition` rather than a shorter one /// invented here: a `data-saves` control renames the download, so the two /// disagreeing means the same export arrives under two names depending on which /// control started it. struct Direct { title: &'static str, description: &'static str, meta: &'static str, route: &'static str, filename: &'static str, } /// The five that answer with a file, in the order the template listed them. const DIRECT: &[Direct] = &[ Direct { title: "Projects & Items", description: "All your project and item metadata including titles, descriptions, prices, and tags.", meta: "JSON format", route: "/api/export/projects", filename: "makenot-work-projects.json", }, Direct { title: "Sales History", description: "Record of all sales you've made, including dates, amounts, and item titles.", meta: "CSV format", route: "/api/export/sales", filename: "makenot-work-sales.csv", }, Direct { title: "Collaborator Payouts", description: "Record of all revenue shared with collaborators on your projects, both incoming and outgoing.", meta: "CSV format", route: "/api/export/splits", filename: "makenot-work-splits.csv", }, Direct { title: "Purchase History", description: "Record of all items you've purchased, for your personal records.", meta: "CSV format", route: "/api/export/purchases", filename: "makenot-work-purchases.csv", }, Direct { title: "Followers & Members", description: "List of users who follow you or have memberships to your projects.", meta: "CSV format", route: "/api/export/followers", filename: "makenot-work-followers.csv", }, ]; /// 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, }) } pub fn screen(viewer: &super::Viewer, _request: Request) -> Result { let page = viewer .block_on(load(&viewer.app.db, viewer.reader()?.id)) .map_err(|_| RouteError::internal("your exports could not be read"))?; Ok(page_screen(&page).into()) } fn page_screen(page: &Page) -> Described { Described::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.") .with( Slot::new(PAGE_REGION, RegionKind::Pane) .with(Node::Link { text: "Back to Dashboard".to_string(), action: Action::get("/dashboard").navigating(), }) .with(Node::page("Export Your Data")) .with(Node::text( "Download your content, projects, and transaction history.", )) .with(Node::Region(cards(page))) .with(Node::section("About Your Data")) .with(Node::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.", )), ) } /// The card set: five direct exports, then the content archive when there is one. fn cards(page: &Page) -> Slot { let mut group = Slot::new(CARDS_REGION, RegionKind::Group); let mut rows: Vec = DIRECT .iter() .map(|export| { Row::new(export.title) .secondary(export.description) .meta(export.meta) .act(super::export_act::control( "Download", export.route, export.filename, )) }) .collect(); if page.has_content { rows.push(content_row(&page.content_size)); } group = group.with(Node::List { rows, more: None }); // The status panel's home. Empty until the export is asked for, which is // what the shipped `
` was. if page.has_content { group = group.with(Node::Region(Slot::new(CONTENT_STATUS, RegionKind::Group))); } group } /// The asynchronous one. See the module header for why it targets a region. fn content_row(size: &str) -> Row { Row::new("Content Files") .secondary( "All your uploaded audio files, cover images, version downloads, and dynamic clips.", ) .meta(format!("ZIP archive ({size})")) .act(Act::new( "Download", Action::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) ); } /// 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); for export in DIRECT { assert!( html.contains(&format!(r#"hx-post="{}""#, export.route)), "{} missing from {html}", export.route ); assert!( html.contains(&format!(r#"data-saves="{}""#, export.filename)), "{} missing from {html}", export.filename ); } } /// 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 { assert!( api.contains(export.filename), "{} is not a filename `routes::api::exports` sets", export.filename ); } } /// 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}"); } } }