studio-engine 0.95.0 → 0.96.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.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Turns a meeting transcript's text into CUES: (seconds, speaker, text), in
5
+ # the order they appear. The knowledge layer's player seeks to a cue's
6
+ # seconds when its line is clicked.
7
+ #
8
+ # Loaded on first use (`require "studio/knowledge_transcript"`), not by
9
+ # `require "studio"`.
10
+ #
11
+ # Two layouts are read, line by line, and may be mixed in one text:
12
+ #
13
+ # A. the stamp, a dash, then the speaker; the text indented below
14
+ # 0:02 - Jordan Example (Example Co)
15
+ # Good morning, everyone.
16
+ #
17
+ # B. the speaker, a bullet, then the stamp; the text on the lines below
18
+ # Jordan Example • 0:02
19
+ # Good morning, everyone.
20
+ #
21
+ # A stamp is M:SS or H:MM:SS. Lines before the first cue (a title, a "VIEW
22
+ # RECORDING" line, a rule) belong to no cue and are dropped. Text that is not
23
+ # a transcript has no cue lines and answers no cues. Nothing here raises on
24
+ # any String, whatever its bytes or encoding; a non-String answers no cues.
25
+ #
26
+ # A layout A cue line starts in the FIRST COLUMN. That is what keeps an
27
+ # indented line of speech such as " 10:30 - we agreed to meet" text.
28
+ #
29
+ # THE BOUNDS, each a constant below. There is one loop, over the lines of at
30
+ # most MAX_TEXT_BYTES of text, and every step in it is linear in the line:
31
+ # MAX_TEXT_BYTES 2 MB of text read; the rest is ignored
32
+ # MAX_CUES 5,000 cues; reading stops at the next one
33
+ # MAX_CUE_LINE_BYTES 512 bytes: a longer line is never a cue line, so no
34
+ # pattern is ever run against a long line
35
+ # MAX_STAMP_BYTES 10 bytes: the longest stamp, "999:59:59" and one spare
36
+ # MAX_SPEAKER_CHARS 120 characters; a longer "speaker" is a line of text
37
+ # MAX_CUE_TEXT_BYTES 8 KB of text kept for one cue; the rest is dropped
38
+ #
39
+ # The one pattern, STAMP, is anchored at both ends, has no nested or
40
+ # unbounded quantifier, and only ever sees a token of MAX_STAMP_BYTES or
41
+ # fewer. Everything else is `strip`, `index`, `rindex` and `start_with?`.
42
+ module KnowledgeTranscript
43
+ MAX_TEXT_BYTES = 2 * 1024 * 1024
44
+ MAX_CUES = 5_000
45
+ MAX_CUE_LINE_BYTES = 512
46
+ MAX_STAMP_BYTES = 10
47
+ MAX_SPEAKER_CHARS = 120
48
+ MAX_CUE_TEXT_BYTES = 8 * 1024
49
+
50
+ STAMP = /\A(?:(\d{1,3}):)?(\d{1,3}):(\d{2})\z/
51
+ # Layout A: what may stand between the stamp and the speaker.
52
+ DASHES = ["- ", "– ", "— "].freeze
53
+ # Layout B: what stands between the speaker and the stamp.
54
+ BULLET = "•"
55
+
56
+ Cue = Struct.new(:seconds, :speaker, :text)
57
+
58
+ # `cues` is frozen. `truncated` is true when a bound cut the reading short
59
+ # (text past MAX_TEXT_BYTES, cues past MAX_CUES, or a cue's text past
60
+ # MAX_CUE_TEXT_BYTES), so a page can say the transcript is incomplete.
61
+ Result = Struct.new(:cues, :truncated) do
62
+ def truncated? = truncated
63
+ end
64
+
65
+ class << self
66
+ # The cues in `text`, as an Array of Cue. Empty when there are none.
67
+ def parse(text)
68
+ read(text).cues
69
+ end
70
+
71
+ # The same reading, with whether a bound cut it short.
72
+ def read(text)
73
+ return Result.new([].freeze, false) unless text.is_a?(String)
74
+
75
+ truncated = text.bytesize > MAX_TEXT_BYTES
76
+ cues = []
77
+ current = nil
78
+
79
+ # Bounded: at most MAX_TEXT_BYTES of text, so at most that many lines.
80
+ utf8(truncated ? text.byteslice(0, MAX_TEXT_BYTES) : text).each_line do |line|
81
+ opening = cue_line(line)
82
+ if opening
83
+ if cues.size >= MAX_CUES
84
+ truncated = true
85
+ break
86
+ end
87
+ current = Cue.new(opening[0], opening[1], +"")
88
+ cues << current
89
+ elsif current
90
+ truncated = true unless append(current.text, line)
91
+ end
92
+ end
93
+
94
+ cues.each { |cue| cue.text.freeze }
95
+ Result.new(cues.freeze, truncated)
96
+ end
97
+
98
+ # Seconds for "M:SS" or "H:MM:SS", or nil when `token` is not a stamp.
99
+ def seconds_of(token)
100
+ return nil if token.bytesize > MAX_STAMP_BYTES
101
+
102
+ match = STAMP.match(token)
103
+ return nil unless match
104
+
105
+ hours, minutes, seconds = match[1], match[2].to_i, match[3].to_i
106
+ return nil if seconds > 59
107
+ return minutes * 60 + seconds if hours.nil?
108
+ return nil if minutes > 59
109
+
110
+ hours.to_i * 3600 + minutes * 60 + seconds
111
+ end
112
+
113
+ private
114
+
115
+ # Valid UTF-8 whatever came in. Bytes with no encoding claim (binary,
116
+ # ASCII) are read as UTF-8; another encoding is converted; whatever is
117
+ # invalid either way becomes U+FFFD.
118
+ def utf8(text)
119
+ claimed = text.encoding
120
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(claimed)
121
+ return text.dup.force_encoding(Encoding::UTF_8).scrub("�")
122
+ end
123
+
124
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
125
+ rescue EncodingError
126
+ text.dup.force_encoding(Encoding::UTF_8).scrub("�")
127
+ end
128
+
129
+ # [seconds, speaker] when `line` opens a cue, else nil.
130
+ def cue_line(line)
131
+ return nil if line.bytesize > MAX_CUE_LINE_BYTES
132
+
133
+ layout_a(line) || layout_b(line)
134
+ end
135
+
136
+ # "0:02 - Jordan Example (Example Co)", starting in the first column.
137
+ def layout_a(line)
138
+ gap = line.index(" ")
139
+ return nil if gap.nil? || gap.zero? || gap > MAX_STAMP_BYTES
140
+
141
+ seconds = seconds_of(line[0, gap])
142
+ return nil if seconds.nil?
143
+
144
+ rest = line[gap..].lstrip
145
+ return nil unless rest.start_with?(*DASHES)
146
+
147
+ speaker = speaker_of(rest[2..])
148
+ speaker && [seconds, speaker]
149
+ end
150
+
151
+ # "Jordan Example • 0:02": the LAST bullet, so a bullet in a name is kept.
152
+ def layout_b(line)
153
+ at = line.rindex(BULLET)
154
+ return nil if at.nil?
155
+
156
+ seconds = seconds_of(line[(at + 1)..].strip)
157
+ return nil if seconds.nil?
158
+
159
+ speaker = speaker_of(line[0, at])
160
+ speaker && [seconds, speaker]
161
+ end
162
+
163
+ def speaker_of(text)
164
+ speaker = text.strip
165
+ speaker.empty? || speaker.size > MAX_SPEAKER_CHARS ? nil : speaker
166
+ end
167
+
168
+ # Adds one line of speech to a cue's text. Answers false when the cue's
169
+ # text was already full or this line had to be cut to fit.
170
+ def append(text, line)
171
+ piece = line.strip
172
+ return true if piece.empty?
173
+
174
+ room = MAX_CUE_TEXT_BYTES - text.bytesize - (text.empty? ? 0 : 1)
175
+ return false if room <= 0
176
+
177
+ fits = piece.bytesize <= room
178
+ piece = piece.byteslice(0, room).scrub("") unless fits
179
+ text << "\n" unless text.empty?
180
+ text << piece
181
+ fits
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../s3"
4
+
5
+ module Studio
6
+ module S3
7
+ # Uploads a LOCAL FILE of any size without holding it in memory: an S3
8
+ # multipart upload, one part read and sent at a time. Studio::S3.upload
9
+ # takes a String and suits a document; a recording is hundreds of megabytes.
10
+ #
11
+ # Loaded on first use (`require "studio/s3/multipart"`), not by
12
+ # `require "studio"`.
13
+ #
14
+ # THE BOUNDS, all enforced in `upload_file`:
15
+ # memory one part (PART_SIZE, 16 MB) at a time, sent sequentially
16
+ # parts MAX_PARTS (10,000, the S3 limit); the loop is `1.upto(MAX_PARTS)`
17
+ # and the first short read ends it
18
+ # bytes `max_bytes:`, counted as parts are read, so a file that grows
19
+ # under the upload stops there
20
+ # time UPLOAD_DEADLINE (2 hours), read before each part. One request
21
+ # is bounded by the SDK client's own timeouts and retries
22
+ # (http_open_timeout 15 s, http_read_timeout 60 s, 3 retries;
23
+ # the suite pins those defaults), not by this deadline.
24
+ #
25
+ # WHAT A FAILURE LEAVES IN THE BUCKET:
26
+ # before completion nothing: the multipart upload is aborted on ANY
27
+ # way out, a signal (SIGTERM, Ctrl-C) included
28
+ # after completion nothing: an object whose size cannot be confirmed
29
+ # is deleted before the error is raised
30
+ # SIGKILL, power the parts sent so far, invisible to listings, until
31
+ # the bucket's abort-incomplete-multipart lifecycle
32
+ # rule removes them (R2's default rule: 7 days). No
33
+ # process can clean up after its own SIGKILL.
34
+ #
35
+ # CHECKSUMS AND R2. aws-sdk-s3 from 1.178 adds a CRC32 to uploads by default
36
+ # (`request_checksum_calculation: when_supported`), and R2 has refused some
37
+ # of the combinations that produces (the Active Storage rule in the hub's
38
+ # object-storage module). This uploader therefore uses its OWN client, built
39
+ # from Studio::S3.client_options plus `when_required` for both checksum
40
+ # settings, and sends a Content-MD5 with every part, which S3 and R2 both
41
+ # verify. The shared Studio::S3.client is untouched.
42
+ #
43
+ # UNPROVEN: every test of this file runs against a stubbed client. The
44
+ # request shape (no x-amz-checksum headers, Content-MD5 on each part) is
45
+ # asserted; that a live R2 bucket accepts it is not.
46
+ module Multipart
47
+ # 16 MB. R2 requires every part but the last to be the same size, and S3
48
+ # requires at least 5 MB; 16 MB puts a 1 GB recording in 64 parts.
49
+ PART_SIZE = 16 * 1024 * 1024
50
+ MIN_PART_SIZE = 5 * 1024 * 1024
51
+ # The S3 limit on parts in one upload.
52
+ MAX_PARTS = 10_000
53
+ # Seconds for the whole upload, read before each part. A 4 GB file is
54
+ # 256 parts; two hours allows about 28 s a part.
55
+ UPLOAD_DEADLINE = 7_200
56
+
57
+ # What an upload answers.
58
+ Uploaded = Struct.new(:key, :byte_size, :parts, keyword_init: true)
59
+
60
+ class << self
61
+ # Streams the file at `path` to `key` (a LOGICAL key, like every
62
+ # Studio::S3 call) and answers an Uploaded. Raises Studio::S3::Error on
63
+ # an empty file, a file over `max_bytes`, an upload past `deadline`
64
+ # seconds, or an object whose stored size does not match what was
65
+ # sent. See WHAT A FAILURE LEAVES IN THE BUCKET above.
66
+ def upload_file(key:, path:, content_type:, max_bytes:, part_size: PART_SIZE, deadline: UPLOAD_DEADLINE,
67
+ client: self.client)
68
+ unless max_bytes.is_a?(Integer) && max_bytes.positive?
69
+ raise ArgumentError, "max_bytes must be a positive Integer, got #{max_bytes.inspect}"
70
+ end
71
+ unless part_size.is_a?(Integer) && part_size >= MIN_PART_SIZE
72
+ raise ArgumentError, "part_size must be an Integer of at least #{MIN_PART_SIZE} bytes, got #{part_size.inspect}"
73
+ end
74
+
75
+ bucket = Studio::S3.bucket
76
+ target = { bucket: bucket, key: Studio::S3.full_key(key) }
77
+ stop_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + deadline
78
+
79
+ File.open(path.to_s, "rb") do |file|
80
+ size = file.size
81
+ raise Error, "refusing to upload an empty file: #{path}" if size.zero?
82
+ raise Error, "#{path} is #{size} bytes, over the #{max_bytes}-byte cap" if size > max_bytes
83
+
84
+ upload_id = client.create_multipart_upload(**target, content_type: content_type).upload_id
85
+ completed = false
86
+ begin
87
+ parts, sent = send_parts(client, target, upload_id, file, part_size, max_bytes, stop_at)
88
+ raise Error, "#{path} changed during the upload: #{size} bytes at open, #{sent} sent" unless sent == size
89
+
90
+ client.complete_multipart_upload(**target, upload_id: upload_id, multipart_upload: { parts: parts })
91
+ completed = true
92
+ ensure
93
+ # `ensure`, not `rescue StandardError`: a SIGTERM or Ctrl-C is
94
+ # not a StandardError, and parts left behind are privileged
95
+ # bytes no listing shows.
96
+ abort_quietly(client, target, upload_id) unless completed
97
+ end
98
+
99
+ verify_stored_size!(client, target, sent)
100
+ Uploaded.new(key: key, byte_size: sent, parts: parts.size)
101
+ end
102
+ end
103
+
104
+ # A client for multipart uploads: Studio::S3's own options, with both
105
+ # checksum settings at when_required (see CHECKSUMS AND R2 above).
106
+ def client
107
+ @client ||= begin
108
+ require "aws-sdk-s3"
109
+ Aws::S3::Client.new(**client_options)
110
+ end
111
+ end
112
+
113
+ def client_options
114
+ Studio::S3.client_options.merge(
115
+ request_checksum_calculation: "when_required",
116
+ response_checksum_validation: "when_required"
117
+ )
118
+ end
119
+
120
+ def reset!
121
+ @client = nil
122
+ end
123
+
124
+ private
125
+
126
+ # Reads and sends one part at a time. Bounded four ways: the loop runs
127
+ # at most MAX_PARTS times, each read is at most part_size bytes, the
128
+ # running total may not pass max_bytes, and the first short read ends it.
129
+ def send_parts(client, target, upload_id, file, part_size, max_bytes, stop_at)
130
+ require "digest/md5"
131
+ parts = []
132
+ sent = 0
133
+ 1.upto(MAX_PARTS) do |number|
134
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > stop_at
135
+ raise Error, "the upload passed its deadline after #{parts.size} part(s)"
136
+ end
137
+
138
+ chunk = file.read(part_size)
139
+ return [parts, sent] if chunk.nil? || chunk.empty?
140
+
141
+ sent += chunk.bytesize
142
+ raise Error, "upload passed the #{max_bytes}-byte cap while reading" if sent > max_bytes
143
+
144
+ part = client.upload_part(**target, upload_id: upload_id, part_number: number, body: chunk,
145
+ content_md5: Digest::MD5.base64digest(chunk))
146
+ parts << { etag: part.etag, part_number: number }
147
+ # A short read is the end of the file, and the last part. Reading
148
+ # on would chase a file that is still being appended to, and every
149
+ # part but the last must be part_size (R2 refuses otherwise).
150
+ return [parts, sent] if chunk.bytesize < part_size
151
+ end
152
+ raise Error, "file needs more than #{MAX_PARTS} parts of #{part_size} bytes" unless file.eof?
153
+
154
+ [parts, sent]
155
+ end
156
+
157
+ # The object is asked for after the upload, so a store that answered
158
+ # 200 to the completion and kept something else fails here, loudly.
159
+ # The upload is COMPLETE by now, so every way out of here that is not
160
+ # success removes the object first: a size that differs, a head_object
161
+ # that raises, or a signal. Otherwise a whole recording would sit in
162
+ # the bucket with no row pointing at it.
163
+ def verify_stored_size!(client, target, sent)
164
+ confirmed = false
165
+ stored = client.head_object(**target).content_length
166
+ confirmed = stored == sent
167
+ return if confirmed
168
+
169
+ raise Error, "#{target[:key]} stored #{stored.inspect} bytes of #{sent} sent; the object was removed"
170
+ ensure
171
+ remove_quietly(client, target) unless confirmed
172
+ end
173
+
174
+ # A new object nothing references: a hard delete, behind the same
175
+ # production-bucket guard as every delete. A failure here must not
176
+ # hide the error that caused it.
177
+ def remove_quietly(client, target)
178
+ Studio::S3.guard_production_bucket!(target[:bucket])
179
+ client.delete_object(**target)
180
+ rescue StandardError
181
+ nil
182
+ end
183
+
184
+ # An abort that fails must not hide the error that caused it.
185
+ def abort_quietly(client, target, upload_id)
186
+ client.abort_multipart_upload(**target, upload_id: upload_id)
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -111,12 +111,10 @@ module Studio
111
111
  #
112
112
  # The warning and success inks are the same contract for the other two
113
113
  # status roles: their default colours fail AA as text on the light
114
- # surfaces too. They are found by #status_ink, which also counts the
115
- # role's own tint as a surface, because they are read inside tinted
116
- # badges. Danger text is not: it belongs on a theme surface, never on a
117
- # danger tint (see #status_ink for why that is a rule, not an accident).
118
- "--color-danger-ink" => contrast_ink(danger, direction: :lighten, start: 0.0, target: 4.5,
119
- against: surfaces),
114
+ # surfaces too. All three are found by #status_ink, which also counts
115
+ # the role's own tint as a surface, because they are read inside tinted
116
+ # badges.
117
+ "--color-danger-ink" => status_ink(danger, surfaces, direction: :lighten),
120
118
  "--color-warning-ink" => status_ink(warning, surfaces, direction: :lighten),
121
119
  "--color-success-ink" => status_ink(success, surfaces, direction: :lighten),
122
120
  "--color-accent" => colors[:accent] || "#F72585"
@@ -168,20 +166,12 @@ module Studio
168
166
  # `bg-<role>/10 text-<role>-ink` badge and flash-panel pattern.
169
167
  STATUS_TINT = 0.10
170
168
 
171
- # The warning and success inks land on the page surfaces AND on their own
172
- # role's tint over each of them (the `bg-warning/10 text-warning-ink`
173
- # badge). The tint is darker than a light surface and lighter than a dark
174
- # one, so an ink tuned to the bare surfaces alone came out below AA inside
175
- # its own badge (default theme: warning 3.91:1 dark, success 4.00:1 dark).
176
- # Counting the tints as surfaces closes that.
177
- #
178
- # --color-danger-ink does NOT go through here, and danger text must not sit
179
- # on a danger tint: tuned to bare surfaces it measures 4.10:1 (light) and
180
- # 4.18:1 (dark) on its own 10% tint. Consumers already build on that rule (turf-monster's error
181
- # contrast guard keeps a control that fails the day danger-ink clears a red
182
- # tint), so retuning danger-ink is a sequenced change, not a drive-by one.
183
- # test/views/engine_class_vocabulary_test.rb refuses danger-ink on a danger
184
- # tint in any engine view.
169
+ # A status ink lands on the page surfaces AND on its own role's tint over
170
+ # each of them (the `bg-warning/10 text-warning-ink` badge). The tint is
171
+ # darker than a light surface and lighter than a dark one, so an ink tuned
172
+ # to the bare surfaces alone comes out below AA inside its own badge
173
+ # (default theme: warning 3.91:1 dark, success 4.00:1 dark, danger 4.10:1
174
+ # light and 4.18:1 dark). Counting the tints as surfaces closes that.
185
175
  def status_ink(role, surfaces, direction:)
186
176
  tints = surfaces.map { |bg| ColorScale.blend(role, bg, STATUS_TINT) }
187
177
  contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + tints)
@@ -314,8 +304,7 @@ module Studio
314
304
  #
315
305
  # The warning and success inks are the same contract for the other two
316
306
  # status roles (see the dark-mode note).
317
- "--color-danger-ink" => contrast_ink(danger, direction: :darken, start: 0.0, target: 4.5,
318
- against: surfaces),
307
+ "--color-danger-ink" => status_ink(danger, surfaces, direction: :darken),
319
308
  "--color-warning-ink" => status_ink(warning, surfaces, direction: :darken),
320
309
  "--color-success-ink" => status_ink(success, surfaces, direction: :darken),
321
310
  "--color-accent" => colors[:accent] || "#F72585"
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.95.0"
2
+ VERSION = "0.96.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -48,6 +48,7 @@ require "studio/link_resolution"
48
48
  require "studio/session_fingerprint"
49
49
  require "studio/session_state"
50
50
  require "studio/email"
51
+ require "studio/email_image_generator"
51
52
  require "studio/email_smoke"
52
53
  require "studio/mail_transport"
53
54
  require "studio/redis"
@@ -383,6 +384,36 @@ module Studio
383
384
  # resolution are always on, so an app sends branded email either way.
384
385
  mattr_accessor :draw_admin_emails_routes, default: false
385
386
 
387
+ # The "Email image generator" link at the top of /admin/emails: where this
388
+ # app's operator makes a new banner. nil (the default) or blank draws no link,
389
+ # and the page renders as it always has. A String must be an absolute http(s)
390
+ # URL (anything else raises here, at boot); a callable receives the request and
391
+ # returns one, and an unusable answer draws no link. The label and the line
392
+ # under it are optional; blank means the engine's generic wording.
393
+ # See Studio::EmailImageGenerator.
394
+ #
395
+ # config.email_manager_generator_url = "https://example.com/email-art"
396
+ # config.email_manager_generator_label = "Header generator"
397
+ # config.email_manager_generator_description = "Open it, copy the prompt, paste it into Claude Code."
398
+ mattr_reader :email_manager_generator_url, default: nil
399
+ mattr_accessor :email_manager_generator_label, default: nil
400
+ mattr_accessor :email_manager_generator_description, default: nil
401
+
402
+ def self.email_manager_generator_url=(value)
403
+ @@email_manager_generator_url = EmailImageGenerator.normalize_url(value)
404
+ end
405
+
406
+ # The link /admin/emails renders for this request, or nil for none.
407
+ def self.email_manager_generator_link(request = nil)
408
+ EmailImageGenerator.link(
409
+ url: email_manager_generator_url,
410
+ label: email_manager_generator_label,
411
+ description: email_manager_generator_description,
412
+ app_name: app_name,
413
+ request: request
414
+ )
415
+ end
416
+
386
417
  # Draw the shared first-name onboarding endpoints
387
418
  # (Studio::OnboardingController#first_name / #skip_first_name). OFF by default,
388
419
  # and for the same hard reason as draw_admin_emails_routes above:
@@ -705,7 +736,8 @@ module Studio
705
736
  # and this is development or test, or production with the flag.
706
737
  def self.lookbook_mounted?
707
738
  ComponentGallery.mounted?(env: Rails.env, in_production: lookbook_in_production,
708
- lookbook_loaded: ComponentGallery.lookbook_loaded?)
739
+ lookbook_loaded: ComponentGallery.lookbook_loaded?,
740
+ load_order_ok: ComponentGallery.load_order_ok?)
709
741
  end
710
742
 
711
743
  # WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Attach a meeting's recording to a knowledge document, from a one-off dyno or
4
+ # a laptop. The recording is COPIED into the app's private bucket beside the
5
+ # document.
6
+ #
7
+ # bin/rails studio:knowledge:attach_recording ID=42 FILE=/tmp/standup.mp4
8
+ # bin/rails studio:knowledge:attach_recording ID=42 URL=https://files.example.com/standup.mp4
9
+ # echo "$DOWNLOAD_URL" | bin/rails studio:knowledge:attach_recording ID=42 URL=-
10
+ #
11
+ # ID is the document's id. Give exactly one of FILE (a local path) or URL (an
12
+ # https download, fetched through the engine's SSRF guard). Optional: NAME, a
13
+ # file name for the object (a fetched recording is otherwise "recording"), and
14
+ # SOURCE_URL, the external page the recording came from, kept on the row as a
15
+ # link.
16
+ #
17
+ # URL=- READS THE URL FROM STANDARD INPUT. A download URL carries a credential,
18
+ # and a command line is logged (a platform's "starting process" line, shell
19
+ # history, `ps`). The URL is never stored and never printed, whichever way it
20
+ # arrives; a failure names its host only.
21
+ #
22
+ # The arguments are environment variables, not rake's [a,b] form, because a
23
+ # URL may hold a comma and rake splits on them.
24
+ #
25
+ # Only audio and video are stored: MP4, M4V, MOV, M4A, WebM, MP3, WAV and Ogg,
26
+ # judged by the file's first bytes (Studio::KnowledgeRecording), up to 4 GB.
27
+ # Anything else is refused and nothing is written. Replacing a recording moves
28
+ # the old object to trash/ for three days (studio:trash:restore).
29
+ #
30
+ # WHAT IT PRINTS: the document's id, the bucket, the key, the type and the
31
+ # size. Not the title and not the link: a document's title is content of the
32
+ # layer, and this output goes to a log.
33
+ #
34
+ # Logic lives in Studio::KnowledgeDoc#attach_recording! and
35
+ # #attach_recording_from_url! (unit-tested); this task only reads its
36
+ # arguments and prints.
37
+ # Guarded like studio_trash.rake: an app that also loads this file must not
38
+ # register a second copy of the action.
39
+ unless Rake::Task.task_defined?("studio:knowledge:attach_recording")
40
+ namespace :studio do
41
+ namespace :knowledge do
42
+ desc "Attach a recording to knowledge document ID from FILE=<path> or URL=<https url> (SOURCE_URL=<page> optional)"
43
+ task attach_recording: :environment do
44
+ require "studio/knowledge_recording"
45
+ usage = "usage: studio:knowledge:attach_recording ID=<document id> (FILE=<path> | URL=<https url, or - for stdin>) " \
46
+ "[NAME=<file name>] [SOURCE_URL=<page>]"
47
+ id = ENV["ID"].to_s.strip
48
+ file = ENV["FILE"].to_s.strip
49
+ url = ENV["URL"].to_s.strip
50
+ name = ENV["NAME"].to_s.strip
51
+ source_url = ENV["SOURCE_URL"].to_s.strip
52
+ abort usage unless id.match?(/\A\d{1,18}\z/)
53
+ abort "#{usage}\ngive FILE or URL, not both" unless file.empty? ^ url.empty?
54
+
55
+ # One line, read with a limit: a URL over the cap is refused by the
56
+ # fetch, so there is no reason to read more of it than that.
57
+ url = $stdin.gets(Studio::KnowledgeRecording::MAX_URL_BYTES + 2).to_s.strip if url == "-"
58
+ abort "#{usage}\nURL=- was given and standard input held no URL" if file.empty? && url.empty?
59
+
60
+ doc = Studio::KnowledgeDoc.find_by(id: id.to_i)
61
+ abort "studio:knowledge:attach_recording: no knowledge document with id #{id}" if doc.nil?
62
+
63
+ replaced = doc.recording? ? doc.recording_key : nil
64
+ options = {}
65
+ options[:source_url] = source_url unless source_url.empty?
66
+ options[:filename] = name unless name.empty?
67
+ begin
68
+ if file.empty?
69
+ doc.attach_recording_from_url!(url, **options)
70
+ else
71
+ doc.attach_recording!(file, **options)
72
+ end
73
+ rescue StandardError => error
74
+ # Every failure is reported the same way, a storage or database
75
+ # error included: its class and message, no backtrace. No message
76
+ # raised on this path carries the download URL.
77
+ abort "studio:knowledge:attach_recording: #{error.class}: #{error.message.to_s[0, 500]}"
78
+ end
79
+
80
+ puts "Attached a recording to knowledge document #{doc.id}"
81
+ puts " bucket: #{Studio::S3.bucket}"
82
+ puts " key: #{doc.recording_key}"
83
+ puts " type: #{doc.recording_mime_type}"
84
+ puts " size: #{doc.recording_byte_size} bytes"
85
+ puts " replaced #{replaced} (moved to trash/ for three days)" if replaced && replaced != doc.recording_key
86
+ end
87
+ end
88
+ end
89
+ 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.95.0
4
+ version: 0.96.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -412,9 +412,11 @@ files:
412
412
  - app/javascript/studio/alpine_shims.js
413
413
  - app/javascript/studio/alpine_stores.js
414
414
  - app/javascript/studio/application.js
415
+ - app/javascript/studio/controllers/modal_host_controller.js
415
416
  - app/javascript/studio/controllers/nav_collapse_controller.js
416
417
  - app/javascript/studio/head_chrome.js
417
418
  - app/javascript/studio/local_path.js
419
+ - app/javascript/studio/modal_host.js
418
420
  - app/javascript/studio/nav_collapse.js
419
421
  - app/javascript/studio/pinned_stack.js
420
422
  - app/javascript/studio/vendor/stimulus.js
@@ -435,6 +437,7 @@ files:
435
437
  - app/models/studio/enumeral.rb
436
438
  - app/models/studio/geo_setting.rb
437
439
  - app/models/studio/knowledge_doc.rb
440
+ - app/models/studio/knowledge_doc/recording.rb
438
441
  - app/models/studio/knowledge_expectation.rb
439
442
  - app/models/studio/link.rb
440
443
  - app/models/studio/model_page.rb
@@ -651,6 +654,7 @@ files:
651
654
  - db/migrate/20261006120001_ensure_error_logs_table.rb
652
655
  - db/migrate/20261006120002_ensure_theme_settings_table.rb
653
656
  - db/migrate/20261006120003_ensure_image_caches_table.rb
657
+ - db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
654
658
  - docs/BOOKING.md
655
659
  - docs/SITE_FOOTER.md
656
660
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -663,6 +667,7 @@ files:
663
667
  - lib/studio/color_scale.rb
664
668
  - lib/studio/component_gallery.rb
665
669
  - lib/studio/email.rb
670
+ - lib/studio/email_image_generator.rb
666
671
  - lib/studio/email_smoke.rb
667
672
  - lib/studio/engine.rb
668
673
  - lib/studio/environment_banner.rb
@@ -674,6 +679,8 @@ files:
674
679
  - lib/studio/ip_locations.rb
675
680
  - lib/studio/js_identifier.rb
676
681
  - lib/studio/js_literal.rb
682
+ - lib/studio/knowledge_recording.rb
683
+ - lib/studio/knowledge_transcript.rb
677
684
  - lib/studio/link_preview.rb
678
685
  - lib/studio/link_resolution.rb
679
686
  - lib/studio/link_token.rb
@@ -691,6 +698,7 @@ files:
691
698
  - lib/studio/public_user.rb
692
699
  - lib/studio/redis.rb
693
700
  - lib/studio/s3.rb
701
+ - lib/studio/s3/multipart.rb
694
702
  - lib/studio/s3/trash.rb
695
703
  - lib/studio/session_fingerprint.rb
696
704
  - lib/studio/session_state.rb
@@ -704,6 +712,7 @@ files:
704
712
  - lib/studio/username_generator.rb
705
713
  - lib/studio/version.rb
706
714
  - lib/tasks/studio_email.rake
715
+ - lib/tasks/studio_knowledge.rake
707
716
  - lib/tasks/studio_ses.rake
708
717
  - lib/tasks/studio_trash.rake
709
718
  - studio-engine.gemspec