| 1 | /* drop.js — drag text files onto the page and they are stored in the |
| 2 | background, one at a time, with a tray in the corner saying how each |
| 3 | one went. Enhancement, and nothing more. |
| 4 | |
| 5 | Four rules it is written to keep, in this order: |
| 6 | |
| 7 | 1. It is additive. The file input and the Store it button below it are |
| 8 | untouched. With scripts off, with this file blocked, or in a |
| 9 | browser missing any piece it needs, the zone never says "ready", |
| 10 | nothing is styled, no tray appears, and the form is exactly the |
| 11 | form it was: choose files, press Store it, the page reloads, the |
| 12 | flash sentence appears. Choosing files by hand still does that |
| 13 | even with this file running — only a DROP takes the background |
| 14 | path. |
| 15 | |
| 16 | 2. There is one place a file can be sent, and this file does not |
| 17 | choose it. The address is read off the form's own action attribute |
| 18 | and used verbatim; there is no address written down in here, no |
| 19 | second endpoint, and the form is never submitted for you. So |
| 20 | everything the handler checks — the three caps, the NUL sniff, the |
| 21 | same-origin check, the whole-body limit that keeps a part off the |
| 22 | disk — is still checked, because it is still the same handler. |
| 23 | TestTheDropScriptPostsOnlyToTheFormsOwnAction holds that shape. |
| 24 | |
| 25 | It asks for one other thing, and only one: the page the reader is |
| 26 | already looking at, so the passage count can stop being the one |
| 27 | drawn before the drop. That address is not written down here |
| 28 | either — it is where the browser already is — and it is a GET of a |
| 29 | page this person had already been served. The same test pins both |
| 30 | shapes and permits no third. |
| 31 | |
| 32 | 3. It never does less than it was asked and calls it done. A drop of |
| 33 | five files where one is a PDF sends four and NAMES the fifth with |
| 34 | the reason, in its own row; it never quietly reduces five to four. |
| 35 | An upload that dies mid-flight is not reported as a refusal |
| 36 | either: the request may have been received and stored in full with |
| 37 | only the answer lost, so that row says the fate is unknown and the |
| 38 | tray points to the document list rather than pretending to know. |
| 39 | |
| 40 | 4. Its words are the server's words. The four numbers, the sniff |
| 41 | window and every sentence come off the page as data attributes, |
| 42 | put there by handlers.go. Nothing is written down twice, so a |
| 43 | refusal in the tray and a refusal in the scriptless flash cannot |
| 44 | drift apart. Missing or unreadable attributes are a hard stop, not |
| 45 | a default: a guessed cap would refuse files the server would have |
| 46 | taken, and a guessed sentence is copy nobody reviewed. |
| 47 | |
| 48 | What it deliberately does not do: navigate, at any point, for any |
| 49 | reason — someone mid-drop does not want the ground moving, and the |
| 50 | half-typed paste box is theirs. When a drop settles it fetches this |
| 51 | same page in the background and replaces the regions the server |
| 52 | marked refreshable, which is a list that changes and nothing else. It |
| 53 | does not build a document row itself either, ever: the server is the |
| 54 | only thing in this app that renders one, and two renderers for one |
| 55 | row drift. It does not read anything but the first sniff-window bytes |
| 56 | of each file, does not take a folder (that needs a different API and |
| 57 | is out of scope), and does not name a file anywhere but on your own |
| 58 | screen. There is no console output in this file at all — a filename |
| 59 | is somebody's content and this codebase keeps content out of logs. */ |
| 60 | (function () { |
| 61 | "use strict"; |
| 62 | |
| 63 | var zone = document.querySelector("[rst-dropzone]"); |
| 64 | if (!zone) return; |
| 65 | |
| 66 | var input = zone.querySelector('input[type="file"]'); |
| 67 | var note = zone.querySelector("[rst-dropzone-note]"); |
| 68 | var hint = zone.querySelector("[rst-dropzone-hint]"); |
| 69 | if (!input || !note || !hint) return; |
| 70 | |
| 71 | // The tray, whole. Every piece of it, or none of it: a tray missing |
| 72 | // its status line would upload files and announce nothing, and a tray |
| 73 | // missing its list would upload them and show nothing. |
| 74 | var tray = document.querySelector("[rst-tray]"); |
| 75 | if (!tray) return; |
| 76 | var list = tray.querySelector("[rst-tray-list]"); |
| 77 | var status = tray.querySelector("[rst-tray-status]"); |
| 78 | var listed = tray.querySelector("[rst-tray-listed]"); |
| 79 | var dismiss = tray.querySelector("[rst-tray-dismiss]"); |
| 80 | if (!list || !status || !listed || !dismiss) return; |
| 81 | |
| 82 | // The form that owns the input, and the one address anything is sent |
| 83 | // to. Read off the form rather than written down here, so a background |
| 84 | // upload and the scriptless submit are incapable of going to different |
| 85 | // places. No action attribute means a form that posts to whatever page |
| 86 | // it happens to be on, which is not something to guess at — hard stop, |
| 87 | // and the plain form is left alone. |
| 88 | var form = input.form; |
| 89 | if (!form) return; |
| 90 | var endpoint = form.getAttribute("action"); |
| 91 | if (!endpoint) return; |
| 92 | // The part name is the input's own, for the same reason: the handler |
| 93 | // reads the parts under one name and a name invented here would post |
| 94 | // files it never looks at. |
| 95 | var field = input.getAttribute("name"); |
| 96 | if (!field) return; |
| 97 | |
| 98 | // Everything this needs, checked before anything is styled. A drop |
| 99 | // target that cannot send a file is a trap, so if any of this is |
| 100 | // missing the visitor gets the plain input instead and never learns |
| 101 | // there was going to be a drop zone. |
| 102 | if (typeof XMLHttpRequest === "undefined") return; |
| 103 | if (typeof FormData === "undefined") return; |
| 104 | if (typeof FileReader === "undefined") return; |
| 105 | |
| 106 | var maxBytes = number("data-max-bytes"); |
| 107 | var maxTotal = number("data-max-total-bytes"); |
| 108 | var maxFiles = number("data-max-files"); |
| 109 | var sniffBytes = number("data-sniff-bytes"); |
| 110 | var maxInFlight = number("data-max-in-flight"); |
| 111 | if (maxBytes === null || maxTotal === null || maxFiles === null) return; |
| 112 | if (sniffBytes === null || maxInFlight === null) return; |
| 113 | // The header that tells the server a re-fetch is not a visit. Its name |
| 114 | // is the server's, read off the page for the same reason the numbers |
| 115 | // are. |
| 116 | var refreshHeader = zone.getAttribute("data-refresh-header"); |
| 117 | if (!refreshHeader) return; |
| 118 | |
| 119 | var msg = {}; |
| 120 | var keys = [ |
| 121 | "toomany", "toototal", "folder", "blocked", |
| 122 | "waiting", "uploading", "storing", "row-stored", |
| 123 | "stored-one", "stored-many", "none-stored", "fresh", |
| 124 | "refused-one", "refused-many", "unsure-one", "unsure-many" |
| 125 | ]; |
| 126 | for (var i = 0; i < keys.length; i++) { |
| 127 | var text = zone.getAttribute("data-msg-" + keys[i]); |
| 128 | if (!text) return; // a refusal with no words is a silent refusal |
| 129 | msg[keys[i]] = text; |
| 130 | } |
| 131 | var reason = {}; |
| 132 | var reasons = ["toobig", "binary", "empty", "unreadable", "noanswer"]; |
| 133 | for (var j = 0; j < reasons.length; j++) { |
| 134 | var clause = zone.getAttribute("data-reason-" + reasons[j]); |
| 135 | if (!clause) return; |
| 136 | reason[reasons[j]] = clause; |
| 137 | } |
| 138 | |
| 139 | function number(attr) { |
| 140 | var raw = zone.getAttribute(attr); |
| 141 | if (!raw) return null; |
| 142 | var n = parseInt(raw, 10); |
| 143 | if (!isFinite(n) || n <= 0) return null; |
| 144 | return n; |
| 145 | } |
| 146 | |
| 147 | // fill is the server's fill(): {name}, {n}, {names}, {pct}. The joining |
| 148 | // punctuation lives out here in the code rather than inside a reviewed |
| 149 | // sentence. |
| 150 | function fill(template, token, value) { |
| 151 | return template.split(token).join(String(value)); |
| 152 | } |
| 153 | |
| 154 | // ---- saying things ------------------------------------------------- |
| 155 | |
| 156 | // say puts one sentence in the live region under the input. It carries |
| 157 | // only the refusals that apply to a WHOLE drop — too many files, too |
| 158 | // much at once, a folder — because those have no row to belong to. |
| 159 | // Everything about a particular file is said in that file's own row. |
| 160 | // |
| 161 | // tone is "good" or "danger"; both resolve to tokens in memoria.css. |
| 162 | // |
| 163 | // It does NOT unhide the note, and that omission is the point: the |
| 164 | // paragraph is unhidden empty at the ready step below, so by the time |
| 165 | // anything is written here the live region has been in the |
| 166 | // accessibility tree for a while. Screen readers commonly announce |
| 167 | // nothing when a region appears and gains its text in the same tick, |
| 168 | // and this region's whole job is to carry refusals — a refusal a blind |
| 169 | // person cannot hear is the silent failure this codebase exists to |
| 170 | // avoid. memoria.css keeps the empty paragraph from taking any space. |
| 171 | function say(text, tone) { |
| 172 | note.textContent = text; |
| 173 | note.setAttribute("rst-dropzone-note", tone); |
| 174 | } |
| 175 | |
| 176 | // ---- the tray ------------------------------------------------------ |
| 177 | |
| 178 | // Rows waiting for a slot, and how many are in the air. Nothing here |
| 179 | // is a percentage: this is a queue, not a measurement. |
| 180 | var queue = []; |
| 181 | var flying = 0; |
| 182 | // A count, not a flag. Two drops can overlap — the second arrives |
| 183 | // while the first is still sniffing its files — and with a boolean the |
| 184 | // first one to finish cleared it for both, so the summary for one drop |
| 185 | // was announced while the other was still being judged and the person |
| 186 | // got two half-sentences about one thing. |
| 187 | var judging = 0; |
| 188 | |
| 189 | // What has settled since the last announcement. Announced as one |
| 190 | // sentence when everything stops, not per file: a five-file drop must |
| 191 | // not be five interruptions, and polite regions queue rather than |
| 192 | // interrupt, so five would arrive as five in a row anyway. |
| 193 | var batch = newBatch(); |
| 194 | |
| 195 | function newBatch() { |
| 196 | return { stored: 0, refused: [], lagging: [], unsure: [] }; |
| 197 | } |
| 198 | |
| 199 | function showTray() { |
| 200 | tray.setAttribute("rst-tray", "open"); |
| 201 | } |
| 202 | |
| 203 | function hideTray() { |
| 204 | tray.setAttribute("rst-tray", ""); |
| 205 | status.textContent = ""; |
| 206 | listed.hidden = true; |
| 207 | } |
| 208 | |
| 209 | // addRow puts one file in the tray and returns the handles for |
| 210 | // changing it later. The row is created the moment the file is judged, |
| 211 | // so rows appear in the order they were dropped. |
| 212 | // |
| 213 | // The bar is aria-hidden: its value changes many times a second and it |
| 214 | // says nothing the phase text beside it does not say in words. |
| 215 | function addRow(name) { |
| 216 | var li = document.createElement("li"); |
| 217 | li.setAttribute("rst-tray-row", ""); |
| 218 | |
| 219 | var label = document.createElement("span"); |
| 220 | label.setAttribute("rst-tray-name", ""); |
| 221 | label.textContent = name; |
| 222 | |
| 223 | var phase = document.createElement("span"); |
| 224 | phase.setAttribute("rst-tray-phase", ""); |
| 225 | phase.textContent = msg.waiting; |
| 226 | |
| 227 | var bar = document.createElement("progress"); |
| 228 | bar.setAttribute("rst-tray-bar", ""); |
| 229 | bar.setAttribute("aria-hidden", "true"); |
| 230 | bar.max = 100; |
| 231 | bar.value = 0; |
| 232 | |
| 233 | li.appendChild(label); |
| 234 | li.appendChild(phase); |
| 235 | li.appendChild(bar); |
| 236 | list.appendChild(li); |
| 237 | showTray(); |
| 238 | return { li: li, label: label, phase: phase, bar: bar }; |
| 239 | } |
| 240 | |
| 241 | // indeterminate is phase two: the request body is sent and the server |
| 242 | // is sealing, chunking and indexing it. Nothing in the browser can see |
| 243 | // that work, so a progress element with no value is the honest shape — |
| 244 | // it says "something is happening" and refuses to say how much. |
| 245 | function indeterminate(row) { |
| 246 | row.bar.removeAttribute("value"); |
| 247 | row.phase.textContent = msg.storing; |
| 248 | } |
| 249 | |
| 250 | // settle writes a row's final result and takes its bar away. |
| 251 | // |
| 252 | // named says whether the result text names the file itself. Every |
| 253 | // clause from handlers.go does — they are written to travel into a |
| 254 | // sentence about a whole drop — so for those the row's separate name |
| 255 | // label goes, and the clause becomes the row. Looking at the built |
| 256 | // page is what settled this: leaving both showed |
| 257 | // |
| 258 | // scan.png |
| 259 | // scan.png is not text |
| 260 | // |
| 261 | // which is the same shape as the search result that repeated its own |
| 262 | // heading, and it was invisible to every test here. The alternative — |
| 263 | // a second, shorter set of reasons with the name taken out — would be |
| 264 | // two spellings of one rule, and that is the mistake this whole file |
| 265 | // is arranged to avoid. |
| 266 | function settle(row, text, tone, named) { |
| 267 | if (row.bar.parentNode) row.bar.parentNode.removeChild(row.bar); |
| 268 | if (named && row.label.parentNode) row.label.parentNode.removeChild(row.label); |
| 269 | row.phase.textContent = text; |
| 270 | row.li.setAttribute("rst-tray-row", tone); |
| 271 | } |
| 272 | |
| 273 | // The landed result is the one that does NOT name its file — it is a |
| 274 | // word, not a sentence — so this row keeps its label: the filename, |
| 275 | // and beside it what became of it. |
| 276 | // |
| 277 | // (That word is not written out in this comment on purpose. |
| 278 | // TestEveryWordTheTrayWritesComesFromGo scans this file whole, |
| 279 | // comments included, for any copy the page publishes, and prose that |
| 280 | // quotes a reviewed string trips it. The direction of error is |
| 281 | // deliberate: a comment that fails loudly beats a scan that skips |
| 282 | // strings it should have caught.) |
| 283 | function stored(row) { |
| 284 | settle(row, msg["row-stored"], "good", false); |
| 285 | batch.stored++; |
| 286 | } |
| 287 | |
| 288 | // named is passed through rather than assumed: every reason clause |
| 289 | // names its file, but a whole-submission sentence does not, and a row |
| 290 | // that dropped its label for one of those would be anonymous. |
| 291 | function refuse(row, clause, named) { |
| 292 | settle(row, clause, "danger", named); |
| 293 | batch.refused.push(clause); |
| 294 | } |
| 295 | |
| 296 | // lagged is the third outcome, and the one it would be easiest to |
| 297 | // round off. The server took the document, committed it, and then |
| 298 | // failed to put it in its search index — so it counts among the ones |
| 299 | // that landed, and the row still has to say what is wrong with it. The |
| 300 | // clause comes from the server, names the file, and replaces the row's |
| 301 | // own label the way a refusal's does. |
| 302 | function lagged(row, clause) { |
| 303 | settle(row, clause, "danger", true); |
| 304 | batch.stored++; |
| 305 | batch.lagging.push(clause); |
| 306 | } |
| 307 | |
| 308 | // unsure is the one that is neither. The upload did not come back with |
| 309 | // an answer, so this file may be stored, may not be, and the only true |
| 310 | // thing to say is that nobody here knows. |
| 311 | function unsure(row, clause) { |
| 312 | settle(row, clause, "danger", true); |
| 313 | batch.unsure.push(clause); |
| 314 | } |
| 315 | |
| 316 | // ---- the summary --------------------------------------------------- |
| 317 | // |
| 318 | // The same sentence the handler composes for a scriptless submission, |
| 319 | // built from the same strings: how many were stored, then every file |
| 320 | // that was not, by name, with the reason for that file. |
| 321 | |
| 322 | function storedPart(n) { |
| 323 | if (n === 1) return msg["stored-one"]; |
| 324 | if (n > 1) return fill(msg["stored-many"], "{n}", n); |
| 325 | return msg["none-stored"]; |
| 326 | } |
| 327 | |
| 328 | // clauseList is a count and then every clause. Never a count on its |
| 329 | // own — "2 refused" without the names leaves a person guessing which |
| 330 | // two. |
| 331 | function clauseList(head, list_) { |
| 332 | return head + ": " + list_.join("; ") + "."; |
| 333 | } |
| 334 | |
| 335 | function refusalSentence(refused) { |
| 336 | var head = refused.length === 1 ? msg["refused-one"] : fill(msg["refused-many"], "{n}", refused.length); |
| 337 | return clauseList(head, refused); |
| 338 | } |
| 339 | |
| 340 | function unsureSentence(unknown) { |
| 341 | var head = unknown.length === 1 ? msg["unsure-one"] : fill(msg["unsure-many"], "{n}", unknown.length); |
| 342 | return clauseList(head, unknown); |
| 343 | } |
| 344 | |
| 345 | // busy is "something is still going to happen". It is the guard on |
| 346 | // announcing and the guard on refreshing the list, and it is one |
| 347 | // function because those two must never disagree: a list fetched while |
| 348 | // a file is still in the air describes a moment that has already |
| 349 | // passed. |
| 350 | function busy() { |
| 351 | return judging > 0 || flying > 0 || queue.length > 0; |
| 352 | } |
| 353 | |
| 354 | // announce says what a whole drop did, once, when nothing is left in |
| 355 | // the air. Called after every settled upload and after judging, and it |
| 356 | // says nothing at all until there is nothing left to wait for. |
| 357 | function announce() { |
| 358 | if (busy()) return; |
| 359 | if (batch.stored === 0 && batch.refused.length === 0 && |
| 360 | batch.lagging.length === 0 && batch.unsure.length === 0) return; |
| 361 | |
| 362 | var parts = [storedPart(batch.stored)]; |
| 363 | if (batch.refused.length > 0) parts.push(refusalSentence(batch.refused)); |
| 364 | // No head and no count in front of these: each clause is a whole |
| 365 | // sentence naming its own file, and they are already counted in the |
| 366 | // number at the front — they were stored. Same shape, same order, as |
| 367 | // the sentence handlers.go composes for the scriptless page. |
| 368 | if (batch.lagging.length > 0) parts.push(batch.lagging.join("; ") + "."); |
| 369 | if (batch.unsure.length > 0) parts.push(unsureSentence(batch.unsure)); |
| 370 | status.textContent = parts.join(" "); |
| 371 | |
| 372 | // The document list is on another page. Once a file has landed, or |
| 373 | // its fate is unknown, point there: for an unconfirmed file, looking |
| 374 | // at the list is the only way to find out. The pointer stays until |
| 375 | // the tray is dismissed, because it is true whether or not the |
| 376 | // refresh below arrives. The refresh brings the footer's passage |
| 377 | // count up to date. |
| 378 | if (batch.stored > 0 || batch.unsure.length > 0) { |
| 379 | listed.hidden = false; |
| 380 | refresh(); |
| 381 | } |
| 382 | |
| 383 | batch = newBatch(); |
| 384 | } |
| 385 | |
| 386 | // ---- the list below -------------------------------------------------- |
| 387 | // |
| 388 | // A background upload never navigates, so everything the server |
| 389 | // rendered before the drop is still on the screen — on this page, the |
| 390 | // passage count in the footer. This asks the server for the page the |
| 391 | // reader is ON again and moves its rst-refresh regions across. |
| 392 | // |
| 393 | // Nothing here composes markup. The regions come out of the server's |
| 394 | // own answer as nodes and go in as nodes. |
| 395 | // |
| 396 | // Every way it can fail — no answer, a status that is not 200, a body |
| 397 | // that is not this page, a region that has gone, a reader with the |
| 398 | // cursor inside one — leaves the page as announce() left it. |
| 399 | |
| 400 | // Which fetch is the current one. A count, not a flag, for the reason |
| 401 | // judging is a count: two drops can overlap, and the answer to the |
| 402 | // first describes a page the second has already changed. An older |
| 403 | // answer arriving late is dropped rather than painted. |
| 404 | var refreshes = 0; |
| 405 | |
| 406 | function refresh() { |
| 407 | // No parser, no swap, and the notice stands. That is the fallback, |
| 408 | // not a lesser version of it. |
| 409 | if (typeof DOMParser === "undefined") return; |
| 410 | var mine = ++refreshes; |
| 411 | var req = new XMLHttpRequest(); |
| 412 | req.open("GET", location.pathname + location.search, true); |
| 413 | // The list as it is NOW. Revalidating costs one conditional request; |
| 414 | // a cached copy would be the same old list with nothing left on the |
| 415 | // page admitting it, which is the one outcome worse than not having |
| 416 | // asked at all. |
| 417 | req.setRequestHeader("Cache-Control", "no-cache"); |
| 418 | // Tells the server this is not a visit, so it leaves the page's |
| 419 | // one-shot notice for the visit that will show it (notice.go). |
| 420 | req.setRequestHeader(refreshHeader, "1"); |
| 421 | req.onload = function () { |
| 422 | // A newer fetch is already out, or a drop started while this one |
| 423 | // was in the air: either way this answer is older than the page it |
| 424 | // would be describing. Whatever is running will ask again when it |
| 425 | // settles. |
| 426 | if (mine !== refreshes || busy()) return; |
| 427 | if (req.status !== 200) return; |
| 428 | put(req.responseText); |
| 429 | }; |
| 430 | // No handler for the ways this ends without an answer, and that is |
| 431 | // the design rather than an omission: each of them leaves the page |
| 432 | // exactly as announce() left it. |
| 433 | req.send(); |
| 434 | } |
| 435 | |
| 436 | // put swaps in the answer's refreshable regions. All of them or none: |
| 437 | // half an answer is a page whose own parts disagree about what is |
| 438 | // stored, and there is already a sentence on screen for "not |
| 439 | // refreshed". |
| 440 | function put(html) { |
| 441 | var answer = new DOMParser().parseFromString(html, "text/html"); |
| 442 | if (!answer) return; |
| 443 | var here = document.querySelectorAll("[rst-refresh]"); |
| 444 | if (!here.length) return; |
| 445 | var pairs = []; |
| 446 | for (var i = 0; i < here.length; i++) { |
| 447 | var name = here[i].getAttribute("rst-refresh"); |
| 448 | if (!name) return; |
| 449 | var fresh = answer.querySelector('[rst-refresh="' + name + '"]'); |
| 450 | // Not this page. A sign-in page after a session ran out, a proxy's |
| 451 | // apology, a truncated body: none of them is the list, and none of |
| 452 | // them goes onto the screen. |
| 453 | if (!fresh) return; |
| 454 | // Somebody has the cursor in there — a delete button reached by |
| 455 | // keyboard is the likely one. Replacing an element takes the focus |
| 456 | // inside it back to the top of the document, which loses a |
| 457 | // reader's place without saying so, and the reader is mid-task. So |
| 458 | // the region stays, the notice stands, and the link is there when |
| 459 | // they are ready for it. |
| 460 | if (holdsFocus(here[i])) return; |
| 461 | pairs.push([here[i], fresh]); |
| 462 | } |
| 463 | for (var j = 0; j < pairs.length; j++) { |
| 464 | var old = pairs[j][0]; |
| 465 | old.parentNode.replaceChild(document.importNode(pairs[j][1], true), old); |
| 466 | } |
| 467 | // The swap is otherwise silent. It goes in the status because the |
| 468 | // status is the one live region this tray has — a second would |
| 469 | // compete with it — and it claims only what just happened. It says |
| 470 | // nothing about any particular file. |
| 471 | var said = status.textContent; |
| 472 | status.textContent = said ? said + " " + msg.fresh : msg.fresh; |
| 473 | } |
| 474 | |
| 475 | function holdsFocus(el) { |
| 476 | var active = document.activeElement; |
| 477 | return !!active && active !== document.body && el.contains(active); |
| 478 | } |
| 479 | |
| 480 | // ---- sending ------------------------------------------------------- |
| 481 | |
| 482 | function pump() { |
| 483 | while (flying < maxInFlight && queue.length > 0) { |
| 484 | send(queue.shift()); |
| 485 | flying++; |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | function landed() { |
| 490 | flying--; |
| 491 | pump(); |
| 492 | announce(); |
| 493 | } |
| 494 | |
| 495 | function send(job) { |
| 496 | var row = job.row; |
| 497 | var body = new FormData(); |
| 498 | body.append(field, job.file, job.file.name); |
| 499 | |
| 500 | var xhr = new XMLHttpRequest(); |
| 501 | xhr.open("POST", endpoint, true); |
| 502 | // The one thing that makes this request different from the form's |
| 503 | // own: the handler answers a judgement instead of a redirect and a |
| 504 | // flash cookie. Same handler, same checks, same words. |
| 505 | xhr.setRequestHeader("Accept", "application/json"); |
| 506 | |
| 507 | xhr.upload.onprogress = function (e) { |
| 508 | if (!e.lengthComputable || !e.total) { |
| 509 | // The browser will not say how much has gone. Then neither will |
| 510 | // this: the bar loses its value rather than gaining a number |
| 511 | // nobody measured. |
| 512 | row.bar.removeAttribute("value"); |
| 513 | return; |
| 514 | } |
| 515 | var pct = Math.floor((e.loaded / e.total) * 100); |
| 516 | if (pct > 100) pct = 100; |
| 517 | row.bar.value = pct; |
| 518 | row.phase.textContent = fill(msg.uploading, "{pct}", pct + "%"); |
| 519 | }; |
| 520 | // The body is sent. Everything from here is the server's work, which |
| 521 | // is not measurable from here — see indeterminate(). |
| 522 | xhr.upload.onload = function () { |
| 523 | indeterminate(row); |
| 524 | }; |
| 525 | |
| 526 | xhr.onload = function () { |
| 527 | // finally, because landed() is what frees this file's slot. An |
| 528 | // exception thrown anywhere inside answered() would otherwise |
| 529 | // leave the in-flight count one too high forever, and every file |
| 530 | // queued behind it would sit at its first phase with nothing |
| 531 | // sending it — a tray full of rows that never move, which is the |
| 532 | // silent failure with a progress bar on it. |
| 533 | try { |
| 534 | answered(job, xhr); |
| 535 | } finally { |
| 536 | landed(); |
| 537 | } |
| 538 | }; |
| 539 | // Every way an upload can end without an answer lands here. None of |
| 540 | // them is a refusal. |
| 541 | xhr.onerror = noAnswer; |
| 542 | xhr.onabort = noAnswer; |
| 543 | xhr.ontimeout = noAnswer; |
| 544 | |
| 545 | function noAnswer() { |
| 546 | unsure(row, fill(reason.noanswer, "{name}", job.file.name)); |
| 547 | landed(); |
| 548 | } |
| 549 | |
| 550 | xhr.send(body); |
| 551 | } |
| 552 | |
| 553 | // answered reads one judgement. A body that is not the judgement — a |
| 554 | // proxy's error page, a truncated response, anything from a status |
| 555 | // that is not 200 — is not a refusal and is not a success: it is the |
| 556 | // absence of an answer, and it says so. |
| 557 | function answered(job, xhr) { |
| 558 | var row = job.row; |
| 559 | var name = job.file.name; |
| 560 | var reply = null; |
| 561 | if (xhr.status === 200) { |
| 562 | try { |
| 563 | reply = JSON.parse(xhr.responseText); |
| 564 | } catch (err) { |
| 565 | reply = null; |
| 566 | } |
| 567 | } |
| 568 | if (!reply || typeof reply.stored !== "number") { |
| 569 | unsure(row, fill(reason.noanswer, "{name}", name)); |
| 570 | return; |
| 571 | } |
| 572 | if (reply.refused && reply.refused.length > 0) { |
| 573 | // One file went up, so one clause comes back. If a future handler |
| 574 | // ever answered with more, every one of them is shown: a list cut |
| 575 | // short here is the silent truncation this file is written |
| 576 | // against. Each clause names the file, so the clause is the row. |
| 577 | refuse(row, reply.refused.join("; "), true); |
| 578 | return; |
| 579 | } |
| 580 | if (reply.lagging && reply.lagging.length > 0) { |
| 581 | // It landed, and the server could not index it. This is checked |
| 582 | // BEFORE the count below, because the count includes it: reading |
| 583 | // the count first would put the landed word in this row and throw |
| 584 | // the only sentence that says what actually happened away. |
| 585 | lagged(row, reply.lagging.join("; ")); |
| 586 | return; |
| 587 | } |
| 588 | if (reply.stored > 0) { |
| 589 | stored(row); |
| 590 | return; |
| 591 | } |
| 592 | if (reply.message) { |
| 593 | // Judged, nothing stored, nothing named — a whole-submission |
| 594 | // refusal. That sentence is about the drop and not about this |
| 595 | // file, so the row keeps its own name above it. |
| 596 | refuse(row, reply.message, false); |
| 597 | return; |
| 598 | } |
| 599 | // Judged into silence. Nothing here knows what became of it. |
| 600 | unsure(row, fill(reason.noanswer, "{name}", name)); |
| 601 | } |
| 602 | |
| 603 | // ---- the drag ------------------------------------------------------ |
| 604 | |
| 605 | // A drag of files, as opposed to a drag of text. Text dragged into the |
| 606 | // textarea or the search box is the browser's business and is left |
| 607 | // alone; only file drags are intercepted. |
| 608 | function hasFiles(e) { |
| 609 | var dt = e.dataTransfer; |
| 610 | if (!dt || !dt.types) return false; |
| 611 | for (var i = 0; i < dt.types.length; i++) { |
| 612 | if (dt.types[i] === "Files") return true; |
| 613 | } |
| 614 | return false; |
| 615 | } |
| 616 | |
| 617 | // dragenter and dragleave fire once per element crossed, so a plain |
| 618 | // boolean flickers off every time the pointer passes a border. Count |
| 619 | // instead. |
| 620 | var depth = 0; |
| 621 | |
| 622 | function over(on) { |
| 623 | if (!on) depth = 0; |
| 624 | mark(on ? "ready over" : "ready"); |
| 625 | } |
| 626 | |
| 627 | function mark(tokens) { |
| 628 | zone.setAttribute("rst-dropzone", tokens); |
| 629 | } |
| 630 | |
| 631 | document.addEventListener("dragenter", function (e) { |
| 632 | if (!hasFiles(e)) return; |
| 633 | depth++; |
| 634 | over(true); |
| 635 | }); |
| 636 | |
| 637 | document.addEventListener("dragover", function (e) { |
| 638 | if (!hasFiles(e)) return; |
| 639 | // This preventDefault is the whole reason a drop anywhere on the |
| 640 | // page is safe: without it the browser navigates to the dropped |
| 641 | // file and the half-filled form is gone. |
| 642 | e.preventDefault(); |
| 643 | if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; |
| 644 | }); |
| 645 | |
| 646 | document.addEventListener("dragleave", function (e) { |
| 647 | if (!hasFiles(e)) return; |
| 648 | depth--; |
| 649 | if (depth <= 0) over(false); |
| 650 | }); |
| 651 | |
| 652 | document.addEventListener("drop", function (e) { |
| 653 | if (!hasFiles(e)) return; |
| 654 | e.preventDefault(); |
| 655 | over(false); |
| 656 | take(e.dataTransfer); |
| 657 | }); |
| 658 | |
| 659 | // ---- the drop ------------------------------------------------------ |
| 660 | |
| 661 | function take(dt) { |
| 662 | // webkitGetAsEntry has to be called now, synchronously: the item |
| 663 | // list is emptied the moment this handler returns. |
| 664 | var folder = looksLikeFolder(dt); |
| 665 | |
| 666 | var files = dt.files; |
| 667 | if (!files || files.length === 0) { |
| 668 | if (folder) { |
| 669 | say(msg.folder, "danger"); |
| 670 | return; |
| 671 | } |
| 672 | // A file drag that arrived with no file. Nobody should have to |
| 673 | // wonder whether it worked. |
| 674 | say(msg.blocked, "danger"); |
| 675 | return; |
| 676 | } |
| 677 | if (folder) { |
| 678 | // Still refused whole, and still out of scope: reading a dropped |
| 679 | // directory needs a different API. A folder alongside files would |
| 680 | // otherwise be dropped on the floor unmentioned. |
| 681 | say(msg.folder, "danger"); |
| 682 | return; |
| 683 | } |
| 684 | // The two submission caps, refused whole and up front, exactly as |
| 685 | // the handler refuses them — a drop half-taken by a limit is the |
| 686 | // shape this codebase treats as its worst failure. |
| 687 | if (files.length > maxFiles) { |
| 688 | say(fill(msg.toomany, "{n}", files.length), "danger"); |
| 689 | return; |
| 690 | } |
| 691 | var total = 0; |
| 692 | for (var i = 0; i < files.length; i++) total += files[i].size; |
| 693 | if (total > maxTotal) { |
| 694 | say(msg.toototal, "danger"); |
| 695 | return; |
| 696 | } |
| 697 | // This drop is going ahead, so the previous drop's whole-drop |
| 698 | // refusal is no longer about anything. Cleared, not overwritten: |
| 699 | // there is nothing to say here now that every file has a row. |
| 700 | say("", ""); |
| 701 | judge(files); |
| 702 | } |
| 703 | |
| 704 | // judge walks the files one at a time — sniffing is asynchronous, so |
| 705 | // this is a loop written as a chain — giving each one a row in the |
| 706 | // order it was dropped, and then either settling that row with the |
| 707 | // reason the file was refused or putting the file in the queue. |
| 708 | function judge(files) { |
| 709 | judging++; |
| 710 | var i = 0; |
| 711 | function next() { |
| 712 | if (i >= files.length) { |
| 713 | judging--; |
| 714 | pump(); |
| 715 | announce(); // a drop of nothing but refusals still has a summary |
| 716 | return; |
| 717 | } |
| 718 | var file = files[i++]; |
| 719 | var row = addRow(file.name); |
| 720 | if (file.size > maxBytes) { |
| 721 | refuse(row, fill(reason.toobig, "{name}", file.name), true); |
| 722 | next(); |
| 723 | return; |
| 724 | } |
| 725 | if (file.size === 0) { |
| 726 | refuse(row, fill(reason.empty, "{name}", file.name), true); |
| 727 | next(); |
| 728 | return; |
| 729 | } |
| 730 | sniff(file, function (binary) { |
| 731 | if (binary === null) { |
| 732 | // Could not read it. Not "it is fine" — the server may still |
| 733 | // take it, but this page will not claim to know either way, |
| 734 | // and it will not send what it could not look at. |
| 735 | refuse(row, fill(reason.unreadable, "{name}", file.name), true); |
| 736 | } else if (binary) { |
| 737 | refuse(row, fill(reason.binary, "{name}", file.name), true); |
| 738 | } else { |
| 739 | queue.push({ file: file, row: row }); |
| 740 | pump(); |
| 741 | } |
| 742 | next(); |
| 743 | }); |
| 744 | } |
| 745 | next(); |
| 746 | } |
| 747 | |
| 748 | // A dropped directory arrives as a single File with no useful |
| 749 | // contents. The entry API is the only reliable way to tell. |
| 750 | function looksLikeFolder(dt) { |
| 751 | var items = dt.items; |
| 752 | if (!items) return false; |
| 753 | for (var i = 0; i < items.length; i++) { |
| 754 | var item = items[i]; |
| 755 | if (!item || item.kind !== "file" || !item.webkitGetAsEntry) continue; |
| 756 | var entry = item.webkitGetAsEntry(); |
| 757 | if (entry && entry.isDirectory) return true; |
| 758 | } |
| 759 | return false; |
| 760 | } |
| 761 | |
| 762 | // sniff reads the same prefix looksLikeText reads and looks for the |
| 763 | // same NUL. done(true) binary, done(false) text, done(null) unknown. |
| 764 | function sniff(file, done) { |
| 765 | var reader = new FileReader(); |
| 766 | reader.onerror = function () { |
| 767 | done(null); |
| 768 | }; |
| 769 | reader.onload = function () { |
| 770 | var bytes; |
| 771 | try { |
| 772 | bytes = new Uint8Array(reader.result); |
| 773 | } catch (err) { |
| 774 | done(null); |
| 775 | return; |
| 776 | } |
| 777 | for (var i = 0; i < bytes.length; i++) { |
| 778 | if (bytes[i] === 0) { |
| 779 | done(true); |
| 780 | return; |
| 781 | } |
| 782 | } |
| 783 | done(false); |
| 784 | }; |
| 785 | try { |
| 786 | reader.readAsArrayBuffer(file.slice(0, sniffBytes)); |
| 787 | } catch (err) { |
| 788 | done(null); |
| 789 | } |
| 790 | } |
| 791 | |
| 792 | // ---- dismissing ---------------------------------------------------- |
| 793 | |
| 794 | // Dismiss clears the rows that have finished and leaves the ones that |
| 795 | // have not. A row still in the air is the one thing this tray must not |
| 796 | // hide: its outcome has not been said yet, and a tray that took it |
| 797 | // away would be the silent failure with a close button on it. The tray |
| 798 | // goes when the last row does. |
| 799 | dismiss.addEventListener("click", function () { |
| 800 | var rows = list.querySelectorAll("[rst-tray-row]"); |
| 801 | for (var i = 0; i < rows.length; i++) { |
| 802 | if (rows[i].getAttribute("rst-tray-row")) list.removeChild(rows[i]); |
| 803 | } |
| 804 | if (!list.firstChild) hideTray(); |
| 805 | }); |
| 806 | |
| 807 | // ---- and only now does the zone exist ------------------------------ |
| 808 | |
| 809 | // The empty live regions go into the accessibility tree first and stay |
| 810 | // there. See say(): this is the only place the note is unhidden, and |
| 811 | // TestTheLiveRegionExistsBeforeItHasAnythingToSay holds it here. The |
| 812 | // tray is unhidden here for the same reason and is held by |
| 813 | // TestTheTrayIsALiveRegionThatExistsBeforeItSpeaks; it paints nothing |
| 814 | // until a row is added and it is marked open. |
| 815 | note.hidden = false; |
| 816 | tray.hidden = false; |
| 817 | hideTray(); |
| 818 | mark("ready"); |
| 819 | hint.hidden = false; |
| 820 | })(); |
| 821 | |