studio-engine 0.99.0.rc1 → 0.100.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a72dfbacf609fb2070a5230c3ef4ca8fe2ab0b8f876de2e18894b21e3338cfc
4
- data.tar.gz: ebcf585ecf8450717ad1d0cd585825777f8f656095559fefac37415d375549b5
3
+ metadata.gz: ccf0cc77e250c21faca747741337d7b492defe5b3af054169b2e78a89a0908a8
4
+ data.tar.gz: 9fcfe0bcdcfd78e53fc67e0d627cabaf40c822428d997756451ad96b11c34f2d
5
5
  SHA512:
6
- metadata.gz: 823e980eb4f59d60405f349f5b6dacc104dc1c25759dd35fecfc94db7b4cbcd5c797ca241850a2f91deedf2e261d96c46a17d498993f2e19d558c189a14628d9
7
- data.tar.gz: 896ee84ba92c657fe476f14dee0db16bfa2ee87eba6ec7b5d87d1e29c5e08cf20fc5ea332e2a56433e69d0120dc2f727928c59b72c1e6e6de7c2c116fa38b7c7
6
+ metadata.gz: 8fa59ab9b1cc9538065e73d21264a7cf49b9afb21d0a4e030091ffe4edaf19a66f511fa00a4a17c8303c0531af1866cfa94e343b7725ed40a187f605a04e7aed
7
+ data.tar.gz: a90ebd5d35c3f7ab4964bf7ca484d760d44d8fd9ced4c44f3f05dd3423c57c89e972685ed8bb6cadd20ee4eda313980e1312f30503b2845e3270ee27bb01b0fb
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. This is the backend; the
1131
- document page does not show a player yet.
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
- log_preview_failure(@preview.error) if @preview.error
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
- redirect_to admin_theme_path, notice: "Theme saved."
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
- redirect_to admin_theme_path, notice: "Theme cache cleared."
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
- // THE REDIRECT IS NOT FOLLOWED. The controller answers a save with a 302 back
141
- // to /admin/theme, and a fetch that follows a 302 keeps a PATCH a PATCH: it
142
- // sends the save again on every hop until the browser gives up.
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 { method: override ? String(override).toUpperCase() : "POST", body: data, redirect: "manual" }
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, or its own redirect (which a fetch
150
- // told not to follow reports as an opaque redirect). A failed save is rendered
151
- // in place with a 422.
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 || response.type === "opaqueredirect"
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("HTTP " + res.status)
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. 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. %>
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
- <%= render "studio/knowledge_docs/preview_fallback", reason: preview.reason, doc: doc %>
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>
@@ -8,5 +8,5 @@
8
8
  </div>
9
9
  <% end %>
10
10
  <turbo-frame id="knowledge-preview">
11
- <%= render "studio/knowledge_docs/preview", preview: @preview, doc: @doc %>
11
+ <%= render "studio/knowledge_docs/preview", preview: @preview, doc: @doc, player: @player %>
12
12
  </turbo-frame>
@@ -1,5 +1,6 @@
1
1
  <%# /admin/knowledge/:id — one document: metadata, access, download, the
2
- inline preview, and the triage form that files it out of the inbox. %>
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
- <% if @doc.file? %>
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">Preview</h2>
40
- <% if @doc.preview_kind %>
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
- <% else %>
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 standalone page's own redirect is unchanged.
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 :fallback.
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
- bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: TEXT_HEAD_BYTES + 1)
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
- Result.new(kind: :text, text: decode(bytes.byteslice(0, TEXT_HEAD_BYTES), partial: partial), truncated: partial)
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
- refuse_over!(doc, INLINE_MAX_BYTES)
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
- Result.new(kind: kind, url: doc.signed_url(inline_as: content_type))
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)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.99.0.rc1"
2
+ VERSION = "0.100.0.rc1"
3
3
  end
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.99.0.rc1
4
+ version: 0.100.0.rc1
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