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 | })(); |