|
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_router::screen::Act;
|
|
52 |
+ |
use quasi_router::{
|
|
53 |
+ |
Action, Document, Node, RegionKind, Request, Response, RouteError, Row, Screen as Described,
|
|
54 |
+ |
Slot,
|
|
55 |
+ |
};
|
|
56 |
+ |
use quasi_webview::Webview;
|
|
57 |
+ |
|
|
58 |
+ |
/// The address, registered whole. See [`super::document_mount`].
|
|
59 |
+ |
pub const PATH: &str = "/dashboard/export";
|
|
60 |
+ |
|
|
61 |
+ |
/// The page's own region, and what the skip link points at.
|
|
62 |
+ |
pub const PAGE_REGION: &str = "export";
|
|
63 |
+ |
|
|
64 |
+ |
/// The card set.
|
|
65 |
+ |
const CARDS_REGION: &str = "export-cards";
|
|
66 |
+ |
|
|
67 |
+ |
/// Where the content export's status panel lands.
|
|
68 |
+ |
///
|
|
69 |
+ |
/// The id the shipped template gave the empty `<div class="export-status">`, so
|
|
70 |
+ |
/// the panel the API already returns arrives where it always did.
|
|
71 |
+ |
pub const CONTENT_STATUS: &str = "content-status";
|
|
72 |
+ |
|
|
73 |
+ |
const MEASURE: layout::Measure = layout::Measure::Wide;
|
|
74 |
+ |
|
|
75 |
+ |
/// One export that hands back a file.
|
|
76 |
+ |
///
|
|
77 |
+ |
/// `filename` is what the reader's disk ends up with, and it is the name the
|
|
78 |
+ |
/// endpoint itself sets in `Content-Disposition` rather than a shorter one
|
|
79 |
+ |
/// invented here: a `data-saves` control renames the download, so the two
|
|
80 |
+ |
/// disagreeing means the same export arrives under two names depending on which
|
|
81 |
+ |
/// control started it.
|
|
82 |
+ |
struct Direct {
|
|
83 |
+ |
title: &'static str,
|
|
84 |
+ |
description: &'static str,
|
|
85 |
+ |
meta: &'static str,
|
|
86 |
+ |
route: &'static str,
|
|
87 |
+ |
filename: &'static str,
|
|
88 |
+ |
}
|
|
89 |
+ |
|
|
90 |
+ |
/// The five that answer with a file, in the order the template listed them.
|
|
91 |
+ |
const DIRECT: &[Direct] = &[
|
|
92 |
+ |
Direct {
|
|
93 |
+ |
title: "Projects & Items",
|
|
94 |
+ |
description: "All your project and item metadata including titles, descriptions, prices, and tags.",
|
|
95 |
+ |
meta: "JSON format",
|
|
96 |
+ |
route: "/api/export/projects",
|
|
97 |
+ |
filename: "makenot-work-projects.json",
|
|
98 |
+ |
},
|
|
99 |
+ |
Direct {
|
|
100 |
+ |
title: "Sales History",
|
|
101 |
+ |
description: "Record of all sales you've made, including dates, amounts, and item titles.",
|
|
102 |
+ |
meta: "CSV format",
|
|
103 |
+ |
route: "/api/export/sales",
|
|
104 |
+ |
filename: "makenot-work-sales.csv",
|
|
105 |
+ |
},
|
|
106 |
+ |
Direct {
|
|
107 |
+ |
title: "Collaborator Payouts",
|
|
108 |
+ |
description: "Record of all revenue shared with collaborators on your projects, both incoming and outgoing.",
|
|
109 |
+ |
meta: "CSV format",
|
|
110 |
+ |
route: "/api/export/splits",
|
|
111 |
+ |
filename: "makenot-work-splits.csv",
|
|
112 |
+ |
},
|
|
113 |
+ |
Direct {
|
|
114 |
+ |
title: "Purchase History",
|
|
115 |
+ |
description: "Record of all items you've purchased, for your personal records.",
|
|
116 |
+ |
meta: "CSV format",
|
|
117 |
+ |
route: "/api/export/purchases",
|
|
118 |
+ |
filename: "makenot-work-purchases.csv",
|
|
119 |
+ |
},
|
|
120 |
+ |
Direct {
|
|
121 |
+ |
title: "Followers & Members",
|
|
122 |
+ |
description: "List of users who follow you or have memberships to your projects.",
|
|
123 |
+ |
meta: "CSV format",
|
|
124 |
+ |
route: "/api/export/followers",
|
|
125 |
+ |
filename: "makenot-work-followers.csv",
|
|
126 |
+ |
},
|
|
127 |
+ |
];
|
|
128 |
+ |
|
|
129 |
+ |
/// What the screen needs from the database.
|
|
130 |
+ |
pub struct Page {
|
|
131 |
+ |
/// Whether the reader has any exportable files at all.
|
|
132 |
+ |
pub has_content: bool,
|
|
133 |
+ |
/// The size line the Content Files card carries, already formatted.
|
|
134 |
+ |
pub content_size: String,
|
|
135 |
+ |
}
|
|
136 |
+ |
|
|
137 |
+ |
/// The two facts the content card needs, read the way the Askama handler read
|
|
138 |
+ |
/// them so the card says the same thing it said before.
|
|
139 |
+ |
pub async fn load(db: &sqlx::PgPool, user: crate::db::UserId) -> crate::error::Result<Page> {
|
|
140 |
+ |
let items = crate::db::items::get_items_by_user(db, user).await?;
|
|
141 |
+ |
let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content);
|
|
142 |
+ |
|
|
143 |
+ |
let known_size = crate::db::creator_tiers::get_user_content_size(db, user).await?;
|
|
144 |
+ |
let has_content = has_item_content || known_size > 0;
|
|
145 |
+ |
|
|
146 |
+ |
let content_size = if !has_content {
|
|
147 |
+ |
"No files".to_string()
|
|
148 |
+ |
} else if has_item_content && known_size > 0 {
|
|
149 |
+ |
format!(
|
|
150 |
+ |
"{} + audio/cover files",
|
|
151 |
+ |
crate::helpers::format_file_size(known_size)
|
|
152 |
+ |
)
|
|
153 |
+ |
} else if known_size > 0 {
|
|
154 |
+ |
crate::helpers::format_file_size(known_size)
|
|
155 |
+ |
} else {
|
|
156 |
+ |
"Audio/cover files".to_string()
|
|
157 |
+ |
};
|
|
158 |
+ |
|
|
159 |
+ |
Ok(Page {
|
|
160 |
+ |
has_content,
|
|
161 |
+ |
content_size,
|
|
162 |
+ |
})
|
|
163 |
+ |
}
|
|
164 |
+ |
|
|
165 |
+ |
pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
|
|
166 |
+ |
let page = viewer
|
|
167 |
+ |
.block_on(load(&viewer.app.db, viewer.user.id))
|
|
168 |
+ |
.map_err(|_| RouteError::internal("your exports could not be read"))?;
|
|
169 |
+ |
Ok(page_screen(&page).into())
|
|
170 |
+ |
}
|
|
171 |
+ |
|
|
172 |
+ |
fn page_screen(page: &Page) -> Described {
|
|
173 |
+ |
Described::single("Export Your Data - Makenotwork")
|
|
174 |
+ |
.measured(MEASURE)
|
|
175 |
+ |
// `padded-page export-page`, which is what
|
|
176 |
+ |
// `dashboards/dashboard-export.html:4` rendered. Composed rather than
|
|
177 |
+ |
// written out: `Document::classed` replaces, so a screen naming only
|
|
178 |
+ |
// its own token would drop its measure (`2790e5c4`).
|
|
179 |
+ |
.documented(
|
|
180 |
+ |
Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])),
|
|
181 |
+ |
)
|
|
182 |
+ |
.summarised("Download your content, projects, and transaction history.")
|
|
183 |
+ |
.with(
|
|
184 |
+ |
Slot::new(PAGE_REGION, RegionKind::Pane)
|
|
185 |
+ |
.with(Node::Link {
|
|
186 |
+ |
text: "Back to Dashboard".to_string(),
|
|
187 |
+ |
action: Action::get("/dashboard").navigating(),
|
|
188 |
+ |
})
|
|
189 |
+ |
.with(Node::page("Export Your Data"))
|
|
190 |
+ |
.with(Node::text(
|
|
191 |
+ |
"Download your content, projects, and transaction history.",
|
|
192 |
+ |
))
|
|
193 |
+ |
.with(Node::Region(cards(page)))
|
|
194 |
+ |
.with(Node::section("About Your Data"))
|
|
195 |
+ |
.with(Node::text(
|
|
196 |
+ |
"Your data belongs to you. These exports contain everything we store \
|
|
197 |
+ |
about your account and content. If you're planning to delete your \
|
|
198 |
+ |
account, we recommend downloading your data first.",
|
|
199 |
+ |
)),
|
|
200 |
+ |
)
|
|
201 |
+ |
}
|
|
202 |
+ |
|
|
203 |
+ |
/// The card set: five direct exports, then the content archive when there is one.
|
|
204 |
+ |
fn cards(page: &Page) -> Slot {
|
|
205 |
+ |
let mut group = Slot::new(CARDS_REGION, RegionKind::Group);
|
|
206 |
+ |
|
|
207 |
+ |
let mut rows: Vec<Row> = DIRECT
|
|
208 |
+ |
.iter()
|
|
209 |
+ |
.map(|export| {
|
|
210 |
+ |
Row::new(export.title)
|
|
211 |
+ |
.secondary(export.description)
|
|
212 |
+ |
.meta(export.meta)
|
|
213 |
+ |
.act(super::export_act::control(
|
|
214 |
+ |
"Download",
|
|
215 |
+ |
export.route,
|
|
216 |
+ |
export.filename,
|
|
217 |
+ |
))
|
|
218 |
+ |
})
|
|
219 |
+ |
.collect();
|
|
220 |
+ |
|
|
221 |
+ |
if page.has_content {
|
|
222 |
+ |
rows.push(content_row(&page.content_size));
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
group = group.with(Node::List { rows, more: None });
|
|
226 |
+ |
|
|
227 |
+ |
// The status panel's home. Empty until the export is asked for, which is
|
|
228 |
+ |
// what the shipped `<div class="export-status" id="content-status">` was.
|
|
229 |
+ |
if page.has_content {
|
|
230 |
+ |
group = group.with(Node::Region(Slot::new(CONTENT_STATUS, RegionKind::Group)));
|
|
231 |
+ |
}
|
|
232 |
+ |
|
|
233 |
+ |
group
|
|
234 |
+ |
}
|
|
235 |
+ |
|
|
236 |
+ |
/// The asynchronous one. See the module header for why it targets a region.
|
|
237 |
+ |
fn content_row(size: &str) -> Row {
|
|
238 |
+ |
Row::new("Content Files")
|
|
239 |
+ |
.secondary(
|
|
240 |
+ |
"All your uploaded audio files, cover images, version downloads, and dynamic clips.",
|
|
241 |
+ |
)
|
|
242 |
+ |
.meta(format!("ZIP archive ({size})"))
|
|
243 |
+ |
.act(Act::new(
|
|
244 |
+ |
"Download",
|
|
245 |
+ |
Action::post("/api/export/content")
|
|
246 |
+ |
.replacing(CONTENT_STATUS)
|
|
247 |
+ |
.awaiting(),
|
|
248 |
+ |
))
|
|
249 |
+ |
}
|
|
250 |
+ |
|
|
251 |
+ |
#[must_use]
|
|
252 |
+ |
pub fn renderer(viewer: &super::Viewer) -> Webview {
|
|
253 |
+ |
Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
|
|
254 |
+ |
"{}{}",
|
|
255 |
+ |
crate::shell::skip_link(PAGE_REGION),
|
|
256 |
+ |
crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)),
|
|
257 |
+ |
)))
|
|
258 |
+ |
}
|
|
259 |
+ |
|
|
260 |
+ |
#[cfg(test)]
|
|
261 |
+ |
mod tests {
|
|
262 |
+ |
use super::*;
|
|
263 |
+ |
|
|
264 |
+ |
fn page(has_content: bool) -> Page {
|
|
265 |
+ |
Page {
|
|
266 |
+ |
has_content,
|
|
267 |
+ |
content_size: "12.3 MB".to_string(),
|
|
268 |
+ |
}
|
|
269 |
+ |
}
|
|
270 |
+ |
|
|
271 |
+ |
fn html(has_content: bool) -> String {
|
|
272 |
+ |
use quasi_axum::Serves as _;
|
|
273 |
+ |
|
|
274 |
+ |
Webview::new().screen(&page_screen(&page(has_content)))
|
|
275 |
+ |
}
|
|
276 |
+ |
|
|
277 |
+ |
/// The done-condition of `2790e5c4` for this screen: the class the template
|
|
278 |
+ |
/// carried, composed from the measure rather than written out.
|
|
279 |
+ |
#[test]
|
|
280 |
+ |
fn the_document_carries_the_class_the_template_carried() {
|
|
281 |
+ |
let screen = page_screen(&page(true));
|
|
282 |
+ |
|
|
283 |
+ |
assert_eq!(
|
|
284 |
+ |
screen.document.body_class.as_deref(),
|
|
285 |
+ |
Some("padded-page export-page")
|
|
286 |
+ |
);
|
|
287 |
+ |
assert!(
|
|
288 |
+ |
html(true).contains("padded-page export-page"),
|
|
289 |
+ |
"{}",
|
|
290 |
+ |
html(true)
|
|
291 |
+ |
);
|
|
292 |
+ |
}
|
|
293 |
+ |
|
|
294 |
+ |
/// Each direct export says where it reads from and what the file is called,
|
|
295 |
+ |
/// and nothing says it twice.
|
|
296 |
+ |
#[test]
|
|
297 |
+ |
fn every_direct_export_names_its_route_and_its_filename() {
|
|
298 |
+ |
let html = html(true);
|
|
299 |
+ |
|
|
300 |
+ |
for export in DIRECT {
|
|
301 |
+ |
assert!(
|
|
302 |
+ |
html.contains(&format!(r#"hx-post="{}""#, export.route)),
|
|
303 |
+ |
"{} missing from {html}",
|
|
304 |
+ |
export.route
|
|
305 |
+ |
);
|
|
306 |
+ |
assert!(
|
|
307 |
+ |
html.contains(&format!(r#"data-saves="{}""#, export.filename)),
|
|
308 |
+ |
"{} missing from {html}",
|
|
309 |
+ |
export.filename
|
|
310 |
+ |
);
|
|
311 |
+ |
}
|
|
312 |
+ |
}
|
|
313 |
+ |
|
|
314 |
+ |
/// The filename a control renames the download to is the one the endpoint
|
|
315 |
+ |
/// already sets, so the same export cannot arrive under two names.
|
|
316 |
+ |
#[test]
|
|
317 |
+ |
fn the_saved_filenames_are_the_ones_the_endpoints_set() {
|
|
318 |
+ |
let api = include_str!("../routes/api/exports/mod.rs");
|
|
319 |
+ |
|
|
320 |
+ |
for export in DIRECT {
|
|
321 |
+ |
assert!(
|
|
322 |
+ |
api.contains(export.filename),
|
|
323 |
+ |
"{} is not a filename `routes::api::exports` sets",
|
|
324 |
+ |
export.filename
|
|
325 |
+ |
);
|
|
326 |
+ |
}
|
|
327 |
+ |
}
|
|
328 |
+ |
|
|
329 |
+ |
/// The content export is the one that does not hand back a file, so it is
|
|
330 |
+ |
/// the one that still targets a region.
|
|
331 |
+ |
#[test]
|
|
332 |
+ |
fn the_content_export_fills_a_region_rather_than_saving_a_file() {
|
|
333 |
+ |
let html = html(true);
|
|
334 |
+ |
|
|
335 |
+ |
assert!(html.contains(r#"hx-post="/api/export/content""#), "{html}");
|
|
336 |
+ |
assert!(html.contains(CONTENT_STATUS), "{html}");
|
|
337 |
+ |
assert!(
|
|
338 |
+ |
!html.contains(r#"data-saves="makenot-work-content"#),
|
|
339 |
+ |
"the content export is queued and mailed, so it saves nothing: {html}"
|
|
340 |
+ |
);
|
|
341 |
+ |
}
|
|
342 |
+ |
|
|
343 |
+ |
/// `has_content` is false for a reader with no files, and the card and its
|
|
344 |
+ |
/// status region both go with it.
|
|
345 |
+ |
#[test]
|
|
346 |
+ |
fn a_reader_with_no_files_is_offered_no_content_export() {
|
|
347 |
+ |
let html = html(false);
|
|
348 |
+ |
|
|
349 |
+ |
assert!(!html.contains("/api/export/content"), "{html}");
|
|
350 |
+ |
assert!(!html.contains("Content Files"), "{html}");
|
|
351 |
+ |
assert!(!html.contains(CONTENT_STATUS), "{html}");
|
|
352 |
+ |
}
|
|
353 |
+ |
|
|
354 |
+ |
/// `736f45a5`: the wait is said once, by the description, rather than spelled
|
|
355 |
+ |
/// as an indicator element per card.
|
|
356 |
+ |
#[test]
|
|
357 |
+ |
fn no_card_spells_a_spinner() {
|
|
358 |
+ |
let html = html(true);
|
|
359 |
+ |
|
|
360 |
+ |
assert!(html.contains("data-awaiting="), "{html}");
|
|
361 |
+ |
for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
|
|
362 |
+ |
assert!(!html.contains(spelling), "{spelling} survives in {html}");
|
|
363 |
+ |
}
|
|
364 |
+ |
}
|
|
365 |
+ |
}
|