Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/viewer.js

82.6 KiB, 1 run

created by r2519314175:1473, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// viewer.js — showing a file as what it IS, and never as characters it is not.
2//
3// Clicking a PDF used to fill the document panel with replacement characters,
4// because `read_file` ends in `from_utf8_lossy` and every byte that is not
5// valid UTF-8 became U+FFFD on the way into a `<pre>`. The app looked broken
6// rather than the format looking unsupported, and those are very different bug
7// reports.
8//
9// `file_probe` in the wasm now says what a file is from its first 512 bytes,
10// and `read_bytes` hands over a range of it byte-exactly. This file is what
11// spends them. It has four tiers, and they are in order of how much is known:
12//
13// 1. THE BROWSER DECODES IT. Pictures, sound, moving pictures, PDF and HTML
14// go to the decoder that exists for them, on a `Blob` carrying the probe's
15// own media type -- a `Blob` with the wrong type is how a correct picture
16// fails to appear, and for a PDF the type is what makes it safe as well.
17// 2. TEXT-SHAPED STRUCTURE. JSON as a tree, CSV and TSV as a table, Markdown
18// through the renderer the chat already uses.
19// 3. THE HONEST FLOOR. Everything else gets a hex and ASCII dump, paged, with
20// the format named. This is the tier that matters: it turns every format
21// nobody wrote a viewer for into something a person can inspect, instead
22// of into an apology.
23// 4. AND IT SAYS WHEN THE NAME AND THE BYTES DISAGREE. A file called `.png`
24// holding PDF bytes is how somebody finds a broken export. Every other
25// viewer hides it.
26//
27// TWO RULES ARE LOAD-BEARING AND NEITHER IS A PREFERENCE.
28//
29// A FRAME gets `sandbox="allow-scripts"` and nothing else, ever. A `blob:` URL
30// INHERITS OUR ORIGIN, so a frame with `allow-same-origin` runs as the app: it
31// reads `localStorage`, where the user's API key lives, and it reaches OPFS.
32// The file being framed may be one an agent wrote a moment ago, after reading a
33// web page that told it what to write. `www/js/web.js` sets this out at length
34// where the Web panel does the same thing. SVG is therefore shown through
35// `<img>` and never a frame: script inside an SVG executes in a frame and does
36// not in an `<img>`, which is the whole reason it sits in the image list.
37//
38// A PDF IS NOT FRAMED, AND THAT IS THE SAME RULE RATHER THAN AN EXCEPTION TO IT.
39// A `sandbox` attribute of any value stops Chrome instantiating its PDF viewer,
40// so a framed PDF drew a broken-page glyph and nothing else -- for months, under
41// a header naming the format and its size, which is how it went unnoticed. PDFs
42// go to an `<embed>` typed from the probe, and what keeps THAT from running as
43// the app is the blob's type: `application/pdf` is handed to the PDF viewer and
44// its bytes are never parsed as a document. Both halves are measured, not
45// assumed; the note on `doc` below records what was measured and how.
46//
47// And nothing large is ever materialised. `createObjectURL` on a real `File`
48// handle is cheap because the browser streams it off disk; on a `Blob` built in
49// memory it is not, and all this has is `read_bytes`, so it is always building.
50// Reads are therefore CAPPED and the cap is always SAID -- a silent truncation
51// reads as a corrupt file. Bytes reach the blob store four megabytes at a time
52// and are dropped as they go, so the JS heap holds one chunk however big the
53// file is. This machine has been driven out of memory three times; a viewer
54// that reads a 900 MB video into wasm memory would be the fourth.
55//
56// AND THERE ARE TWO DOORS OUT OF IT, added because the app could already write
57// and edit an Office document and no part of the browser could ask it to.
58// `office_write_docx` and `office_write_left` had been exported from the wasm
59// with no caller in `www/js/` at all, so a capability shipped and nobody could
60// reach it -- a failure this project has had three times.
61//
62// * `fv-save` hands the bytes over as a file, on the app's own <a download>
63// route. It is the bytes CURRENTLY HELD, so it means the same thing before
64// and after an edit, and nothing it does touches the file on disk.
65// * `fv-edit` opens `fv-editrow` and applies a SURGICAL edit -- find and
66// replace in a text document, one cell in a spreadsheet -- through
67// `office_edit_doc` / `office_edit_sheet`, which rewrite the runs they were
68// asked to and copy every other part of the archive across byte for byte.
69// Round-tripping a stranger's document through Markdown to change one word
70// is data loss with a friendly face.
71//
72// A DECK GETS NEITHER, and the Markdown tier gets the write. Both of those are
73// in `EDIT_DOOR` and `WRITE_AS`, with the reasons beside them.
74//
75// window.DaimondViewer = { probe, verdict, show, close, at, opener,
76// editable, KIND_HANDLERS }
77// window.DaimondDoc = { show } // what the DAIMON calls
78//
79// `opts` carries `{ store, t, onError, wasm }`. Every user-visible string in
80// this file goes through `opts.t`, so the file holds no English of its own that
81// a translation pass cannot reach.
82//
83// AND ONE THING HERE IS NOT FOR THE USER AT ALL. `DaimondDoc` at the bottom is
84// the door the model reaches this panel through, and it exists because the
85// absence of it was reported as a limitation of the app: asked to display a PDF
86// that was sitting in the workspace, a daimon answered that it could not show
87// one inline, "the file tools return raw bytes for it rather than a rendered
88// view". Every word of that is true about its TOOLBOX and false about Daimond,
89// which had been drawing PDFs since the `doc` tier below was written. A model
90// reasons from the tools it holds, so a working surface it cannot reach is a
91// surface it will tell the user does not exist.
92(function () {
93 'use strict';
94
95 // ── Where the wasm is ────────────────────────────────────────────
96 //
97 // `daimond.js` is the app's one module and imports the package directly.
98 // This is a classic script, so it asks for the same URL by dynamic import
99 // -- the module map is keyed by URL, so this is the SAME instance, already
100 // initialised, and not a second copy of the wasm. A caller that already
101 // holds the namespace may hand it over as `opts.wasm` and skip all of it.
102 var SELF = (document.currentScript && document.currentScript.src) || '';
103 var PKG = /\/js\/[^/]*$/.test(SELF)
104 ? SELF.replace(/\/js\/[^/]*$/, '/pkg/oxedyne_daimond.js')
105 : new URL('pkg/oxedyne_daimond.js', document.baseURI).href;
106 var pkgP = null;
107
108 function mod(opts) {
109 if (opts && opts.wasm) return Promise.resolve(opts.wasm);
110 if (!pkgP) pkgP = import(PKG);
111 return pkgP;
112 }
113
114 // ── Caps ─────────────────────────────────────────────────────────
115
116 /// The most that is ever assembled into a `Blob` for the browser to decode.
117 /// Past this the file is named and its bytes are dumped instead, because a
118 /// prefix of an MP4 is not a shorter video -- it is a broken one.
119 var CAP_WHOLE = 64 * 1024 * 1024;
120 /// The most text that is ever decoded and put on screen. A prefix of text IS
121 /// readable text, so this one truncates and says so.
122 var CAP_TEXT = 2 * 1024 * 1024;
123 /// How much reaches the JS heap at once on the way to the blob store.
124 var CHUNK = 4 * 1024 * 1024;
125 /// The most of an Office document that is ever unpacked. It is a ZIP, so its
126 /// parts inflate several times over and the ceiling that matters is on what
127 /// comes out; this is the ceiling on what goes in, and it matches
128 /// `OFFICE_READ_MAX` in `src/tools.rs` so the panel and the model agree about
129 /// which documents can be read.
130 var CAP_OFFICE = 20 * 1024 * 1024;
131 /// One page of the hex dump. Nothing more than this is ever held.
132 var PAGE = 4096;
133 /// Rows of a table, and nodes of a JSON tree, before it is cut and said.
134 var MAX_ROWS = 1000;
135 var MAX_COLS = 200;
136 var MAX_NODES = 4000;
137
138 // ── What handles what ────────────────────────────────────────────
139
140 /// Format (the `media` variant name from `oxedyne_fe2o3_stds::media`) to the
141 /// handler that draws it. Public so a test can see what is covered without
142 /// rendering anything.
143 ///
144 /// `'*'` is the floor: any format with no entry here, and any format whose
145 /// entry is text-shaped when the bytes turn out not to BE text, lands on the
146 /// hex dump. `'text'` means the Doc panel's own rendering, which has line
147 /// numbers and an editor and is not this file's business.
148 var KIND_HANDLERS = {
149 // 1 — the browser decodes it.
150 Png: 'image', Jpeg: 'image', Gif: 'image', Webp: 'image',
151 Avif: 'image', Heic: 'image', Bmp: 'image', Ico: 'image',
152 Tiff: 'image', Svg: 'image',
153 Mp3: 'audio', Wav: 'audio', Flac: 'audio', Ogg: 'audio',
154 M4a: 'audio',
155 Mp4: 'video', Webm: 'video', Matroska: 'video', Avi: 'video',
156 QuickTime: 'video',
157 Pdf: 'doc', Html: 'frame',
158 // 1b — the browser cannot decode it and we can. A Word document is a ZIP
159 // of XML, and `Media` has named it `Docx` correctly all along while this
160 // table had no entry for it -- so somebody's CV opened as a PAGED HEX
161 // DUMP under a header saying "Word document". That was a defect and not a
162 // missing feature, which is why it is fixed ahead of the rest.
163 //
164 // `Xlsx` and `Pptx` are deliberately NOT here yet. A handler that opened
165 // and then apologised would be worse than the dump, which at least lets a
166 // person see the bytes; they arrive when they can be read.
167 Docx: 'office',
168 // A spreadsheet, once it could genuinely be read. It was deliberately
169 // absent while it could not: a handler that opens and then apologises is
170 // worse than the dump, which at least lets a person see the bytes and
171 // know that nothing was interpreted for them.
172 Xlsx: 'sheet',
173 // The OpenDocument pair, which reach the SAME two tiers: what a reader
174 // wants out of a text document is the same thing whichever vocabulary it
175 // was written in, and that is the whole point of the neutral models
176 // underneath. `Media` tells them apart from their own opening bytes, so a
177 // file somebody renamed still lands on the right one.
178 //
179 // `Odp` and `Pptx` are deliberately absent. Both can be READ, but a deck
180 // wants a tier that draws slides rather than paragraphs, and that tier
181 // needs wording no translation file has yet. A handler that opened and
182 // then apologised would be worse than the dump.
183 Odt: 'office', Ods: 'sheet',
184 // 2 — text-shaped structure.
185 Json: 'json', Csv: 'table', Tsv: 'table', Markdown: 'markdown',
186 Text: 'text',
187 // 3 — the honest floor.
188 Unknown: 'hex',
189 '*': 'hex',
190 };
191
192 /// The handlers that decode bytes as characters, and so may only run when the
193 /// probe says the bytes ARE characters.
194 var TEXTY = { text: 1, json: 1, table: 1, markdown: 1 };
195
196 /// Whether a tier needs the WHOLE file in memory before it can draw anything,
197 /// which is what `CAP_WHOLE` is a ceiling on.
198 ///
199 /// Asked in two places -- by `draw`, which acts on it, and by `verdict`, which
200 /// tells the model what will happen -- so it is written once. The two saying
201 /// different things would have the model promise a video over a hex dump.
202 function wholeFile(h) {
203 return h === 'image' || h === 'audio' || h === 'video' || h === 'frame'
204 || h === 'doc' || h === 'office' || h === 'sheet';
205 }
206
207 /// Which handler `info` resolves to.
208 ///
209 /// The order matters and it is the whole guard against the original bug: a
210 /// `.log` full of NULs is `Media::Text` by name and is not text, and routing
211 /// it to a text handler on the strength of its name is exactly how a screen
212 /// of U+FFFD happened in the first place. `info.text` is the probe's answer
213 /// to "and are the bytes actually characters", and it overrules the table.
214 function handlerFor(info) {
215 var h = KIND_HANDLERS[info && info.media];
216 if (!h) h = info && info.text ? 'text' : KIND_HANDLERS['*'];
217 if (TEXTY[h] && !(info && info.text)) h = KIND_HANDLERS['*'];
218 return h;
219 }
220
221 /// Whether a panel should hand these bytes to an EDITOR rather than draw them.
222 ///
223 /// `text`, NOT `chars`, AND THE DIFFERENCE IS A SHIPPED BUG. `chars` is a fact
224 /// about 512 bytes -- "these decode as characters" -- while `text` is that AND
225 /// "the format is a text one". The Doc panel asked `chars`, so a PDF that
226 /// carries no binary comment after `%PDF-` and no compressed stream in its
227 /// first half-kilobyte answered yes: 19 of the 1044 PDFs on the author's own
228 /// disk do, and clicking one filled the panel with `%PDF-1.4`, then
229 /// `1 0 obj<</Type/Catalog…`, numbered, in a <pre>. That is precisely the
230 /// salad this file exists to prevent, arriving through the door beside the one
231 /// that was closed.
232 ///
233 /// The reason given for the wider question was that `Makefile` has no
234 /// extension, so its format would be Unknown and `text` false. It is not:
235 /// `Media::sniff` falls back to `Media::Text` for any run of characters it
236 /// recognises nothing else in, so `Makefile`, `LICENSE` and `README` all come
237 /// back `Media::Text` with `text` true (measured, not assumed). `chars` was
238 /// guarding a case that does not exist, and the guard is what let the PDF
239 /// through. If that fallback ever changes, the no-extension check in
240 /// `dev/verify_fileview.mjs` goes red, which is where the guard belongs.
241 ///
242 /// # Arguments
243 /// * `info` - What `probe` returned, or null when the probe could not answer.
244 function editable(info) {
245 if (!info || !info.text) return false;
246 // A DRAWING IS NOT SOURCE, even though it is written in characters.
247 //
248 // `Media::Svg.is_text()` is true -- it is XML -- so an SVG satisfies
249 // `text` and would go to the editor, where it appears as a screenful of
250 // angle brackets. Its `kind()` is `Image` and `KIND_HANDLERS` has said
251 // `Svg: 'image'` all along, so the viewer has always known how to draw
252 // one; the panel simply could not reach it.
253 //
254 // This is the same over-reach as the bug above, pointed the other way.
255 // Routing on `chars` sent PDFs to the editor because their first bytes
256 // looked like characters; routing on `text` alone sends drawings there
257 // because their whole FORMAT is characters. The question worth asking is
258 // what the thing IS, and a picture is a picture.
259 //
260 // Deliberately narrow: only `Image`. HTML is text whose kind is a
261 // document and a person may genuinely want either the source or the
262 // page, so it stays in the editor until there is a control to choose --
263 // guessing wrong there takes away the only way to fix a broken page.
264 if (info.kind === 'Image') return false;
265 return true;
266 }
267
268 // ── Small helpers ────────────────────────────────────────────────
269
270 /// The app's string for `key`, or `english` where there is no table yet.
271 ///
272 /// The bound function is called `tOr` everywhere below, and the name is not a
273 /// preference: `dev/i18nfallback.mjs` finds fallbacks by looking for `tOr(`,
274 /// `tf(` and `tr(`, so a helper called anything else keeps its English out of
275 /// that check and free to drift from the catalogue. Every `fileview.*` string
276 /// is in `i18n/en.js`; the second argument is what shows while the tables are
277 /// still loading, and it must stay byte for byte the catalogue's own.
278 function tOrOf(opts) {
279 var fn = opts && opts.t;
280 return function (key, english, vars) {
281 if (typeof fn !== 'function') return fill(english, vars);
282 var s = fn(key, vars);
283 // `DaimondI18n.t` answers with the key itself when nothing has the
284 // string, which is a debugging aid and not something to show a user.
285 return (s == null || s === key) ? fill(english, vars) : s;
286 };
287 }
288
289 /// Fill `{name}` placeholders, the way `DaimondI18n` does.
290 function fill(s, vars) {
291 return String(s == null ? '' : s).replace(/\{(\w+)\}/g, function (whole, k) {
292 return (vars && vars[k] != null) ? String(vars[k]) : whole;
293 });
294 }
295
296 /// A byte count as the file browser writes it.
297 function fmtBytes(n) {
298 if (!n) return '0 B';
299 var u = ['B', 'KB', 'MB', 'GB'], i = 0;
300 while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
301 return (i === 0 ? n : n.toFixed(1)) + ' ' + u[i];
302 }
303
304 /// An exact count, grouped, for the places where the exact number is the
305 /// point -- a hex offset is not "12 KB".
306 function fmtExact(n) {
307 try { return Number(n).toLocaleString(); } catch (e) { return String(n); }
308 }
309
310 function el(tag, cls, text) {
311 var n = document.createElement(tag);
312 if (cls) n.className = cls;
313 if (text != null) n.textContent = text;
314 return n;
315 }
316
317 // ── Object URLs, every one of which is revoked ───────────────────
318 //
319 // A long session opening thirty files leaks thirty of these otherwise, and
320 // each one pins its whole blob. `close()` lets go of all of them, and `show`
321 // calls `close` before it draws, so replacing the open file releases the one
322 // that was there.
323
324 var urls = [];
325
326 function mint(blob) {
327 var u = URL.createObjectURL(blob);
328 urls.push(u);
329 return u;
330 }
331
332 // ── Reading ──────────────────────────────────────────────────────
333
334 /// `len` bytes of `path` from `offset`, through whichever root `opts.store`
335 /// names. The wasm copies into a fresh JS array rather than handing back a
336 /// view onto its own linear memory, so what comes back is safe to hold
337 /// across an await -- which a view is not, and that cost five sessions once.
338 async function readBytes(path, offset, len, opts) {
339 var m = await mod(opts);
340 var fn = (opts && opts.store) ? m.store_read_bytes : m.read_bytes;
341 return await fn(path, offset, len);
342 }
343
344 /// The whole of a file as a `Blob` of `mime`, assembled a chunk at a time.
345 ///
346 /// Each chunk becomes its own small `Blob` immediately and the array holding
347 /// it is dropped, so the browser's blob store -- which may spill to disk --
348 /// carries the file and the JS heap never holds more than one chunk. Building
349 /// an array of `Uint8Array` and handing that to `new Blob` would hold the
350 /// whole file in the heap, which is the thing being avoided.
351 async function wholeBlob(path, size, mime, opts) {
352 var parts = [], off = 0;
353 while (off < size) {
354 var n = Math.min(CHUNK, size - off);
355 var u8 = await readBytes(path, off, n, opts);
356 if (!u8.length) break; // the file shrank under us; stop rather than spin
357 parts.push(new Blob([u8]));
358 off += u8.length;
359 }
360 return new Blob(parts, { type: mime });
361 }
362
363 /// The first `CAP_TEXT` bytes of a file, decoded. Returns `{ text, capped }`.
364 async function headText(path, size, opts) {
365 var want = Math.min(size, CAP_TEXT);
366 var u8 = await readBytes(path, 0, want, opts);
367 // Not `fatal`: this is only reached when the probe already said the bytes
368 // are characters, and a cut multi-byte character at the cap must not turn
369 // a two-megabyte read into an error.
370 var text = new TextDecoder('utf-8').decode(u8);
371 return { text: text, capped: size > want };
372 }
373
374 // ── The public entry points ──────────────────────────────────────
375
376 /// What `path` is, without reading much of it.
377 ///
378 /// The answer is the probe's own JSON -- `{size, media, kind, mime, label,
379 /// text, byMagic, byName, disagree}` -- with one field added: `handler`, the
380 /// name of the tier that will draw it. A caller routes on `handler`, and in
381 /// particular keeps its own rendering when it is `'text'`.
382 ///
383 /// # Arguments
384 /// * `path` - The path, relative to whichever root `opts.store` names.
385 /// * `opts` - `{ store, wasm }`.
386 async function probe(path, opts) {
387 var m = await mod(opts);
388 var fn = (opts && opts.store) ? m.store_file_probe : m.file_probe;
389 var info = JSON.parse(await fn(path));
390 info.handler = handlerFor(info);
391 return info;
392 }
393
394 /// What showing `path` would put on screen, said without drawing any of it.
395 ///
396 /// ONE ANSWER to "what will the user see", read by the panel's own routing and
397 /// by the daimon's `file_show` alike. A second table in Rust naming which
398 /// formats have a viewer would be a second answer, free to drift from this
399 /// file the first time a format changes tier -- and what drifts is a promise
400 /// made to a user by a model that cannot check it.
401 ///
402 /// `tier` is one of the handler names above, with three answers they do not
403 /// carry:
404 ///
405 /// * `editor` where the Doc panel keeps its own text view -- source, a
406 /// `Makefile`, `DAIMOND.md` -- because that is the panel's routing and not
407 /// this file's, and the model is being told what the PANEL will do;
408 /// * `hex` for a file too big for the tier it belongs to, since a prefix of
409 /// an MP4 is not a shorter video;
410 /// * `empty` for a file of no bytes, which draws a sentence and nothing else.
411 ///
412 /// # Arguments
413 /// * `path` - The path, relative to whichever root `opts.store` names.
414 /// * `opts` - `{ store, wasm }`.
415 async function verdict(path, opts) {
416 var info = await probe(path, opts);
417 var tier = editable(info) ? 'editor' : info.handler;
418 if (wholeFile(tier) && info.size > CAP_WHOLE) tier = 'hex';
419 if (!info.size) tier = 'empty';
420 return {
421 path: path,
422 tier: tier,
423 media: info.media,
424 // The LABEL, in the library's English. The variant name is an
425 // identifier and a sentence built from it reads "a Pdf".
426 label: info.label,
427 size: info.size,
428 cap: CAP_WHOLE,
429 disagree: !!info.disagree,
430 named: info.byNameLabel || '',
431 found: info.byMagicLabel || '',
432 };
433 }
434
435 // ── Where a document opens ───────────────────────────────────────
436 //
437 // MEASURED, headless Chromium 1229, on a three-page PDF whose pages carry
438 // different amounts of ink, read back off the screen rather than off the DOM:
439 //
440 // * `#page=N` on a `blob:` URL DOES reach the browser's PDF viewer through
441 // an `<embed>` -- pages 1, 2 and 3 came up 4.8%, 29.4% and 55.9% dark.
442 // `#zoom=scale,left,top` (in-page scroll) and `#view=FitH` move it too.
443 // * Changing ONLY the fragment on a live `<embed>` moves nothing. A fresh
444 // object URL is what makes the viewer read the fragment again -- which a
445 // redraw mints anyway.
446 // * NOTHING READS THE POSITION BACK. `contentWindow` and `contentDocument`
447 // are both `undefined` (the viewer is out of process), scrolling produces
448 // no message, and none of `getViewport`, `viewport`, `documentDimensions`
449 // or `getSelectedText` posted to the element is answered. The only thing
450 // it ever says is `{type:'documentLoaded'}`, from the PDF extension's
451 // origin, once.
452 //
453 // So a document can be REOPENED where it was last AIMED, and cannot be
454 // reopened where the reader had scrolled to. That asymmetry is worth knowing
455 // before anything is built on top of this: a rebuilt PDF put back on screen
456 // lands wherever we last said, and page 1 is where we say by default.
457 //
458 // Kept out of `last` deliberately: `show` calls `close` before it draws, and
459 // a caller aims at a file and THEN opens it, so an aim cleared by `close`
460 // would be cleared between being set and being used.
461 var aim = null; // { path, page }
462
463 /// Open `path` at `page` the next time it is drawn.
464 ///
465 /// A page of 0 or nothing does NOT mean the top: it means "wherever this file
466 /// was last aimed", which for a file nobody has aimed is the top. That is the
467 /// difference between showing a rebuilt document and losing the reader's place
468 /// in it -- redraw it with no page and it comes back where it was put, rather
469 /// than at page 1. It is the most that is available, since nothing can read
470 /// where the reader had actually scrolled to (see above).
471 ///
472 /// # Arguments
473 /// * `path` - The file the aim belongs to; an aim for one file never moves another.
474 /// * `page` - 1-based page number, or nothing to keep this file's own aim.
475 function at(path, page) {
476 var n = Math.floor(Number(page) || 0);
477 if (n > 0) { aim = { path: path, page: n }; return; }
478 if (!aim || aim.path !== path) aim = null;
479 }
480
481 /// Which page `path` will open at, or 0 for the top.
482 function aimPage(path) {
483 return (aim && aim.path === path && aim.page > 0) ? aim.page : 0;
484 }
485
486 /// The URL fragment that carries the aim for `path`, or the empty string.
487 function aimFrag(path) {
488 var n = aimPage(path);
489 return n ? '#page=' + n : '';
490 }
491
492 // The view currently on screen, so a change of language can redraw it. An
493 // app that is translated everywhere except the panel you are looking at is
494 // a bug class this project has had before.
495 var last = null;
496 var epoch = 0; // bumped by every show and close, so a slow read cannot land late
497
498 /// Draw `path` into `el`, replacing whatever was there.
499 ///
500 /// # Arguments
501 /// * `host` - The element to fill. It is emptied first.
502 /// * `path` - The path that was probed.
503 /// * `info` - What `probe` returned.
504 /// * `opts` - `{ store, t, onError, wasm }`.
505 async function show(host, path, info, opts) {
506 // Where the hex dump had got to, if this is the same file being redrawn
507 // in another language. Read before `close`, which forgets it.
508 var resume = (last && last.host === host && last.path === path) ? last.hexAt : 0;
509 close();
510 if (!host) return;
511 var mine = ++epoch;
512 var tOr = tOrOf(opts);
513 var handler = (info && info.handler) || handlerFor(info);
514 last = { host: host, path: path, info: info, opts: opts, hexAt: resume };
515
516 var root = el('div', 'fileview');
517 root.setAttribute('data-viewer', handler);
518 root.appendChild(meta(info, tOr));
519 if (info && info.disagree) root.appendChild(disagreeLine(info, tOr));
520 var body = el('div', 'fv-body');
521 root.appendChild(body);
522 host.textContent = '';
523 host.appendChild(root);
524
525 try {
526 await draw(handler, body, path, info, opts, tOr, mine, resume);
527 } catch (e) {
528 if (mine !== epoch) return;
529 body.textContent = '';
530 body.appendChild(el('p', 'fv-warn',
531 tOr('fileview.read_failed', 'This file could not be read: {reason}',
532 { reason: (e && e.message) ? e.message : String(e) })));
533 if (opts && typeof opts.onError === 'function') opts.onError(e);
534 }
535 }
536
537 /// Let go of everything the viewer is holding: every object URL it minted,
538 /// and the view it would otherwise redraw on a change of language.
539 function close() {
540 epoch++;
541 for (var i = 0; i < urls.length; i++) {
542 try { URL.revokeObjectURL(urls[i]); } catch (e) { /* already gone */ }
543 }
544 urls = [];
545 last = null;
546 }
547
548 // ── The header every tier carries ────────────────────────────────
549
550 /// The format and the size, in one quiet line.
551 ///
552 /// The format name is looked up per variant with the library's own English
553 /// as the fallback, so a translation can name a PDF in the reader's language
554 /// without this file holding a table of format names in any language.
555 function meta(info, tOr) {
556 var row = el('div', 'fv-meta');
557 row.appendChild(el('span', 'fv-fmt', fmtName(info && info.media, info && info.label, tOr)));
558 row.appendChild(el('span', 'fv-size', fmtBytes((info && info.size) || 0)));
559 return row;
560 }
561
562 function fmtName(media, label, tOr) {
563 var v = media || 'Unknown';
564 return tOr('fileview.fmt.' + v, label || v);
565 }
566
567 /// One line, when the name and the bytes do not agree.
568 ///
569 /// `identify` acts on the bytes, so the format shown is always what the bytes
570 /// said; the line says both and says which won, because a person looking at a
571 /// broken export needs to know the claim as well as the evidence.
572 function disagreeLine(info, tOr) {
573 return el('p', 'fv-warn fv-disagree',
574 tOr('fileview.disagree',
575 'The name says {named}. The bytes say {found}, and the bytes are what is shown.',
576 {
577 // The LABEL, not the variant name. `byName`/`byMagic` are
578 // identifiers -- `Pdf`, `Text` -- and a sentence built from them
579 // read "The bytes say Pdf", which is the code's word for the
580 // format arriving on screen in front of a person who is already
581 // looking at something that went wrong.
582 named: fmtName(info.byName, info.byNameLabel, tOr),
583 found: fmtName(info.byMagic, info.byMagicLabel, tOr),
584 }));
585 }
586
587 // ── The tiers ────────────────────────────────────────────────────
588
589 /// Draw one tier into `body`. `mine` is the epoch this draw belongs to; every
590 /// await is followed by a check of it, so a file opened while another was
591 /// still reading cannot paint over the newer one.
592 async function draw(handler, body, path, info, opts, tOr, mine, resume) {
593 var size = (info && info.size) || 0;
594 if (!size) {
595 body.appendChild(el('p', 'fv-note', tOr('fileview.empty', 'This file is empty.')));
596 return;
597 }
598
599 // Tier 1 wants the whole file, so it is the one with a hard ceiling. Over
600 // it the file is NAMED and its bytes are dumped: a prefix of a container
601 // format is not a smaller file, it is a corrupt one, and handing it to a
602 // decoder produces exactly the "this app is broken" impression this whole
603 // file exists to remove.
604 if (wholeFile(handler) && size > CAP_WHOLE) {
605 body.appendChild(el('p', 'fv-note', tOr('fileview.too_large',
606 'A {fmt} of {size} is too large to hold in memory here. Its bytes follow; '
607 + 'download it to open it elsewhere.',
608 { fmt: fmtName(info.media, info.label, tOr), size: fmtBytes(size) })));
609 await hex(body, path, info, opts, tOr, mine, resume);
610 return;
611 }
612
613 switch (handler) {
614 case 'image': return await media(body, 'img', path, info, opts, tOr, mine);
615 case 'audio': return await media(body, 'audio', path, info, opts, tOr, mine);
616 case 'video': return await media(body, 'video', path, info, opts, tOr, mine);
617 case 'frame': return await frame(body, path, info, opts, tOr, mine);
618 case 'doc': return await doc(body, path, info, opts, tOr, mine);
619 case 'json': return await json(body, path, info, opts, tOr, mine);
620 case 'table': return await table(body, path, info, opts, tOr, mine);
621 case 'markdown': return await markdown(body, path, info, opts, tOr, mine);
622 case 'office': return await office(body, path, info, opts, tOr, mine, resume);
623 case 'sheet': return await sheet(body, path, info, opts, tOr, mine, resume);
624 case 'text': return await plain(body, path, info, opts, tOr, mine);
625 default: return await hex(body, path, info, opts, tOr, mine, resume);
626 }
627 }
628
629 /// A picture, a sound or a moving picture, on a `Blob` carrying the probe's
630 /// media type. The type is not decoration: a `Blob` typed
631 /// `application/octet-stream` is a picture that does not appear.
632 async function media(body, tag, path, info, opts, tOr, mine) {
633 var blob = await wholeBlob(path, info.size, info.mime, opts);
634 if (mine !== epoch) return;
635 var n = el(tag, 'fv-' + tag);
636 if (tag === 'img') {
637 n.alt = path;
638 } else {
639 n.controls = true;
640 n.preload = 'metadata';
641 }
642 // A decoder that cannot read the bytes says so, rather than leaving a
643 // broken-image glyph and no explanation. AVIF, HEIC and Matroska are all
644 // formats a given browser may simply not carry.
645 n.addEventListener('error', function () {
646 if (n.parentNode) n.parentNode.replaceChild(el('p', 'fv-warn',
647 tOr('fileview.decode_failed', 'This browser could not decode this {fmt}.',
648 { fmt: fmtName(info.media, info.label, tOr) })), n);
649 });
650 n.src = mint(blob);
651 body.appendChild(n);
652 }
653
654 /// A PDF, handed to the browser's own document viewer.
655 ///
656 /// WHY THIS IS NOT THE SANDBOXED FRAME BELOW, WHICH IS WHERE IT USED TO GO.
657 /// A `sandbox` attribute of ANY value stops Chrome instantiating its PDF
658 /// viewer -- the viewer is an internal resource and a sandboxed frame may not
659 /// reach it -- so every PDF ever opened here drew a grey box with a
660 /// broken-page glyph in it. `allow-scripts allow-same-origin` does not help
661 /// either; it is the attribute's presence, not its value. Measured in
662 /// Chromium 150: sandboxed frame, broken glyph; `<embed>`, `<object>` and a
663 /// plain frame, the document. The panel said "PDF document, 966.3 KB" over
664 /// the top of it, which is how the state passed for working.
665 ///
666 /// AND THE SECURITY ARGUMENT THE SANDBOX WAS MAKING STILL HOLDS -- it is just
667 /// not this element that has to make it. A `blob:` URL inherits our origin,
668 /// so an unsandboxed frame over a file an agent wrote runs as the app and
669 /// reaches `localStorage`, where the API key is. What closes that here is the
670 /// BLOB'S TYPE: `application/pdf` sends Chrome to the PDF viewer and it never
671 /// parses the bytes as a document. Measured, with a file of HTML carrying a
672 /// script that writes to `parent`: typed `application/pdf` it did not run, in
673 /// `<embed>`, in `<object>` and in a bare frame alike; typed `text/html` it
674 /// ran in every one of them. The type is not ours to be wrong about, either
675 /// -- it comes from `identify`, which reads the leading bytes, so a `.pdf`
676 /// full of HTML is `Media::Html` and goes to the sandboxed frame below.
677 ///
678 /// `<embed>` rather than a bare frame because it takes the type EXPLICITLY,
679 /// which is what keeps the browser off its own sniffing, and because it has
680 /// no navigable document for anything to reach through.
681 ///
682 /// The fragment is the one thing this element takes instruction from -- see
683 /// the note on `aim` above for what was measured about it, and for the half
684 /// that does not work.
685 async function doc(body, path, info, opts, tOr, mine) {
686 var blob = await wholeBlob(path, info.size, info.mime, opts);
687 if (mine !== epoch) return;
688 var e = el('embed', 'fv-doc');
689 e.setAttribute('type', info.mime || 'application/pdf');
690 e.setAttribute('title', tOr('fileview.frame_title', 'The contents of {name}',
691 { name: path.split('/').pop() || path }));
692 e.src = mint(blob) + aimFrag(path);
693 body.appendChild(e);
694 }
695
696 /// An HTML page, in a frame that runs in an opaque origin.
697 ///
698 /// `allow-scripts` AND NOTHING ELSE. Not `allow-same-origin`, which would
699 /// hand the framed file our origin and with it `localStorage` and OPFS; not
700 /// `allow-forms`, `allow-popups`, `allow-modals` or `allow-top-navigation`.
701 /// The one flag is there so a page's own scripting works while it stays in an
702 /// origin of its own.
703 async function frame(body, path, info, opts, tOr, mine) {
704 var blob = await wholeBlob(path, info.size, info.mime, opts);
705 if (mine !== epoch) return;
706 var f = el('iframe', 'fv-frame');
707 f.setAttribute('sandbox', 'allow-scripts');
708 f.setAttribute('referrerpolicy', 'no-referrer');
709 f.setAttribute('title', tOr('fileview.frame_title', 'The contents of {name}',
710 { name: path.split('/').pop() || path }));
711 f.src = mint(blob);
712 body.appendChild(f);
713 }
714
715 /// Text with no structure this file claims to understand.
716 ///
717 /// The Doc panel keeps its own text rendering -- the line-number gutter, the
718 /// editor, the conflict check -- and a caller routes on `info.handler ===
719 /// 'text'` before it ever calls `show`. This is the fallback for a caller
720 /// that did not, and it is deliberately plain: something honest on screen
721 /// beats a blank panel, but nothing here should tempt anybody to move the
722 /// editor into it.
723 async function plain(body, path, info, opts, tOr, mine) {
724 var got = await headText(path, info.size, opts);
725 if (mine !== epoch) return;
726 if (got.capped) body.appendChild(cappedLine(info.size, CAP_TEXT, tOr));
727 body.appendChild(el('pre', 'fv-plain', got.text));
728 }
729
730 /// Markdown through the renderer the chat already uses.
731 ///
732 /// `DaimondRender.md` sanitises, and it drops `script style iframe form input
733 /// button svg` whole. That is correct and is not loosened here: the file being
734 /// rendered may have been written by an agent, and this panel is inside our
735 /// origin.
736 /// AND IT IS WHERE MARKDOWN BECOMES A REAL DOCUMENT. `office_write` turns this
737 /// text into the bytes of a `.docx` or a `.odt`, which is the one thing a
738 /// person cannot do for themselves and the app could already do for them: the
739 /// writer had no caller in `www/js/` at all, so the capability existed and
740 /// nobody could ask for it.
741 ///
742 /// The model does not emit document XML and there is no tool that lets it. It
743 /// writes Markdown, which it does well, and the conversion from there is
744 /// deterministic code -- which removes the failure class rather than mitigating
745 /// it. The same is true of a person: this is the door for both.
746 async function markdown(body, path, info, opts, tOr, mine) {
747 var got = await headText(path, info.size, opts);
748 if (mine !== epoch) return;
749 var m = await mod(opts);
750 if (mine !== epoch) return;
751 if (got.capped) body.appendChild(cappedLine(info.size, CAP_TEXT, tOr));
752 // A CAPPED READ IS NOT WRITTEN OUT. The first two megabytes of a file are
753 // readable text and are NOT a shorter document: what would land in
754 // somebody's downloads is a document missing its end, with nothing about it
755 // to say so. So the controls are absent and the reason is said.
756 if (got.capped) {
757 body.appendChild(el('p', 'fv-note', tOr('fileview.save_capped',
758 'Only the start of this file is on screen, so it is not written out as a '
759 + 'document: what came back would be a document missing its end.')));
760 } else {
761 saveAsRow(body, m, got.text, path, tOr);
762 }
763 // `md-body` is render.css's own hook for a block of rendered markdown; it
764 // carries the link colours, so a document's links look like the app's.
765 var box = el('div', 'fv-md md-body');
766 if (window.DaimondRender && DaimondRender.md) box.innerHTML = DaimondRender.md(got.text);
767 else box.appendChild(el('pre', 'fv-plain', got.text));
768 body.appendChild(box);
769 }
770
771 /// One button per format Markdown may be written out as.
772 ///
773 /// It SAYS WHAT THE DOCUMENT DOES NOT CARRY, beside the file it just handed
774 /// over. `office_write_left` is asked before the write and the answer is shown
775 /// after it, which is a deliberate order and not the one this comment first
776 /// claimed: it said "before it writes one", and the code has always written the
777 /// file and then said. A picture referenced by a Markdown file does not travel
778 /// into the document, and the person asked for a document, so they get one and
779 /// are told what is missing from it -- rather than being stopped by a question
780 /// about a picture they may not care about. If that trade is ever reconsidered,
781 /// the note moves above the `handOver` and this paragraph changes with it.
782 function saveAsRow(body, m, md, path, tOr) {
783 var row = el('div', 'fv-bar');
784 var say = el('p', 'fv-warn');
785 say.hidden = true;
786
787 // ONE CONTROL AND A LIST, not one button per format. Six buttons is about
788 // 1400px of chrome standing above a document that is 380px wide on a phone,
789 // and it would have wanted six labels in eight languages. A picker wants
790 // none: every option is named through `fileview.fmt.<variant>`, the open
791 // family the panel's header already reads, so a locale that has translated
792 // "Word document" once has translated it here too.
793 var pick = el('select');
794 pick.setAttribute('data-media-pick', '1');
795 var drew = 0;
796 for (var i = 0; i < WRITE_AS.length; i++) {
797 var as = WRITE_AS[i];
798 if (!canWrite(m, as.media)) continue;
799 drew++;
800 var o = el('option', null, tOr('fileview.fmt.' + as.media, as.en));
801 o.value = as.media;
802 pick.appendChild(o);
803 }
804 // Nothing to offer: no picker, no button, no apology. A build whose writer
805 // is absent says nothing rather than drawing a control that cannot work.
806 if (!drew) return;
807
808 var save = el('button', 'fv-btn fv-save', tOr('fileview.save_as', 'Save as a document'));
809 save.type = 'button';
810 save.title = tOr('fileview.save_as_help',
811 'Write this text out as a real document and save it to your own device. '
812 + 'The file here is not changed.');
813 save.addEventListener('click', function () {
814 var media = pick.value;
815 var as = null;
816 for (var j = 0; j < WRITE_AS.length; j++) {
817 if (WRITE_AS[j].media === media) { as = WRITE_AS[j]; break; }
818 }
819 if (!as) return;
820 // Asked BEFORE the write, answered AFTER it. See the note on `markdown`
821 // above for why that order is deliberate.
822 var left = leftLine(m, md, as.media, tOr);
823 try {
824 var out = writeAs(m, md, as.media);
825 if (!out || !out.length) {
826 throw new Error(tOr('fileview.edit_nothing',
827 'the editor returned no document'));
828 }
829 // `application/octet-stream`, and the SUFFIX is what names it. A type
830 // per format would be a second table of media types to keep in step
831 // with `Media`, and the download's name is what the operating system
832 // opens a document by anyway.
833 handOver(out, 'application/octet-stream', stemOf(baseName(path)) + as.ext);
834 say.className = 'fv-note';
835 say.textContent = left;
836 say.hidden = !left;
837 } catch (e) {
838 // A FORMAT THAT REFUSED IS NAMED. `office_write` takes six and a
839 // person may pick one whose writer objects to this particular prose --
840 // a spreadsheet out of text holding no table, say. "This could not be
841 // saved" alone would leave them pressing the same button again.
842 say.className = 'fv-warn';
843 say.textContent = tOr('fileview.save_as_failed',
844 'This could not be saved as {fmt}: {why}',
845 { fmt: tOr('fileview.fmt.' + as.media, as.en),
846 why: (e && e.message) ? e.message : String(e) });
847 say.hidden = false;
848 }
849 });
850
851 // The button carries the words and the picker carries the same words as its
852 // accessible name, rather than a visible label repeating the button beside
853 // it. One phrase, one key, and nothing on screen said twice.
854 pick.setAttribute('aria-label', tOr('fileview.save_as', 'Save as a document'));
855 row.appendChild(pick);
856 row.appendChild(save);
857 body.appendChild(row);
858 body.appendChild(say);
859 }
860
861 /// The whole of a file as one `Uint8Array`, assembled a chunk at a time.
862 ///
863 /// Unlike `wholeBlob`, this has to end up contiguous, because what it feeds
864 /// is a wasm function that takes a slice. It is therefore only ever used
865 /// where a hard ceiling is already in force -- see `CAP_OFFICE`.
866 async function wholeBytes(path, size, opts) {
867 var out = new Uint8Array(size), off = 0;
868 while (off < size) {
869 var u8 = await readBytes(path, off, Math.min(CHUNK, size - off), opts);
870 if (!u8.length) break; // the file shrank under us; stop rather than spin
871 out.set(u8, off);
872 off += u8.length;
873 }
874 return off === size ? out : out.subarray(0, off);
875 }
876
877 /// What a reading view did not draw, as a sentence in the reader's language.
878 ///
879 /// The wasm hands over a kind and a count for each -- `[{kind:'chart',n:1}]` --
880 /// and never a finished phrase, because a phrase built in Rust is English a
881 /// translation pass cannot reach, and this file holds none of that.
882 ///
883 /// The number and the kind are BOTH the information. "4 things are not drawn:
884 /// 3 text boxes, 1 chart" tells a reader whether to go and open the file
885 /// properly; "some content is not shown" tells them only that this viewer
886 /// cannot be trusted.
887 function undrawnLine(undrawn, tOr) {
888 if (!undrawn || !undrawn.length) return '';
889 var total = 0, parts = [];
890 for (var i = 0; i < undrawn.length; i++) {
891 var it = undrawn[i];
892 total += it.n;
893 // One key per kind, and the count is the key's own argument, so a
894 // language that pluralises differently does it in the translation file
895 // rather than here.
896 parts.push(tOr('fileview.undrawn_' + it.kind, DEFAULT_UNDRAWN[it.kind]
897 || '{n} of something', { n: it.n }));
898 }
899 return tOr('fileview.office_undrawn',
900 '{total} things are not drawn: {parts}.',
901 { total: total, parts: parts.join(', ') });
902 }
903
904 /// What a document written from this Markdown will NOT carry, as a sentence in
905 /// the reader's language, or '' when it carries everything.
906 ///
907 /// THE SAME ARRANGEMENT AS `undrawnLine`, AND THAT IS THE POINT. It used to be
908 /// a finished English sentence built in Rust and printed verbatim -- the one
909 /// piece of English in this panel a translation pass could not reach, in a file
910 /// whose own header says it holds none. `office_write_left` now hands back
911 /// `[{kind, n, names}]` and the wording is composed here, per locale, exactly
912 /// as the reading view's does.
913 ///
914 /// `names` is the one thing `undrawn` has no equivalent for: a document being
915 /// READ has no source names for what it could not draw, and Markdown does --
916 /// they are paths the author wrote. So an image can be named rather than
917 /// counted, which is the difference between "1 image is not carried" and
918 /// knowing it was the one picture that mattered.
919 ///
920 /// `media` matters because the answer differs by format: a spreadsheet written
921 /// from prose has nothing to say, and a deck can leave speaker's notes behind,
922 /// which the old English never mentioned at all.
923 function leftLine(m, md, media, tOr) {
924 if (typeof m.office_write_left !== 'function') return '';
925 var left = null;
926 try {
927 left = m.office_write_left(md, media);
928 } catch (e) {
929 // A writer that cannot say what it would leave out is not a reason to
930 // refuse the write; it is a reason to say nothing about the loss.
931 return '';
932 }
933 if (!left || !left.length) return '';
934 var parts = [];
935 for (var i = 0; i < left.length; i++) {
936 var it = left[i] || {};
937 var names = (it.names && it.names.length) ? it.names.join(', ') : '';
938 parts.push(names
939 ? tOr('fileview.left_' + it.kind + '_named',
940 DEFAULT_LEFT_NAMED[it.kind] || '{n} of something: {names}',
941 { n: it.n, names: names })
942 : tOr('fileview.left_' + it.kind, DEFAULT_LEFT[it.kind]
943 || '{n} of something', { n: it.n }));
944 }
945 return tOr('fileview.write_left',
946 'Not everything in this text reaches the document: {parts}.',
947 { parts: parts.join(', ') });
948 }
949
950 /// The English each kind of loss falls back to. Two forms per kind, because a
951 /// count with the sources named is a different sentence from a bare count and
952 /// not the same one with a list bolted on.
953 var DEFAULT_LEFT = {
954 image: '{n} image(s)',
955 notes: '{n} slide(s) of speaker\u2019s notes',
956 };
957 var DEFAULT_LEFT_NAMED = {
958 image: '{n} image(s): {names}',
959 notes: '{n} slide(s) of speaker\u2019s notes: {names}',
960 };
961
962 /// The English each kind falls back to, which is what ships until the
963 /// translation files carry the keys above.
964 var DEFAULT_UNDRAWN = {
965 image: '{n} image(s)',
966 chart: '{n} chart(s)',
967 diagram: '{n} diagram(s)',
968 textbox: '{n} text box(es)',
969 object: '{n} embedded object(s)',
970 equation: '{n} equation(s)',
971 footnote: '{n} footnote(s)',
972 endnote: '{n} endnote(s)',
973 comment: '{n} comment(s)',
974 };
975
976 // ── Handing a document back to the user ─────────────────────────
977 //
978 // THE ROUTE IS THE APP'S OWN AND IS NOT INVENTED HERE. Every other place
979 // Daimond gives somebody a file -- the preview panel's ⤓, the document
980 // panel's, the chat backup -- builds one `Blob`, mints an object URL, clicks a
981 // synthetic `<a download>` and revokes the URL straight after. This is the
982 // same three lines, so there is one handover in the app to change rather than
983 // two to keep in step.
984 //
985 // It deliberately does NOT go through `mint`. Those URLs are revoked by
986 // `close`, and `show` calls `close` before it draws, so a download holding a
987 // minted URL would be cancelled by the next file somebody opened -- silently,
988 // with a file of zero bytes in their downloads folder.
989
990 function baseName(path) { return String(path).split('/').pop() || 'file'; }
991
992 /// A file name with its extension taken off.
993 function stemOf(name) { return String(name).replace(/\.[^./]+$/, '') || String(name); }
994
995 /// Give `bytes` to the user as a file called `name`.
996 function handOver(bytes, mime, name) {
997 var a = document.createElement('a');
998 a.href = URL.createObjectURL(
999 new Blob([bytes], { type: mime || 'application/octet-stream' }));
1000 a.download = name;
1001 a.rel = 'noopener';
1002 a.click();
1003 URL.revokeObjectURL(a.href);
1004 }
1005
1006 /// Which formats may be edited, and which wasm door does it.
1007 ///
1008 /// NEITHER PRESENTATION FORMAT IS HERE, and that is the contract's decision
1009 /// rather than an omission: a slide is a position on a canvas, so an edit that
1010 /// changes the words without knowing the geometry puts text over other text.
1011 /// Nothing below asks about `Pptx` or `Odp` by name -- absence from this table
1012 /// is the whole of how the control fails to appear.
1013 var EDIT_DOOR = {
1014 Docx: 'office_edit_doc', Odt: 'office_edit_doc',
1015 Xlsx: 'office_edit_sheet', Ods: 'office_edit_sheet',
1016 };
1017
1018 /// The formats a Markdown file may be written out AS, and the library's own
1019 /// English for each.
1020 ///
1021 /// This is where the writer in `src/wasm/office.rs` reaches a person. It had
1022 /// no caller in `www/js/` at all: the app could turn Markdown into a real
1023 /// document and nobody could ask it to.
1024 ///
1025 /// ALL SIX, because the writer writes all six and a person who wants an `.odp`
1026 /// should not be told to go and convert one somewhere else. What each format
1027 /// makes of the same prose differs and the difference is not a loss: the text
1028 /// documents take the whole thing, the decks split it at its headings into
1029 /// slides, and the spreadsheets take its TABLES, one sheet each.
1030 ///
1031 /// THE NAMES COME FROM THE `fileview.fmt.` FAMILY, which is the same open
1032 /// extension point the panel's own header reads and which needs no new key in
1033 /// any locale: a translation that wants to name a PowerPoint in the reader's
1034 /// language adds `fileview.fmt.Pptx` and it is picked up here too. The English
1035 /// beside each is `Media::label`'s own, copied from
1036 /// `fe2o3_stds::media` so the picker and the header say one thing.
1037 var WRITE_AS = [
1038 { media: 'Docx', ext: '.docx', en: 'Word document' },
1039 { media: 'Odt', ext: '.odt', en: 'OpenDocument text' },
1040 { media: 'Xlsx', ext: '.xlsx', en: 'Excel spreadsheet' },
1041 { media: 'Ods', ext: '.ods', en: 'OpenDocument spreadsheet' },
1042 { media: 'Pptx', ext: '.pptx', en: 'PowerPoint presentation' },
1043 { media: 'Odp', ext: '.odp', en: 'OpenDocument presentation' },
1044 ];
1045
1046 /// Write `md` out as the bytes of `media`, or nothing where this build cannot.
1047 ///
1048 /// `office_write(md, media)` is the one door. `office_write_docx(md)` is the
1049 /// older single-format one and is still exported, so it stands in where the
1050 /// general one is not in the bundle yet -- a Word document being the case that
1051 /// existed before either name did.
1052 function writeAs(m, md, media) {
1053 if (typeof m.office_write === 'function') return m.office_write(md, media);
1054 if (media === 'Docx' && typeof m.office_write_docx === 'function') {
1055 return m.office_write_docx(md);
1056 }
1057 return null;
1058 }
1059
1060 /// Whether this build can write `media` from Markdown at all.
1061 function canWrite(m, media) {
1062 return typeof m.office_write === 'function'
1063 || (media === 'Docx' && typeof m.office_write_docx === 'function');
1064 }
1065
1066 /// The controls a reader gets over the document in front of them, and the row
1067 /// of fields one of them opens. Answers the LAST node of the block, which is
1068 /// what a caller redrawing the content below it walks from.
1069 ///
1070 /// `fv-save` hands over the bytes CURRENTLY HELD -- the file as it arrived, or
1071 /// the file with an edit spliced into it -- so "save a copy" means the same
1072 /// thing before and after an edit, and the copy is the only thing that ever
1073 /// changes. Nothing here writes to the workspace: the file a person is looking
1074 /// at is left exactly as it was, which is what makes the control safe to press
1075 /// without a question first.
1076 ///
1077 /// `fv-edit` opens `fv-editrow`. Apply hands the whole archive to the wasm and
1078 /// takes a whole new archive back, because the edit is SURGICAL there: every
1079 /// part of the ZIP the editor does not understand is copied across byte for
1080 /// byte. Round-tripping the document through Markdown would be data loss with
1081 /// a friendly face, and this panel is usually looking at a stranger's file.
1082 function actions(body, m, st, path, info, tOr, onEdited) {
1083 var row = el('div', 'fv-bar');
1084 var fields = el('div', 'fv-editrow');
1085 var say = el('p', 'fv-warn');
1086 fields.hidden = true;
1087 say.hidden = true;
1088 body.appendChild(row);
1089 body.appendChild(fields);
1090 body.appendChild(say);
1091
1092 function tell(text, bad) {
1093 say.className = bad ? 'fv-warn' : 'fv-note';
1094 say.textContent = text;
1095 say.hidden = !text;
1096 }
1097
1098 var save = el('button', 'fv-btn fv-save', tOr('fileview.save', 'Save a copy'));
1099 save.type = 'button';
1100 save.title = tOr('fileview.save_help',
1101 'Save a copy of this to your own device. The file here is not changed.');
1102 save.addEventListener('click', function () {
1103 try {
1104 handOver(st.bytes, info.mime, baseName(path));
1105 tell('');
1106 } catch (e) {
1107 tell(tOr('fileview.save_failed', 'This could not be saved: {why}',
1108 { why: (e && e.message) ? e.message : String(e) }), true);
1109 }
1110 });
1111 row.appendChild(save);
1112
1113 var door = EDIT_DOOR[st.media];
1114 // A control that throws when it is pressed is worse than no control, so the
1115 // Edit button exists only where the wasm door behind it does.
1116 if (!door || typeof m[door] !== 'function') return say;
1117
1118 var edit = el('button', 'fv-btn fv-edit', tOr('fileview.edit', 'Make an edit'));
1119 edit.type = 'button';
1120 edit.setAttribute('aria-expanded', 'false');
1121 edit.addEventListener('click', function () {
1122 fields.hidden = !fields.hidden;
1123 edit.setAttribute('aria-expanded', fields.hidden ? 'false' : 'true');
1124 if (!fields.hidden) {
1125 var first = fields.querySelector('input, select');
1126 if (first) first.focus();
1127 }
1128 });
1129 row.appendChild(edit);
1130
1131 /// One labelled field. The label is the accessible name as well, so nothing
1132 /// here needs an `aria-label` saying the same words twice.
1133 function field(key, english, kind, name) {
1134 var lab = el('label');
1135 lab.appendChild(el('span', null, tOr(key, english)));
1136 var input = el('input');
1137 input.type = kind;
1138 input.setAttribute('data-edit', name);
1139 if (kind === 'number') { input.min = '1'; input.step = '1'; }
1140 lab.appendChild(input);
1141 fields.appendChild(lab);
1142 return input;
1143 }
1144
1145 var apply = el('button', 'fv-btn', tOr('fileview.edit_apply', 'Apply'));
1146 apply.type = 'button';
1147 apply.setAttribute('data-edit', 'apply');
1148 apply.disabled = true;
1149
1150 var edits = null; // answers the JSON the wasm takes, or '' when it cannot yet
1151
1152 if (door === 'office_edit_doc') {
1153 var find = field('fileview.edit_find', 'Find', 'text', 'find');
1154 var repl = field('fileview.edit_replace', 'Replace with', 'text', 'replace');
1155 var nth = field('fileview.edit_nth', 'Which one', 'number', 'nth');
1156 edits = function () {
1157 var f = find.value;
1158 if (!f) return '';
1159 var one = { find: f, replace: repl.value };
1160 // Absent means every occurrence, which is the format's own rule; a 0
1161 // or a blank box must therefore send no `nth` at all rather than one.
1162 var n = Math.floor(Number(nth.value) || 0);
1163 if (n > 0) one.nth = n;
1164 return JSON.stringify([one]);
1165 };
1166 find.addEventListener('input', function () { apply.disabled = !find.value; });
1167 fields.appendChild(apply);
1168 fields.appendChild(el('p', 'fv-note', tOr('fileview.edit_note',
1169 'Leave “{which}” blank to change every one. Everything else in the file is '
1170 + 'left byte for byte as it was.',
1171 { which: tOr('fileview.edit_nth', 'Which one') })));
1172 } else {
1173 var pick = el('select');
1174 pick.setAttribute('data-edit', 'sheet');
1175 for (var i = 0; i < (st.sheets || []).length; i++) {
1176 var o = el('option', null, st.sheets[i]);
1177 o.value = st.sheets[i];
1178 pick.appendChild(o);
1179 }
1180 var wrap = el('label');
1181 wrap.appendChild(el('span', null, tOr('fileview.edit_sheet', 'Sheet')));
1182 wrap.appendChild(pick);
1183 fields.appendChild(wrap);
1184 var ref = field('fileview.edit_cell', 'Cell', 'text', 'ref');
1185 var val = field('fileview.edit_value', 'Value', 'text', 'value');
1186 edits = function () {
1187 var r = ref.value.trim();
1188 if (!r) return '';
1189 var one = { sheet: pick.value, ref: r.toUpperCase() };
1190 // The convention every spreadsheet already taught this person: a
1191 // leading `=` is a formula and anything else is a value. It needs no
1192 // control of its own, and a control would be a second way to say the
1193 // same thing.
1194 if (/^=/.test(val.value)) one.formula = val.value;
1195 else one.value = val.value;
1196 return JSON.stringify([one]);
1197 };
1198 ref.addEventListener('input', function () { apply.disabled = !ref.value.trim(); });
1199 fields.appendChild(apply);
1200 fields.appendChild(el('p', 'fv-note', tOr('fileview.edit_cell_note',
1201 'A value beginning with “=” is stored as a formula. Nothing is '
1202 + 'recalculated, here or in the file.')));
1203 }
1204
1205 apply.addEventListener('click', async function () {
1206 var json = edits();
1207 if (!json) return;
1208 var out = null;
1209 try {
1210 // `await` on a value that is not a promise costs nothing, and it is
1211 // what keeps the EDITOR'S OWN REASON on screen either way. The exports
1212 // are synchronous today and throw; were one ever to reject instead, a
1213 // bare call would put a pending promise in `out` and the reader would
1214 // be told "the editor returned no document" in place of the sentence
1215 // naming the string that did not match. A user told the wrong reason
1216 // for a refusal is barely better than one told nothing.
1217 out = await m[door](st.bytes, st.media, json);
1218 } catch (e) {
1219 // An unmatched `find` is an error naming the string, not a silent
1220 // no-op, and the bytes are left alone: a failed edit that had already
1221 // replaced them would leave the reader looking at a document nobody
1222 // asked for.
1223 tell(tOr('fileview.edit_failed', 'That edit was not made: {why}',
1224 { why: (e && e.message) ? e.message : String(e) }), true);
1225 return;
1226 }
1227 if (!out || !out.length) {
1228 tell(tOr('fileview.edit_failed', 'That edit was not made: {why}',
1229 { why: tOr('fileview.edit_nothing', 'the editor returned no document') }), true);
1230 return;
1231 }
1232 st.bytes = out instanceof Uint8Array ? out : new Uint8Array(out);
1233 st.edits++;
1234 tell('');
1235 onEdited();
1236 });
1237 return say;
1238 }
1239
1240 /// The line that says the document on screen is not the document on disk.
1241 ///
1242 /// Never omitted once an edit has been applied. The panel is showing prose
1243 /// nothing else in the app can see, and a reader who closed it thinking the
1244 /// file had changed would have lost the edit without being told.
1245 function editedLine(st, tOr) {
1246 return el('p', 'fv-warn', tOr('fileview.edited',
1247 'Edited here, {n} time(s). The file itself has not changed — save a copy to '
1248 + 'keep this.', { n: st.edits }));
1249 }
1250
1251 /// A Word document, read into the prose it holds.
1252 ///
1253 /// TWO THINGS HERE ARE DELIBERATE AND NEITHER IS A PREFERENCE.
1254 ///
1255 /// It renders MARKDOWN through `DaimondRender.md`, not HTML through a frame.
1256 /// The document is a STRANGER'S -- it arrived by mail, or a share, or a drag
1257 /// -- and `DaimondRender.md` is the sanitiser this app already trusts for
1258 /// prose it did not write, dropping `script style iframe form input button
1259 /// svg` whole. Handing a stranger's markup to a frame would mean getting the
1260 /// sandbox exactly right for a second time, and the first time is what the
1261 /// note at the top of this file is about.
1262 ///
1263 /// And it SAYS WHAT IT DID NOT DRAW, by name and by count. A reading view
1264 /// that quietly dropped a chart would be lying by omission. "4 things are not
1265 /// drawn: 3 text boxes, 1 chart" tells a reader whether to go and open the
1266 /// file properly; "some content is not shown" tells them only that this
1267 /// viewer cannot be trusted.
1268 async function office(body, path, info, opts, tOr, mine, resume) {
1269 if (info.size > CAP_OFFICE) {
1270 body.appendChild(el('p', 'fv-note', tOr('fileview.office_too_large',
1271 'A {fmt} of {size} is too large to unpack here. Its bytes follow.',
1272 { fmt: fmtName(info.media, info.label, tOr), size: fmtBytes(info.size) })));
1273 await hex(body, path, info, opts, tOr, mine, resume);
1274 return;
1275 }
1276 var m = await mod(opts);
1277 if (mine !== epoch) return;
1278 var u8 = await wholeBytes(path, info.size, opts);
1279 if (mine !== epoch) return;
1280 var st = { bytes: u8, media: info.media, edits: 0, sheets: [] };
1281 var got = null, why = '';
1282 try {
1283 got = m.office_read_doc(st.bytes, st.media);
1284 } catch (e) {
1285 why = (e && e.message) || String(e);
1286 }
1287 // A document that cannot be read is NAMED and its bytes are shown, which
1288 // is the same floor every other format falls to. An encrypted document
1289 // arrives here, and the reason it gives says so.
1290 if (!got) {
1291 body.appendChild(el('p', 'fv-warn', tOr('fileview.office_failed',
1292 'This document could not be read: {why}', { why: why })));
1293 await hex(body, path, info, opts, tOr, mine, resume);
1294 return;
1295 }
1296 // THE EDIT IS READ BACK RATHER THAN ASSUMED. Every redraw parses the bytes
1297 // the editor produced, through the same reader that drew the file when it
1298 // arrived, so what the reader now sees is what a reader of the saved copy
1299 // will see. Painting the replacement into the old markdown would show an
1300 // edit that the archive might not carry.
1301 var anchor = null;
1302 function redraw() {
1303 while (anchor.nextSibling) body.removeChild(anchor.nextSibling);
1304 var g = null, w = '';
1305 try { g = m.office_read_doc(st.bytes, st.media); }
1306 catch (e) { w = (e && e.message) || String(e); }
1307 if (!g) {
1308 // THE EDITED BANNER FIRST, EVEN HERE -- especially here. `editedLine`
1309 // claims never to be omitted once an edit has been applied, and this
1310 // path omitted it: an edit that made the document unreadable drew the
1311 // failure alone, so the one reader who most needs to know the file
1312 // itself is untouched was the one reader not told.
1313 if (st.edits) body.appendChild(editedLine(st, tOr));
1314 body.appendChild(el('p', 'fv-warn', tOr('fileview.office_failed',
1315 'This document could not be read: {why}', { why: w })));
1316 return;
1317 }
1318 drawDoc(body, g, st, tOr);
1319 }
1320 anchor = actions(body, m, st, path, info, tOr, redraw);
1321 drawDoc(body, got, st, tOr);
1322 }
1323
1324 /// One reading of a text document, on screen.
1325 function drawDoc(body, got, st, tOr) {
1326 if (st.edits) body.appendChild(editedLine(st, tOr));
1327 // `fv-note` and `fv-warn` and nothing new: the stylesheet is another lane's
1328 // file, and a band that needed a class nobody had written would render as
1329 // unstyled text on top of the document. These two are what every other
1330 // tier here already says its caveats in.
1331 body.appendChild(el('p', 'fv-note', tOr('fileview.office_reading',
1332 'Reading view. This is what the document says, not how it prints.')));
1333 var missing = undrawnLine(got.undrawn, tOr);
1334 if (missing) body.appendChild(el('p', 'fv-note', missing));
1335 if (got.tracked) {
1336 body.appendChild(el('p', 'fv-note', tOr('fileview.office_tracked',
1337 '{n} tracked insertion(s) are shown as accepted; deletions are not shown.',
1338 { n: got.tracked })));
1339 }
1340 if (got.macros) {
1341 body.appendChild(el('p', 'fv-warn', tOr('fileview.office_macros',
1342 'This file contains macros. They are not run and not read.')));
1343 }
1344 var box = el('div', 'fv-md md-body');
1345 if (window.DaimondRender && DaimondRender.md) box.innerHTML = DaimondRender.md(got.markdown);
1346 else box.appendChild(el('pre', 'fv-plain', got.markdown));
1347 body.appendChild(box);
1348 }
1349
1350 /// A spreadsheet, drawn as the grid it is.
1351 ///
1352 /// THE VALUE SHOWN IS THE ONE STORED IN THE FILE. Both formats keep each
1353 /// cell's last computed value beside its formula, and that is the number the
1354 /// person who wrote the file SAW. Recalculating would also make a file differ
1355 /// from itself the moment it held `NOW`, `TODAY` or `RAND`, so a document
1356 /// opened and saved untouched would show as changed -- and the check that
1357 /// exists to catch a damaging edit would fire on a healthy file instead.
1358 ///
1359 /// Every sheet is drawn, each under its own tab name, because a workbook whose
1360 /// second sheet is silently absent is a workbook a person makes a decision on
1361 /// without knowing what they missed. Each is CUT to a rectangle and the cut is
1362 /// SAID -- a silent truncation reads as a corrupt file.
1363 async function sheet(body, path, info, opts, tOr, mine, resume) {
1364 if (info.size > CAP_OFFICE) {
1365 body.appendChild(el('p', 'fv-note', tOr('fileview.office_too_large',
1366 'A {fmt} of {size} is too large to unpack here. Its bytes follow.',
1367 { fmt: fmtName(info.media, info.label, tOr), size: fmtBytes(info.size) })));
1368 await hex(body, path, info, opts, tOr, mine, resume);
1369 return;
1370 }
1371 var m = await mod(opts);
1372 if (mine !== epoch) return;
1373 var u8 = await wholeBytes(path, info.size, opts);
1374 if (mine !== epoch) return;
1375 var st = { bytes: u8, media: info.media, edits: 0, sheets: [] };
1376 var got = null, why = '';
1377 try {
1378 got = m.office_read_sheet(st.bytes, st.media, MAX_ROWS, MAX_COLS);
1379 } catch (e) {
1380 why = (e && e.message) || String(e);
1381 }
1382 if (!got) {
1383 body.appendChild(el('p', 'fv-warn', tOr('fileview.sheet_failed',
1384 'This spreadsheet could not be read: {why}', { why: why })));
1385 await hex(body, path, info, opts, tOr, mine, resume);
1386 return;
1387 }
1388 // The tab names, so a cell can be named the way the workbook names it. Read
1389 // off the file rather than typed by the user: a sheet name is the one part
1390 // of a cell reference nobody can guess.
1391 for (var n = 0; n < got.sheets.length; n++) st.sheets.push(got.sheets[n].name);
1392 var anchor = null;
1393 function redraw() {
1394 while (anchor.nextSibling) body.removeChild(anchor.nextSibling);
1395 var g = null, w = '';
1396 try { g = m.office_read_sheet(st.bytes, st.media, MAX_ROWS, MAX_COLS); }
1397 catch (e) { w = (e && e.message) || String(e); }
1398 if (!g) {
1399 if (st.edits) body.appendChild(editedLine(st, tOr));
1400 body.appendChild(el('p', 'fv-warn', tOr('fileview.sheet_failed',
1401 'This spreadsheet could not be read: {why}', { why: w })));
1402 return;
1403 }
1404 drawSheet(body, g, st, tOr);
1405 }
1406 anchor = actions(body, m, st, path, info, tOr, redraw);
1407 drawSheet(body, got, st, tOr);
1408 }
1409
1410 /// One reading of a workbook, on screen.
1411 function drawSheet(body, got, st, tOr) {
1412 if (st.edits) body.appendChild(editedLine(st, tOr));
1413 body.appendChild(el('p', 'fv-note', tOr('fileview.sheet_stored',
1414 'Values are as stored in the file. Formulas are not recalculated.')));
1415 if (got.macros) {
1416 body.appendChild(el('p', 'fv-warn', tOr('fileview.office_macros',
1417 'This file contains macros. They are not run and not read.')));
1418 }
1419 for (var i = 0; i < got.sheets.length; i++) {
1420 var s = got.sheets[i];
1421 body.appendChild(el('h3', 'fv-sheetname', s.name));
1422 var wrap = el('div', 'fv-tablewrap');
1423 var tbl = el('table', 'fv-table');
1424 // The column letters and the row numbers are drawn, because they are how
1425 // a person names a cell to somebody else and how `sheet_read` takes a
1426 // range. A bare grid leaves them counting columns.
1427 var head = el('tr');
1428 head.appendChild(el('th', 'fv-rownum', ''));
1429 for (var h = 0; h < s.heads.length; h++) {
1430 head.appendChild(el('th', null, s.heads[h]));
1431 }
1432 tbl.appendChild(head);
1433 for (var r = 0; r < s.cells.length; r++) {
1434 var tr = el('tr');
1435 tr.appendChild(el('th', 'fv-rownum', String(r + 1)));
1436 for (var c = 0; c < s.cells[r].length; c++) {
1437 tr.appendChild(el('td', null, s.cells[r][c]));
1438 }
1439 tbl.appendChild(tr);
1440 }
1441 wrap.appendChild(tbl);
1442 body.appendChild(wrap);
1443 if (s.cut) {
1444 body.appendChild(el('p', 'fv-note', tOr('fileview.sheet_capped',
1445 'Showing {shown} of {rows} rows and {cols} columns of this sheet.',
1446 { shown: fmtExact(s.cells.length), rows: fmtExact(s.rows),
1447 cols: fmtExact(s.cols) })));
1448 }
1449 if (s.formulas) {
1450 body.appendChild(el('p', 'fv-note', tOr('fileview.sheet_formulas',
1451 '{n} cell(s) here carry a formula; the value shown is the stored one.',
1452 { n: s.formulas })));
1453 }
1454 }
1455 if (got.missing && got.missing.length) {
1456 body.appendChild(el('p', 'fv-warn', tOr('fileview.sheet_missing',
1457 '{n} sheet(s) are named by this workbook and could not be read: {names}.',
1458 { n: got.missing.length, names: got.missing.join(', ') })));
1459 }
1460 }
1461
1462 /// JSON as a tree that opens and closes.
1463 async function json(body, path, info, opts, tOr, mine) {
1464 var got = await headText(path, info.size, opts);
1465 if (mine !== epoch) return;
1466 if (got.capped) body.appendChild(cappedLine(info.size, CAP_TEXT, tOr));
1467 var data, ok = true;
1468 try { data = JSON.parse(got.text); } catch (e) { ok = false; }
1469 if (!ok) {
1470 // Truncated at the cap, or a `.jsonl` stream, or simply malformed. All
1471 // three are worth saying rather than papering over, and the text is
1472 // still the most useful thing to show.
1473 body.appendChild(el('p', 'fv-note',
1474 tOr('fileview.json_bad', 'This is not one JSON value, so it is shown as text.')));
1475 body.appendChild(el('pre', 'fv-plain', got.text));
1476 return;
1477 }
1478 var budget = { left: MAX_NODES };
1479 body.appendChild(node(data, null, 0, budget));
1480 if (budget.left <= 0) {
1481 body.appendChild(el('p', 'fv-note',
1482 tOr('fileview.tree_capped', 'The tree is cut short here; the file is larger than it shows.')));
1483 }
1484 }
1485
1486 /// One JSON value. Objects and arrays past the top level arrive closed, so a
1487 /// deep document opens as a shape rather than as a wall.
1488 function node(v, key, depth, budget) {
1489 if (budget.left-- <= 0) return el('div', 'fv-jrow', '…');
1490 var isArr = Array.isArray(v);
1491 var isObj = v !== null && typeof v === 'object' && !isArr;
1492 if (!isArr && !isObj) {
1493 var row = el('div', 'fv-jrow');
1494 if (key !== null) row.appendChild(el('span', 'fv-jkey', key));
1495 row.appendChild(el('span', 'fv-jval fv-j-' + (v === null ? 'null' : typeof v),
1496 v === null ? 'null' : (typeof v === 'string' ? v : String(v))));
1497 return row;
1498 }
1499 var keys = isArr ? null : Object.keys(v);
1500 var n = isArr ? v.length : keys.length;
1501 var d = el('details', 'fv-jnode');
1502 if (depth < 1) d.open = true;
1503 var s = el('summary', 'fv-jsum');
1504 if (key !== null) s.appendChild(el('span', 'fv-jkey', key));
1505 // Brackets and a count: a shape and a number say what this is in every
1506 // language, so there is nothing here to translate.
1507 s.appendChild(el('span', 'fv-jshape', (isArr ? '[…]' : '{…}') + ' ' + n));
1508 d.appendChild(s);
1509 var kids = el('div', 'fv-jkids');
1510 if (isArr) {
1511 for (var i = 0; i < n; i++) {
1512 kids.appendChild(node(v[i], String(i), depth + 1, budget));
1513 if (budget.left <= 0) break;
1514 }
1515 } else {
1516 for (var j = 0; j < n; j++) {
1517 kids.appendChild(node(v[keys[j]], keys[j], depth + 1, budget));
1518 if (budget.left <= 0) break;
1519 }
1520 }
1521 d.appendChild(kids);
1522 return d;
1523 }
1524
1525 /// CSV or TSV as a table.
1526 ///
1527 /// The first row is drawn as a header. That is a guess, and it is the guess
1528 /// nearly every one of these files rewards; a wrong one costs a reader one
1529 /// bold row and nothing else.
1530 async function table(body, path, info, opts, tOr, mine) {
1531 var got = await headText(path, info.size, opts);
1532 if (mine !== epoch) return;
1533 if (got.capped) body.appendChild(cappedLine(info.size, CAP_TEXT, tOr));
1534 var rows = parseDelim(got.text, info.media === 'Tsv' ? '\t' : ',');
1535 var wrap = el('div', 'fv-tablewrap');
1536 var tbl = el('table', 'fv-table');
1537 var shown = Math.min(rows.length, MAX_ROWS);
1538 for (var r = 0; r < shown; r++) {
1539 var tr = el('tr');
1540 var cells = rows[r].slice(0, MAX_COLS);
1541 for (var c = 0; c < cells.length; c++) {
1542 tr.appendChild(el(r === 0 ? 'th' : 'td', null, cells[c]));
1543 }
1544 tbl.appendChild(tr);
1545 }
1546 wrap.appendChild(tbl);
1547 body.appendChild(wrap);
1548 if (rows.length > shown) {
1549 body.appendChild(el('p', 'fv-note', tOr('fileview.rows_capped',
1550 'Showing the first {shown} rows of {total}.',
1551 { shown: fmtExact(shown), total: fmtExact(rows.length) })));
1552 }
1553 }
1554
1555 /// Split delimited text into rows of fields.
1556 ///
1557 /// Quoting is honoured for CSV, where a field may hold the delimiter, a
1558 /// newline or a doubled quote. TSV has no quoting convention worth the name,
1559 /// so a tab is always a tab.
1560 function parseDelim(text, delim) {
1561 var rows = [], row = [], cur = '', q = false, quoting = (delim === ',');
1562 for (var i = 0; i < text.length; i++) {
1563 var c = text.charAt(i);
1564 if (q) {
1565 if (c !== '"') { cur += c; continue; }
1566 if (text.charAt(i + 1) === '"') { cur += '"'; i++; } else { q = false; }
1567 continue;
1568 }
1569 if (quoting && c === '"' && cur === '') { q = true; continue; }
1570 if (c === delim) { row.push(cur); cur = ''; continue; }
1571 if (c === '\n') { row.push(cur); cur = ''; rows.push(row); row = []; continue; }
1572 if (c === '\r') { continue; }
1573 cur += c;
1574 }
1575 if (cur !== '' || row.length) { row.push(cur); rows.push(row); }
1576 return rows;
1577 }
1578
1579 /// The floor: the bytes themselves, sixteen to a line, hex beside ASCII.
1580 ///
1581 /// One page is read at a time and no page is kept, so walking a gigabyte
1582 /// costs four kilobytes of memory. The bar names the exact range and the
1583 /// exact total, because at this tier the exact number IS the information --
1584 /// "12 KB" is no use to somebody counting into a header.
1585 async function hex(body, path, info, opts, tOr, mine, at) {
1586 // Two sentences, because one with a `{fmt}` hole in it cannot serve both
1587 // cases: the format is the whole point when it is known, and when it is not
1588 // the hole fills with the word "Unknown" and the line reads "no viewer here
1589 // for a Unknown".
1590 body.appendChild(el('p', 'fv-note', info.media === 'Unknown'
1591 ? tOr('fileview.hex_note_unknown',
1592 'Nothing here recognises this file, so these are its bytes.')
1593 : tOr('fileview.hex_note',
1594 'There is no viewer here for a {fmt}, so these are its bytes.',
1595 { fmt: fmtName(info.media, info.label, tOr) })));
1596
1597 var bar = el('div', 'fv-hexbar');
1598 var prev = el('button', 'fv-btn', tOr('fileview.hex_prev', 'Earlier bytes'));
1599 var next = el('button', 'fv-btn', tOr('fileview.hex_next', 'Later bytes'));
1600 var at_ = el('span', 'fv-hexat');
1601 prev.type = 'button'; next.type = 'button';
1602 bar.appendChild(prev); bar.appendChild(next); bar.appendChild(at_);
1603 var pre = el('pre', 'fv-hex');
1604 body.appendChild(bar);
1605 body.appendChild(pre);
1606
1607 // A remembered offset is clamped to a page boundary inside the file, so a
1608 // redraw of a file that has since shrunk lands somewhere that exists.
1609 var lastPage = Math.max(0, Math.floor(Math.max(0, info.size - 1) / PAGE) * PAGE);
1610 var off = Math.min(Math.max(0, at || 0), lastPage);
1611
1612 async function page() {
1613 var u8 = await readBytes(path, off, PAGE, opts);
1614 if (mine !== epoch) return;
1615 pre.textContent = hexLines(u8, off);
1616 at_.textContent = tOr('fileview.hex_at', 'Bytes {from} to {to} of {total}', {
1617 from: fmtExact(off),
1618 to: fmtExact(off + Math.max(u8.length, 1) - 1),
1619 total: fmtExact(info.size),
1620 });
1621 prev.disabled = off <= 0;
1622 next.disabled = off + PAGE >= info.size;
1623 if (last) last.hexAt = off;
1624 }
1625
1626 prev.addEventListener('click', function () {
1627 off = Math.max(0, off - PAGE);
1628 page();
1629 });
1630 next.addEventListener('click', function () {
1631 if (off + PAGE < info.size) { off += PAGE; page(); }
1632 });
1633 await page();
1634 }
1635
1636 /// One page of bytes as `offset hex hex … |ascii|`.
1637 function hexLines(u8, base) {
1638 var out = '';
1639 for (var i = 0; i < u8.length; i += 16) {
1640 var line = (base + i).toString(16);
1641 while (line.length < 8) line = '0' + line;
1642 var hexPart = '', asc = '';
1643 for (var j = 0; j < 16; j++) {
1644 if (j === 8) hexPart += ' ';
1645 if (i + j < u8.length) {
1646 var b = u8[i + j];
1647 hexPart += (b < 16 ? '0' : '') + b.toString(16) + ' ';
1648 asc += (b >= 0x20 && b < 0x7f) ? String.fromCharCode(b) : '.';
1649 } else {
1650 hexPart += ' ';
1651 }
1652 }
1653 out += line + ' ' + hexPart + ' |' + asc + '|\n';
1654 }
1655 return out;
1656 }
1657
1658 /// The line that says a read stopped short. Never omitted: a truncation
1659 /// nobody mentions reads as a corrupt file.
1660 function cappedLine(size, cap, tOr) {
1661 return el('p', 'fv-note', tOr('fileview.capped',
1662 'Showing the first {shown} of {total}.',
1663 { shown: fmtBytes(cap), total: fmtBytes(size) }));
1664 }
1665
1666 // ── A change of language redraws what is on screen ───────────────
1667 //
1668 // Every string above is fetched when it is drawn, so a panel already drawn
1669 // keeps the old language until something redraws it. The hex page is carried
1670 // across, so the redraw does not send a reader back to offset zero.
1671
1672 if (window.DaimondI18n && DaimondI18n.onChange) {
1673 DaimondI18n.onChange(function () {
1674 if (!last) return;
1675 var l = last;
1676 try { show(l.host, l.path, l.info, l.opts); } catch (e) { /* nothing to redraw */ }
1677 });
1678 }
1679
1680 // ── The daimon's door ────────────────────────────────────────────
1681 //
1682 // `file_show` in `src/tools.rs` calls `DaimondDoc.show` from the wasm, the way
1683 // the agent's web tools call `window.DaimondWeb`. It resolves with `verdict`'s
1684 // own answer as JSON, so the sentence the model then says to the user is built
1685 // from the table at the top of this file and not from a copy of it in Rust.
1686 //
1687 // THE OPENER IS REGISTERED RATHER THAN REACHED FOR. Only `daimond.js` can put
1688 // a file in the Doc panel -- the panel, its header, its download and its
1689 // editor are all inside that module's closure, and `openFile` there is what
1690 // decides between the editor and this viewer. So that module hands the
1691 // function over and this file keeps the question of what showing one MEANS.
1692 // The alternative was a second opener, which is a second answer to the
1693 // routing question that has already been got wrong twice.
1694 var opener = null;
1695
1696 /// Register the function that puts a workspace file in the document panel.
1697 /// Called once, by `daimond.js`, with its own `openFile`.
1698 function setOpener(fn) {
1699 opener = (typeof fn === 'function') ? fn : null;
1700 }
1701
1702 // ── Whose screen it is ───────────────────────────────────────────
1703 //
1704 // `Tool::file_show` in src/tools.rs already refuses a DISPATCHED WORKER, for a
1705 // reason it states in full: nobody is reading that transcript, several workers
1706 // run at once, and "the document panel belongs to the conversation the user is
1707 // actually in". Every clause of that is just as true of a daimon whose Diamond
1708 // is not the one on screen -- it was simply never asked. A background daimon
1709 // editing a crystal would open the panel over whatever the user was doing, in
1710 // a Diamond that had nothing to do with it.
1711 //
1712 // So the same question is asked of every caller that names an owner: is the
1713 // conversation asking the one in view? The answer lives in `daimond.js`, which
1714 // owns the rail, the Diamond selection and the panels; this file owns what
1715 // showing MEANS, and asks.
1716 //
1717 // A show that loses the race is REMEMBERED rather than dropped. A daimon
1718 // showing a file is telling the user something, and the moment they open that
1719 // Diamond is the moment it is worth seeing -- which is how `pendingFolds`
1720 // already treats a proposal made while the user was elsewhere.
1721
1722 /// Answers the id of the conversation on screen, or `''` for none.
1723 var screenOwner = null;
1724
1725 /// The last file each absent owner asked to show, by owner id.
1726 var deferred = Object.create(null);
1727
1728 /// Register the function that says which conversation is on screen.
1729 /// Called once, by `daimond.js`.
1730 function setScreenOwner(fn) {
1731 screenOwner = (typeof fn === 'function') ? fn : null;
1732 }
1733
1734 /// The file `owner` asked to show while it was off screen, and forget it.
1735 ///
1736 /// Taken rather than read: it is shown once, when the user arrives. Leaving it
1737 /// would reopen the panel on every later visit to that Diamond, long after the
1738 /// turn that asked had been forgotten by everyone.
1739 ///
1740 /// # Arguments
1741 /// * `owner` - The conversation being opened.
1742 function takeDeferred(owner) {
1743 var p = owner ? deferred[owner] : '';
1744 if (owner) delete deferred[owner];
1745 return p || '';
1746 }
1747
1748 /// Whether a show asked for by `owner` may take the screen now.
1749 ///
1750 /// Unowned shows -- the user's own click, an ordinary chat before the engine
1751 /// learned to name itself -- are the user's own act and always may. Only a
1752 /// caller that NAMES an owner can be told it is not the one in view, which is
1753 /// what keeps this from refusing anything it cannot actually attribute.
1754 ///
1755 /// # Arguments
1756 /// * `owner` - The conversation asking, or `''` when nothing named one.
1757 function mayTakeScreen(owner) {
1758 if (!owner || !screenOwner) return true;
1759 try { return screenOwner() === owner; }
1760 catch (e) { return true; } // a page that cannot answer must not lose its shows
1761 }
1762
1763 /// Put `path` in front of the user, and say what they are now looking at.
1764 ///
1765 /// Rejects with a plain-English `Error` when there is no panel to show it in;
1766 /// the Rust edge passes that message through verbatim, because it is the only
1767 /// instruction the model gets about what to do next.
1768 ///
1769 /// # Arguments
1770 /// * `path` - A workspace-relative path. Never bytes: a view that was handed
1771 /// CONTENT could not be refreshed when the file changed, and the same file
1772 /// shown again is the whole of how a rebuilt document reaches the reader.
1773 /// * `page` - Which page to open a PDF at, or nothing to leave it where this
1774 /// file was last aimed -- which is what makes a rebuilt document come back
1775 /// in the reader's place rather than at page 1.
1776 /// * `owner` - The conversation asking, or nothing when the caller cannot say.
1777 /// See `mayTakeScreen`.
1778 async function showToUser(path, page, owner) {
1779 if (!opener) {
1780 throw new Error('Daimond’s document panel is not on this page, so there is '
1781 + 'nothing to show a file in.');
1782 }
1783 var v = await verdict(path, {});
1784 // Answered before the draw and reported in the verdict, so the sentence the
1785 // model says to the user is the one thing that actually happened. A tool
1786 // result claiming a file is on screen when the user is looking at another
1787 // Diamond is worse than no tool at all: it is the model telling them to
1788 // look at something that is not there.
1789 if (!mayTakeScreen(owner)) {
1790 if (owner) deferred[owner] = path;
1791 v.shown = false;
1792 v.page = aimPage(path);
1793 return JSON.stringify(v);
1794 }
1795 v.shown = true;
1796 at(path, page); // before the draw, which is what reads it
1797 // The page ACTUALLY used, not the one asked for. They differ whenever a
1798 // re-show keeps an earlier aim, and a model told the argument back would
1799 // tell the user page 1 while they are looking at page 214.
1800 v.page = aimPage(path);
1801 await opener(path);
1802 return JSON.stringify(v);
1803 }
1804
1805 window.DaimondDoc = { show: showToUser };
1806
1807 // `verdict` never sets `shown`; only `showToUser` does, and it sets it on both
1808 // paths. So a caller reading it gets a fact about this show and never about
1809 // what the panel happens to be holding.
1810
1811 window.DaimondViewer = {
1812 probe: probe,
1813 verdict: verdict,
1814 show: show,
1815 close: close,
1816 // Where a document opens next time it is drawn.
1817 at: at,
1818 opener: setOpener,
1819 // Who is on screen, and what an absent owner asked for while it was. Both
1820 // registered from `daimond.js`, which is the only module that knows.
1821 screenOwner: setScreenOwner,
1822 takeDeferred: takeDeferred,
1823 mayTakeScreen: mayTakeScreen,
1824 // The routing question a panel with an editor in it has to answer, kept
1825 // here beside the table it is answered from rather than restated by every
1826 // caller -- one caller restating it is what put a PDF in a <pre>.
1827 editable: editable,
1828 KIND_HANDLERS: Object.freeze(KIND_HANDLERS),
1829 // The second lock, published for the same reason the first one is: a test
1830 // can then see that no deck has an editor WITHOUT rendering one, and a
1831 // deck added here goes red on its own rather than only when somebody also
1832 // routes it to a reading tier. Two locks that can only be checked together
1833 // are one lock.
1834 EDIT_DOOR: Object.freeze(EDIT_DOOR),
1835 };
1836})();