studio-engine 0.98.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +10 -0
- data/app/controllers/studio/knowledge_docs_controller.rb +24 -1
- data/app/javascript/studio/controllers/modal_host_controller.js +6 -16
- data/app/javascript/studio/modal_host.js +25 -5
- data/app/models/studio/knowledge_doc.rb +37 -2
- data/app/views/studio/knowledge_docs/_preview.html.erb +66 -0
- data/app/views/studio/knowledge_docs/_preview_fallback.html.erb +9 -0
- data/app/views/studio/knowledge_docs/_preview_sheet.html.erb +43 -0
- data/app/views/studio/knowledge_docs/_preview_styles.html.erb +24 -0
- data/app/views/studio/knowledge_docs/preview.html.erb +12 -0
- data/app/views/studio/knowledge_docs/show.html.erb +26 -2
- data/lib/studio/knowledge_preview/number_format.rb +342 -0
- data/lib/studio/knowledge_preview/xlsx.rb +646 -0
- data/lib/studio/knowledge_preview/xml_guard.rb +344 -0
- data/lib/studio/knowledge_preview/zip.rb +242 -0
- data/lib/studio/knowledge_preview.rb +322 -0
- data/lib/studio/s3.rb +39 -4
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +5 -2
- data/studio-engine.gemspec +4 -0
- metadata +26 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c5140413bf4edd1a888bea60b99a3ad688930302c6ea037dceef808cb233705
|
|
4
|
+
data.tar.gz: 31c5c7205a47e8c2a6366f9262c5e61425732fde284e4618450e6825e2a1a3af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d7b3b42b70bccae520be5c5db88d0d3544e30aa6810349e17c056b142856f1bcde44afb2edb7e49df4fdc727a3fa750ec9ed26213df24c64dc8d825f8527eca
|
|
7
|
+
data.tar.gz: 2d55e2f7f597bbfeb3e0f628d3e435f0d77aff729b341724c3eb975eea924aa0dc72dce1c0758e48f457d52c3676185bb45af9c9d5efd03f1f61a096952ec5cd
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.99.0 — 2026-10-09
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A modal left open no longer comes back on Back when `studio/application`
|
|
12
|
+
fails to load.** `studio/modal_host` binds the stale-modal sweep itself, on
|
|
13
|
+
a bfcache `pageshow` and on `turbo:before-cache`, once per document, for
|
|
14
|
+
every host on the page. The `modal-host` controller bound it before, so a
|
|
15
|
+
failed boot left the stores registered and the sweep unbound. The controller
|
|
16
|
+
now registers its host's store and nothing else, so each event sweeps a
|
|
17
|
+
store once. `clearStaleModalsIn(doc, win)` is exported beside
|
|
18
|
+
`clearStaleModals(win, name)`. No markup changes.
|
|
19
|
+
|
|
7
20
|
## 0.98.0 — 2026-10-09
|
|
8
21
|
|
|
9
22
|
### Changed
|
|
@@ -352,6 +365,70 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
352
365
|
before. A non-http(s) String raises `ArgumentError` at boot; a callable
|
|
353
366
|
answering one draws no link. The link opens in a new tab with
|
|
354
367
|
`rel="noopener noreferrer"`. See README, *The image generator link*.
|
|
368
|
+
- **Knowledge documents preview in the page.** `/admin/knowledge/:id` gains a
|
|
369
|
+
Preview section, so reading a document no longer means downloading it:
|
|
370
|
+
- `.xlsx` and `.xlsm` workbooks render as tables, one tab per sheet. A formula
|
|
371
|
+
shows the value Excel last calculated for it, and numbers render through
|
|
372
|
+
their number format (dates, percentages, currency, accounting negatives).
|
|
373
|
+
- `.csv` and `.tsv` render as one table.
|
|
374
|
+
- PDFs and images (`png`, `jpg`, `gif`, `webp`) display inline, fetched by the
|
|
375
|
+
browser from a 15-minute signed URL.
|
|
376
|
+
- Text, markdown, JSON and caption files display as preformatted text. Stored
|
|
377
|
+
content is always escaped, never rendered as HTML.
|
|
378
|
+
- Anything else (`.xls`, `.docx`, SVG), anything over a cap, and any file that
|
|
379
|
+
will not parse shows one sentence saying why, beside the download link. The
|
|
380
|
+
page never fails on a bad file.
|
|
381
|
+
|
|
382
|
+
The section is a lazy `<turbo-frame>` over a new admin-only endpoint,
|
|
383
|
+
`GET /admin/knowledge/:id/preview` (`admin_knowledge_doc_preview_path`), so the
|
|
384
|
+
show page itself reads nothing from the bucket. It rides the existing
|
|
385
|
+
`Studio.draw_knowledge_routes` opt-in; there is no migration and no new setting.
|
|
386
|
+
|
|
387
|
+
The caps, all constants on `Studio::KnowledgePreview`:
|
|
388
|
+
- A sheet: 500 rows and 50 columns. A cell: 32,767 characters (Excel's own
|
|
389
|
+
limit; cut with a mark past it).
|
|
390
|
+
- A workbook: 20 sheets, 50,000 filled cells, 100,000 rendered cells (empty
|
|
391
|
+
ones included) and 2 MB of cell text. The sheet that spends the last of one
|
|
392
|
+
of these is cut there and says so; later sheets are counted in a notice.
|
|
393
|
+
- A workbook read as a whole: a 20 MB file; 64 MB inflated across every part
|
|
394
|
+
read (`INFLATE_BUDGET`, a part read twice counted twice); 16 MB of text handed
|
|
395
|
+
over by the XML parser, kept or not (`MAX_PARSED_TEXT_BYTES`); and 10 seconds
|
|
396
|
+
of wall-clock time (`SPREADSHEET_DEADLINE_SECONDS`). Past any of these the
|
|
397
|
+
preview falls back to the download link.
|
|
398
|
+
- What the XML parser is given, enforced on the byte stream before it
|
|
399
|
+
(`Studio::KnowledgePreview::XmlGuard`): a part must be UTF-8 and open with
|
|
400
|
+
`<`; a part with a `<!DOCTYPE` is refused; a text node or CDATA section
|
|
401
|
+
stops at 1 MB; the start tags of all elements open at once stop at 64 KB
|
|
402
|
+
together; comments and processing instructions stop at 64 KB a part;
|
|
403
|
+
nesting stops at 256 and namespace declarations at 1,024. A workbook part in
|
|
404
|
+
UTF-16, or one carrying megabyte comments, falls back to the download link.
|
|
405
|
+
- A workbook's tables: 4,096 relationships and 4,096 sheets (more is refused);
|
|
406
|
+
4,096 custom number formats and 512 distinct formats in use (past either, a
|
|
407
|
+
number renders as General). A number format longer than Excel's 255
|
|
408
|
+
characters is ignored, and no number renders past thirty decimals.
|
|
409
|
+
- The first 1 MB of a CSV, the first 256 KB of a text file, and 100 MB for an
|
|
410
|
+
inline PDF or image.
|
|
411
|
+
|
|
412
|
+
A sheet or file cut by a cap says so and links the download.
|
|
413
|
+
- `Studio::KnowledgePreview` (`lib/studio/knowledge_preview.rb`), loaded on
|
|
414
|
+
first use and not by `require "studio"`: `kind_for(filename:, mime_type:)`,
|
|
415
|
+
`for(doc)` returning a `Result`, `read_spreadsheet(bytes)` and
|
|
416
|
+
`read_delimited(bytes)`. The workbook reader is the engine's own, on Zlib and
|
|
417
|
+
nokogiri; it adds no spreadsheet gem.
|
|
418
|
+
- `Studio::KnowledgeDoc#preview`, `#preview_kind` and `#filename`, and
|
|
419
|
+
`#signed_url(inline_as: "application/pdf")`.
|
|
420
|
+
- `Studio::S3.download(key:, max_bytes:)` reads only the head of an object (a
|
|
421
|
+
ranged GET; `max_bytes` must be a positive Integer), and `Studio::S3.signed_url`
|
|
422
|
+
takes `response_content_disposition:` and `response_content_type:`, signed into
|
|
423
|
+
the URL. Both are optional; existing calls are unchanged. The signing is tested
|
|
424
|
+
against an R2-style endpoint; that R2 serves the overridden headers is
|
|
425
|
+
documented by Cloudflare and not yet verified against a live bucket.
|
|
426
|
+
|
|
427
|
+
### Changed
|
|
428
|
+
|
|
429
|
+
- The gemspec declares `csv` (`>= 3.0`). It left Ruby's default gems in 3.4 and
|
|
430
|
+
the CSV preview requires it. Apps whose lockfile already names `csv` see no
|
|
431
|
+
change; the rest gain one pure-Ruby gem on their next engine bump.
|
|
355
432
|
|
|
356
433
|
### Changed
|
|
357
434
|
|
data/README.md
CHANGED
|
@@ -967,6 +967,16 @@ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
|
|
|
967
967
|
endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
|
|
968
968
|
`upload` writes but returns `nil`; serve private objects with `signed_url`.
|
|
969
969
|
|
|
970
|
+
`signed_url` also takes `response_content_disposition:` and
|
|
971
|
+
`response_content_type:`, which are signed into the URL. On S3 they override the
|
|
972
|
+
headers the object is served with, and Cloudflare documents the same for R2; the
|
|
973
|
+
engine's tests cover the signing against an R2-style endpoint, and nobody has yet
|
|
974
|
+
verified the served headers against a live R2 bucket. `"inline"` plus a content
|
|
975
|
+
type the caller chose is how the knowledge layer shows a private PDF or image in
|
|
976
|
+
the page (`Studio::KnowledgeDoc#signed_url(inline_as:)`).
|
|
977
|
+
`download(key:, max_bytes:)` reads only the head of an object; `max_bytes` must
|
|
978
|
+
be a positive Integer.
|
|
979
|
+
|
|
970
980
|
`Studio::S3.delete` moves the object to `trash/` for three days rather than
|
|
971
981
|
deleting it; see [Trash and restore](#trash-and-restore).
|
|
972
982
|
|
|
@@ -13,7 +13,7 @@ module Studio
|
|
|
13
13
|
# deliberate second step, not a side effect of upload.
|
|
14
14
|
class KnowledgeDocsController < ApplicationController
|
|
15
15
|
before_action :require_admin
|
|
16
|
-
before_action :set_doc, only: [:show, :update, :download]
|
|
16
|
+
before_action :set_doc, only: [:show, :update, :download, :preview]
|
|
17
17
|
|
|
18
18
|
VIEWS = %w[folders flat].freeze
|
|
19
19
|
|
|
@@ -112,8 +112,31 @@ module Studio
|
|
|
112
112
|
redirect_to @doc.signed_url, allow_other_host: true
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# GET /admin/knowledge/:id/preview — the document read in the page. The
|
|
116
|
+
# show page loads this into a <turbo-frame>, so the bucket read and the
|
|
117
|
+
# parse happen here and never hold up the page itself.
|
|
118
|
+
#
|
|
119
|
+
# Always a 200: Studio::KnowledgePreview answers every failure (unknown
|
|
120
|
+
# type, over a cap, damaged, storage down) as a fallback Result carrying
|
|
121
|
+
# one sentence, rendered beside the download link. The response is
|
|
122
|
+
# no-store because it holds either the document's contents or a signed
|
|
123
|
+
# URL to them.
|
|
124
|
+
def preview
|
|
125
|
+
@preview = @doc.preview
|
|
126
|
+
log_preview_failure(@preview.error) if @preview.error
|
|
127
|
+
response.headers["Cache-Control"] = "no-store"
|
|
128
|
+
end
|
|
129
|
+
|
|
115
130
|
private
|
|
116
131
|
|
|
132
|
+
# An unexpected failure behind a fallback is worth a record; a failure to
|
|
133
|
+
# record it is not worth the page.
|
|
134
|
+
def log_preview_failure(error)
|
|
135
|
+
create_error_log(error)
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Rails.logger.warn "[knowledge preview] could not log #{error.class}: #{e.class}: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
117
140
|
def expectation_params
|
|
118
141
|
params.require(:knowledge_expectation)
|
|
119
142
|
.permit(:entity, :title, :path, :category, :cadence, :start_on,
|
|
@@ -14,27 +14,17 @@
|
|
|
14
14
|
// The stack, the focus trap and the registries are studio/modal_host, which
|
|
15
15
|
// registers each host's store on alpine:init (a full load, where a controller
|
|
16
16
|
// connects too late) and before a Turbo render. connect() registers it too, for
|
|
17
|
-
// a Turbo visit and for markup that arrived any other way
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// a Turbo visit and for markup that arrived any other way. The bfcache and
|
|
18
|
+
// Turbo snapshot cleanup is studio/modal_host's own binding, one per document,
|
|
19
|
+
// so it holds when this controller never loads; binding it here as well would
|
|
20
|
+
// sweep each store twice. Registered by studio/application.
|
|
20
21
|
import { Controller } from "@hotwired/stimulus"
|
|
21
|
-
import { registerModalStore
|
|
22
|
+
import { registerModalStore } from "studio/modal_host"
|
|
22
23
|
|
|
23
24
|
export default class extends Controller {
|
|
24
25
|
static values = { store: { type: String, default: "modals" }, scoped: Boolean }
|
|
25
26
|
|
|
26
27
|
connect() {
|
|
27
|
-
|
|
28
|
-
registerModalStore(window.Alpine, { store: name, scoped: this.scopedValue }, { doc: document, win: window })
|
|
29
|
-
|
|
30
|
-
this.onPageShow = (event) => { if (event.persisted) clearStaleModals(window, name) }
|
|
31
|
-
this.onBeforeCache = () => clearStaleModals(window, name)
|
|
32
|
-
window.addEventListener("pageshow", this.onPageShow)
|
|
33
|
-
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
disconnect() {
|
|
37
|
-
window.removeEventListener("pageshow", this.onPageShow)
|
|
38
|
-
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
28
|
+
registerModalStore(window.Alpine, { store: this.storeValue, scoped: this.scopedValue }, { doc: document, win: window })
|
|
39
29
|
}
|
|
40
30
|
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// data-modal-host-store-value="modals" (the scoped host: its own name)
|
|
13
13
|
// data-modal-host-scoped-value="true" (the scoped host only)
|
|
14
14
|
//
|
|
15
|
-
// and studio/controllers/modal_host_controller
|
|
15
|
+
// and studio/controllers/modal_host_controller registers the host's store when
|
|
16
|
+
// the element connects.
|
|
16
17
|
//
|
|
17
18
|
// WHEN A STORE REGISTERS, AND WHY NOT IN connect(). Alpine evaluates
|
|
18
19
|
// $store.<name>.current() as it starts, which is BEFORE Stimulus connects any
|
|
@@ -538,9 +539,9 @@ export function registerHostsIn(root, env) {
|
|
|
538
539
|
for (var i = 0; i < hosts.length; i++) registerModalStore(env.win.Alpine, hosts[i], env)
|
|
539
540
|
}
|
|
540
541
|
|
|
541
|
-
// The bfcache and Turbo snapshot cleanup: a modal left open does
|
|
542
|
-
// on the next visit, but a dismissible: false card survives (see
|
|
543
|
-
// closeAllDismissible).
|
|
542
|
+
// The bfcache and Turbo snapshot cleanup for one store: a modal left open does
|
|
543
|
+
// not reappear on the next visit, but a dismissible: false card survives (see
|
|
544
|
+
// closeAllDismissible).
|
|
544
545
|
export function clearStaleModals(win, name) {
|
|
545
546
|
if (!win.Alpine || !win.Alpine.store) return
|
|
546
547
|
var store = win.Alpine.store(name)
|
|
@@ -548,7 +549,19 @@ export function clearStaleModals(win, name) {
|
|
|
548
549
|
else if (store && typeof store.closeAll === 'function') store.closeAll()
|
|
549
550
|
}
|
|
550
551
|
|
|
551
|
-
//
|
|
552
|
+
// Sweeps the store of every host on the page, each store once.
|
|
553
|
+
export function clearStaleModalsIn(doc, win) {
|
|
554
|
+
var hosts = hostsIn(doc)
|
|
555
|
+
var swept = []
|
|
556
|
+
for (var i = 0; i < hosts.length; i++) {
|
|
557
|
+
if (swept.indexOf(hosts[i].store) !== -1) continue
|
|
558
|
+
swept.push(hosts[i].store)
|
|
559
|
+
clearStaleModals(win, hosts[i].store)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Installs the globals, the three registration paths and the stale-modal
|
|
564
|
+
// sweep, once per document.
|
|
552
565
|
var installedOn = new WeakSet()
|
|
553
566
|
|
|
554
567
|
export function installModalHost(doc, win) {
|
|
@@ -565,6 +578,13 @@ export function installModalHost(doc, win) {
|
|
|
565
578
|
doc.addEventListener('turbo:before-frame-render', function (event) {
|
|
566
579
|
if (event.detail) registerHostsIn(event.detail.newFrame, env)
|
|
567
580
|
})
|
|
581
|
+
// THE SWEEP IS BOUND HERE, the one place, and not by the controller: this
|
|
582
|
+
// module loads by its own tag, so the sweep survives a boot that fails to
|
|
583
|
+
// load, and one binding per document sweeps each store once per event.
|
|
584
|
+
win.addEventListener('pageshow', function (event) {
|
|
585
|
+
if (event.persisted) clearStaleModalsIn(doc, win)
|
|
586
|
+
})
|
|
587
|
+
doc.addEventListener('turbo:before-cache', function () { clearStaleModalsIn(doc, win) })
|
|
568
588
|
// Alpine already running (a host that loads it ahead of the module tags).
|
|
569
589
|
if (win.Alpine && win.Alpine.version) registerHostsIn(doc, env)
|
|
570
590
|
}
|
|
@@ -167,10 +167,45 @@ module Studio
|
|
|
167
167
|
end
|
|
168
168
|
|
|
169
169
|
# 15-minute presigned GET — the only way a private object leaves the bucket.
|
|
170
|
-
|
|
170
|
+
#
|
|
171
|
+
# inline_as: a content type. The URL then serves the object with
|
|
172
|
+
# Content-Disposition: inline and THAT type, whatever was stored, which is
|
|
173
|
+
# what lets the show page embed a PDF or an image (Studio::KnowledgePreview
|
|
174
|
+
# picks the type from the file's kind, never from mime_type).
|
|
175
|
+
def signed_url(expires_in: 900, inline_as: nil)
|
|
171
176
|
raise Studio::S3::Error, "no file attached to #{title.inspect}" unless file?
|
|
172
177
|
|
|
173
|
-
|
|
178
|
+
if inline_as
|
|
179
|
+
Studio::S3.signed_url(key: s3_key, expires_in: expires_in,
|
|
180
|
+
response_content_disposition: "inline", response_content_type: inline_as)
|
|
181
|
+
else
|
|
182
|
+
Studio::S3.signed_url(key: s3_key, expires_in: expires_in)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The stored object's file name (the upload's name, sanitized, behind the
|
|
187
|
+
# key's timestamp prefix). There is no filename column; the key carries it.
|
|
188
|
+
def filename
|
|
189
|
+
File.basename(s3_key.to_s)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# --- preview ----------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
# :spreadsheet, :delimited, :pdf, :image, :text, or nil when the show page
|
|
195
|
+
# can only offer the download. Reads nothing from the bucket.
|
|
196
|
+
def preview_kind
|
|
197
|
+
return nil unless file?
|
|
198
|
+
|
|
199
|
+
require "studio/knowledge_preview"
|
|
200
|
+
Studio::KnowledgePreview.kind_for(filename: filename, mime_type: mime_type)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The Studio::KnowledgePreview::Result for this document. Reads the
|
|
204
|
+
# bucket, so the show page loads it lazily (KnowledgeDocsController#preview)
|
|
205
|
+
# rather than calling it inline. Never raises.
|
|
206
|
+
def preview
|
|
207
|
+
require "studio/knowledge_preview"
|
|
208
|
+
Studio::KnowledgePreview.for(self)
|
|
174
209
|
end
|
|
175
210
|
|
|
176
211
|
# --- lifecycle ------------------------------------------------------------
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<%# A document's inline preview, by kind. locals: preview (a
|
|
2
|
+
Studio::KnowledgePreview::Result), doc. Stored content is only ever
|
|
3
|
+
emitted as escaped text: a table cell, a <pre>, or (for a PDF or image)
|
|
4
|
+
not at all, the browser fetching it from a signed URL. %>
|
|
5
|
+
<%= render "studio/knowledge_docs/preview_styles" %>
|
|
6
|
+
<div id="knowledge-preview-body" data-preview-kind="<%= preview.kind %>">
|
|
7
|
+
<% case preview.kind %>
|
|
8
|
+
<% when :table %>
|
|
9
|
+
<% sheets = preview.sheets %>
|
|
10
|
+
<% if sheets.size > 1 || sheets.first.name.present? %>
|
|
11
|
+
<%# One radio per sheet, then the tabs, then the panels: all siblings,
|
|
12
|
+
so :checked can reach both the tab's look and the panel's display. %>
|
|
13
|
+
<style>
|
|
14
|
+
<% sheets.each_index do |index| %>
|
|
15
|
+
#knowledge-sheet-radio-<%= index %>:checked ~ .knowledge-sheet-tabs label[for="knowledge-sheet-radio-<%= index %>"] { opacity: 1; font-weight: 600; background: rgb(148 163 184 / .18); }
|
|
16
|
+
#knowledge-sheet-radio-<%= index %>:checked ~ .knowledge-sheet-panels #knowledge-sheet-panel-<%= index %> { display: block; }
|
|
17
|
+
<% end %>
|
|
18
|
+
</style>
|
|
19
|
+
<% sheets.each_index do |index| %>
|
|
20
|
+
<input type="radio" name="knowledge-sheet" class="knowledge-sheet-radio"
|
|
21
|
+
id="knowledge-sheet-radio-<%= index %>"<%= " checked".html_safe if index.zero? %>>
|
|
22
|
+
<% end %>
|
|
23
|
+
<div class="knowledge-sheet-tabs" id="knowledge-sheet-tabs">
|
|
24
|
+
<% sheets.each_with_index do |sheet, index| %>
|
|
25
|
+
<label class="knowledge-sheet-tab" for="knowledge-sheet-radio-<%= index %>"><%= sheet.name.presence || "Sheet #{index + 1}" %><%= " (hidden)" if sheet.hidden %></label>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<% if preview.omitted_sheets.to_i.positive? %>
|
|
29
|
+
<p class="knowledge-preview-note" id="knowledge-preview-omitted-sheets">
|
|
30
|
+
<%= pluralize(preview.omitted_sheets, "more sheet") %> not shown;
|
|
31
|
+
<%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
|
|
32
|
+
</p>
|
|
33
|
+
<% end %>
|
|
34
|
+
<div class="knowledge-sheet-panels">
|
|
35
|
+
<% sheets.each_with_index do |sheet, index| %>
|
|
36
|
+
<div class="knowledge-sheet-panel" id="knowledge-sheet-panel-<%= index %>">
|
|
37
|
+
<%= render "studio/knowledge_docs/preview_sheet", sheet: sheet, doc: doc %>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
41
|
+
<% else %>
|
|
42
|
+
<%= render "studio/knowledge_docs/preview_sheet", sheet: sheets.first, doc: doc %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% when :text %>
|
|
45
|
+
<% if preview.truncated %>
|
|
46
|
+
<p class="knowledge-preview-note" data-preview-truncated>
|
|
47
|
+
Showing the start of this file;
|
|
48
|
+
<%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
|
|
49
|
+
</p>
|
|
50
|
+
<% end %>
|
|
51
|
+
<pre class="knowledge-preview-text" id="knowledge-preview-text"><%= preview.text %></pre>
|
|
52
|
+
<% when :pdf %>
|
|
53
|
+
<%# No sandbox attribute, on purpose: a sandboxed frame does not get
|
|
54
|
+
Chromium's built-in PDF viewer, so the document would not show. What
|
|
55
|
+
guards this frame is that the bucket serves the object as
|
|
56
|
+
application/pdf whatever was stored (signed into the URL), from the
|
|
57
|
+
bucket's own origin, which holds no session of this app. %>
|
|
58
|
+
<iframe class="knowledge-preview-pdf" id="knowledge-preview-pdf" src="<%= preview.url %>"
|
|
59
|
+
title="<%= doc.title %>" referrerpolicy="no-referrer" loading="lazy"></iframe>
|
|
60
|
+
<% when :image %>
|
|
61
|
+
<img class="knowledge-preview-image" id="knowledge-preview-image" src="<%= preview.url %>"
|
|
62
|
+
alt="<%= doc.title %>" referrerpolicy="no-referrer">
|
|
63
|
+
<% else %>
|
|
64
|
+
<%= render "studio/knowledge_docs/preview_fallback", reason: preview.reason, doc: doc %>
|
|
65
|
+
<% end %>
|
|
66
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# The preview that could not be shown: one plain sentence and the same
|
|
2
|
+
download link the page header carries. locals: reason, doc. %>
|
|
3
|
+
<p id="knowledge-preview-fallback" class="knowledge-preview-note">
|
|
4
|
+
<%= reason %>
|
|
5
|
+
<% if doc.file? %>
|
|
6
|
+
<%= link_to "Download it instead (15-min link)", admin_knowledge_doc_download_path(doc),
|
|
7
|
+
id: "knowledge-preview-download", data: { turbo: false } %>
|
|
8
|
+
<% end %>
|
|
9
|
+
</p>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<%# One sheet (or a whole CSV) as a grid: column letters across the top and
|
|
2
|
+
row numbers down the side, as the spreadsheet itself shows them, so a
|
|
3
|
+
figure can be named by its cell. locals: sheet (KnowledgePreview::Sheet),
|
|
4
|
+
doc. Every cell is escaped text. %>
|
|
5
|
+
<% width = sheet.rows.first.to_a.size %>
|
|
6
|
+
<% if sheet.truncated_rows || sheet.truncated_columns %>
|
|
7
|
+
<p class="knowledge-preview-note" data-preview-truncated>
|
|
8
|
+
Showing the first
|
|
9
|
+
<%= [ ("#{number_with_delimiter(sheet.row_limit)} rows" if sheet.truncated_rows),
|
|
10
|
+
("#{width} columns" if sheet.truncated_columns) ].compact.to_sentence %>;
|
|
11
|
+
<%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
|
|
12
|
+
</p>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% if sheet.rows.empty? %>
|
|
15
|
+
<p class="knowledge-preview-note" data-preview-empty>This sheet is empty.</p>
|
|
16
|
+
<% else %>
|
|
17
|
+
<div class="knowledge-preview-scroll">
|
|
18
|
+
<table class="knowledge-preview-table">
|
|
19
|
+
<thead>
|
|
20
|
+
<tr>
|
|
21
|
+
<th></th>
|
|
22
|
+
<% width.times do |index| %>
|
|
23
|
+
<th scope="col"><%= Studio::KnowledgePreview.column_letter(index) %></th>
|
|
24
|
+
<% end %>
|
|
25
|
+
</tr>
|
|
26
|
+
</thead>
|
|
27
|
+
<tbody>
|
|
28
|
+
<% sheet.rows.each_with_index do |row, index| %>
|
|
29
|
+
<tr>
|
|
30
|
+
<th scope="row"><%= index + 1 %></th>
|
|
31
|
+
<% row.each do |cell| %>
|
|
32
|
+
<% if cell %>
|
|
33
|
+
<td<%= ' class="knowledge-cell-number"'.html_safe if cell.numeric %>><%= cell.text %></td>
|
|
34
|
+
<% else %>
|
|
35
|
+
<td></td>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# The preview's look. A scoped style block, like the chip styles, because a
|
|
2
|
+
host's Tailwind build may not scan this gem's views. Colours are slate at
|
|
3
|
+
low alpha over whatever the host paints, so one definition reads in a
|
|
4
|
+
light theme and a dark one. The sheet tabs are radio buttons and sibling
|
|
5
|
+
selectors: no script, so no dependency on the host's Alpine or its CSP. %>
|
|
6
|
+
<style>
|
|
7
|
+
.knowledge-preview-note { font-size: .8rem; opacity: .75; margin: 0 0 .5rem; }
|
|
8
|
+
.knowledge-preview-scroll { overflow: auto; max-height: 70vh; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
|
|
9
|
+
.knowledge-preview-table { border-collapse: collapse; font-size: .8rem; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
|
10
|
+
.knowledge-preview-table th, .knowledge-preview-table td { border: 1px solid rgb(148 163 184 / .3); padding: .2rem .5rem;
|
|
11
|
+
white-space: pre; max-width: 28rem; overflow: hidden; text-overflow: ellipsis; text-align: left; vertical-align: top; }
|
|
12
|
+
.knowledge-preview-table th { background: rgb(148 163 184 / .14); font-weight: 500; opacity: .8; text-align: center; }
|
|
13
|
+
.knowledge-preview-table td.knowledge-cell-number { text-align: right; }
|
|
14
|
+
.knowledge-preview-text { overflow: auto; max-height: 70vh; margin: 0; padding: .75rem; font-size: .8rem; line-height: 1.45;
|
|
15
|
+
white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
|
|
16
|
+
.knowledge-preview-pdf { display: block; width: 100%; height: 80vh; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
|
|
17
|
+
.knowledge-preview-image { display: block; max-width: 100%; height: auto; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
|
|
18
|
+
.knowledge-sheet-radio { position: absolute; opacity: 0; pointer-events: none; }
|
|
19
|
+
.knowledge-sheet-tabs { display: flex; flex-wrap: wrap; gap: .25rem; margin-bottom: .5rem; }
|
|
20
|
+
.knowledge-sheet-tab { padding: .2rem .65rem; font-size: .8rem; cursor: pointer; white-space: nowrap;
|
|
21
|
+
border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; opacity: .7; }
|
|
22
|
+
.knowledge-sheet-panel { display: none; }
|
|
23
|
+
.knowledge-sheet-radio:focus-visible ~ .knowledge-sheet-tabs { outline: 2px solid rgb(59 130 246 / .7); outline-offset: 2px; }
|
|
24
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# /admin/knowledge/:id/preview — the body the show page's turbo-frame loads.
|
|
2
|
+
The frame tag is written by hand so the view needs no Turbo helper. Opened
|
|
3
|
+
directly (a browser without Turbo follows the frame's own link here), it
|
|
4
|
+
is a small page of its own with a way back. %>
|
|
5
|
+
<% unless request.headers["Turbo-Frame"].present? %>
|
|
6
|
+
<div class="mb-4 text-sm">
|
|
7
|
+
<%= link_to "← #{@doc.title}", admin_knowledge_doc_path(@doc) %>
|
|
8
|
+
</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
<turbo-frame id="knowledge-preview">
|
|
11
|
+
<%= render "studio/knowledge_docs/preview", preview: @preview, doc: @doc %>
|
|
12
|
+
</turbo-frame>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
<%# /admin/knowledge/:id — one document: metadata, access, download,
|
|
2
|
-
triage form that files it out of the inbox. %>
|
|
1
|
+
<%# /admin/knowledge/:id — one document: metadata, access, download, the
|
|
2
|
+
inline preview, and the triage form that files it out of the inbox. %>
|
|
3
3
|
<%= render "studio/knowledge_docs/chip_styles" %>
|
|
4
4
|
|
|
5
5
|
<div class="mb-4 text-sm">
|
|
@@ -34,6 +34,30 @@
|
|
|
34
34
|
</div>
|
|
35
35
|
<% end %>
|
|
36
36
|
|
|
37
|
+
<% if @doc.file? %>
|
|
38
|
+
<div id="knowledge-preview-section" class="mb-6">
|
|
39
|
+
<h2 class="font-semibold mb-2">Preview</h2>
|
|
40
|
+
<% if @doc.preview_kind %>
|
|
41
|
+
<%# Lazy: the frame fetches the preview once it scrolls into view, so
|
|
42
|
+
this page never waits on a bucket read or a parse. Without Turbo
|
|
43
|
+
the link inside is the way in. %>
|
|
44
|
+
<turbo-frame id="knowledge-preview" loading="lazy" src="<%= admin_knowledge_doc_preview_path(@doc) %>">
|
|
45
|
+
<p class="text-sm opacity-70">
|
|
46
|
+
Loading the preview…
|
|
47
|
+
<%= link_to "Open it", admin_knowledge_doc_preview_path(@doc), id: "knowledge-preview-open" %>
|
|
48
|
+
</p>
|
|
49
|
+
</turbo-frame>
|
|
50
|
+
<% else %>
|
|
51
|
+
<%# A kind with no preview is known from the row alone: say so here
|
|
52
|
+
rather than spend a request to learn it. %>
|
|
53
|
+
<p id="knowledge-preview-fallback" class="text-sm opacity-70">
|
|
54
|
+
There is no inline preview for this kind of file.
|
|
55
|
+
<%= link_to "Download it instead (15-min link)", admin_knowledge_doc_download_path(@doc), id: "knowledge-preview-download" %>
|
|
56
|
+
</p>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
60
|
+
|
|
37
61
|
<div id="knowledge-triage" class="p-4 border rounded">
|
|
38
62
|
<h2 class="font-semibold mb-3">File it</h2>
|
|
39
63
|
<%= form_with url: admin_knowledge_doc_path(@doc), method: :patch, local: true do |form| %>
|