@wji.ai/cli 0.7.0 → 0.8.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.
Files changed (49) hide show
  1. package/README.md +4 -1
  2. package/dist/index.js +7162 -76
  3. package/package.json +6 -9
  4. package/skill/short-drama/SKILL.md +22 -10
  5. package/skill/short-drama/reference/bgm-sfx.md +124 -0
  6. package/skill/short-drama/reference/canvas-recipes.md +19 -2
  7. package/skill/short-drama/reference/film-mixing-guideline.md +447 -0
  8. package/skill/short-drama/reference/mix-over-video.md +89 -0
  9. package/skill/short-drama/reference/operations-cli.md +1 -1
  10. package/skill/short-drama/reference/schema.md +4 -2
  11. package/skill/short-drama/reference/stage-characters.md +2 -2
  12. package/skill/short-drama/reference/stage-storyboard.md +29 -6
  13. package/skill/short-drama/reference/workspace-layout.md +2 -2
  14. package/skill/short-drama/scripts/selftest.mjs +54 -9
  15. package/skill/short-drama/scripts/short-drama.mjs +150 -12
  16. package/dist/commands/agent.js +0 -294
  17. package/dist/commands/asset.js +0 -241
  18. package/dist/commands/auth.js +0 -216
  19. package/dist/commands/backreel-enquiry.js +0 -171
  20. package/dist/commands/backreel-episode.js +0 -243
  21. package/dist/commands/backreel-fund.js +0 -299
  22. package/dist/commands/backreel.js +0 -443
  23. package/dist/commands/canvas.js +0 -1706
  24. package/dist/commands/class.js +0 -395
  25. package/dist/commands/config.js +0 -113
  26. package/dist/commands/crew.js +0 -205
  27. package/dist/commands/fsnode.js +0 -288
  28. package/dist/commands/gen.js +0 -356
  29. package/dist/commands/model.js +0 -136
  30. package/dist/commands/npc-album.js +0 -201
  31. package/dist/commands/npc-live.js +0 -316
  32. package/dist/commands/npc.js +0 -437
  33. package/dist/commands/org.js +0 -85
  34. package/dist/commands/preset.js +0 -176
  35. package/dist/commands/profile.js +0 -129
  36. package/dist/commands/skill.js +0 -272
  37. package/dist/commands/workspace.js +0 -470
  38. package/dist/core/archive.js +0 -95
  39. package/dist/core/backreel.js +0 -23
  40. package/dist/core/client.js +0 -269
  41. package/dist/core/config.js +0 -95
  42. package/dist/core/crew.js +0 -546
  43. package/dist/core/image.js +0 -106
  44. package/dist/core/members.js +0 -54
  45. package/dist/core/npc.js +0 -108
  46. package/dist/core/output.js +0 -174
  47. package/dist/core/prompt.js +0 -118
  48. package/dist/core/vod.js +0 -149
  49. package/dist/core/zip.js +0 -87
@@ -1,443 +0,0 @@
1
- import { Command } from 'commander';
2
- import { POSTS, backreelClient, idOf, oneOf } from '../core/backreel.js';
3
- import { IMAGE_SLOTS, checkImage } from '../core/image.js';
4
- import { episodeCommand } from './backreel-episode.js';
5
- import { enquiryCommand } from './backreel-enquiry.js';
6
- import { fundCommand } from './backreel-fund.js';
7
- import { confirm, readJson, readValue } from '../core/prompt.js';
8
- import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
9
- import { NPC_JOB_TYPES, npcLabel, parseJob, parsePositions, resolveNpc, today, updateJobs } from '../core/npc.js';
10
- const KINDS = ['showcase', 'crowdfunding'];
11
- const PROJECT_STATUSES = ['planning', 'pre_production', 'in_production', 'post_production', 'completed'];
12
- const CREDIT_KINDS = ['creative', 'crew', 'cast'];
13
- /** The web's LookBook slot limit (compose/store.ts MEDIA_SLOT_LIMIT); the API itself has no cap. */
14
- const MEDIA_MAX = 9;
15
- /** Backend LOGLINE_MAX (src/backreel/post/descriptionHtml.ts). */
16
- const LOGLINE_MAX = 200;
17
- /** BackreelVerificationKind — what `creators --role` filters on. */
18
- const VERIFICATION_KINDS = ['investor', 'production_company', 'brand', 'distributor', 'sales_agent'];
19
- /** Credit fields the create / update DTO accepts; the rest of a read-back credit is dropped. */
20
- const CREDIT_FIELDS = ['kind', 'name', 'role', 'user', 'photo', 'avatarUrl', 'link', 'source', 'npcId', 'order'];
21
- export function backreelCommand(globals) {
22
- const br = new Command('backreel')
23
- .aliases(['br'])
24
- .description('Manage your Backreel posts (works) and their credits, including NPC cast');
25
- br.command('list', { isDefault: true })
26
- .description('Your posts, drafts included; --all for everyone\'s published posts')
27
- .option('--all', 'search all published posts instead of yours')
28
- .option('-k, --keyword <text>', 'search text')
29
- .option('--page <n>', 'page number', '1')
30
- .option('--limit <n>', 'page size', '20')
31
- .action(async (opts) => {
32
- const client = backreelClient(globals);
33
- const result = await client.get(`${POSTS}/${opts.all ? 'search' : 'search/my'}`, {
34
- keyword: opts.keyword,
35
- page: Number(opts.page),
36
- limit: Number(opts.limit),
37
- });
38
- render(result, (r) => {
39
- table(r?.data || [], [
40
- { header: 'id', value: (p) => p.id },
41
- { header: 'title', value: (p) => truncate(p.title, 28) },
42
- { header: 'kind', value: (p) => p.kind },
43
- { header: 'publish', value: (p) => p.publishStatus },
44
- { header: 'project', value: (p) => p.projectStatus },
45
- { header: 'credits', value: (p) => (p.credits || []).length },
46
- { header: 'updated', value: (p) => relativeTime(p.updatedAt) },
47
- ]);
48
- const p = r?.pagination;
49
- if (p)
50
- info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
51
- });
52
- });
53
- br.command('get <postId>')
54
- .description('Show a post and its credits')
55
- .action(async (id) => {
56
- const client = backreelClient(globals);
57
- const post = await client.getFresh(`${POSTS}/list/${id}`);
58
- render(post, () => printPost(post));
59
- });
60
- postOptions(br.command('create <title>').description('Create a post (always a draft until `backreel publish`)')).action(async (title, opts) => {
61
- const client = backreelClient(globals);
62
- // The create DTO validates kind and description although the entity has defaults.
63
- const body = { kind: 'showcase', description: '', ...(await postBody(opts)), title };
64
- const created = await client.post(`${POSTS}/create`, body);
65
- render(created, (p) => success(`Created draft "${p.title}" — ${p.id}`));
66
- });
67
- postOptions(br.command('set <postId>')
68
- .description('Update a post; only the fields you pass change')
69
- .option('--title <text>', 'title')).action(async (id, opts) => {
70
- const client = backreelClient(globals);
71
- const body = await postBody(opts);
72
- if (opts.title)
73
- body.title = opts.title;
74
- if (!Object.keys(body).length)
75
- throw new Error('Nothing to change — pass at least one field flag');
76
- const updated = await client.put(`${POSTS}/list/${id}`, body);
77
- render(updated, () => success(`Updated "${updated.title}": ${Object.keys(body).join(', ')}`));
78
- });
79
- for (const action of ['publish', 'unpublish']) {
80
- br.command(`${action} <postId>`)
81
- .description(action === 'publish' ? 'Publish a post' : 'Take a post offline')
82
- .action(async (id) => {
83
- const client = backreelClient(globals);
84
- const post = await client.post(`${POSTS}/list/${id}/${action}`);
85
- render(post, (p) => success(`"${p.title}" is now ${p.publishStatus}`));
86
- });
87
- }
88
- br.command('rm <postId>')
89
- .description('Delete a post')
90
- .option('-y, --yes', 'skip confirmation')
91
- .action(async (id, opts) => {
92
- const client = backreelClient(globals);
93
- const post = await client.getFresh(`${POSTS}/list/${id}`);
94
- if (!(await confirm(`Delete post "${post.title}" (${id})?`, opts.yes)))
95
- return info('Aborted');
96
- const result = await client.delete(`${POSTS}/list/${id}`);
97
- render(result, () => success(`Deleted "${post.title}"`));
98
- });
99
- br.command('creators')
100
- .description('Browse Backreel creators: editor picks, then ranked by recent likes / favorites')
101
- .option('-k, --keyword <text>', 'match name or @handle')
102
- .option('--role <kind>', `only creators verified as: ${VERIFICATION_KINDS.join(' | ')}`)
103
- .option('--verified', 'only verified creators')
104
- .option('--no-featured', 'skip the editor-picked creators')
105
- .option('--page <n>', 'page number', '1')
106
- .option('--limit <n>', 'page size (max 48)', '20')
107
- .action(async (opts) => {
108
- const client = backreelClient(globals);
109
- if (opts.role)
110
- oneOf(opts.role, VERIFICATION_KINDS, '--role');
111
- const result = await client.get('/backreel/creator/list', {
112
- keyword: opts.keyword,
113
- role: opts.role,
114
- verifiedOnly: opts.verified ? 'true' : undefined,
115
- withFeatured: opts.featured ? undefined : 'false',
116
- page: Number(opts.page),
117
- limit: Number(opts.limit),
118
- });
119
- render(result, (r) => {
120
- const columns = [
121
- { header: 'id', value: (c) => c.id },
122
- { header: 'name', value: (c) => truncate(c.name, 24) },
123
- { header: 'handle', value: (c) => (c.handle ? `@${c.handle}` : '') },
124
- { header: 'reels', value: (c) => c.reelCount },
125
- { header: 'followers', value: (c) => c.followerCount },
126
- { header: 'likes', value: (c) => c.likeCount },
127
- { header: 'verified', value: (c) => (c.verifiedKinds || []).join(', ') },
128
- ];
129
- if (r?.featured?.length) {
130
- info(color.bold('Featured'));
131
- table(r.featured, [...columns, { header: 'note', value: (c) => truncate(c.featuredNote, 30) }]);
132
- process.stdout.write('\n');
133
- info(color.bold('Hot'));
134
- }
135
- table(r?.data || [], columns);
136
- const p = r?.pagination;
137
- if (p) {
138
- const more = p.page * p.limit < p.total ? ` · more: --page ${p.page + 1}` : '';
139
- info(color.dim(`${p.total} ranked · page ${p.page}${more}`));
140
- }
141
- });
142
- });
143
- br.command('image <file...>')
144
- .description('Check image size / ratio for a slot, upload it, and optionally put it on a post')
145
- .requiredOption('--slot <slot>', `${Object.keys(IMAGE_SLOTS).join(' | ')} (see \`backreel image-specs\`)`)
146
- .option('--post <postId>', 'cover / portrait: set it on the post; media: append to the LookBook')
147
- .option('--check', 'only check, do not upload')
148
- .action(async (files, opts) => {
149
- const slot = oneOf(opts.slot, Object.keys(IMAGE_SLOTS), '--slot');
150
- if (files.length > 1 && slot !== 'media')
151
- throw new Error(`--slot ${slot} takes one image`);
152
- const checks = await Promise.all(files.map((f) => checkImage(f, slot)));
153
- for (const c of checks) {
154
- const dims = c.dims ? `${c.dims.width}×${c.dims.height} ${c.dims.format}` : '?';
155
- info(`${c.file}: ${dims}, ${(c.bytes / 1048576).toFixed(1)} MB → ${slot} (${c.spec.label}, ideal ${c.spec.ideal})`);
156
- for (const e of c.errors)
157
- info(color.red(` ✗ ${e}`));
158
- for (const w of c.warnings)
159
- info(color.yellow(` ! ${w}`));
160
- if (!c.errors.length && !c.warnings.length)
161
- info(color.green(' ✓ fits'));
162
- }
163
- if (checks.some((c) => c.errors.length))
164
- throw new Error('Fix the errors above first');
165
- if (opts.check)
166
- return render(checks, () => { });
167
- const client = backreelClient(globals);
168
- const ids = [];
169
- for (const c of checks) {
170
- const asset = await client.upload('/assets/v2/upload', c.file, {
171
- width: c.dims ? String(c.dims.width) : undefined,
172
- height: c.dims ? String(c.dims.height) : undefined,
173
- });
174
- ids.push(asset.id);
175
- success(`${c.file} → ${asset.id}`);
176
- }
177
- if (!opts.post)
178
- return render({ assets: ids }, () => { });
179
- const field = { cover: 'cover', portrait: 'coverPortrait', media: 'assets' }[slot];
180
- if (!field)
181
- throw new Error('--post works with cover, portrait and media; use the photo id with `credit add --photo` or `fund supporter add --avatar`');
182
- let value = ids[0];
183
- if (field === 'assets') {
184
- const post = await client.getFresh(`${POSTS}/list/${opts.post}`);
185
- value = [...(post.assets || []).map(idOf), ...ids];
186
- if (value.length > MEDIA_MAX)
187
- throw new Error(`The LookBook would have ${value.length} items; the limit is ${MEDIA_MAX}`);
188
- }
189
- const updated = await client.put(`${POSTS}/list/${opts.post}`, { [field]: value });
190
- render(updated, (p) => success(`Set ${field} on "${p.title}"${field === 'assets' ? ` (${value.length} items)` : ''}`));
191
- });
192
- br.command('image-specs')
193
- .description('Recommended image sizes for every Backreel slot')
194
- .action(() => {
195
- render(IMAGE_SLOTS, () => {
196
- table(Object.entries(IMAGE_SLOTS).map(([slot, s]) => ({ slot, ...s })), [
197
- { header: 'slot', value: (r) => r.slot },
198
- { header: 'ratio', value: (r) => r.label },
199
- { header: 'ideal', value: (r) => r.ideal },
200
- { header: 'min', value: (r) => r.min.join('×') },
201
- { header: 'note', value: (r) => r.note },
202
- ]);
203
- info(color.dim('JPEG / PNG / WebP, ≤25 MB. Long edge over 3840 px is downsized, files over 10 MB are re-encoded as JPEG.'));
204
- });
205
- });
206
- br.command('check <postId>')
207
- .description('Publish readiness and health: required fields, images, episodes, campaign')
208
- .action(async (id) => {
209
- const client = backreelClient(globals);
210
- const p = await client.getFresh(`${POSTS}/list/${id}`);
211
- const blockers = [];
212
- const advice = [];
213
- const hasText = !!(p.descriptionText || '').trim() || /<img\b/i.test(p.description || '');
214
- const episodes = p.episodes || [];
215
- if (!(p.title || '').trim())
216
- blockers.push('title is empty');
217
- if (!hasText)
218
- blockers.push('description is empty (needs text or an image) — `backreel set -d @synopsis.md`');
219
- if (!p.cover && !(p.assets || []).length && !episodes.length)
220
- blockers.push('needs a cover, LookBook media or an episode');
221
- if (p.kind === 'crowdfunding' && !(p.campaign?.goalAmount >= 1))
222
- blockers.push('crowdfunding goal is 0 — `backreel fund set --goal`');
223
- if (!p.cover)
224
- advice.push('no 16:9 cover — cards and shares fall back to a blank frame');
225
- if (!p.coverPortrait)
226
- advice.push('no 9:16 portrait cover — mobile crops the 16:9 cover');
227
- if (!(p.logline || '').trim())
228
- advice.push('no logline — the page falls back to the first sentence of the description');
229
- if (!(p.credits || []).length)
230
- advice.push('no credits');
231
- const pending = episodes.filter((e) => e.vodVid && e.vodStatus !== 'ready');
232
- if (pending.length)
233
- advice.push(`${pending.length} episode(s) not ready on VOD (${pending.map((e) => `#${(e.order ?? 0) + 1} ${e.vodStatus || '?'}`).join(', ')}) — \`backreel episode status\``);
234
- if (p.trailerDurationSec && p.trailerDurationSec > 31)
235
- advice.push(`trailer is ${p.trailerDurationSec}s; the limit is 30s`);
236
- if (p.donation?.enabled && p.kind === 'crowdfunding' && p.campaign?.deadline && new Date(p.campaign.deadline) < new Date()) {
237
- advice.push('the campaign deadline has passed — donations are closed');
238
- }
239
- render({ id: p.id, publishStatus: p.publishStatus, blockers, advice }, () => {
240
- info(`"${p.title}" — ${p.kind}, ${p.publishStatus}`);
241
- for (const b of blockers)
242
- info(color.red(` ✗ ${b}`));
243
- for (const a of advice)
244
- info(color.yellow(` ! ${a}`));
245
- if (!blockers.length)
246
- info(color.green(p.publishStatus === 'published' ? ' ✓ published' : ' ✓ ready to publish — `wji backreel publish`'));
247
- });
248
- });
249
- br.addCommand(episodeCommand(globals));
250
- br.addCommand(enquiryCommand(globals));
251
- br.addCommand(fundCommand(globals));
252
- // ── credits ────────────────────────────────────────────────────────────
253
- const credit = br.command('credit').aliases(['credits']).description('Cast and crew of a post (max 30)');
254
- credit
255
- .command('list <postId>', { isDefault: true })
256
- .description('Numbered credits')
257
- .action(async (id) => {
258
- const client = backreelClient(globals);
259
- const post = await client.getFresh(`${POSTS}/list/${id}`);
260
- render(post.credits || [], (credits) => printCredits(credits));
261
- });
262
- credit
263
- .command('add <postId>')
264
- .description('Add a credit; --npc casts one of your NPCs')
265
- .option('--name <name>', 'display name (defaults to the NPC or user name)')
266
- .requiredOption('--role <role>', 'job or character, e.g. 導演 or 飾 阿明')
267
- .option('--kind <kind>', CREDIT_KINDS.join(' | '), 'cast')
268
- .option('--npc <npc>', 'link an NPC City NPC (id, @handle or name)')
269
- .option('--user <userId>', 'link a WJI user (their name and avatar win)')
270
- .option('--link <url>', 'profile link')
271
- .option('--photo <assetId>', 'square photo asset (see `backreel image --slot photo`)')
272
- .option('--job <type>', `also add a work-history job to the NPC: ${NPC_JOB_TYPES.join(' | ')}`)
273
- .action(async (id, opts) => {
274
- if (!CREDIT_KINDS.includes(opts.kind))
275
- throw new Error(`--kind must be one of ${CREDIT_KINDS.join(', ')}`);
276
- if (opts.job && !opts.npc)
277
- throw new Error('--job needs --npc');
278
- if (opts.job && !NPC_JOB_TYPES.includes(opts.job)) {
279
- throw new Error(`--job must be one of ${NPC_JOB_TYPES.join(', ')}`);
280
- }
281
- const client = backreelClient(globals);
282
- const npc = opts.npc ? await resolveNpc(client, opts.npc) : undefined;
283
- // Any public NPC can be credited, but only your own gets a job. The soul
284
- // route is owner-only, so it checks that before anything is written.
285
- if (npc && opts.job) {
286
- await client.get(`/npc-city/residents/${npc.id}/soul`).catch(() => {
287
- throw new Error(`${npcLabel(npc)} is not yours, so --job can't add to its work history (the credit alone works without --job)`);
288
- });
289
- }
290
- const name = opts.name || npc?.name;
291
- if (!name && !opts.user)
292
- throw new Error('Pass --name, --npc or --user');
293
- const entry = { kind: opts.kind, name: name || '-', role: opts.role };
294
- if (opts.user)
295
- entry.user = opts.user;
296
- if (opts.link)
297
- entry.link = opts.link;
298
- if (opts.photo)
299
- entry.photo = opts.photo;
300
- if (npc) {
301
- entry.npcId = npc.id;
302
- if (npc.image)
303
- entry.avatarUrl = npc.image;
304
- }
305
- const post = await updateCredits(client, id, (credits) => [...credits, entry]);
306
- // The job is written after the credit, dated today and titled after the post.
307
- let job;
308
- if (npc && opts.job) {
309
- job = parseJob({ type: opts.job, title: post.title, detail: opts.role, date: today() });
310
- await updateJobs(client, npc.id, (jobs) => [...jobs, job]);
311
- }
312
- render({ post: post.id, credits: post.credits, job: job ?? null }, () => {
313
- success(`Credited ${name || opts.user} as ${opts.role} on "${post.title}" (#${post.credits.length})`);
314
- if (job)
315
- success(`Added ${job.type} "${job.title}" to ${npcLabel(npc)}'s work history`);
316
- });
317
- });
318
- credit
319
- .command('rm <postId> <n...>')
320
- .description('Remove credits by number')
321
- .option('-y, --yes', 'skip confirmation')
322
- .action(async (id, ns, opts) => {
323
- const client = backreelClient(globals);
324
- const post = await client.getFresh(`${POSTS}/list/${id}`);
325
- const credits = post.credits || [];
326
- const positions = parsePositions(ns, credits.length);
327
- const names = positions.map((i) => `#${i + 1} ${credits[i].name}`).join(', ');
328
- if (!(await confirm(`Remove ${names} from "${post.title}"?`, opts.yes)))
329
- return info('Aborted');
330
- const updated = await updateCredits(client, id, (current) => {
331
- if (JSON.stringify(current) !== JSON.stringify(credits.map(toCreditDto))) {
332
- throw new Error('The credits changed since they were read — list them again and retry');
333
- }
334
- return current.filter((_, i) => !positions.includes(i));
335
- });
336
- render(updated.credits, () => success(`Removed ${positions.length} credit(s); ${updated.credits.length} left`));
337
- });
338
- return br;
339
- }
340
- // ── helpers ──────────────────────────────────────────────────────────────
341
- function postOptions(cmd) {
342
- return cmd
343
- .option('--kind <kind>', KINDS.join(' | '))
344
- .option('--logline <text>', `one-line hook, max ${LOGLINE_MAX} chars; public even when the description is locked ("" clears it)`)
345
- .option('--category <category>', 'category')
346
- .option('--project-status <status>', PROJECT_STATUSES.join(' | '))
347
- .option('--tags <tags>', 'comma-separated tags (replaces the list)')
348
- .option('-d, --description <text>', 'description, plain text or HTML (@file / - supported)')
349
- .option('--cover <assetId>', 'landscape cover asset')
350
- .option('--cover-portrait <assetId>', 'portrait cover asset')
351
- .option('--trailer <assetId>', 'trailer video asset (≤30 s)')
352
- .option('--media <assetId...>', `LookBook gallery, images or videos in order (replaces it; ≤${MEDIA_MAX}; "none" clears)`)
353
- .option('--body <json>', 'any other post fields as JSON (@file / - supported); flags win');
354
- }
355
- async function postBody(opts) {
356
- const body = { ...((await readJson(opts.body)) || {}) };
357
- if (opts.kind)
358
- body.kind = oneOf(opts.kind, KINDS, '--kind');
359
- if (opts.logline !== undefined) {
360
- // Same normalisation the server applies: plain text, one line.
361
- const logline = String(await readValue(opts.logline)).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
362
- if (logline.length > LOGLINE_MAX)
363
- throw new Error(`--logline is ${logline.length} chars; the limit is ${LOGLINE_MAX}`);
364
- body.logline = logline;
365
- }
366
- if (opts.category)
367
- body.category = opts.category;
368
- if (opts.projectStatus)
369
- body.projectStatus = oneOf(opts.projectStatus, PROJECT_STATUSES, '--project-status');
370
- if (opts.tags !== undefined)
371
- body.tags = String(opts.tags).split(',').map((t) => t.trim()).filter(Boolean);
372
- if (opts.description !== undefined)
373
- body.description = toHtml(await readValue(opts.description));
374
- if (opts.cover)
375
- body.cover = opts.cover;
376
- if (opts.coverPortrait)
377
- body.coverPortrait = opts.coverPortrait;
378
- if (opts.trailer)
379
- body.trailer = opts.trailer;
380
- if (opts.media) {
381
- const media = opts.media.filter((m) => m !== 'none');
382
- if (media.length > MEDIA_MAX)
383
- throw new Error(`--media takes at most ${MEDIA_MAX} items (the web's LookBook limit)`);
384
- body.assets = media;
385
- }
386
- return body;
387
- }
388
- /** Descriptions are stored as sanitized HTML; plain text becomes paragraphs. */
389
- function toHtml(text) {
390
- if (/<\/?(p|br|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|div|span|img|hr)\b[^>]*>/i.test(text))
391
- return text;
392
- const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
393
- return text
394
- .trim()
395
- .split(/\n{2,}/)
396
- .map((para) => `<p>${escape(para).replace(/\n/g, '<br>')}</p>`)
397
- .join('');
398
- }
399
- const toCreditDto = (c) => Object.fromEntries(CREDIT_FIELDS.filter((k) => c[k] !== undefined && c[k] !== null).map((k) => [k, c[k]]));
400
- /** Credits have no ids: read fresh, change, write the whole array back in order. */
401
- async function updateCredits(client, id, fn) {
402
- const post = await client.getFresh(`${POSTS}/list/${id}`);
403
- const next = fn((post.credits || []).map(toCreditDto)).map((c, i) => ({ ...c, order: i }));
404
- if (next.length > 30)
405
- throw new Error('A post can have at most 30 credits');
406
- return client.put(`${POSTS}/list/${id}`, { credits: next });
407
- }
408
- const assetLabel = (a) => (a ? a.url || a.id || a : undefined);
409
- function printPost(p) {
410
- detail([
411
- ['id', p.id],
412
- ['title', p.title],
413
- ['kind', p.kind],
414
- ['publish', p.publishStatus],
415
- ['project', p.projectStatus],
416
- ['category', p.category],
417
- ['tags', (p.tags || []).join(', ')],
418
- ['logline', p.logline],
419
- ['description', truncate(p.descriptionText || p.description, 120)],
420
- ['cover', assetLabel(p.cover)],
421
- ['coverPortrait', assetLabel(p.coverPortrait)],
422
- ['media', (p.assets || []).length ? `${p.assets.length} item(s)` : undefined],
423
- ['episodes', (p.episodes || []).length ? `${p.episodes.length} (\`backreel episode list\`)` : undefined],
424
- ['campaign', p.campaign ? `${p.campaign.raisedAmount}/${p.campaign.goalAmount} ${(p.campaign.currency || '').toUpperCase()}` : undefined],
425
- ['donations', p.donation?.enabled ? 'on' : undefined],
426
- ['trailer', p.trailer ? `${assetLabel(p.trailer)}${p.trailerDurationSec ? ` (${p.trailerDurationSec}s)` : ''}` : undefined],
427
- ['creator', p.creator?.name],
428
- ['likes', p.likeCount],
429
- ['updated', relativeTime(p.updatedAt)],
430
- ]);
431
- process.stdout.write('\n');
432
- printCredits(p.credits || []);
433
- }
434
- function printCredits(credits) {
435
- table(credits.map((c, i) => ({ ...c, n: i + 1 })), [
436
- { header: '#', value: (c) => c.n },
437
- { header: 'kind', value: (c) => c.kind },
438
- { header: 'name', value: (c) => truncate(c.name, 20) },
439
- { header: 'role', value: (c) => truncate(c.role, 24) },
440
- { header: 'source', value: (c) => c.source },
441
- { header: 'npc / user', value: (c) => c.npcId || c.user },
442
- ]);
443
- }