featureparity 0.0.21 → 0.0.23
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/lib/fp/cli.rb +40 -0
- data/lib/fp/client.rb +70 -0
- data/lib/fp/commands/analyze.rb +10 -0
- data/lib/fp/commands/attach.rb +109 -46
- data/lib/fp/commands/attachments.rb +6 -0
- data/lib/fp/commands/base.rb +144 -0
- data/lib/fp/commands/changed_tests.rb +12 -0
- data/lib/fp/commands/changes.rb +9 -0
- data/lib/fp/commands/check.rb +41 -3
- data/lib/fp/commands/ci_report.rb +17 -0
- data/lib/fp/commands/comment.rb +10 -0
- data/lib/fp/commands/config_cmd.rb +9 -0
- data/lib/fp/commands/conversation.rb +5 -0
- data/lib/fp/commands/export.rb +10 -0
- data/lib/fp/commands/help.rb +81 -6
- data/lib/fp/commands/history.rb +8 -0
- data/lib/fp/commands/init.rb +50 -1
- data/lib/fp/commands/list.rb +12 -0
- data/lib/fp/commands/matrix.rb +9 -0
- data/lib/fp/commands/profile.rb +9 -0
- data/lib/fp/commands/projects.rb +63 -3
- data/lib/fp/commands/propose.rb +18 -0
- data/lib/fp/commands/propose_change.rb +73 -2
- data/lib/fp/commands/report.rb +157 -7
- data/lib/fp/commands/repos.rb +11 -0
- data/lib/fp/commands/setup.rb +11 -0
- data/lib/fp/commands/show.rb +42 -3
- data/lib/fp/commands/showroom.rb +194 -0
- data/lib/fp/commands/showroom_media.rb +334 -0
- data/lib/fp/commands/surfaces.rb +142 -3
- data/lib/fp/commands/version.rb +3 -0
- data/lib/fp/commands/whoami.rb +6 -0
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/junit.rb +213 -18
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +45 -0
- data/skills/fp/references/cli.md +64 -1
- metadata +4 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp showroom --project <slug> - the client showroom, as a client will see it
|
|
6
|
+
#
|
|
7
|
+
# The showroom (Fizzy #1479) is the one page a client gets: the welcome note, then
|
|
8
|
+
# a card per surface with a link, a button label, and instructions. Its content is
|
|
9
|
+
# written through `fp projects edit --showroom-welcome` and `fp surfaces edit
|
|
10
|
+
# --showroom-*`, and this command is how an agent checks the result without a
|
|
11
|
+
# browser: the same payload the client is served, plus the surfaces that are *not*
|
|
12
|
+
# on it and why, so a card that silently dropped out (both fields cleared) is
|
|
13
|
+
# noticed.
|
|
14
|
+
#
|
|
15
|
+
# Whether the share link is on is a human's business — the API refuses an agent's
|
|
16
|
+
# key — so it is reported as unknown rather than guessed at.
|
|
17
|
+
#
|
|
18
|
+
# `fp showroom media ...` manages the screenshots and videos on the showroom — a
|
|
19
|
+
# surface's card, or the gallery under the welcome note — and is handled by
|
|
20
|
+
# ShowroomMedia; its flags are documented here so `fp showroom --help` covers both.
|
|
21
|
+
class Showroom < Base
|
|
22
|
+
help_summary 'Preview the client showroom exactly as a client will see it, and manage its media'
|
|
23
|
+
help_usage 'fp showroom --project <slug>',
|
|
24
|
+
'fp showroom media --project <slug>',
|
|
25
|
+
'fp showroom media add <file> --project <slug> [--surface <key>] [--caption "..."] [--position N]',
|
|
26
|
+
'fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]',
|
|
27
|
+
'fp showroom media remove <id> --project <slug> [--yes]'
|
|
28
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
29
|
+
help_flag :surface, "media add: the surface whose card shows it (omit for the project gallery)"
|
|
30
|
+
help_flag :caption, 'media add/edit: text shown under the image or video (edit: empty clears)'
|
|
31
|
+
help_flag :position, 'media add/edit: slot within its card or the gallery, 1 is first; others shift (edit: empty moves it last)'
|
|
32
|
+
help_flag :yes, 'media remove: skip the typed-filename confirmation'
|
|
33
|
+
help_example 'fp showroom --project stowzilla'
|
|
34
|
+
help_example 'fp showroom media add e2e/checkout.webm --project stowzilla --surface web --caption "Checking out"'
|
|
35
|
+
help_example 'fp showroom media add overview.png --project stowzilla --position 1'
|
|
36
|
+
help_example 'fp showroom media edit <id> --project stowzilla --caption ""'
|
|
37
|
+
help_example 'fp showroom media remove <id> --project stowzilla --yes'
|
|
38
|
+
help_note "Prints the welcome note, the gallery, and each surface's card, then the surfaces that are NOT shown and why.\n" \
|
|
39
|
+
"Write the content with `fp projects edit --showroom-welcome` and `fp surfaces edit --showroom-*`.\n" \
|
|
40
|
+
"Media: png, jpg, gif, webp, mp4, webm, mov; max 100 MB. SVG and PDF are refused.\n" \
|
|
41
|
+
'The share link is reported as unknown: only a person can see or change it.'
|
|
42
|
+
def run(args)
|
|
43
|
+
if args.first == 'media'
|
|
44
|
+
return ShowroomMedia.new(client: client, output: output, config: config).run(args.drop(1))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
opts, = parse_flags(args)
|
|
48
|
+
require_project!(opts)
|
|
49
|
+
|
|
50
|
+
project_id = resolve_project_id(opts[:project])
|
|
51
|
+
|
|
52
|
+
result = client.get_showroom(project_id)
|
|
53
|
+
unless result[:ok]
|
|
54
|
+
output.error(result[:error], status: result[:status])
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
showroom = result[:data]['showroom'] || {}
|
|
59
|
+
shown_ids = (showroom['surfaces'] || []).map { |surface| surface['id'] }
|
|
60
|
+
hidden = hidden_surfaces(project_id, shown_ids)
|
|
61
|
+
share = share_link_status(project_id)
|
|
62
|
+
|
|
63
|
+
output.success({ showroom: showroom, hidden: hidden, share_link: share, project: opts[:project] }) do
|
|
64
|
+
print_showroom(showroom, hidden, share, project: opts[:project])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Surfaces with no link, no instructions, and no media. The showroom payload leaves
|
|
71
|
+
# them out (and includes a media-only surface), so whatever the payload doesn't
|
|
72
|
+
# carry is hidden; they are read from the surface list.
|
|
73
|
+
def hidden_surfaces(project_id, shown_ids)
|
|
74
|
+
result = client.list_surfaces(project_id)
|
|
75
|
+
return [] unless result[:ok]
|
|
76
|
+
|
|
77
|
+
(result[:data]['surfaces'] || []).reject { |surface| shown_ids.include?(surface['id']) }.map do |surface|
|
|
78
|
+
{ 'key' => surface['key'], 'name' => surface['name'], 'kind' => surface['kind'],
|
|
79
|
+
'reason' => 'no showroom link or instructions, and no media' }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# { 'state' => 'on' | 'off' | 'unknown', 'token' => ..., 'reason' => ... }
|
|
84
|
+
def share_link_status(project_id)
|
|
85
|
+
result = client.get_showroom_share_link(project_id)
|
|
86
|
+
if result[:ok]
|
|
87
|
+
link = result[:data]['share_link']
|
|
88
|
+
link ? { 'state' => 'on', 'token' => link['token'], 'created_by' => link['created_by'] } : { 'state' => 'off' }
|
|
89
|
+
elsif result[:status] == 403
|
|
90
|
+
{ 'state' => 'unknown', 'reason' => 'only a person can see or change the share link (Project Settings → Showroom)' }
|
|
91
|
+
else
|
|
92
|
+
{ 'state' => 'unknown', 'reason' => result[:error] }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def print_showroom(showroom, hidden, share, project:)
|
|
97
|
+
info = showroom['project'] || {}
|
|
98
|
+
title = "Showroom: #{info['name'] || project}"
|
|
99
|
+
puts title
|
|
100
|
+
puts '=' * title.length
|
|
101
|
+
puts
|
|
102
|
+
|
|
103
|
+
welcome = text_or_nil(info['welcome'])
|
|
104
|
+
if welcome
|
|
105
|
+
puts 'Welcome:'
|
|
106
|
+
welcome.each_line { |line| puts " #{line.rstrip}" }
|
|
107
|
+
else
|
|
108
|
+
puts 'Welcome: (none) — set one with: fp projects edit ' \
|
|
109
|
+
"#{info['slug'] || project} --showroom-welcome \"...\""
|
|
110
|
+
end
|
|
111
|
+
puts
|
|
112
|
+
|
|
113
|
+
gallery = showroom['gallery'] || []
|
|
114
|
+
unless gallery.empty?
|
|
115
|
+
puts "Gallery (#{media_summary(gallery)}):"
|
|
116
|
+
print_media(gallery, indent: ' ')
|
|
117
|
+
puts
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
surfaces = showroom['surfaces'] || []
|
|
121
|
+
if surfaces.empty?
|
|
122
|
+
puts "Surfaces shown: none. The client sees only the welcome note#{gallery.empty? ? '' : ' and the gallery'}."
|
|
123
|
+
else
|
|
124
|
+
puts "Surfaces shown (#{surfaces.size}):"
|
|
125
|
+
surfaces.each { |surface| print_card(surface) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless hidden.empty?
|
|
129
|
+
puts
|
|
130
|
+
puts "Not shown (#{hidden.size}):"
|
|
131
|
+
hidden.each do |surface|
|
|
132
|
+
puts " - #{surface['name'] || surface['key']} (#{surface['key']}): #{surface['reason']}"
|
|
133
|
+
end
|
|
134
|
+
puts " Add one with: fp surfaces edit <key> --project #{project} --showroom-url ... " \
|
|
135
|
+
'--showroom-instructions-file ...'
|
|
136
|
+
puts " or: fp showroom media add <file> --project #{project} --surface <key>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
puts
|
|
140
|
+
case share['state']
|
|
141
|
+
when 'on'
|
|
142
|
+
puts "Share link: on (created by #{share['created_by'] || 'unknown'}) — the content above is live."
|
|
143
|
+
when 'off'
|
|
144
|
+
puts 'Share link: off — only signed-in clients and the team can see this.'
|
|
145
|
+
else
|
|
146
|
+
puts "Share link: unknown — #{share['reason']}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def print_card(surface)
|
|
151
|
+
puts
|
|
152
|
+
puts " #{surface['name']} (#{surface['kind']})"
|
|
153
|
+
url = text_or_nil(surface['url'])
|
|
154
|
+
label = text_or_nil(surface['label'])
|
|
155
|
+
puts " Link: #{url ? "#{url}#{label ? " [#{label}]" : ''}" : '(none)'}"
|
|
156
|
+
instructions = text_or_nil(surface['instructions'])
|
|
157
|
+
if instructions
|
|
158
|
+
puts ' Instructions:'
|
|
159
|
+
instructions.each_line { |line| puts " #{line.rstrip}" }
|
|
160
|
+
else
|
|
161
|
+
puts ' Instructions: (none)'
|
|
162
|
+
end
|
|
163
|
+
media = surface['media'] || []
|
|
164
|
+
if media.empty?
|
|
165
|
+
puts ' Media: (none)'
|
|
166
|
+
else
|
|
167
|
+
puts " Media: #{media_summary(media)}"
|
|
168
|
+
print_media(media, indent: ' ')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# "2 images, 1 video"
|
|
173
|
+
def media_summary(media)
|
|
174
|
+
counts = media.group_by { |item| item['media_kind'] == 'video' ? 'video' : 'image' }
|
|
175
|
+
%w[image video].filter_map do |kind|
|
|
176
|
+
count = counts.fetch(kind, []).size
|
|
177
|
+
"#{count} #{kind}#{count == 1 ? '' : 's'}" if count.positive?
|
|
178
|
+
end.join(', ')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def print_media(media, indent:)
|
|
182
|
+
media.each do |item|
|
|
183
|
+
caption = text_or_nil(item['caption'])
|
|
184
|
+
puts "#{indent}- #{item['media_kind'] || 'image'}: #{caption || '(no caption)'}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def text_or_nil(value)
|
|
189
|
+
text = value.to_s.strip
|
|
190
|
+
text.empty? ? nil : text
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'attach'
|
|
5
|
+
|
|
6
|
+
module Fp
|
|
7
|
+
module Commands
|
|
8
|
+
# fp showroom media [--project <slug>] - list the showroom's media
|
|
9
|
+
# fp showroom media add <file> [--surface <key>] [--caption] [--position N]
|
|
10
|
+
# fp showroom media edit <id> [--caption "..."] [--position N]
|
|
11
|
+
# fp showroom media remove <id> [--yes]
|
|
12
|
+
#
|
|
13
|
+
# Plenty of clients never open the demo, so the showroom can show them the thing
|
|
14
|
+
# instead: screenshots or a short clip on a surface's card, or in the project gallery
|
|
15
|
+
# under the welcome note (no --surface). The agent that ran the e2e suite already has
|
|
16
|
+
# the captures, so it puts them there rather than leaving a human to re-upload them.
|
|
17
|
+
#
|
|
18
|
+
# Dispatched from `fp showroom media ...`; its help lives on `fp showroom --help`.
|
|
19
|
+
# Uploads reuse attach's flow (presign, PUT the bytes, confirm) and its visual file
|
|
20
|
+
# types — images and videos only. SVG can carry script and PDFs don't render inline,
|
|
21
|
+
# so both are refused before anything is sent.
|
|
22
|
+
class ShowroomMedia < Base
|
|
23
|
+
CONTENT_TYPES = Attach::CONTENT_TYPES.slice(*Attach::VISUAL_EXTENSIONS).freeze
|
|
24
|
+
MAX_FILE_SIZE = Attach::MAX_FILE_SIZE
|
|
25
|
+
MAX_CAPTION_LENGTH = 500
|
|
26
|
+
|
|
27
|
+
LIST_FLAGS = { project: :string }.freeze
|
|
28
|
+
ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string }.freeze
|
|
29
|
+
EDIT_FLAGS = { project: :string, caption: :string, position: :string }.freeze
|
|
30
|
+
REMOVE_FLAGS = { project: :string, yes: :boolean }.freeze
|
|
31
|
+
|
|
32
|
+
def run(args)
|
|
33
|
+
case args.first
|
|
34
|
+
when 'add'
|
|
35
|
+
add(args[1..])
|
|
36
|
+
when 'edit'
|
|
37
|
+
edit(args[1..])
|
|
38
|
+
when 'remove', 'rm', 'delete'
|
|
39
|
+
remove(args[1..])
|
|
40
|
+
else
|
|
41
|
+
list(args)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Gallery first, then each surface's media in the order a client sees the cards.
|
|
48
|
+
def list(args)
|
|
49
|
+
opts, = parse_flags(args, LIST_FLAGS)
|
|
50
|
+
require_project!(opts)
|
|
51
|
+
project_id = resolve_project_id(opts[:project])
|
|
52
|
+
|
|
53
|
+
media = fetch!(client.list_showroom_media(project_id))['media'] || []
|
|
54
|
+
groups = group_media(media, surfaces_for(project_id))
|
|
55
|
+
|
|
56
|
+
flat = groups.flat_map do |group|
|
|
57
|
+
group[:items].map { |item| item.merge('surface_key' => group[:key]) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
output.success({ media: flat, project: opts[:project] }) do
|
|
61
|
+
if media.empty?
|
|
62
|
+
puts "No showroom media in project '#{opts[:project]}'."
|
|
63
|
+
puts "Add some with: fp showroom media add <file> --project #{opts[:project]} [--surface <key>]"
|
|
64
|
+
else
|
|
65
|
+
puts "Showroom media for '#{opts[:project]}' (#{media.size}):"
|
|
66
|
+
groups.each do |group|
|
|
67
|
+
puts
|
|
68
|
+
puts "#{group[:title]} (#{group[:items].size}):"
|
|
69
|
+
group[:items].each { |item| print_item_line(item) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add(args)
|
|
76
|
+
opts, positional = parse_flags(args, ADD_FLAGS)
|
|
77
|
+
path = positional.first
|
|
78
|
+
if path.nil? || path.strip.empty?
|
|
79
|
+
output.error('Usage: fp showroom media add <file> --project <slug> [--surface <key>] ' \
|
|
80
|
+
'[--caption "..."] [--position N]')
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Everything that can be checked locally is checked before a byte is sent.
|
|
85
|
+
file = prepare_file!(path)
|
|
86
|
+
caption = flag_text(opts[:caption]).strip if opts.key?(:caption)
|
|
87
|
+
caption = nil if caption&.empty?
|
|
88
|
+
check_caption!(caption) if caption
|
|
89
|
+
position = position!(opts[:position], clear: nil) if opts.key?(:position)
|
|
90
|
+
if opts.key?(:surface) && flag_text(opts[:surface]).strip.empty?
|
|
91
|
+
output.error('--surface needs a surface key. Omit it to add to the project gallery.')
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
require_project!(opts)
|
|
96
|
+
project_id = resolve_project_id(opts[:project])
|
|
97
|
+
surface = opts[:surface] ? find_surface!(project_id, opts[:surface]) : nil
|
|
98
|
+
|
|
99
|
+
presign = fetch!(client.presign_showroom_media(
|
|
100
|
+
project_id,
|
|
101
|
+
filename: file[:filename], content_type: file[:content_type],
|
|
102
|
+
size_bytes: file[:size], surface_id: surface&.dig('id')
|
|
103
|
+
), prefix: 'Failed to get upload URL')['presign'] || {}
|
|
104
|
+
|
|
105
|
+
uploaded = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
|
|
106
|
+
fetch!(uploaded, prefix: 'Upload failed')
|
|
107
|
+
|
|
108
|
+
item = fetch!(client.create_showroom_media(
|
|
109
|
+
project_id,
|
|
110
|
+
media_id: presign['media_id'], s3_key: presign['s3_key'],
|
|
111
|
+
filename: file[:filename], content_type: file[:content_type], size_bytes: file[:size],
|
|
112
|
+
surface_id: surface&.dig('id'), caption: caption, position: position
|
|
113
|
+
), prefix: 'Failed to confirm upload')['media'] || {}
|
|
114
|
+
|
|
115
|
+
where = surface ? "surface '#{surface['key']}'" : 'the project gallery'
|
|
116
|
+
output.success({ media: item, surface: surface&.dig('key'), project: opts[:project] }) do
|
|
117
|
+
puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
|
|
118
|
+
print_item_detail(item)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def edit(args)
|
|
123
|
+
opts, positional = parse_flags(args, EDIT_FLAGS)
|
|
124
|
+
media_id = positional.first
|
|
125
|
+
if media_id.nil? || media_id.strip.empty?
|
|
126
|
+
output.error('Usage: fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]')
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Only what was passed is sent. Empty caption clears it. Positions are kept dense
|
|
131
|
+
# (1..n) per card or gallery: N inserts at slot N and shifts the rest, and an empty
|
|
132
|
+
# value — sent as 0, since the API types it as an integer — moves the item to the end.
|
|
133
|
+
params = {}
|
|
134
|
+
if opts.key?(:caption)
|
|
135
|
+
params[:caption] = flag_text(opts[:caption]).strip
|
|
136
|
+
check_caption!(params[:caption])
|
|
137
|
+
end
|
|
138
|
+
params[:position] = position!(opts[:position], clear: 0) if opts.key?(:position)
|
|
139
|
+
|
|
140
|
+
if params.empty?
|
|
141
|
+
output.error('Nothing to change. Pass --caption and/or --position (an empty value clears either).')
|
|
142
|
+
exit 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
require_project!(opts)
|
|
146
|
+
project_id = resolve_project_id(opts[:project])
|
|
147
|
+
item = fetch!(client.update_showroom_media(project_id, media_id, params))['media'] || {}
|
|
148
|
+
|
|
149
|
+
output.success({ media: item, project: opts[:project] }) do
|
|
150
|
+
puts "Updated showroom media #{media_id} in project '#{opts[:project]}'."
|
|
151
|
+
print_item_detail(item)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def remove(args)
|
|
156
|
+
opts, positional = parse_flags(args, REMOVE_FLAGS)
|
|
157
|
+
media_id = positional.first
|
|
158
|
+
if media_id.nil? || media_id.strip.empty?
|
|
159
|
+
output.error('Usage: fp showroom media remove <id> --project <slug> [--yes]')
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
require_project!(opts)
|
|
164
|
+
project_id = resolve_project_id(opts[:project])
|
|
165
|
+
|
|
166
|
+
# Read it first: the confirmation names the file, and an unknown id fails here
|
|
167
|
+
# rather than as a bare 404 from the delete.
|
|
168
|
+
media = fetch!(client.list_showroom_media(project_id))['media'] || []
|
|
169
|
+
item = media.find { |m| m['id'] == media_id }
|
|
170
|
+
unless item
|
|
171
|
+
output.error("Showroom media '#{media_id}' not found in project '#{opts[:project]}'. " \
|
|
172
|
+
"List it with: fp showroom media --project #{opts[:project]}", status: 404)
|
|
173
|
+
exit 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
confirm_remove!(item, opts)
|
|
177
|
+
fetch!(client.delete_showroom_media(project_id, media_id), prefix: 'Failed to remove showroom media')
|
|
178
|
+
|
|
179
|
+
output.success({ deleted: true, media: { 'id' => media_id, 'filename' => item['filename'] },
|
|
180
|
+
project: opts[:project] }) do
|
|
181
|
+
puts "Removed #{item['filename']} (#{media_id}) from the showroom of project '#{opts[:project]}'."
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Same guard as `fp surfaces delete`: --yes skips the prompt, an interactive
|
|
186
|
+
# terminal types the filename back, and anything else is refused.
|
|
187
|
+
def confirm_remove!(item, opts)
|
|
188
|
+
return if opts[:yes]
|
|
189
|
+
|
|
190
|
+
unless interactive?
|
|
191
|
+
output.error("Refusing to remove '#{item['filename']}' without confirmation. " \
|
|
192
|
+
'Re-run with --yes to confirm from a non-interactive session.')
|
|
193
|
+
exit 1
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
warn "This will remove '#{item['filename']}' from the showroom of project '#{opts[:project]}'. " \
|
|
197
|
+
'Clients stop seeing it at once.'
|
|
198
|
+
$stderr.print "Type '#{item['filename']}' to confirm: "
|
|
199
|
+
$stderr.flush
|
|
200
|
+
|
|
201
|
+
typed = $stdin.gets&.strip
|
|
202
|
+
return if typed == item['filename']
|
|
203
|
+
|
|
204
|
+
output.error('Aborted. The filename did not match; nothing was removed.')
|
|
205
|
+
exit 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def interactive?
|
|
209
|
+
$stdin.respond_to?(:tty?) && $stdin.tty?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# { path:, filename:, content_type:, size: }, or exit with the reason it can't go up.
|
|
213
|
+
def prepare_file!(path)
|
|
214
|
+
unless File.file?(path)
|
|
215
|
+
output.error("File not found: #{path}")
|
|
216
|
+
exit 1
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
filename = File.basename(path)
|
|
220
|
+
extension = File.extname(filename).delete('.').downcase
|
|
221
|
+
content_type = CONTENT_TYPES[extension]
|
|
222
|
+
unless content_type
|
|
223
|
+
why = extension == 'svg' ? ' (SVG can carry script)' : ''
|
|
224
|
+
output.error("Unsupported file type: .#{extension}#{why}. The showroom takes images " \
|
|
225
|
+
"(png, jpg, jpeg, gif, webp) and videos (mp4, webm, mov) only.")
|
|
226
|
+
exit 1
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
size = File.size(path)
|
|
230
|
+
if size.zero?
|
|
231
|
+
output.error("File is empty: #{path}")
|
|
232
|
+
exit 1
|
|
233
|
+
end
|
|
234
|
+
if size > MAX_FILE_SIZE
|
|
235
|
+
output.error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
|
|
236
|
+
exit 1
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
{ path: path, filename: filename, content_type: content_type, size: size }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def check_caption!(caption)
|
|
243
|
+
return if caption.length <= MAX_CAPTION_LENGTH
|
|
244
|
+
|
|
245
|
+
output.error("--caption is #{caption.length} characters; the limit is #{MAX_CAPTION_LENGTH}.")
|
|
246
|
+
exit 1
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# 1 is first. An empty value returns `clear` (nil on add — it goes at the end
|
|
250
|
+
# anyway — and 0 on edit, which moves it to the end); anything else must be a whole
|
|
251
|
+
# number of 1 or more.
|
|
252
|
+
def position!(value, clear:)
|
|
253
|
+
text = flag_text(value).strip
|
|
254
|
+
return clear if text.empty?
|
|
255
|
+
|
|
256
|
+
position = Integer(text, 10, exception: false)
|
|
257
|
+
return position if position&.positive?
|
|
258
|
+
|
|
259
|
+
output.error("Invalid --position '#{text}'. Must be a whole number of 1 or more; " \
|
|
260
|
+
'pass an empty value to clear it.')
|
|
261
|
+
exit 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A string flag given with no value parses as `true`; treat that as empty.
|
|
265
|
+
def flag_text(value)
|
|
266
|
+
value == true ? '' : value.to_s
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def surfaces_for(project_id)
|
|
270
|
+
result = client.list_surfaces(project_id)
|
|
271
|
+
result[:ok] ? (result[:data]['surfaces'] || []) : []
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def find_surface!(project_id, key)
|
|
275
|
+
fetch!(client.find_surface_by_key(project_id, key))['surface']
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# [{ title:, key:, items: }] — the gallery, then each surface with media in showroom
|
|
279
|
+
# order (placed surfaces lowest first, then by name, as Surface#showroom_sort_key
|
|
280
|
+
# does). Within a group the server's order stands: it already sorts by position.
|
|
281
|
+
def group_media(media, surfaces)
|
|
282
|
+
groups = []
|
|
283
|
+
gallery = media.select { |m| m['surface_id'].to_s.empty? }
|
|
284
|
+
groups << { title: 'Gallery (under the welcome note)', key: nil, items: gallery } unless gallery.empty?
|
|
285
|
+
|
|
286
|
+
by_surface = media.reject { |m| m['surface_id'].to_s.empty? }.group_by { |m| m['surface_id'] }
|
|
287
|
+
surfaces.sort_by { |s| surface_sort_key(s) }.each do |surface|
|
|
288
|
+
items = by_surface.delete(surface['id'])
|
|
289
|
+
next unless items
|
|
290
|
+
|
|
291
|
+
groups << { title: "#{surface['name'] || surface['key']} (#{surface['key']})", key: surface['key'],
|
|
292
|
+
items: items }
|
|
293
|
+
end
|
|
294
|
+
by_surface.each do |surface_id, items|
|
|
295
|
+
groups << { title: "Unknown surface #{surface_id}", key: nil, items: items }
|
|
296
|
+
end
|
|
297
|
+
groups
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def surface_sort_key(surface)
|
|
301
|
+
position = surface['showroom_position'].to_i
|
|
302
|
+
position = nil if position < 1
|
|
303
|
+
[position ? 0 : 1, position || 0, surface['name'].to_s.downcase, surface['id'].to_s]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def print_item_line(item)
|
|
307
|
+
position = item['position'].to_i.positive? ? "##{item['position'].to_i}" : '-'
|
|
308
|
+
caption = item['caption'].to_s.strip
|
|
309
|
+
line = " #{item['id']} #{position.ljust(3)} #{item['media_kind'].to_s.ljust(5)} #{item['filename']}"
|
|
310
|
+
line += " \"#{caption}\"" unless caption.empty?
|
|
311
|
+
puts line
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def print_item_detail(item)
|
|
315
|
+
puts
|
|
316
|
+
puts " ID: #{item['id']}"
|
|
317
|
+
puts " Kind: #{item['media_kind']} (#{item['content_type']})"
|
|
318
|
+
puts " File: #{item['filename']}#{item['size_bytes'] ? " (#{format_size(item['size_bytes'].to_i)})" : ''}"
|
|
319
|
+
caption = item['caption'].to_s.strip
|
|
320
|
+
puts " Caption: #{caption.empty? ? '(none)' : caption}"
|
|
321
|
+
position = item['position'].to_i
|
|
322
|
+
puts " Position: #{position.positive? ? position : '(last)'}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The response data, or exit with the API's error.
|
|
326
|
+
def fetch!(result, prefix: nil)
|
|
327
|
+
return result[:data] || {} if result[:ok]
|
|
328
|
+
|
|
329
|
+
output.error([prefix, result[:error]].compact.join(': '), status: result[:status])
|
|
330
|
+
exit 1
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|