oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

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