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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -16
- data/README.md +193 -12
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/controllers/modal_host_controller.js +40 -0
- data/app/javascript/studio/modal_host.js +574 -0
- data/app/models/concerns/sluggable.rb +11 -1
- data/app/models/studio/knowledge_doc/recording.rb +214 -0
- data/app/models/studio/knowledge_doc.rb +3 -0
- data/app/views/layouts/studio/_head.html.erb +10 -2
- data/app/views/studio/emails/index.html.erb +13 -0
- data/app/views/studio/modals/_host.html.erb +33 -597
- data/app/views/studio/modals/_load_convention.html.erb +6 -27
- data/app/views/studio/modals/_scoped_host.html.erb +14 -257
- data/app/views/studio/modals/auth/_resend_footer.html.erb +2 -2
- data/app/views/studio/modals/blocks/_processing_card.html.erb +1 -1
- data/app/views/style/_modals.html.erb +12 -15
- data/db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb +19 -0
- data/lib/studio/component_gallery.rb +13 -3
- data/lib/studio/email_image_generator.rb +125 -0
- data/lib/studio/engine.rb +2 -5
- data/lib/studio/knowledge_recording.rb +579 -0
- data/lib/studio/knowledge_transcript.rb +185 -0
- data/lib/studio/s3/multipart.rb +193 -0
- data/lib/studio/theme_resolver.rb +11 -22
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +33 -1
- data/lib/tasks/studio_knowledge.rake +89 -0
- metadata +10 -1
|
@@ -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.
|
|
115
|
-
# role's own tint as a surface, because they are read inside tinted
|
|
116
|
-
# badges.
|
|
117
|
-
|
|
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
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
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" =>
|
|
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"
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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
|