studio-engine 0.99.0 → 0.100.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 +115 -64
- data/README.md +87 -2
- data/app/controllers/studio/knowledge_docs_controller.rb +6 -1
- data/app/controllers/theme_settings_controller.rb +12 -2
- data/app/javascript/studio/controllers/knowledge_transcript_controller.js +22 -0
- data/app/javascript/studio/knowledge_transcript.js +191 -0
- data/app/javascript/studio/stimulus.js +5 -2
- data/app/javascript/studio/theme_editor.js +22 -9
- data/app/views/studio/knowledge_docs/_preview.html.erb +23 -4
- data/app/views/studio/knowledge_docs/_preview_player.html.erb +14 -0
- data/app/views/studio/knowledge_docs/_preview_styles.html.erb +37 -1
- data/app/views/studio/knowledge_docs/_preview_transcript.html.erb +59 -0
- data/app/views/studio/knowledge_docs/preview.html.erb +1 -1
- data/app/views/studio/knowledge_docs/show.html.erb +20 -5
- data/app/views/style/_theme.html.erb +2 -1
- data/lib/studio/knowledge_preview.rb +118 -11
- data/lib/studio/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca6e456bb2fc78ec18b876b4fe04a8147ad5f5f1bf45528fe452724c79945803
|
|
4
|
+
data.tar.gz: 819f007bf28a403a1ec285d708d14f6f37fff0f6398f0d35308f7e990a894682
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb1bf6e60e590388d8bf1ce273e11c1b27087d621a70727db69f5d02d657cd5c8c02b8df06d5813e7c86561bbbd412ba9f77feaf8a0070955fdb53c25df050d4
|
|
7
|
+
data.tar.gz: c6937889dd8066a0e5ddc1efcc7d10b6f7583db7b10213904e78428e840d15fbda5900baf0c3fe21091b7cf8e7593f84f8b3a67263bc2c4b37d380088987966f
|
data/CHANGELOG.md
CHANGED
|
@@ -4,8 +4,123 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.100.0 — 2026-10-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A meeting plays beside its transcript on the knowledge document page.**
|
|
12
|
+
`/admin/knowledge/:id`, in its preview frame:
|
|
13
|
+
- A document with a recording gets a native `<video>` or `<audio>` player.
|
|
14
|
+
Its source is the six-hour presigned URL (`recording_url`), written only
|
|
15
|
+
into the admin-only, `no-store` preview response; the show page holds no
|
|
16
|
+
signed URL and the app reads none of the recording.
|
|
17
|
+
- A text document that holds at least two cue lines is shown as cues (time,
|
|
18
|
+
speaker, text) in place of the preformatted block, with the plain text kept
|
|
19
|
+
under them, closed. Any other text file previews exactly as before.
|
|
20
|
+
- With a recording, a click on a cue seeks the player there and plays, and
|
|
21
|
+
the cue being spoken is marked and kept in view inside the cue list. With
|
|
22
|
+
none, the cues still render with each time as plain text.
|
|
23
|
+
- With no stored recording, an http(s) `recording_link` shows as a link that
|
|
24
|
+
opens in a new tab (`rel="noopener noreferrer"`).
|
|
25
|
+
- A document whose own file is audio or video (`mp4`, `m4v`, `mov`, `webm`,
|
|
26
|
+
`ogv`, `mp3`, `m4a`, `wav`, `ogg`, `oga`, `weba`) plays in the same player,
|
|
27
|
+
served as the type its extension names.
|
|
28
|
+
|
|
29
|
+
A transcript longer than the 256 KB text head is read a second time, up to
|
|
30
|
+
the parser's 2 MB; one preview makes two ranged reads at most. All 5,000 cues
|
|
31
|
+
the parser allows are rendered, and a transcript cut by a cap says so and
|
|
32
|
+
links the download. Speaker names and cue text are escaped.
|
|
33
|
+
|
|
34
|
+
The script is `studio/knowledge_transcript` and a `knowledge-transcript`
|
|
35
|
+
controller that `studio/stimulus` registers lazily, so a page without a
|
|
36
|
+
recording fetches neither. No migration, no setting, no new route. An app
|
|
37
|
+
without the recording columns shows no player and no link. README, *The
|
|
38
|
+
document page*, has every bound and what was measured at the caps.
|
|
39
|
+
- `Studio::KnowledgePreview.player_for(doc)` (a `Player` of `kind`, `url` and
|
|
40
|
+
`error`, or nil without a recording), `.clock(seconds)`, and two more kinds
|
|
41
|
+
from `kind_for`: `:video` and `:audio`. `Result` gains `cues` and
|
|
42
|
+
`cues_truncated`, and the kind `:transcript`.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **The style guide's Save and Regenerate toast success only when the server
|
|
47
|
+
wrote.** `studio/theme_editor` asks `/admin/theme` for JSON and counts a 2xx
|
|
48
|
+
from that request alone as saved; `ThemeSettingsController` answers the fetch
|
|
49
|
+
`204` after the write and `422` with the reason when it refuses. A redirect,
|
|
50
|
+
which the fetch does not follow, and a `401` toast "Save failed" with a
|
|
51
|
+
prompt to sign in: an ended session bounced the save to sign-in before and
|
|
52
|
+
the editor toasted "Theme saved" over nothing written. A save still sends one
|
|
53
|
+
request. A form post to `/admin/theme` keeps its redirect and flash.
|
|
54
|
+
|
|
7
55
|
## 0.99.0 — 2026-10-09
|
|
8
56
|
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- **Knowledge documents preview in the page.** `/admin/knowledge/:id` gains a
|
|
60
|
+
Preview section, so reading a document no longer means downloading it:
|
|
61
|
+
- `.xlsx` and `.xlsm` workbooks render as tables, one tab per sheet. A formula
|
|
62
|
+
shows the value Excel last calculated for it, and numbers render through
|
|
63
|
+
their number format (dates, percentages, currency, accounting negatives).
|
|
64
|
+
- `.csv` and `.tsv` render as one table.
|
|
65
|
+
- PDFs and images (`png`, `jpg`, `gif`, `webp`) display inline, fetched by the
|
|
66
|
+
browser from a 15-minute signed URL.
|
|
67
|
+
- Text, markdown, JSON and caption files display as preformatted text. Stored
|
|
68
|
+
content is always escaped, never rendered as HTML.
|
|
69
|
+
- Anything else (`.xls`, `.docx`, SVG), anything over a cap, and any file that
|
|
70
|
+
will not parse shows one sentence saying why, beside the download link. The
|
|
71
|
+
page never fails on a bad file.
|
|
72
|
+
|
|
73
|
+
The section is a lazy `<turbo-frame>` over a new admin-only endpoint,
|
|
74
|
+
`GET /admin/knowledge/:id/preview` (`admin_knowledge_doc_preview_path`), so the
|
|
75
|
+
show page itself reads nothing from the bucket. It rides the existing
|
|
76
|
+
`Studio.draw_knowledge_routes` opt-in; there is no migration and no new setting.
|
|
77
|
+
|
|
78
|
+
The caps, all constants on `Studio::KnowledgePreview`:
|
|
79
|
+
- A sheet: 500 rows and 50 columns. A cell: 32,767 characters (Excel's own
|
|
80
|
+
limit; cut with a mark past it).
|
|
81
|
+
- A workbook: 20 sheets, 50,000 filled cells, 100,000 rendered cells (empty
|
|
82
|
+
ones included) and 2 MB of cell text. The sheet that spends the last of one
|
|
83
|
+
of these is cut there and says so; later sheets are counted in a notice.
|
|
84
|
+
- A workbook read as a whole: a 20 MB file; 64 MB inflated across every part
|
|
85
|
+
read (`INFLATE_BUDGET`, a part read twice counted twice); 16 MB of text handed
|
|
86
|
+
over by the XML parser, kept or not (`MAX_PARSED_TEXT_BYTES`); and 10 seconds
|
|
87
|
+
of wall-clock time (`SPREADSHEET_DEADLINE_SECONDS`). Past any of these the
|
|
88
|
+
preview falls back to the download link.
|
|
89
|
+
- What the XML parser is given, enforced on the byte stream before it
|
|
90
|
+
(`Studio::KnowledgePreview::XmlGuard`): a part must be UTF-8 and open with
|
|
91
|
+
`<`; a part with a `<!DOCTYPE` is refused; a text node or CDATA section
|
|
92
|
+
stops at 1 MB; the start tags of all elements open at once stop at 64 KB
|
|
93
|
+
together; comments and processing instructions stop at 64 KB a part;
|
|
94
|
+
nesting stops at 256 and namespace declarations at 1,024. A workbook part in
|
|
95
|
+
UTF-16, or one carrying megabyte comments, falls back to the download link.
|
|
96
|
+
- A workbook's tables: 4,096 relationships and 4,096 sheets (more is refused);
|
|
97
|
+
4,096 custom number formats and 512 distinct formats in use (past either, a
|
|
98
|
+
number renders as General). A number format longer than Excel's 255
|
|
99
|
+
characters is ignored, and no number renders past thirty decimals.
|
|
100
|
+
- The first 1 MB of a CSV, the first 256 KB of a text file, and 100 MB for an
|
|
101
|
+
inline PDF or image.
|
|
102
|
+
|
|
103
|
+
A sheet or file cut by a cap says so and links the download.
|
|
104
|
+
- `Studio::KnowledgePreview` (`lib/studio/knowledge_preview.rb`), loaded on
|
|
105
|
+
first use and not by `require "studio"`: `kind_for(filename:, mime_type:)`,
|
|
106
|
+
`for(doc)` returning a `Result`, `read_spreadsheet(bytes)` and
|
|
107
|
+
`read_delimited(bytes)`. The workbook reader is the engine's own, on Zlib and
|
|
108
|
+
nokogiri; it adds no spreadsheet gem.
|
|
109
|
+
- `Studio::KnowledgeDoc#preview`, `#preview_kind` and `#filename`, and
|
|
110
|
+
`#signed_url(inline_as: "application/pdf")`.
|
|
111
|
+
- `Studio::S3.download(key:, max_bytes:)` reads only the head of an object (a
|
|
112
|
+
ranged GET; `max_bytes` must be a positive Integer), and `Studio::S3.signed_url`
|
|
113
|
+
takes `response_content_disposition:` and `response_content_type:`, signed into
|
|
114
|
+
the URL. Both are optional; existing calls are unchanged. The signing is tested
|
|
115
|
+
against an R2-style endpoint; that R2 serves the overridden headers is
|
|
116
|
+
documented by Cloudflare and not yet verified against a live bucket.
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- The gemspec declares `csv` (`>= 3.0`). It left Ruby's default gems in 3.4 and
|
|
121
|
+
the CSV preview requires it. Apps whose lockfile already names `csv` see no
|
|
122
|
+
change; the rest gain one pure-Ruby gem on their next engine bump.
|
|
123
|
+
|
|
9
124
|
### Fixed
|
|
10
125
|
|
|
11
126
|
- **A modal left open no longer comes back on Back when `studio/application`
|
|
@@ -365,70 +480,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
365
480
|
before. A non-http(s) String raises `ArgumentError` at boot; a callable
|
|
366
481
|
answering one draws no link. The link opens in a new tab with
|
|
367
482
|
`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.
|
|
432
483
|
|
|
433
484
|
### Changed
|
|
434
485
|
|
data/README.md
CHANGED
|
@@ -1127,8 +1127,8 @@ upload carried a Content-Disposition; supply it otherwise.
|
|
|
1127
1127
|
|
|
1128
1128
|
A knowledge document (`Studio::KnowledgeDoc`) is usually a meeting's transcript.
|
|
1129
1129
|
Its recording can be stored beside it, in the same private bucket, so the audio
|
|
1130
|
-
or video does not stay on a notetaker's servers.
|
|
1131
|
-
|
|
1130
|
+
or video does not stay on a notetaker's servers. The document page plays it
|
|
1131
|
+
beside the transcript: see [The document page](#the-document-page) below.
|
|
1132
1132
|
|
|
1133
1133
|
**Adopting it needs a migration.** Run `bin/rails studio_engine:install:migrations
|
|
1134
1134
|
&& bin/rails db:migrate`. It adds four nullable columns to
|
|
@@ -1277,6 +1277,91 @@ indented below and `Speaker • 0:02` with the text below, and `M:SS` or
|
|
|
1277
1277
|
text per cue, never raises, and answers no cues for text that is not a
|
|
1278
1278
|
transcript. `read(text)` answers the cues and whether a bound cut them short.
|
|
1279
1279
|
|
|
1280
|
+
### The document page
|
|
1281
|
+
|
|
1282
|
+
`/admin/knowledge/:id` shows a meeting as its recording beside its transcript.
|
|
1283
|
+
Everything below is in the page's preview frame (`GET /admin/knowledge/:id/preview`,
|
|
1284
|
+
admin only, `Cache-Control: no-store`); the show page itself reads nothing from
|
|
1285
|
+
the bucket and holds no signed URL.
|
|
1286
|
+
|
|
1287
|
+
- **The player.** A document with a recording (`recording?`) gets a native
|
|
1288
|
+
`<video controls preload="metadata">`, or `<audio controls>` when the
|
|
1289
|
+
recording is audio (`recording_kind`).
|
|
1290
|
+
- **The transcript.** A text document whose first 256 KB holds at least two cue
|
|
1291
|
+
lines (`Studio::KnowledgePreview::TRANSCRIPT_MIN_CUES`) is shown as cues: the
|
|
1292
|
+
time, the speaker and what was said. The same text is kept under them, closed,
|
|
1293
|
+
as "Plain text", so a file read as a transcript by mistake is still readable
|
|
1294
|
+
and the lines above the first cue are still on the page. Any other text file
|
|
1295
|
+
previews exactly as before.
|
|
1296
|
+
- **Click to play.** With a recording, a click on a cue's time or its line sets
|
|
1297
|
+
the player to that cue and plays. While it plays, the cue being spoken is
|
|
1298
|
+
marked and kept in view inside the cue list. Only the list is ever scrolled,
|
|
1299
|
+
never the page, and not for five seconds after the reader last scrolled the
|
|
1300
|
+
list. Without a recording the cues still render, with each time as plain text.
|
|
1301
|
+
- **The external link.** A document with no stored recording whose
|
|
1302
|
+
`recording_link` is an http(s) link shows "Open it where it was recorded", in
|
|
1303
|
+
a new tab with `rel="noopener noreferrer"`. A stored recording is played, not
|
|
1304
|
+
linked.
|
|
1305
|
+
- **Audio and video files.** A document whose own file is `mp4`, `m4v`, `mov`,
|
|
1306
|
+
`webm`, `ogv`, `mp3`, `m4a`, `wav`, `ogg`, `oga` or `weba` plays in the same
|
|
1307
|
+
player. It is served as the type its extension names (signed into the URL),
|
|
1308
|
+
never as the type that was stored.
|
|
1309
|
+
- **Layout.** From 64rem wide the player sits beside the cues; below that it sits
|
|
1310
|
+
above them. The cue list scrolls inside itself in both, so the player stays in
|
|
1311
|
+
view while the lines scroll.
|
|
1312
|
+
|
|
1313
|
+
**How the player gets its source.** The preview frame writes a presigned GET
|
|
1314
|
+
(`recording_url`, six hours) into the player's `src`. The alternative, an
|
|
1315
|
+
admin-only endpoint that redirects each request to a fresh URL, was not taken.
|
|
1316
|
+
It would keep the URL out of the frame's source, but not shorten its life: a
|
|
1317
|
+
browser that sends its later ranged requests straight to the redirected URL
|
|
1318
|
+
needs that URL to outlive the sitting all the same. And how each browser's
|
|
1319
|
+
player behaves through a cross-origin redirect is not something this gem's
|
|
1320
|
+
browser lane, which is Chromium only, could establish. The frame already
|
|
1321
|
+
writes a signed URL into a `src` for a PDF and an image; this is the same
|
|
1322
|
+
move with a longer life. The recording's bytes are reachable only through that
|
|
1323
|
+
URL, which is written only into an admin-only, `no-store` response. A page
|
|
1324
|
+
left open past six hours needs a reload before it will seek again.
|
|
1325
|
+
|
|
1326
|
+
**Stored content is text.** A cue's speaker and words are escaped. The only
|
|
1327
|
+
attribute a cue writes is its whole number of seconds. A player's `src` is a
|
|
1328
|
+
URL the engine signed and checked to be http(s); the external link is
|
|
1329
|
+
`recording_link`, which answers http(s) links only.
|
|
1330
|
+
|
|
1331
|
+
**The script.** `studio/knowledge_transcript` (logic, with `node:test` tests) and
|
|
1332
|
+
the `knowledge-transcript` controller, registered lazily by `studio/stimulus`
|
|
1333
|
+
like the geo manager's: a page with no recording never fetches either. The
|
|
1334
|
+
markup arrives inside a lazy `<turbo-frame>` and the controller connects when it
|
|
1335
|
+
does. If the module fails to load, the player keeps its own controls, the cues
|
|
1336
|
+
stay readable, and the page says click-to-play did not load.
|
|
1337
|
+
|
|
1338
|
+
**An app without the recording columns** renders the page as before: no player,
|
|
1339
|
+
no link, nothing signed. A transcript still shows as cues there, since that
|
|
1340
|
+
needs no column.
|
|
1341
|
+
|
|
1342
|
+
**The bounds.**
|
|
1343
|
+
|
|
1344
|
+
| What | Bound |
|
|
1345
|
+
|------|-------|
|
|
1346
|
+
| Bucket reads for one text preview | Two ranged GETs at most: 256 KB + 1 byte, then, only when that head was cut and already holds two cues, 2 MB + 1 byte (`Studio::KnowledgeTranscript::MAX_TEXT_BYTES`). 2,359,298 bytes in all |
|
|
1347
|
+
| Recording bytes read by the app | None. The URL is signed locally; the browser streams by range |
|
|
1348
|
+
| Text parsed | 2 MB a pass, two passes (the head, then the longer read) |
|
|
1349
|
+
| Cues rendered | 5,000 (`MAX_CUES`), all of them; past it, or past 2 MB, a notice says the transcript is longer than shown and links the download |
|
|
1350
|
+
| One cue | 8 KB of text, a 120-character speaker |
|
|
1351
|
+
| Plain text under the cues | The first 256 KB, as a text preview shows |
|
|
1352
|
+
| Page size | Six times the text kept (HTML escaping at its worst) plus about 250 bytes of markup a cue. 3.6 MB for 4,951 cues of ordinary speech at the 2 MB cap; 14.7 MB for 5,000 cues of nothing but quote characters, under a 16.2 MB ceiling the suite pins |
|
|
1353
|
+
| Script, on connect | One pass over the cues and one sort |
|
|
1354
|
+
| Script, per `timeupdate` | One binary search: 13 comparisons at 5,000 cues. The page is written only when the current cue changes (two class changes and one scroll of the list) |
|
|
1355
|
+
|
|
1356
|
+
Measured in headless Chromium on a development Mac, at 4,951 cues and 2 MB of
|
|
1357
|
+
text: 19,891 elements, the page loaded in 0.76 s and click-to-play was ready at
|
|
1358
|
+
0.90 s; a `timeupdate` inside one cue took 4 microseconds, and one that changed
|
|
1359
|
+
the cue 0.23 ms. Not measured: Safari, Firefox, or a phone.
|
|
1360
|
+
|
|
1361
|
+
**Not verified against a live bucket:** that R2 answers the player's ranged
|
|
1362
|
+
requests on a six-hour presigned URL, and that it serves a document's own audio
|
|
1363
|
+
or video file with the overridden content type.
|
|
1364
|
+
|
|
1280
1365
|
## Remote image URLs
|
|
1281
1366
|
|
|
1282
1367
|
`Studio::ImageCache.validate_source_url!(url)` is the check to run before the
|
|
@@ -121,9 +121,14 @@ module Studio
|
|
|
121
121
|
# one sentence, rendered beside the download link. The response is
|
|
122
122
|
# no-store because it holds either the document's contents or a signed
|
|
123
123
|
# URL to them.
|
|
124
|
+
#
|
|
125
|
+
# The recording's player is rendered here too, and only here: its source
|
|
126
|
+
# is a six-hour signed URL, which belongs in this no-store response and
|
|
127
|
+
# not in the show page. Signing reads nothing from the bucket.
|
|
124
128
|
def preview
|
|
125
129
|
@preview = @doc.preview
|
|
126
|
-
|
|
130
|
+
@player = Studio::KnowledgePreview.player_for(@doc)
|
|
131
|
+
[@preview.error, @player&.error].compact.each { |error| log_preview_failure(error) }
|
|
127
132
|
response.headers["Cache-Control"] = "no-store"
|
|
128
133
|
end
|
|
129
134
|
|
|
@@ -13,9 +13,11 @@ class ThemeSettingsController < ApplicationController
|
|
|
13
13
|
rescue_and_log(target: @theme_setting) do
|
|
14
14
|
@theme_setting.update!(theme_params)
|
|
15
15
|
Rails.cache.delete("studio/theme/#{Studio.app_name}")
|
|
16
|
-
|
|
16
|
+
done "Theme saved."
|
|
17
17
|
end
|
|
18
18
|
rescue StandardError => e
|
|
19
|
+
return render(json: { error: e.message }, status: :unprocessable_entity) if request.format.json?
|
|
20
|
+
|
|
19
21
|
@defaults = Studio.theme_config
|
|
20
22
|
@preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
|
|
21
23
|
flash.now[:alert] = "Error saving theme: #{e.message}"
|
|
@@ -24,11 +26,19 @@ class ThemeSettingsController < ApplicationController
|
|
|
24
26
|
|
|
25
27
|
def regenerate
|
|
26
28
|
Rails.cache.delete("studio/theme/#{Studio.app_name}")
|
|
27
|
-
|
|
29
|
+
done "Theme cache cleared."
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
private
|
|
31
33
|
|
|
34
|
+
# The style guide saves by fetch and asks for JSON: a 204 is its proof of the
|
|
35
|
+
# write. A form post is redirected back to the editor.
|
|
36
|
+
def done(notice)
|
|
37
|
+
return head(:no_content) if request.format.json?
|
|
38
|
+
|
|
39
|
+
redirect_to admin_theme_path, notice: notice
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
def theme_params
|
|
33
43
|
params.require(:theme_setting).permit(:primary, :accent1, :accent2, :warning, :danger, :dark, :light)
|
|
34
44
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// knowledge-transcript: a recording beside its transcript on the knowledge
|
|
2
|
+
// layer's document page, on the engine's Stimulus application (studio/stimulus,
|
|
3
|
+
// which reads data-studio-controller and registers this controller lazily). It
|
|
4
|
+
// sits on the transcript's wrapper, which arrives inside a lazy turbo-frame:
|
|
5
|
+
//
|
|
6
|
+
// <div data-studio-controller="knowledge-transcript">
|
|
7
|
+
// <video data-knowledge-player ...> <ol data-knowledge-cues> ...
|
|
8
|
+
//
|
|
9
|
+
// The seeking, the marking and the following are studio/knowledge_transcript.
|
|
10
|
+
import { Controller } from "@hotwired/stimulus"
|
|
11
|
+
import { mountTranscript } from "studio/knowledge_transcript"
|
|
12
|
+
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
connect() {
|
|
15
|
+
this.transcript = mountTranscript(this.element)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
disconnect() {
|
|
19
|
+
if (this.transcript) this.transcript.destroy()
|
|
20
|
+
this.transcript = null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// studio/knowledge_transcript: a meeting's recording beside its transcript, on
|
|
2
|
+
// the knowledge layer's document page (/admin/knowledge/:id). A click on a cue
|
|
3
|
+
// seeks the player there and plays; while it plays, the cue being spoken is
|
|
4
|
+
// marked and kept in view inside the cue list.
|
|
5
|
+
//
|
|
6
|
+
// The markup is studio/knowledge_docs/_preview_transcript:
|
|
7
|
+
//
|
|
8
|
+
// <div data-studio-controller="knowledge-transcript">
|
|
9
|
+
// <video data-knowledge-player controls ...></video>
|
|
10
|
+
// <ol data-knowledge-cues>
|
|
11
|
+
// <li data-seconds="62"><button type="button">1:02</button> ...</li>
|
|
12
|
+
//
|
|
13
|
+
// It arrives inside a lazy <turbo-frame>, after the page has loaded; the
|
|
14
|
+
// knowledge-transcript controller (registered lazily by studio/stimulus) mounts
|
|
15
|
+
// it whenever the element appears. Without this module the page still works:
|
|
16
|
+
// the player has its own controls and the cues are plain text.
|
|
17
|
+
//
|
|
18
|
+
// WHAT IT COSTS, since a transcript can hold 5,000 cues:
|
|
19
|
+
// - mounting reads each cue's data-seconds once and sorts the cues by time
|
|
20
|
+
// once (cueOrder);
|
|
21
|
+
// - a `timeupdate` (a browser fires several a second) is one binary search,
|
|
22
|
+
// MAX_SEARCH_STEPS comparisons at most whatever the cue count (cueAt), and
|
|
23
|
+
// touches the page only when the current cue CHANGED: two class writes, and
|
|
24
|
+
// one scroll of the cue list;
|
|
25
|
+
// - every cue shares the one click listener on the list.
|
|
26
|
+
//
|
|
27
|
+
// FOLLOWING NEVER FIGHTS THE READER. Only the cue list's own scrollTop is ever
|
|
28
|
+
// written, never the page's. When the reader scrolls the list, following stops
|
|
29
|
+
// for FOLLOW_PAUSE_MS after their last scroll; the scroll this module causes is
|
|
30
|
+
// told apart from theirs by the position it left the list at. A click on a cue
|
|
31
|
+
// resumes following at once.
|
|
32
|
+
//
|
|
33
|
+
// It imports nothing; test/javascript/knowledge_transcript.test.mjs loads it
|
|
34
|
+
// as a data: module. Bound to the page by the knowledge-transcript controller.
|
|
35
|
+
|
|
36
|
+
export const PLAYER = "[data-knowledge-player]"
|
|
37
|
+
export const CUES = "[data-knowledge-cues]"
|
|
38
|
+
export const CUE = "[data-seconds]"
|
|
39
|
+
export const CURRENT_CLASS = "knowledge-cue-current"
|
|
40
|
+
export const READY_ATTRIBUTE = "data-knowledge-transcript-ready"
|
|
41
|
+
|
|
42
|
+
// How long after the reader's last scroll of the cue list following stays off.
|
|
43
|
+
export const FOLLOW_PAUSE_MS = 5000
|
|
44
|
+
// How far down the list's visible height a followed cue is placed.
|
|
45
|
+
export const FOLLOW_OFFSET = 0.25
|
|
46
|
+
// The most halvings cueAt makes: enough for 2^32 cues.
|
|
47
|
+
export const MAX_SEARCH_STEPS = 32
|
|
48
|
+
|
|
49
|
+
// A cue's time in seconds, or NaN for anything that is not a time.
|
|
50
|
+
export function secondsOf(value) {
|
|
51
|
+
if (value === null || value === undefined || value === "") return NaN
|
|
52
|
+
const seconds = Number(value)
|
|
53
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : NaN
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The cue positions ordered by time, earliest first; cues that share a time
|
|
57
|
+
// keep their page order. A transcript is nearly always in time order already,
|
|
58
|
+
// but nothing promises it, and cueAt needs it. A cue with no time is left out.
|
|
59
|
+
export function cueOrder(seconds) {
|
|
60
|
+
const order = []
|
|
61
|
+
for (let index = 0; index < seconds.length; index++) {
|
|
62
|
+
if (Number.isFinite(seconds[index])) order.push(index)
|
|
63
|
+
}
|
|
64
|
+
return order.sort((a, b) => (seconds[a] - seconds[b]) || (a - b))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The position (in `seconds`) of the cue being spoken at `time`: the last cue,
|
|
68
|
+
// by time, that has started. -1 before the first cue. A binary search over
|
|
69
|
+
// `order`.
|
|
70
|
+
export function cueAt(order, seconds, time) {
|
|
71
|
+
if (!Number.isFinite(time)) return -1
|
|
72
|
+
let low = 0
|
|
73
|
+
let high = order.length
|
|
74
|
+
for (let step = 0; step < MAX_SEARCH_STEPS && low < high; step++) {
|
|
75
|
+
const middle = (low + high) >>> 1
|
|
76
|
+
if (seconds[order[middle]] <= time) low = middle + 1
|
|
77
|
+
else high = middle
|
|
78
|
+
}
|
|
79
|
+
return low === 0 ? -1 : order[low - 1]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Where the cue list should scroll to so the cue is in view, or null when it
|
|
83
|
+
// already is. Positions are in the list's own scroll coordinates.
|
|
84
|
+
// cueTop, cueHeight: the cue; scrollTop, viewHeight: the list's window
|
|
85
|
+
export function followTop({ cueTop, cueHeight, scrollTop, viewHeight }) {
|
|
86
|
+
if (cueTop >= scrollTop && cueTop + cueHeight <= scrollTop + viewHeight) return null
|
|
87
|
+
return Math.max(0, Math.round(cueTop - viewHeight * FOLLOW_OFFSET))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Whether the reader has text selected, in which case a click on a line is the
|
|
91
|
+
// end of a selection and not a request to seek.
|
|
92
|
+
function selecting(win) {
|
|
93
|
+
if (!win || typeof win.getSelection !== "function") return false
|
|
94
|
+
const selection = win.getSelection()
|
|
95
|
+
return !!selection && !selection.isCollapsed && String(selection).length > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Binds one transcript. `root` holds the player and the cue list. Returns
|
|
99
|
+
// { destroy, current } or null when either is missing. `now` and `win` are
|
|
100
|
+
// arguments so a test can supply them.
|
|
101
|
+
export function mountTranscript(root, { now = () => Date.now(), win = (typeof window === "undefined" ? null : window) } = {}) {
|
|
102
|
+
const media = root.querySelector(PLAYER)
|
|
103
|
+
const list = root.querySelector(CUES)
|
|
104
|
+
if (!media || !list) return null
|
|
105
|
+
|
|
106
|
+
const cues = Array.from(list.querySelectorAll(CUE))
|
|
107
|
+
const seconds = cues.map((cue) => secondsOf(cue.getAttribute("data-seconds")))
|
|
108
|
+
const order = cueOrder(seconds)
|
|
109
|
+
|
|
110
|
+
let current = -1
|
|
111
|
+
let pausedUntil = 0
|
|
112
|
+
// The scrollTop this module last left the list at; a scroll event that
|
|
113
|
+
// finds the list there is its own.
|
|
114
|
+
let placedAt = null
|
|
115
|
+
|
|
116
|
+
const follow = (cue) => {
|
|
117
|
+
if (now() < pausedUntil) return
|
|
118
|
+
const listBox = list.getBoundingClientRect()
|
|
119
|
+
const cueBox = cue.getBoundingClientRect()
|
|
120
|
+
const top = followTop({
|
|
121
|
+
cueTop: cueBox.top - listBox.top + list.scrollTop,
|
|
122
|
+
cueHeight: cueBox.height,
|
|
123
|
+
scrollTop: list.scrollTop,
|
|
124
|
+
viewHeight: list.clientHeight
|
|
125
|
+
})
|
|
126
|
+
if (top === null) return
|
|
127
|
+
list.scrollTop = top
|
|
128
|
+
placedAt = list.scrollTop
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const mark = () => {
|
|
132
|
+
const index = cueAt(order, seconds, media.currentTime)
|
|
133
|
+
if (index === current) return
|
|
134
|
+
if (current >= 0) {
|
|
135
|
+
cues[current].classList.remove(CURRENT_CLASS)
|
|
136
|
+
cues[current].removeAttribute("aria-current")
|
|
137
|
+
}
|
|
138
|
+
current = index
|
|
139
|
+
if (index < 0) return
|
|
140
|
+
cues[index].classList.add(CURRENT_CLASS)
|
|
141
|
+
cues[index].setAttribute("aria-current", "true")
|
|
142
|
+
follow(cues[index])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const seek = (event) => {
|
|
146
|
+
const target = event.target
|
|
147
|
+
const cue = target && typeof target.closest === "function" ? target.closest(CUE) : null
|
|
148
|
+
if (!cue || !list.contains(cue)) return
|
|
149
|
+
// The time itself is a button and always seeks; the rest of the line seeks
|
|
150
|
+
// unless the click finished a text selection.
|
|
151
|
+
const onButton = typeof target.closest === "function" && !!target.closest("button")
|
|
152
|
+
if (!onButton && selecting(win)) return
|
|
153
|
+
|
|
154
|
+
const at = secondsOf(cue.getAttribute("data-seconds"))
|
|
155
|
+
if (!Number.isFinite(at)) return
|
|
156
|
+
pausedUntil = 0
|
|
157
|
+
media.currentTime = at
|
|
158
|
+
const playing = typeof media.play === "function" ? media.play() : null
|
|
159
|
+
if (playing && typeof playing.catch === "function") playing.catch(() => {})
|
|
160
|
+
mark()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const scrolled = () => {
|
|
164
|
+
if (placedAt !== null && Math.abs(list.scrollTop - placedAt) < 1) return
|
|
165
|
+
placedAt = null
|
|
166
|
+
pausedUntil = now() + FOLLOW_PAUSE_MS
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
media.addEventListener("timeupdate", mark)
|
|
170
|
+
media.addEventListener("seeked", mark)
|
|
171
|
+
list.addEventListener("click", seek)
|
|
172
|
+
list.addEventListener("scroll", scrolled, { passive: true })
|
|
173
|
+
root.setAttribute(READY_ATTRIBUTE, "")
|
|
174
|
+
mark()
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
current: () => current,
|
|
178
|
+
destroy() {
|
|
179
|
+
media.removeEventListener("timeupdate", mark)
|
|
180
|
+
media.removeEventListener("seeked", mark)
|
|
181
|
+
list.removeEventListener("click", seek)
|
|
182
|
+
list.removeEventListener("scroll", scrolled)
|
|
183
|
+
root.removeAttribute(READY_ATTRIBUTE)
|
|
184
|
+
if (current >= 0) {
|
|
185
|
+
cues[current].classList.remove(CURRENT_CLASS)
|
|
186
|
+
cues[current].removeAttribute("aria-current")
|
|
187
|
+
}
|
|
188
|
+
current = -1
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -53,7 +53,9 @@ export const schema = {
|
|
|
53
53
|
// that must work whenever its page does is not listed here: a failed load is
|
|
54
54
|
// final for the document (studio/lazy_controllers). The board is listed: a
|
|
55
55
|
// board whose controller failed still renders, counts and toasts, and says that
|
|
56
|
-
// its cards do not drag (data-studio-controller-failed="board").
|
|
56
|
+
// its cards do not drag (data-studio-controller-failed="board"). So is the
|
|
57
|
+
// knowledge transcript: a recording whose controller failed still plays from
|
|
58
|
+
// its own controls, and its page says that click-to-play did not load.
|
|
57
59
|
export const LAZY = {
|
|
58
60
|
"board": () => import("studio/controllers/board_controller"),
|
|
59
61
|
"geo-settings": () => import("studio/controllers/geo_settings_controller"),
|
|
@@ -61,7 +63,8 @@ export const LAZY = {
|
|
|
61
63
|
"email-banner-scale": () => import("studio/controllers/email_banner_scale_controller"),
|
|
62
64
|
"booking": () => import("studio/controllers/booking_controller"),
|
|
63
65
|
"footer-map": () => import("studio/controllers/footer_map_controller"),
|
|
64
|
-
"survey": () => import("studio/controllers/survey_controller")
|
|
66
|
+
"survey": () => import("studio/controllers/survey_controller"),
|
|
67
|
+
"knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller")
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
export const application = Application.start(document.documentElement, schema)
|
|
@@ -137,20 +137,33 @@ export function themeEditor(seed) {
|
|
|
137
137
|
// The editor's form carries _method=patch, and sending the real method means
|
|
138
138
|
// nothing depends on Rack::MethodOverride parsing a multipart fetch body.
|
|
139
139
|
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// sends the save again on every hop
|
|
140
|
+
// It asks for JSON, which the controller answers with a 204 once it has
|
|
141
|
+
// written. NO REDIRECT IS FOLLOWED: a fetch that follows a 302 keeps a PATCH a
|
|
142
|
+
// PATCH and sends the save again on every hop.
|
|
143
143
|
export function formRequest(data) {
|
|
144
144
|
const override = data.get("_method")
|
|
145
145
|
if (override) data.delete("_method")
|
|
146
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
method: override ? String(override).toUpperCase() : "POST",
|
|
148
|
+
body: data,
|
|
149
|
+
headers: { Accept: "application/json" },
|
|
150
|
+
redirect: "manual"
|
|
151
|
+
}
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
// Whether the server took the save: a 2xx
|
|
150
|
-
//
|
|
151
|
-
//
|
|
154
|
+
// Whether the server took the save: a 2xx from the request itself. A redirect
|
|
155
|
+
// (opaque, because none is followed) is the admin gate turning the request
|
|
156
|
+
// away, and a refused save is a 422.
|
|
152
157
|
export function saved(response) {
|
|
153
|
-
return response.ok
|
|
158
|
+
return response.ok === true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const SIGNED_OUT = "Not signed in as an admin. Sign in and save again."
|
|
162
|
+
|
|
163
|
+
// Why a response that is not a save failed, for the toast.
|
|
164
|
+
export function failureReason(response) {
|
|
165
|
+
if (response.type === "opaqueredirect" || response.status === 401) return SIGNED_OUT
|
|
166
|
+
return "HTTP " + response.status
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
// x-data="dsThemeEditor({...})": style/_theme.
|
|
@@ -179,7 +192,7 @@ export function dsThemeEditor(seed) {
|
|
|
179
192
|
const request = formRequest(new FormData(form))
|
|
180
193
|
fetch(form.action, request)
|
|
181
194
|
.then((res) => {
|
|
182
|
-
if (!saved(res)) throw new Error(
|
|
195
|
+
if (!saved(res)) throw new Error(failureReason(res))
|
|
183
196
|
return res
|
|
184
197
|
})
|
|
185
198
|
.then(() => { this.toast("notice", label, "Saved in place — the page stayed put.") })
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
<%# A document's inline preview, by kind. locals: preview (a
|
|
2
|
-
Studio::KnowledgePreview::Result), doc
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
Studio::KnowledgePreview::Result), doc, and optionally player (a
|
|
3
|
+
Studio::KnowledgePreview::Player for the document's recording). Stored
|
|
4
|
+
content is only ever emitted as escaped text: a table cell, a <pre>, a
|
|
5
|
+
transcript cue, or (for a PDF, image, audio or video file) not at all,
|
|
6
|
+
the browser fetching it from a signed URL. %>
|
|
7
|
+
<% player = local_assigns[:player] %>
|
|
5
8
|
<%= render "studio/knowledge_docs/preview_styles" %>
|
|
9
|
+
<% if player && preview.kind != :transcript %>
|
|
10
|
+
<%# A recording beside a document that is not a transcript: the player
|
|
11
|
+
alone, above whatever the file shows. A transcript places its own. %>
|
|
12
|
+
<div class="knowledge-recording" id="knowledge-recording">
|
|
13
|
+
<%= render "studio/knowledge_docs/preview_player", player: player, title: doc.title %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
6
16
|
<div id="knowledge-preview-body" data-preview-kind="<%= preview.kind %>">
|
|
7
17
|
<% case preview.kind %>
|
|
18
|
+
<% when :transcript %>
|
|
19
|
+
<%= render "studio/knowledge_docs/preview_transcript", preview: preview, doc: doc, player: player %>
|
|
8
20
|
<% when :table %>
|
|
9
21
|
<% sheets = preview.sheets %>
|
|
10
22
|
<% if sheets.size > 1 || sheets.first.name.present? %>
|
|
@@ -60,7 +72,14 @@
|
|
|
60
72
|
<% when :image %>
|
|
61
73
|
<img class="knowledge-preview-image" id="knowledge-preview-image" src="<%= preview.url %>"
|
|
62
74
|
alt="<%= doc.title %>" referrerpolicy="no-referrer">
|
|
75
|
+
<% when :video, :audio %>
|
|
76
|
+
<%# The document's own file is the media. Served as the type its name
|
|
77
|
+
earned (signed into the URL), never as the type that was stored. %>
|
|
78
|
+
<%= render "studio/knowledge_docs/preview_player",
|
|
79
|
+
player: Studio::KnowledgePreview::Player.new(kind: preview.kind, url: preview.url),
|
|
80
|
+
title: doc.title, dom_id: "knowledge-preview-media" %>
|
|
63
81
|
<% else %>
|
|
64
|
-
|
|
82
|
+
<%# A document that is only a recording has no file to fall back from. %>
|
|
83
|
+
<%= render "studio/knowledge_docs/preview_fallback", reason: preview.reason, doc: doc unless player && !doc.file? %>
|
|
65
84
|
<% end %>
|
|
66
85
|
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# One native player. locals: player (a Studio::KnowledgePreview::Player),
|
|
2
|
+
title, and optionally dom_id. The source is a signed URL the engine made;
|
|
3
|
+
nothing a document holds reaches an attribute here but its title. The
|
|
4
|
+
transcript controller finds the element by data-knowledge-player. %>
|
|
5
|
+
<% dom_id = local_assigns[:dom_id] || "knowledge-player" %>
|
|
6
|
+
<% if !player.playable? %>
|
|
7
|
+
<p class="knowledge-preview-note" id="knowledge-player-unavailable">The recording could not be loaded. Reload the page to try again.</p>
|
|
8
|
+
<% elsif player.kind == :audio %>
|
|
9
|
+
<audio class="knowledge-player" id="<%= dom_id %>" data-knowledge-player
|
|
10
|
+
controls preload="metadata" src="<%= player.url %>" aria-label="<%= title %>"></audio>
|
|
11
|
+
<% else %>
|
|
12
|
+
<video class="knowledge-player knowledge-player-video" id="<%= dom_id %>" data-knowledge-player
|
|
13
|
+
controls playsinline preload="metadata" src="<%= player.url %>" aria-label="<%= title %>"></video>
|
|
14
|
+
<% end %>
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
host's Tailwind build may not scan this gem's views. Colours are slate at
|
|
3
3
|
low alpha over whatever the host paints, so one definition reads in a
|
|
4
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.
|
|
5
|
+
selectors: no script, so no dependency on the host's Alpine or its CSP.
|
|
6
|
+
|
|
7
|
+
The transcript is one grid: the player beside the cues from 64rem up, and
|
|
8
|
+
above them below that. The cue list is its own scroll box in both, so the
|
|
9
|
+
player stays where it is while the lines scroll. A cue looks clickable only
|
|
10
|
+
once the controller has connected (data-knowledge-transcript-ready). %>
|
|
6
11
|
<style>
|
|
7
12
|
.knowledge-preview-note { font-size: .8rem; opacity: .75; margin: 0 0 .5rem; }
|
|
8
13
|
.knowledge-preview-scroll { overflow: auto; max-height: 70vh; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
|
|
@@ -21,4 +26,35 @@
|
|
|
21
26
|
border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; opacity: .7; }
|
|
22
27
|
.knowledge-sheet-panel { display: none; }
|
|
23
28
|
.knowledge-sheet-radio:focus-visible ~ .knowledge-sheet-tabs { outline: 2px solid rgb(59 130 246 / .7); outline-offset: 2px; }
|
|
29
|
+
.knowledge-recording { margin-bottom: .75rem; }
|
|
30
|
+
.knowledge-player { display: block; width: 100%; border-radius: .375rem; }
|
|
31
|
+
.knowledge-player-video { max-height: 70vh; background: #000; }
|
|
32
|
+
.knowledge-transcript { display: grid; gap: .75rem; grid-template-columns: minmax(0, 1fr); }
|
|
33
|
+
.knowledge-transcript-hint, .knowledge-transcript-failed { display: none; margin: .5rem 0 0; }
|
|
34
|
+
[data-knowledge-transcript-ready] .knowledge-transcript-hint { display: block; }
|
|
35
|
+
[data-studio-controller-failed~="knowledge-transcript"] .knowledge-transcript-failed { display: block; }
|
|
36
|
+
.knowledge-cues { list-style: none; margin: 0; padding: 0; overflow: auto; max-height: 70vh; overscroll-behavior: contain;
|
|
37
|
+
border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
|
|
38
|
+
.knowledge-cue { display: grid; grid-template-columns: 4.25rem minmax(0, 1fr); column-gap: .5rem; padding: .4rem .6rem;
|
|
39
|
+
font-size: .8rem; line-height: 1.45; border-top: 1px solid rgb(148 163 184 / .2); }
|
|
40
|
+
.knowledge-cue:first-child { border-top: 0; }
|
|
41
|
+
.knowledge-cue-time { grid-row: 1 / span 2; align-self: start; justify-self: start; padding: 0; border: 0; background: none;
|
|
42
|
+
color: inherit; font: inherit; font-variant-numeric: tabular-nums; opacity: .7; }
|
|
43
|
+
.knowledge-cue-speaker { font-weight: 600; overflow-wrap: anywhere; }
|
|
44
|
+
.knowledge-cue-text { grid-column: 2; margin: 0; white-space: pre-line; overflow-wrap: anywhere; }
|
|
45
|
+
.knowledge-cue-current { background: rgb(59 130 246 / .16); box-shadow: inset 3px 0 0 rgb(59 130 246 / .8); }
|
|
46
|
+
[data-knowledge-transcript-ready] .knowledge-cue { cursor: pointer; }
|
|
47
|
+
[data-knowledge-transcript-ready] .knowledge-cue:hover { background: rgb(148 163 184 / .14); }
|
|
48
|
+
[data-knowledge-transcript-ready] .knowledge-cue-current:hover { background: rgb(59 130 246 / .22); }
|
|
49
|
+
[data-knowledge-transcript-ready] button.knowledge-cue-time { cursor: pointer; opacity: 1; text-decoration: underline; }
|
|
50
|
+
.knowledge-transcript-plain { font-size: .8rem; }
|
|
51
|
+
.knowledge-transcript-plain > summary { cursor: pointer; opacity: .75; margin-bottom: .5rem; }
|
|
52
|
+
@media (max-width: 63.99rem) {
|
|
53
|
+
.knowledge-transcript-with-player .knowledge-player-video { max-height: 35vh; }
|
|
54
|
+
.knowledge-transcript-with-player .knowledge-cues { max-height: 50vh; }
|
|
55
|
+
}
|
|
56
|
+
@media (min-width: 64rem) {
|
|
57
|
+
.knowledge-transcript-with-player { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
|
58
|
+
.knowledge-transcript-with-player .knowledge-transcript-plain { grid-column: 1 / -1; }
|
|
59
|
+
}
|
|
24
60
|
</style>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<%# A meeting transcript as cues: the time, who spoke, what they said.
|
|
2
|
+
locals: preview (kind :transcript), doc, player (or nil).
|
|
3
|
+
|
|
4
|
+
With a playable recording the wrapper names the knowledge-transcript
|
|
5
|
+
controller (studio/knowledge_transcript): a click on a cue seeks the
|
|
6
|
+
player, and the cue being spoken is marked and kept in view. Without one
|
|
7
|
+
there is no controller and each time is plain text.
|
|
8
|
+
|
|
9
|
+
Every cue's speaker and text came out of a stored file and is emitted
|
|
10
|
+
escaped. The only attribute a cue writes is its whole number of seconds.
|
|
11
|
+
|
|
12
|
+
One listener serves every cue (the controller listens on the list), so
|
|
13
|
+
the cues carry no action attribute of their own. %>
|
|
14
|
+
<% playable = player&.playable? %>
|
|
15
|
+
<div class="knowledge-transcript<%= " knowledge-transcript-with-player" if player %>" id="knowledge-transcript"<% if playable %> data-studio-controller="knowledge-transcript"<% end %>>
|
|
16
|
+
<% if player %>
|
|
17
|
+
<div id="knowledge-transcript-player">
|
|
18
|
+
<%= render "studio/knowledge_docs/preview_player", player: player, title: doc.title %>
|
|
19
|
+
<% if playable %>
|
|
20
|
+
<p class="knowledge-preview-note knowledge-transcript-hint" id="knowledge-transcript-hint">Click a line to play from there.</p>
|
|
21
|
+
<p class="knowledge-preview-note knowledge-transcript-failed" id="knowledge-transcript-failed">Click-to-play could not load. The player still works; reload the page to try again.</p>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
<div id="knowledge-transcript-lines">
|
|
26
|
+
<% if preview.cues_truncated %>
|
|
27
|
+
<p class="knowledge-preview-note" id="knowledge-transcript-truncated" data-transcript-truncated>
|
|
28
|
+
This transcript is longer than the page shows;
|
|
29
|
+
<%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
|
|
30
|
+
</p>
|
|
31
|
+
<% end %>
|
|
32
|
+
<ol class="knowledge-cues" id="knowledge-cues" data-knowledge-cues>
|
|
33
|
+
<% preview.cues.each do |cue| %>
|
|
34
|
+
<li class="knowledge-cue" data-seconds="<%= cue.seconds.to_i %>">
|
|
35
|
+
<% if playable %>
|
|
36
|
+
<button type="button" class="knowledge-cue-time"><%= Studio::KnowledgePreview.clock(cue.seconds) %></button>
|
|
37
|
+
<% else %>
|
|
38
|
+
<span class="knowledge-cue-time"><%= Studio::KnowledgePreview.clock(cue.seconds) %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
<span class="knowledge-cue-speaker"><%= cue.speaker %></span>
|
|
41
|
+
<p class="knowledge-cue-text"><%= cue.text %></p>
|
|
42
|
+
</li>
|
|
43
|
+
<% end %>
|
|
44
|
+
</ol>
|
|
45
|
+
</div>
|
|
46
|
+
<%# What the text preview shows for this file, kept one click away: a file
|
|
47
|
+
read as a transcript by mistake is still readable, and the lines above
|
|
48
|
+
the first cue (a title, a date) are still on the page. %>
|
|
49
|
+
<details class="knowledge-transcript-plain" id="knowledge-transcript-plain">
|
|
50
|
+
<summary>Plain text</summary>
|
|
51
|
+
<% if preview.truncated %>
|
|
52
|
+
<p class="knowledge-preview-note" data-preview-truncated>
|
|
53
|
+
Showing the start of this file;
|
|
54
|
+
<%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
|
|
55
|
+
</p>
|
|
56
|
+
<% end %>
|
|
57
|
+
<pre class="knowledge-preview-text" id="knowledge-preview-text"><%= preview.text %></pre>
|
|
58
|
+
</details>
|
|
59
|
+
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<%# /admin/knowledge/:id — one document: metadata, access, download, the
|
|
2
|
-
inline preview
|
|
2
|
+
inline preview (with its recording and transcript), and the triage form
|
|
3
|
+
that files it out of the inbox. %>
|
|
3
4
|
<%= render "studio/knowledge_docs/chip_styles" %>
|
|
4
5
|
|
|
5
6
|
<div class="mb-4 text-sm">
|
|
@@ -34,10 +35,24 @@
|
|
|
34
35
|
</div>
|
|
35
36
|
<% end %>
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
<%# The recording is a second object beside the file. Both readers answer
|
|
39
|
+
"none" on a table without the recording columns, and this section then
|
|
40
|
+
renders exactly as it did before them. %>
|
|
41
|
+
<% recording = @doc.recording? %>
|
|
42
|
+
<% recording_link = recording ? nil : @doc.recording_link %>
|
|
43
|
+
<% if @doc.file? || recording || recording_link %>
|
|
38
44
|
<div id="knowledge-preview-section" class="mb-6">
|
|
39
|
-
<h2 class="font-semibold mb-2"
|
|
40
|
-
<% if
|
|
45
|
+
<h2 class="font-semibold mb-2"><%= @doc.file? ? "Preview" : "Recording" %></h2>
|
|
46
|
+
<% if recording_link %>
|
|
47
|
+
<%# No recording is stored, only where it lives. recording_link answers
|
|
48
|
+
http(s) links alone, so nothing else can reach this href. %>
|
|
49
|
+
<p id="knowledge-recording-link" class="text-sm mb-2">
|
|
50
|
+
The recording is not stored here.
|
|
51
|
+
<%= link_to "Open it where it was recorded", recording_link, id: "knowledge-recording-open",
|
|
52
|
+
target: "_blank", rel: "noopener noreferrer" %>
|
|
53
|
+
</p>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% if @doc.preview_kind || recording %>
|
|
41
56
|
<%# Lazy: the frame fetches the preview once it scrolls into view, so
|
|
42
57
|
this page never waits on a bucket read or a parse. Without Turbo
|
|
43
58
|
the link inside is the way in. %>
|
|
@@ -47,7 +62,7 @@
|
|
|
47
62
|
<%= link_to "Open it", admin_knowledge_doc_preview_path(@doc), id: "knowledge-preview-open" %>
|
|
48
63
|
</p>
|
|
49
64
|
</turbo-frame>
|
|
50
|
-
<%
|
|
65
|
+
<% elsif @doc.file? %>
|
|
51
66
|
<%# A kind with no preview is known from the row alone: say so here
|
|
52
67
|
rather than spend a request to learn it. %>
|
|
53
68
|
<p id="knowledge-preview-fallback" class="text-sm opacity-70">
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
Save-in-place: unlike the standalone /admin/theme page (which redirects to
|
|
7
7
|
itself on save), THIS section intercepts Save + Regenerate and submits them
|
|
8
8
|
via fetch — the page never navigates away, the live preview stays put, and
|
|
9
|
-
the outcome is a toast. The
|
|
9
|
+
the outcome is a toast. The fetch asks for JSON and the controller answers
|
|
10
|
+
it 204 once it has written; the standalone page's redirect is unchanged.
|
|
10
11
|
The editor is studio/theme_editor's dsThemeEditor factory, which
|
|
11
12
|
studio/alpine_shims publishes.
|
|
12
13
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
4
|
# What /admin/knowledge/:id shows of a document without a download: a
|
|
5
|
-
# workbook or CSV as tables, a PDF or image inline, text as text
|
|
5
|
+
# workbook or CSV as tables, a PDF or image inline, text as text, a meeting
|
|
6
|
+
# transcript as timestamped cues, and an audio or video file in a player.
|
|
6
7
|
#
|
|
7
8
|
# NOT required by lib/studio.rb. Studio::KnowledgeDoc requires it on first
|
|
8
9
|
# use, and the spreadsheet reader (nokogiri, zlib) and `csv` load later
|
|
@@ -51,6 +52,27 @@ module Studio
|
|
|
51
52
|
# previews its first rows.
|
|
52
53
|
# TEXT_HEAD_BYTES likewise for text.
|
|
53
54
|
# INLINE_MAX_BYTES a PDF or image past this is not embedded.
|
|
55
|
+
#
|
|
56
|
+
# THE TRANSCRIPT PATH. A text file is read as today (one ranged GET of
|
|
57
|
+
# TEXT_HEAD_BYTES + 1). Only when that head was cut short AND already holds
|
|
58
|
+
# TRANSCRIPT_MIN_CUES cues is the object read a second time, up to the
|
|
59
|
+
# transcript parser's own cap:
|
|
60
|
+
#
|
|
61
|
+
# bucket reads at most two GETs for one preview: 256 KB + 1, then
|
|
62
|
+
# Studio::KnowledgeTranscript::MAX_TEXT_BYTES + 1
|
|
63
|
+
# (2 MB + 1). 2,359,298 bytes in all.
|
|
64
|
+
# text parsed the parser stops at MAX_TEXT_BYTES whatever it is
|
|
65
|
+
# handed; it runs once on the head and once on the
|
|
66
|
+
# longer read.
|
|
67
|
+
# cues KnowledgeTranscript::MAX_CUES (5,000), every one
|
|
68
|
+
# rendered; their text is part of the 2 MB parsed,
|
|
69
|
+
# so it totals 2 MB at most, 8 KB a cue, and a
|
|
70
|
+
# speaker is 120 characters at most.
|
|
71
|
+
# plain text kept the same TEXT_HEAD_BYTES head a text file shows,
|
|
72
|
+
# rendered collapsed under the cues.
|
|
73
|
+
#
|
|
74
|
+
# An audio or video file is never read here: the browser streams it from a
|
|
75
|
+
# signed URL by range, so no size cap applies to it.
|
|
54
76
|
module KnowledgePreview
|
|
55
77
|
class Error < StandardError; end
|
|
56
78
|
# The file is not what its name says, or is damaged. The message completes
|
|
@@ -73,6 +95,14 @@ module Studio
|
|
|
73
95
|
DELIMITED_HEAD_BYTES = 1 * MEGABYTE
|
|
74
96
|
TEXT_HEAD_BYTES = 256 * 1024
|
|
75
97
|
INLINE_MAX_BYTES = 100 * MEGABYTE
|
|
98
|
+
# Cue lines a text file's head must hold before it is shown as a
|
|
99
|
+
# transcript. One line that happens to read "Agenda • 9:00" is not one.
|
|
100
|
+
TRANSCRIPT_MIN_CUES = 2
|
|
101
|
+
# How long a signed URL for an audio or video file is good for. The same
|
|
102
|
+
# six hours, for the same reason, as a recording's
|
|
103
|
+
# (Studio::KnowledgeDoc::Recording::RECORDING_URL_TTL): the player makes a
|
|
104
|
+
# new ranged request on every seek, so the URL must outlive the sitting.
|
|
105
|
+
MEDIA_URL_TTL = 6 * 60 * 60
|
|
76
106
|
|
|
77
107
|
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
78
108
|
|
|
@@ -82,7 +112,10 @@ module Studio
|
|
|
82
112
|
".pdf" => :pdf,
|
|
83
113
|
".png" => :image, ".jpg" => :image, ".jpeg" => :image, ".gif" => :image, ".webp" => :image,
|
|
84
114
|
".txt" => :text, ".md" => :text, ".markdown" => :text, ".log" => :text,
|
|
85
|
-
".json" => :text, ".vtt" => :text, ".srt" => :text
|
|
115
|
+
".json" => :text, ".vtt" => :text, ".srt" => :text,
|
|
116
|
+
".mp4" => :video, ".m4v" => :video, ".mov" => :video, ".webm" => :video, ".ogv" => :video,
|
|
117
|
+
".mp3" => :audio, ".m4a" => :audio, ".wav" => :audio, ".ogg" => :audio, ".oga" => :audio,
|
|
118
|
+
".weba" => :audio
|
|
86
119
|
}.freeze
|
|
87
120
|
|
|
88
121
|
KIND_BY_MIME = {
|
|
@@ -90,7 +123,10 @@ module Studio
|
|
|
90
123
|
"text/csv" => :delimited, "text/tab-separated-values" => :delimited,
|
|
91
124
|
"application/pdf" => :pdf,
|
|
92
125
|
"image/png" => :image, "image/jpeg" => :image, "image/gif" => :image, "image/webp" => :image,
|
|
93
|
-
"application/json" => :text
|
|
126
|
+
"application/json" => :text,
|
|
127
|
+
"video/mp4" => :video, "video/quicktime" => :video, "video/webm" => :video, "video/ogg" => :video,
|
|
128
|
+
"audio/mpeg" => :audio, "audio/mp4" => :audio, "audio/wav" => :audio, "audio/ogg" => :audio,
|
|
129
|
+
"audio/webm" => :audio
|
|
94
130
|
}.freeze
|
|
95
131
|
|
|
96
132
|
# The content type an inline object is SERVED as. Chosen here, from the
|
|
@@ -101,8 +137,14 @@ module Studio
|
|
|
101
137
|
INLINE_TYPE_BY_EXTENSION = {
|
|
102
138
|
".pdf" => "application/pdf",
|
|
103
139
|
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
104
|
-
".gif" => "image/gif", ".webp" => "image/webp"
|
|
140
|
+
".gif" => "image/gif", ".webp" => "image/webp",
|
|
141
|
+
".mp4" => "video/mp4", ".m4v" => "video/mp4", ".mov" => "video/quicktime",
|
|
142
|
+
".webm" => "video/webm", ".ogv" => "video/ogg",
|
|
143
|
+
".mp3" => "audio/mpeg", ".m4a" => "audio/mp4", ".wav" => "audio/wav",
|
|
144
|
+
".ogg" => "audio/ogg", ".oga" => "audio/ogg", ".weba" => "audio/webm"
|
|
105
145
|
}.freeze
|
|
146
|
+
# The kinds a browser streams by range from a signed URL.
|
|
147
|
+
MEDIA_KINDS = %i[video audio].freeze
|
|
106
148
|
|
|
107
149
|
Cell = Struct.new(:text, :numeric)
|
|
108
150
|
# rows is a rectangle of Cell-or-nil. row_limit is the row count the
|
|
@@ -110,14 +152,27 @@ module Studio
|
|
|
110
152
|
Sheet = Struct.new(:name, :rows, :truncated_rows, :truncated_columns, :hidden, :row_limit,
|
|
111
153
|
keyword_init: true)
|
|
112
154
|
|
|
113
|
-
# kind: :table, :pdf, :image, :text or
|
|
155
|
+
# kind: :table, :pdf, :image, :video, :audio, :text, :transcript or
|
|
156
|
+
# :fallback.
|
|
157
|
+
# A :transcript carries `cues` (Studio::KnowledgeTranscript::Cue, frozen)
|
|
158
|
+
# and `cues_truncated` (a bound cut the cues short), plus the same `text`
|
|
159
|
+
# head and `truncated` flag a :text result has.
|
|
114
160
|
# error: the unexpected exception behind a fallback, for the caller to
|
|
115
161
|
# log; nil when the fallback is an ordinary refusal.
|
|
116
162
|
Result = Struct.new(:kind, :sheets, :omitted_sheets, :text, :truncated, :url, :reason, :error,
|
|
117
|
-
keyword_init: true) do
|
|
163
|
+
:cues, :cues_truncated, keyword_init: true) do
|
|
118
164
|
def fallback? = kind == :fallback
|
|
119
165
|
end
|
|
120
166
|
|
|
167
|
+
# What the page needs to play a document's recording: kind is :video or
|
|
168
|
+
# :audio and url a signed http(s) URL. When the URL could not be signed
|
|
169
|
+
# both are nil and `error` holds why, for the caller to log.
|
|
170
|
+
Player = Struct.new(:kind, :url, :error, keyword_init: true) do
|
|
171
|
+
def playable? = !url.nil?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
WEB_URL = %r{\Ahttps?://}i
|
|
175
|
+
|
|
121
176
|
NUMERIC_TEXT = /\A\(?[-+$€£]?\s?\d[\d,]*(\.\d+)?%?\)?\z/
|
|
122
177
|
|
|
123
178
|
class << self
|
|
@@ -164,7 +219,7 @@ module Studio
|
|
|
164
219
|
when :spreadsheet then spreadsheet(doc, storage)
|
|
165
220
|
when :delimited then delimited(doc, storage)
|
|
166
221
|
when :text then text(doc, storage)
|
|
167
|
-
when :pdf, :image then inline(doc, kind)
|
|
222
|
+
when :pdf, :image, :video, :audio then inline(doc, kind)
|
|
168
223
|
else fallback(no_preview_reason(doc))
|
|
169
224
|
end
|
|
170
225
|
rescue TooLarge => e
|
|
@@ -178,6 +233,31 @@ module Studio
|
|
|
178
233
|
fallback("The preview could not be built.", error: e)
|
|
179
234
|
end
|
|
180
235
|
|
|
236
|
+
# The Player for a document's recording, or nil when it has none (and
|
|
237
|
+
# on a table without the recording columns). Signs a URL and reads
|
|
238
|
+
# nothing. Never raises: a URL that cannot be signed is a Player that is
|
|
239
|
+
# not playable.
|
|
240
|
+
def player_for(doc)
|
|
241
|
+
return nil unless doc.respond_to?(:recording?) && doc.recording?
|
|
242
|
+
|
|
243
|
+
url = doc.recording_url.to_s
|
|
244
|
+
# The signer only ever answers http(s). This is the last check before
|
|
245
|
+
# the value becomes a src attribute.
|
|
246
|
+
raise Error, "the recording's signed URL is not an http(s) URL" unless url.match?(WEB_URL)
|
|
247
|
+
|
|
248
|
+
Player.new(kind: doc.recording_kind == :audio ? :audio : :video, url: url)
|
|
249
|
+
rescue StandardError, LoadError => e
|
|
250
|
+
Player.new(error: e)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# 62 -> "1:02", 3725 -> "1:02:05": how a cue's time reads on the page.
|
|
254
|
+
def clock(seconds)
|
|
255
|
+
total = [seconds.to_i, 0].max
|
|
256
|
+
hours, rest = total.divmod(3600)
|
|
257
|
+
minutes, secs = rest.divmod(60)
|
|
258
|
+
hours.positive? ? format("%d:%02d:%02d", hours, minutes, secs) : format("%d:%02d", minutes, secs)
|
|
259
|
+
end
|
|
260
|
+
|
|
181
261
|
# --- readers, usable without a document ---------------------------------
|
|
182
262
|
|
|
183
263
|
def read_spreadsheet(bytes, **overrides)
|
|
@@ -296,19 +376,46 @@ module Studio
|
|
|
296
376
|
end
|
|
297
377
|
|
|
298
378
|
def text(doc, storage)
|
|
299
|
-
|
|
379
|
+
store = storage || Studio::S3
|
|
380
|
+
bytes = store.download(key: doc.s3_key, max_bytes: TEXT_HEAD_BYTES + 1)
|
|
300
381
|
partial = bytes.bytesize > TEXT_HEAD_BYTES
|
|
301
|
-
|
|
382
|
+
head = decode(bytes.byteslice(0, TEXT_HEAD_BYTES), partial: partial)
|
|
383
|
+
transcript(doc, store, head, partial) || Result.new(kind: :text, text: head, truncated: partial)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The :transcript Result when `head` reads as one, else nil. `head` is
|
|
387
|
+
# the text preview's own read. A head that was the whole file is all
|
|
388
|
+
# there is to parse; a head that was cut is read again, once, up to the
|
|
389
|
+
# parser's cap, because a meeting's transcript is often longer than the
|
|
390
|
+
# 256 KB a text preview shows.
|
|
391
|
+
def transcript(doc, store, head, partial)
|
|
392
|
+
require "studio/knowledge_transcript"
|
|
393
|
+
reading = Studio::KnowledgeTranscript.read(head)
|
|
394
|
+
return nil if reading.cues.size < TRANSCRIPT_MIN_CUES
|
|
395
|
+
|
|
396
|
+
cut = false
|
|
397
|
+
if partial
|
|
398
|
+
cap = Studio::KnowledgeTranscript::MAX_TEXT_BYTES
|
|
399
|
+
bytes = store.download(key: doc.s3_key, max_bytes: cap + 1)
|
|
400
|
+
cut = bytes.bytesize > cap
|
|
401
|
+
reading = Studio::KnowledgeTranscript.read(decode(bytes.byteslice(0, cap), partial: cut))
|
|
402
|
+
end
|
|
403
|
+
Result.new(kind: :transcript, cues: reading.cues, cues_truncated: cut || reading.truncated?,
|
|
404
|
+
text: head, truncated: partial)
|
|
302
405
|
end
|
|
303
406
|
|
|
304
407
|
def inline(doc, kind)
|
|
305
|
-
|
|
408
|
+
media = MEDIA_KINDS.include?(kind)
|
|
409
|
+
# A player streams by range, so an audio or video file has no size at
|
|
410
|
+
# which showing it costs more.
|
|
411
|
+
refuse_over!(doc, INLINE_MAX_BYTES) unless media
|
|
306
412
|
content_type = inline_content_type(filename: doc.filename, mime_type: doc.mime_type)
|
|
307
413
|
# Without a type of our choosing the URL would be signed to serve the
|
|
308
414
|
# STORED type, which is the one thing this path exists to prevent.
|
|
309
415
|
raise Unreadable, "its type cannot be shown inline" if content_type.nil?
|
|
310
416
|
|
|
311
|
-
|
|
417
|
+
url = media ? doc.signed_url(expires_in: MEDIA_URL_TTL, inline_as: content_type) : doc.signed_url(inline_as: content_type)
|
|
418
|
+
Result.new(kind: kind, url: url)
|
|
312
419
|
end
|
|
313
420
|
|
|
314
421
|
def refuse_over!(doc, cap)
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.100.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -438,6 +438,7 @@ files:
|
|
|
438
438
|
- app/javascript/studio/controllers/geo_settings_controller.js
|
|
439
439
|
- app/javascript/studio/controllers/hold_button_controller.js
|
|
440
440
|
- app/javascript/studio/controllers/identity_mini_controller.js
|
|
441
|
+
- app/javascript/studio/controllers/knowledge_transcript_controller.js
|
|
441
442
|
- app/javascript/studio/controllers/link_preview_card_controller.js
|
|
442
443
|
- app/javascript/studio/controllers/link_sidebar_controller.js
|
|
443
444
|
- app/javascript/studio/controllers/modal_host_controller.js
|
|
@@ -455,6 +456,7 @@ files:
|
|
|
455
456
|
- app/javascript/studio/hold_button_hooks.js
|
|
456
457
|
- app/javascript/studio/identity_mini.js
|
|
457
458
|
- app/javascript/studio/image_upload.js
|
|
459
|
+
- app/javascript/studio/knowledge_transcript.js
|
|
458
460
|
- app/javascript/studio/lazy_controllers.js
|
|
459
461
|
- app/javascript/studio/leveling_activity.js
|
|
460
462
|
- app/javascript/studio/link_preview_card.js
|
|
@@ -580,8 +582,10 @@ files:
|
|
|
580
582
|
- app/views/studio/knowledge_docs/_doc_row.html.erb
|
|
581
583
|
- app/views/studio/knowledge_docs/_preview.html.erb
|
|
582
584
|
- app/views/studio/knowledge_docs/_preview_fallback.html.erb
|
|
585
|
+
- app/views/studio/knowledge_docs/_preview_player.html.erb
|
|
583
586
|
- app/views/studio/knowledge_docs/_preview_sheet.html.erb
|
|
584
587
|
- app/views/studio/knowledge_docs/_preview_styles.html.erb
|
|
588
|
+
- app/views/studio/knowledge_docs/_preview_transcript.html.erb
|
|
585
589
|
- app/views/studio/knowledge_docs/_upload_form.html.erb
|
|
586
590
|
- app/views/studio/knowledge_docs/coverage.html.erb
|
|
587
591
|
- app/views/studio/knowledge_docs/index.html.erb
|