Skip to main content

max / makenotwork

12.9 KB · 339 lines History Blame Raw
1 //! The data-export portal at `/dashboard/export`, described.
2 //!
3 //! The no-lock-in guarantee's own screen: six exports, five of which hand back
4 //! a file and one of which is assembled in the background and mailed. It
5 //! replaces `templates/dashboards/dashboard-export.html`,
6 //! `dashboard::forms::export_portal`, `ExportPortalTemplate` and
7 //! `static/dashboard-export-inline.js`.
8 //!
9 //! # The five direct exports were a sixth hand-written export control
10 //!
11 //! [`super::export_act`] is the described control for "post this route and keep
12 //! the answer as a file", and five sites in four templates already use it.
13 //! `project_overview`'s module header calls this page's hand-written buttons the
14 //! sixth. They were: `hx-post` + `hx-target` + `hx-swap` + `hx-indicator`, plus
15 //! a `<span class="htmx-indicator"> ...</span>` and an empty status `<div>`, per
16 //! card, six times over. All of it says what [`Action::saving`] says in one
17 //! word, so the cards go through `export_act::control` and the six spellings go
18 //! with them (`736f45a5`).
19 //!
20 //! **This changes what the reader gets, and the change is a fix.** The shipped
21 //! buttons posted with htmx, so `is_htmx_request` held and the API took its
22 //! htmx branch: a `data:` URI built from the whole body, which
23 //! `routes::api::exports` truncates with the line "Export truncated. Enable
24 //! JavaScript to download the full file." A `data-saves` control cancels the
25 //! htmx request and reissues a plain `fetch` (`htmx-glue.ts`), which carries no
26 //! `HX-Request`, so the API streams the real file instead. The five sites
27 //! already described made this same trade; these six are the last that had not.
28 //!
29 //! # Content Files is not one of them, and that is why it keeps a region
30 //!
31 //! `/api/export/content` does not answer with a file. It queues a background
32 //! job, uploads a ZIP to S3 and mails a link, answering the request with a
33 //! status panel ([`export_pending_html`](crate::routes::api::exports)). So it
34 //! is the one card whose act still targets a region: [`Action::replacing`],
35 //! pointed at [`CONTENT_STATUS`], which is the documented use for a route the
36 //! description layer does not serve.
37 //!
38 //! # Export All is not carried over
39 //!
40 //! `data-action="exportAll"` ran `window.exportAll`, which selected
41 //! `.export-card button.secondary` while every button rendered `btn-secondary`.
42 //! The selector matched nothing, so the handler returned at its first guard and
43 //! the button did nothing at all -- no label change, no disabled state, no
44 //! error. Measured 2026-08-31 and filed as a problem; it shipped that way
45 //! rather than drifting (`git show b9dd22a6`). Describing a control that has
46 //! never worked would be inventing a feature inside a conversion, and fixing it
47 //! is a product call about what "all" means when one of the six is asynchronous.
48 //! The problem holds that question.
49
50 use makeover_layout as layout;
51 use quasi_declare::declare;
52 use quasi_router::{Document, Request, Response, RouteError};
53 use quasi_webview::Webview;
54
55 /// The address, registered whole. See [`super::document_mount`].
56 pub const PATH: &str = "/dashboard/export";
57
58 /// The page's own region, and what the skip link points at.
59 pub const PAGE_REGION: &str = "export";
60
61 /// The card set.
62 const CARDS_REGION: &str = "export-cards";
63
64 /// Where the content export's status panel lands.
65 ///
66 /// The id the shipped template gave the empty `<div class="export-status">`, so
67 /// the panel the API already returns arrives where it always did.
68 pub const CONTENT_STATUS: &str = "content-status";
69
70 const MEASURE: layout::Measure = layout::Measure::Wide;
71
72 /// What the screen needs from the database.
73 pub struct Page {
74 /// Whether the reader has any exportable files at all.
75 pub has_content: bool,
76 /// The size line the Content Files card carries, already formatted.
77 pub content_size: String,
78 }
79
80 /// The two facts the content card needs, read the way the Askama handler read
81 /// them so the card says the same thing it said before.
82 pub async fn load(db: &sqlx::PgPool, user: crate::db::UserId) -> crate::error::Result<Page> {
83 let items = crate::db::items::get_items_by_user(db, user).await?;
84 let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content);
85
86 let known_size = crate::db::creator_tiers::get_user_content_size(db, user).await?;
87 let has_content = has_item_content || known_size > 0;
88
89 let content_size = if !has_content {
90 "No files".to_string()
91 } else if has_item_content && known_size > 0 {
92 format!(
93 "{} + audio/cover files",
94 crate::helpers::format_file_size(known_size)
95 )
96 } else if known_size > 0 {
97 crate::helpers::format_file_size(known_size)
98 } else {
99 "Audio/cover files".to_string()
100 };
101
102 Ok(Page {
103 has_content,
104 content_size,
105 })
106 }
107
108 /// The one read this page makes, for the mount that serves it from a residual.
109 pub(crate) fn reading(viewer: &super::Viewer) -> Result<Page, RouteError> {
110 viewer
111 .block_on(load(&viewer.app.db, viewer.reader()?.id))
112 .map_err(|_| RouteError::internal("your exports could not be read"))
113 }
114
115 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
116 Ok(page_screen(&reading(viewer)?).into())
117 }
118
119 declare! {
120 /// The whole document: the title, the measure, the body.
121 pub(crate) shape page_screen(page: &Page) -> Screen;
122
123 screen single "Export Your Data - Makenotwork" {
124 measured MEASURE;
125 // `padded-page export-page`, which is what
126 // `dashboards/dashboard-export.html:4` rendered. Composed rather than
127 // written out: `Document::classed` replaces, so a screen naming only its
128 // own token would drop its measure (`2790e5c4`).
129 documented Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"]));
130 summarised "Download your content, projects, and transaction history.";
131
132 include page_region(page);
133 }
134 }
135
136 declare! {
137 /// The page's one region, split out so it can be staged.
138 ///
139 /// Everything on it but the content card is words, so the residual is one
140 /// literal around two branches: whether this reader has files at all.
141 #[staged]
142 pub(crate) shape page_region(page: &Page) -> Slot;
143
144 region PAGE_REGION as Pane {
145 link "Back to Dashboard" to get "/dashboard" navigating;
146 page "Export Your Data";
147 text "Download your content, projects, and transaction history.";
148 include cards(page);
149 section "About Your Data";
150 text "Your data belongs to you. These exports contain everything we store \
151 about your account and content. If you're planning to delete your \
152 account, we recommend downloading your data first.";
153 }
154 }
155
156 declare! {
157 /// The card set: five direct exports, then the content archive when there is
158 /// one.
159 ///
160 /// The five go through [`super::export_act::control`], which is this
161 /// server's one sentence about posting a route and keeping the answer as a
162 /// file. What a caller must not restate is that sentence, so the control
163 /// arrives built and the row places it.
164 #[staged]
165 shape cards(page: &Page) -> Slot;
166
167 region CARDS_REGION as Group {
168 list {
169 for export in copy "content/export-portal.toml" as direct {
170 row export.title {
171 secondary export.description;
172 meta export.meta;
173 include super::export_act::control("Download", export.route, export.filename);
174 }
175 }
176
177 include content_row(&page.content_size) when page.has_content;
178 }
179
180 // The status panel's home. Empty until the export is asked for, which is
181 // what the shipped `<div class="export-status" id="content-status">` was.
182 region CONTENT_STATUS as Group when page.has_content {}
183 }
184 }
185
186 declare! {
187 /// The asynchronous one. See the module header for why it targets a region.
188 #[staged]
189 shape content_row(size: &str) -> Row;
190
191 row "Content Files" {
192 secondary "All your uploaded audio files, cover images, version downloads, and dynamic clips.";
193 meta "ZIP archive ({size})";
194 act "Download" to post "/api/export/content" replacing CONTENT_STATUS awaiting;
195 }
196 }
197
198 #[must_use]
199 pub fn renderer(viewer: &super::Viewer) -> Webview {
200 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
201 "{}{}",
202 crate::shell::skip_link(PAGE_REGION),
203 crate::shell::site_header(viewer.user.as_ref()),
204 )))
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210
211 fn page(has_content: bool) -> Page {
212 Page {
213 has_content,
214 content_size: "12.3 MB".to_string(),
215 }
216 }
217
218 fn html(has_content: bool) -> String {
219 use quasi_axum::Serves as _;
220
221 Webview::new().screen(&page_screen(&page(has_content)))
222 }
223
224 /// The done-condition of `2790e5c4` for this screen: the class the template
225 /// carried, composed from the measure rather than written out.
226 #[test]
227 fn the_document_carries_the_class_the_template_carried() {
228 let screen = page_screen(&page(true));
229
230 assert_eq!(
231 screen.document.body_class.as_deref(),
232 Some("padded-page export-page")
233 );
234 assert!(
235 html(true).contains("padded-page export-page"),
236 "{}",
237 html(true)
238 );
239 }
240
241 /// The five direct exports, read the way the macro reads them.
242 ///
243 /// `policy`'s rule: the file is asked what the page should say rather than
244 /// a second copy of it being kept here. It was a `const DIRECT` in this
245 /// module and these tests iterated it, which checked the page against the
246 /// same array the page was built from; now they check it against the file.
247 fn direct() -> Vec<toml::Table> {
248 let copy: toml::Table = include_str!("../../content/export-portal.toml")
249 .parse()
250 .expect("the export copy is TOML");
251
252 copy["direct"]
253 .as_array()
254 .expect("a list of direct exports")
255 .iter()
256 .map(|export| export.as_table().expect("a table").clone())
257 .collect()
258 }
259
260 /// What a card says it is, out of the copy.
261 fn says(export: &toml::Table, key: &str) -> String {
262 export[key].as_str().expect("a string").to_owned()
263 }
264
265 /// Each direct export says where it reads from and what the file is called,
266 /// and nothing says it twice.
267 #[test]
268 fn every_direct_export_names_its_route_and_its_filename() {
269 let html = html(true);
270
271 assert_eq!(direct().len(), 5, "five exports answer with a file");
272 for export in direct() {
273 let route = says(&export, "route");
274 let filename = says(&export, "filename");
275
276 assert!(
277 html.contains(&format!(r#"hx-post="{route}""#)),
278 "{route} missing from {html}"
279 );
280 assert!(
281 html.contains(&format!(r#"data-saves="{filename}""#)),
282 "{filename} missing from {html}"
283 );
284 }
285 }
286
287 /// The filename a control renames the download to is the one the endpoint
288 /// already sets, so the same export cannot arrive under two names.
289 #[test]
290 fn the_saved_filenames_are_the_ones_the_endpoints_set() {
291 let api = include_str!("../routes/api/exports/mod.rs");
292
293 for export in direct() {
294 let filename = says(&export, "filename");
295 assert!(
296 api.contains(&filename),
297 "{filename} is not a filename `routes::api::exports` sets"
298 );
299 }
300 }
301
302 /// The content export is the one that does not hand back a file, so it is
303 /// the one that still targets a region.
304 #[test]
305 fn the_content_export_fills_a_region_rather_than_saving_a_file() {
306 let html = html(true);
307
308 assert!(html.contains(r#"hx-post="/api/export/content""#), "{html}");
309 assert!(html.contains(CONTENT_STATUS), "{html}");
310 assert!(
311 !html.contains(r#"data-saves="makenot-work-content"#),
312 "the content export is queued and mailed, so it saves nothing: {html}"
313 );
314 }
315
316 /// `has_content` is false for a reader with no files, and the card and its
317 /// status region both go with it.
318 #[test]
319 fn a_reader_with_no_files_is_offered_no_content_export() {
320 let html = html(false);
321
322 assert!(!html.contains("/api/export/content"), "{html}");
323 assert!(!html.contains("Content Files"), "{html}");
324 assert!(!html.contains(CONTENT_STATUS), "{html}");
325 }
326
327 /// `736f45a5`: the wait is said once, by the description, rather than spelled
328 /// as an indicator element per card.
329 #[test]
330 fn no_card_spells_a_spinner() {
331 let html = html(true);
332
333 assert!(html.contains("data-awaiting="), "{html}");
334 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
335 assert!(!html.contains(spelling), "{spelling} survives in {html}");
336 }
337 }
338 }
339