mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Web: Media Browser reports a picked file it can't read (#226)
Add Files... and Upload Folder... read every picked file in one go and rejected on the first file the browser couldn't read; the Media Browser dropped that rejection, so nothing was added and nothing said why. Each file is now read on its own: the readable ones are added and the status bar names the unreadable ones with the browser's reason and suggests picking them again. Cancelling the picker is not reported. Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7612b220ef
commit
82a1862fa1
5 files changed
+122
-19
No files matched your search
@@ -615,21 +615,26 @@ export async function browseRead(path) {
|
||||
}
|
||||
|
||||
/// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a
|
||||
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder).
|
||||
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file
|
||||
/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with [].
|
||||
export function browsePickFiles(directory) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
if (directory) input.webkitdirectory = true;
|
||||
input.oncancel = () => resolve([]);
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const out = [];
|
||||
for (const f of input.files) {
|
||||
out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
|
||||
const out = [];
|
||||
for (const f of input.files) {
|
||||
const path = (directory && f.webkitRelativePath) || f.name;
|
||||
try {
|
||||
out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
|
||||
} catch (e) {
|
||||
out.push({ path, error: String((e && (e.message || e.name)) || e) });
|
||||
}
|
||||
resolve(out);
|
||||
} catch (e) { reject(e); }
|
||||
}
|
||||
resolve(out);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr
|
||||
crate::post(move |app| then(app, v));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default();
|
||||
let msg = crate::persist::js_err(e);
|
||||
// Cancelling a picker is not an error.
|
||||
if !msg.contains("abort") && !msg.contains("AbortError") {
|
||||
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
|
||||
@@ -234,20 +234,42 @@ impl Browser for WebBrowser {
|
||||
Ok(json!({"pending": true}))
|
||||
}
|
||||
"addFiles" | "uploadFolder" => {
|
||||
let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" };
|
||||
let p = pick_files(id == "uploadFolder");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return };
|
||||
let mut n = 0;
|
||||
for f in js_sys::Array::from(&list).iter() {
|
||||
let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string());
|
||||
let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok());
|
||||
if let (Some(path), Some(bytes)) = (path, bytes) {
|
||||
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
|
||||
n += 1;
|
||||
let list = match wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
Ok(list) => js_sys::Array::from(&list),
|
||||
Err(e) => {
|
||||
let msg = crate::persist::js_err(e);
|
||||
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Files that can't be read are reported, not dropped silently (#226).
|
||||
let (mut n, mut unread) = (0, vec![]);
|
||||
for f in list.iter() {
|
||||
let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok();
|
||||
let Some(path) = get("path").and_then(|v| v.as_string()) else { continue };
|
||||
match get("bytes").and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()) {
|
||||
Some(bytes) => {
|
||||
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
|
||||
n += 1;
|
||||
}
|
||||
None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) {
|
||||
Some(why) => format!("{path} ({why})"),
|
||||
None => path,
|
||||
}),
|
||||
}
|
||||
}
|
||||
// Cancelled: nothing to report.
|
||||
if n == 0 && unread.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::post(move |app| {
|
||||
app.ui.status = format!("{n} file(s) added to Browser Storage");
|
||||
if !unread.is_empty() {
|
||||
app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", "));
|
||||
}
|
||||
go(app, STORAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,8 @@ pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.with(|e| e.borrow().clone())
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
/// A JavaScript error (a rejected promise's reason) as text.
|
||||
pub(crate) fn js_err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm
|
||||
// build needed:
|
||||
//
|
||||
// node apps/effectcraft-web/tests/page.mjs
|
||||
//
|
||||
// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error
|
||||
// instead of failing the whole pick silently, and cancelling picks nothing (#226).
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
globalThis.self = globalThis;
|
||||
|
||||
class FakeElement {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.textContent = "";
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attributes[k] = v;
|
||||
}
|
||||
append(...c) {
|
||||
this.children.push(...c);
|
||||
}
|
||||
*all() {
|
||||
yield this;
|
||||
for (const c of this.children) yield* c.all();
|
||||
}
|
||||
}
|
||||
let picker;
|
||||
globalThis.document = {
|
||||
body: new FakeElement("body"),
|
||||
createElement(tag) {
|
||||
const el = new FakeElement(tag);
|
||||
if (tag === "input") {
|
||||
picker = el;
|
||||
el.click = () => {};
|
||||
}
|
||||
return el;
|
||||
},
|
||||
getElementById(id) {
|
||||
return [...this.body.all()].find((e) => e.id === id) ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
|
||||
// #226: one readable and one unreadable file.
|
||||
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
|
||||
const picked = host.browsePickFiles(false);
|
||||
picker.files = [
|
||||
file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer),
|
||||
file("red-corner.png", async () => {
|
||||
throw new DOMException("Generated unreadable selected file", "NotReadableError");
|
||||
}),
|
||||
];
|
||||
await picker.onchange();
|
||||
const out = await picked;
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]);
|
||||
assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" });
|
||||
const cancelled = host.browsePickFiles(false);
|
||||
picker.oncancel();
|
||||
assert.deepEqual(await cancelled, [], "cancelling picks nothing");
|
||||
|
||||
console.log("page: ok");
|
||||
Reference in new issue
Block a user