@gentbajko/slopify 3.9.0 → 3.10.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 (85) hide show
  1. package/dist/catalog/model-info.js +33 -0
  2. package/dist/edge/http/app.js +2 -0
  3. package/dist/edge/http/channels.js +6 -1
  4. package/dist/edge/http/document-themes.js +2 -2
  5. package/dist/edge/http/files.js +6 -2
  6. package/dist/edge/http/outputs.js +67 -0
  7. package/dist/edge/http/planning.js +18 -3
  8. package/dist/edge/http/projects.js +7 -4
  9. package/dist/edge/http/providers.js +4 -6
  10. package/dist/edge/http/revision-files.js +25 -7
  11. package/dist/edge/http/schedules.js +30 -0
  12. package/dist/edge/http/trash.js +51 -2
  13. package/dist/extension/slopify-studio-chrome.zip +0 -0
  14. package/dist/extension/slopify-studio-firefox.zip +0 -0
  15. package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
  16. package/dist/patch-notes/3.10.0.md +79 -0
  17. package/dist/patch-notes/3.9.1.md +12 -0
  18. package/dist/patch-notes/index.json +12 -0
  19. package/dist/slices/admission/heads.js +59 -0
  20. package/dist/slices/admission/rules.js +6 -1
  21. package/dist/slices/batch/index.js +22 -0
  22. package/dist/slices/channels/rebrand.js +7 -1
  23. package/dist/slices/channels/repo.js +2 -1
  24. package/dist/slices/channels/schema.js +5 -0
  25. package/dist/slices/channels/service.js +23 -3
  26. package/dist/slices/estimate/index.js +2 -0
  27. package/dist/slices/library/transfer.js +164 -0
  28. package/dist/slices/narration/aliases-table.js +82 -0
  29. package/dist/slices/outputs/add.js +192 -0
  30. package/dist/slices/outputs/model.js +36 -0
  31. package/dist/slices/outputs/service.js +136 -0
  32. package/dist/slices/rebuild/model.js +1 -1
  33. package/dist/slices/rebuild/recipe-text.js +9 -4
  34. package/dist/slices/rebuild/recipe-visual.js +6 -0
  35. package/dist/slices/rebuild/runtime-local.js +10 -3
  36. package/dist/slices/rebuild/runtime-provider.js +7 -0
  37. package/dist/slices/revisions/downloads.js +38 -17
  38. package/dist/slices/revisions/model.js +3 -1
  39. package/dist/slices/revisions/mutation-assets.js +29 -2
  40. package/dist/slices/revisions/mutation-prepare.js +44 -9
  41. package/dist/slices/revisions/schema.js +4 -0
  42. package/dist/slices/schedules/agenda.js +21 -16
  43. package/dist/slices/schedules/held-bulk.js +66 -0
  44. package/dist/slices/schedules/topics.js +1 -1
  45. package/dist/slices/storage/backup-import.js +8 -4
  46. package/dist/slices/storage/downloads.js +42 -4
  47. package/dist/slices/voices/adaptation.js +19 -0
  48. package/dist/slices/voices/model.js +4 -2
  49. package/dist/slices/youtube/model.js +33 -2
  50. package/dist/tutorials/AI-CLIs.md +1 -1
  51. package/dist/tutorials/Backups.md +5 -5
  52. package/dist/tutorials/Calendar.md +2 -2
  53. package/dist/tutorials/Channels.md +1 -1
  54. package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
  55. package/dist/tutorials/Document-Themes.md +2 -2
  56. package/dist/tutorials/Editing-a-Project.md +2 -2
  57. package/dist/tutorials/FAQ.md +1 -1
  58. package/dist/tutorials/Home-and-Projects.md +33 -22
  59. package/dist/tutorials/Intros-and-Outros.md +4 -4
  60. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
  61. package/dist/tutorials/Library-Overview.md +16 -15
  62. package/dist/tutorials/Multiple-Voices.md +3 -3
  63. package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
  64. package/dist/tutorials/PDF-Documents.md +5 -5
  65. package/dist/tutorials/Play-Narration.md +3 -3
  66. package/dist/tutorials/Play-Outputs.md +1 -1
  67. package/dist/tutorials/Play-Overview.md +4 -4
  68. package/dist/tutorials/Play-Title-and-Article.md +3 -3
  69. package/dist/tutorials/Project-Page.md +7 -6
  70. package/dist/tutorials/Prompts.md +1 -1
  71. package/dist/tutorials/Publishing-to-YouTube.md +2 -2
  72. package/dist/tutorials/Recovery-and-Retries.md +1 -1
  73. package/dist/tutorials/Schedules.md +1 -1
  74. package/dist/tutorials/Settings-Reference.md +35 -31
  75. package/dist/tutorials/Templates.md +1 -1
  76. package/dist/tutorials/Trash.md +5 -5
  77. package/dist/tutorials/Troubleshooting.md +2 -2
  78. package/dist/tutorials/Where-Your-Files-Live.md +4 -4
  79. package/dist/web/assets/index-CW_6Ho-M.js +325 -0
  80. package/dist/web/assets/index-DmPZKDb1.css +1 -0
  81. package/dist/web/assets/{pdf-D04vvdOD.js → pdf-C4K73cRQ.js} +1 -1
  82. package/dist/web/index.html +2 -2
  83. package/package.json +1 -1
  84. package/dist/web/assets/index-IWb_SctN.js +0 -310
  85. package/dist/web/assets/index-_D68Tk8q.css +0 -1
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { bookSchema } from "../voices/model.js";
3
+ import { formats } from "./model.js";
4
+ import { liveProject } from "./repo.js";
5
+ import { runConfigSchema } from "./schema.js";
6
+ // Every live project for 07 Projects, Home and the command palette, newest first, with only the
7
+ // settings a list row reads. SQLite picks the three values out of the stored configuration, so
8
+ // a list never parses or sends a project's rendered prompts, provided text or episode memory.
9
+ const headRow = z.object({
10
+ id: z.string(),
11
+ title: z.string(),
12
+ format: z.enum(formats),
13
+ created_at: z.string(),
14
+ updated_at: z.string(),
15
+ paused: z.number().default(0),
16
+ sources: z.string(),
17
+ article_prompt: z.string().nullable(),
18
+ book: z.string().nullable(),
19
+ });
20
+ const listingConfigSchema = z.object({
21
+ sources: runConfigSchema.shape.sources,
22
+ articlePrompt: z.string().optional(),
23
+ voices: z.object({ book: bookSchema.optional() }).optional(),
24
+ });
25
+ const headSelect = `SELECT projects.id, projects.title, projects.format, projects.created_at,
26
+ projects.updated_at, COALESCE(project_controls.paused, 0) AS paused,
27
+ json_extract(projects.config, '$.sources') AS sources,
28
+ json_extract(projects.config, '$.articlePrompt') AS article_prompt,
29
+ json_extract(projects.config, '$.voices.book') AS book
30
+ FROM projects LEFT JOIN project_controls ON project_controls.project_id = projects.id`;
31
+ export function listProjectHeads(db) {
32
+ return db
33
+ .prepare(`${headSelect} WHERE ${liveProject()} ORDER BY created_at DESC, id DESC`)
34
+ .all()
35
+ .map((raw) => {
36
+ const row = headRow.parse(raw);
37
+ return {
38
+ id: row.id,
39
+ title: row.title,
40
+ format: row.format,
41
+ config: listingConfigOf(row),
42
+ createdAt: row.created_at,
43
+ updatedAt: row.updated_at,
44
+ paused: row.paused === 1,
45
+ };
46
+ });
47
+ }
48
+ function listingConfigOf(row) {
49
+ const parsed = listingConfigSchema.parse({
50
+ sources: JSON.parse(row.sources),
51
+ ...(row.article_prompt === null ? {} : { articlePrompt: row.article_prompt }),
52
+ ...(row.book === null ? {} : { voices: { book: JSON.parse(row.book) } }),
53
+ });
54
+ return {
55
+ sources: parsed.sources,
56
+ ...(parsed.articlePrompt === undefined ? {} : { articlePrompt: parsed.articlePrompt }),
57
+ ...(parsed.voices?.book === undefined ? {} : { voices: { book: parsed.voices.book } }),
58
+ };
59
+ }
@@ -222,7 +222,10 @@ function checkImagePrompts(draft, fields) {
222
222
  function checkProvided(draft, staged, fields) {
223
223
  const { sources, provided } = draft;
224
224
  if (sources.research === "provide" && blank(provided.research)) {
225
- fields.push({ field: "provided.research", message: "Paste the research notes." });
225
+ fields.push({
226
+ field: "provided.research",
227
+ message: "Paste the text to adapt, or your research notes.",
228
+ });
226
229
  }
227
230
  if (sources.article === "provide" && blank(provided.article)) {
228
231
  fields.push({ field: "provided.article", message: "Paste the article." });
@@ -324,6 +327,8 @@ export function llmUses(draft) {
324
327
  uses.push({ section: "article", label: "writing the article" });
325
328
  if (usesVoices(draft) && draft.voices?.source === "attribute")
326
329
  uses.push({ section: "narration", label: "working out who speaks each line" });
330
+ if (usesVoices(draft) && draft.voices?.source === "adapt")
331
+ uses.push({ section: "narration", label: "rewriting the text as a conversation" });
327
332
  if (usesNarrationPreparation(draft))
328
333
  uses.push({ section: "narration", label: "preparing the narration's delivery" });
329
334
  if (draft.intro?.mode === "llm")
@@ -84,3 +84,25 @@ export function pumpQueue(db, runner) {
84
84
  return;
85
85
  }
86
86
  }
87
+ // Batch queue's Up and Down: a video still waiting its turn trades places with the next waiting
88
+ // one before or after it. A video already started (active) or finished keeps its place, so
89
+ // nothing admitted is ever overtaken.
90
+ export function moveQueued(db, projectId, by) {
91
+ return transact(db, () => {
92
+ const waiting = queueEntries(db).filter((entry) => entry.state === "queued");
93
+ const at = waiting.findIndex((entry) => entry.projectId === projectId);
94
+ if (at === -1)
95
+ return { ok: false, reason: "not-queued" };
96
+ const other = waiting[at + by];
97
+ const self = waiting[at];
98
+ if (other === undefined || self === undefined)
99
+ return { ok: false, reason: "edge" };
100
+ // `position` is the row's key, so the swap goes through a free one.
101
+ const free = -1 - Math.max(self.position, other.position);
102
+ const set = db.prepare("UPDATE project_queue SET position = ? WHERE project_id = ?");
103
+ set.run(free, self.projectId);
104
+ set.run(self.position, other.projectId);
105
+ set.run(other.position, self.projectId);
106
+ return { ok: true, queue: queueEntries(db) };
107
+ });
108
+ }
@@ -89,9 +89,15 @@ export function rebrandedEdit(db, base, edit) {
89
89
  // The value a brand-kit setting takes: the new kit's when the current one is unset, the
90
90
  // fallback, or what the old kit put there; otherwise the person's own, kept.
91
91
  function swapped(current, old, next, fallback) {
92
- const fromOldKit = current === undefined || current === fallback || (old !== undefined && current === old);
92
+ const fromOldKit = current === undefined || current === fallback || (old !== undefined && same(current, old));
93
93
  return fromOldKit ? (next ?? fallback) : current;
94
94
  }
95
+ // Channels store colours as #RRGGBB, a project's own colour boxes may hold #rrggbb: the same colour.
96
+ function same(one, other) {
97
+ return one.startsWith("#") && other.startsWith("#")
98
+ ? one.toLowerCase() === other.toLowerCase()
99
+ : one === other;
100
+ }
95
101
  function assign(target, key, value) {
96
102
  if (value === undefined)
97
103
  delete target[key];
@@ -38,6 +38,7 @@ const memberRow = z.object({
38
38
  description: z.string(),
39
39
  voice_json: z.string().nullable().optional(),
40
40
  host: z.number().optional(),
41
+ position: z.number().optional(),
41
42
  version: z.number(),
42
43
  created_at: z.string(),
43
44
  updated_at: z.string(),
@@ -112,7 +113,7 @@ function counts(db, sql) {
112
113
  }
113
114
  export function castOfChannel(db, channelId) {
114
115
  const members = db
115
- .prepare("SELECT * FROM cast_members WHERE channel_id=? ORDER BY lower(name), id")
116
+ .prepare("SELECT * FROM cast_members WHERE channel_id=? ORDER BY position, lower(name), id")
116
117
  .all(channelId)
117
118
  .map((row) => memberRow.parse(row));
118
119
  const images = new Map();
@@ -123,6 +123,11 @@ export const castMemberCreateSchema = castMemberInputSchema.extend({ id }).stric
123
123
  export const castMemberUpdateSchema = castMemberInputSchema
124
124
  .extend({ baseVersion: z.number().int().positive() })
125
125
  .strict();
126
+ // Moves one member to a place in its channel's cast (0 is first); a place past the end is last.
127
+ export const castMoveSchema = z
128
+ .object({ memberId: id, to: z.number().int().nonnegative() })
129
+ .strict()
130
+ .readonly();
126
131
  export const castGenerateSchema = z
127
132
  .object({
128
133
  prompt: z
@@ -1,7 +1,7 @@
1
1
  import { transact } from "../../kernel/db/tx.js";
2
2
  import { defaultChannelId } from "./model.js";
3
3
  import { castMemberById, castOfChannel, channelById, channelSummaries } from "./repo.js";
4
- import { castMemberCreateSchema, castMemberUpdateSchema, channelAiDisclosureSchema, channelCreateSchema, channelUpdateSchema, templateChannelSchema, } from "./schema.js";
4
+ import { castMemberCreateSchema, castMemberUpdateSchema, castMoveSchema, channelAiDisclosureSchema, channelCreateSchema, channelUpdateSchema, templateChannelSchema, } from "./schema.js";
5
5
  export function listChannels(deps) {
6
6
  return channelSummaries(deps.db);
7
7
  }
@@ -127,8 +127,8 @@ export function createCastMember(deps, channelId, input) {
127
127
  : { ok: false, reason: "conflict" };
128
128
  const at = deps.clock.now().toISOString();
129
129
  deps.db
130
- .prepare("INSERT INTO cast_members(id,channel_id,kind,name,aliases_json,description,voice_json,host,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)")
131
- .run(id, channelId, kind, name, JSON.stringify(unique(aliases, name)), description, voice === undefined || voice === null ? null : JSON.stringify(voice), host === true ? 1 : 0, at, at);
130
+ .prepare("INSERT INTO cast_members(id,channel_id,kind,name,aliases_json,description,voice_json,host,position,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,(SELECT coalesce(max(position)+1,0) FROM cast_members WHERE channel_id=?),?,?)")
131
+ .run(id, channelId, kind, name, JSON.stringify(unique(aliases, name)), description, voice === undefined || voice === null ? null : JSON.stringify(voice), host === true ? 1 : 0, channelId, at, at);
132
132
  const created = castMemberById(deps.db, id);
133
133
  if (created === undefined)
134
134
  throw new Error("The new cast member could not be read back");
@@ -167,6 +167,26 @@ export function deleteCastMember(deps, id) {
167
167
  ? { ok: false, reason: "not-found" }
168
168
  : { ok: true, value: { deleted: true } };
169
169
  }
170
+ // Moves a member to a place in its channel's cast and numbers the whole cast again from 0, so
171
+ // the order never depends on ties. Answers with the cast in its new order.
172
+ export function moveCastMember(deps, channelId, input) {
173
+ const parsed = castMoveSchema.safeParse(input);
174
+ if (!parsed.success)
175
+ return { ok: false, reason: "invalid-input" };
176
+ const { memberId, to } = parsed.data;
177
+ return transact(deps.db, () => {
178
+ const order = castOfChannel(deps.db, channelId).map((member) => member.id);
179
+ const from = order.indexOf(memberId);
180
+ if (from < 0)
181
+ return { ok: false, reason: "not-found" };
182
+ order.splice(from, 1);
183
+ order.splice(Math.min(to, order.length), 0, memberId);
184
+ const place = deps.db.prepare("UPDATE cast_members SET position=? WHERE id=?");
185
+ for (const [index, id] of order.entries())
186
+ place.run(index, id);
187
+ return { ok: true, value: castOfChannel(deps.db, channelId) };
188
+ });
189
+ }
170
190
  // Aliases compared without case, the name itself left out: matching ignores case anyway.
171
191
  function unique(aliases, name) {
172
192
  const seen = new Set([name.toLowerCase()]);
@@ -147,6 +147,8 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
147
147
  requests.push({ ...choice, kind: "tts-estimate", characters: extras + llmExtras });
148
148
  if (draft.voices?.source === "attribute")
149
149
  text("Speaker split", articleChars + 1500, Math.round(articleChars * 1.1), "One LLM call hands the text's passages to the speakers.");
150
+ if (draft.voices?.source === "adapt")
151
+ text("Conversation script", articleChars + 1500, Math.round(articleChars * 1.2), "One LLM call rewrites the text as a conversation between the speakers; the article stays as written.");
150
152
  }
151
153
  else
152
154
  requests.push(generatedArticle || llmExtras > 0
@@ -0,0 +1,164 @@
1
+ import { z } from "zod";
2
+ import { documentThemeNameMax } from "../document/model.js";
3
+ import { documentThemeSchema } from "../document/theme-schema.js";
4
+ import { narrationAliasSchema } from "../narration/aliases-schema.js";
5
+ import { bodyMax, entryCategories, entryModes, nameMax, promptKinds, } from "./model.js";
6
+ // The file Library → Import or export writes and reads: prompts, intros and outros, PDF themes
7
+ // and narration aliases as plain items, not database rows (the full backup in
8
+ // slices/storage/backup-format.ts carries rows for one install's own restore). One item that
9
+ // doesn't fit is skipped with its reason; the rest still import. Browser-safe: the web app
10
+ // builds and reads these files, then saves each item through the same routes as its editor.
11
+ export const libraryFileFormat = "slopify-library";
12
+ export const libraryFileVersion = 1;
13
+ // Far above a real Library, low enough that a wrong file is refused before it is parsed.
14
+ export const libraryFileMaxBytes = 20 * 1024 * 1024;
15
+ const itemsMax = 10_000;
16
+ const sectionWords = {
17
+ prompts: "prompts",
18
+ entries: "intros or outros",
19
+ documentThemes: "PDF themes",
20
+ aliases: "narration aliases",
21
+ };
22
+ // Only what the editor saves travels: ids, keywords and dates are the install's own.
23
+ const pickers = {
24
+ prompts: (item) => ({ kind: item.kind, name: item.name, body: item.body }),
25
+ entries: (item) => ({
26
+ category: item.category,
27
+ mode: item.mode,
28
+ name: item.name,
29
+ body: item.body,
30
+ }),
31
+ documentThemes: (item) => ({ name: item.name, values: item.values }),
32
+ aliases: (item) => ({
33
+ written: item.written,
34
+ spoken: item.spoken,
35
+ wholeWord: item.wholeWord,
36
+ caseSensitive: item.caseSensitive,
37
+ }),
38
+ };
39
+ export function libraryFile(sections, now) {
40
+ return {
41
+ format: libraryFileFormat,
42
+ version: libraryFileVersion,
43
+ exportedAt: now.toISOString(),
44
+ ...(sections.prompts === undefined ? {} : { prompts: sections.prompts.map(pickers.prompts) }),
45
+ ...(sections.entries === undefined ? {} : { entries: sections.entries.map(pickers.entries) }),
46
+ ...(sections.documentThemes === undefined
47
+ ? {}
48
+ : { documentThemes: sections.documentThemes.map(pickers.documentThemes) }),
49
+ ...(sections.aliases === undefined ? {} : { aliases: sections.aliases.map(pickers.aliases) }),
50
+ };
51
+ }
52
+ const name = (max) => z
53
+ .string({ error: "it has no name." })
54
+ .trim()
55
+ .min(1, { error: "it has no name." })
56
+ .max(max, { error: `its name is longer than ${String(max)} characters.` });
57
+ const body = z
58
+ .string({ error: "it has no text." })
59
+ .max(bodyMax, { error: `its text is longer than ${String(bodyMax)} characters.` });
60
+ const itemSchemas = {
61
+ prompts: z.object({
62
+ kind: z.enum(promptKinds, { error: `its kind isn't one of ${promptKinds.join(", ")}.` }),
63
+ name: name(nameMax),
64
+ body,
65
+ }),
66
+ entries: z.object({
67
+ category: z.enum(entryCategories, { error: "it is neither an intro nor an outro." }),
68
+ mode: z.enum(entryModes, { error: `its mode isn't one of ${entryModes.join(", ")}.` }),
69
+ name: name(nameMax),
70
+ body,
71
+ }),
72
+ documentThemes: z.object({ name: name(documentThemeNameMax), values: documentThemeSchema }),
73
+ aliases: narrationAliasSchema,
74
+ };
75
+ const head = z
76
+ .object({ format: z.literal(libraryFileFormat), version: z.number().int().positive() })
77
+ .loose();
78
+ // Reads one section of a file. A wrong or newer file is refused as a whole, with what to do.
79
+ export function readLibraryFile(text, section) {
80
+ const what = sectionWords[section];
81
+ let raw;
82
+ try {
83
+ raw = JSON.parse(text);
84
+ }
85
+ catch {
86
+ return {
87
+ ok: false,
88
+ message: `it isn't a Slopify library file (the text isn't JSON). Pick a .json file made by Export.`,
89
+ };
90
+ }
91
+ const top = head.safeParse(raw);
92
+ if (!top.success)
93
+ return {
94
+ ok: false,
95
+ message: `it isn't a Slopify library file. Pick a .json file made by Export on a Library tab.`,
96
+ };
97
+ if (top.data.version > libraryFileVersion)
98
+ return {
99
+ ok: false,
100
+ message: "it was made by a newer Slopify. Update Slopify (Settings → Updates), then import it again.",
101
+ };
102
+ const list = top.data[section];
103
+ if (!Array.isArray(list) || list.length === 0)
104
+ return {
105
+ ok: false,
106
+ message: `it holds no ${what}. Import it on the Library tab it was exported from.`,
107
+ };
108
+ if (list.length > itemsMax)
109
+ return {
110
+ ok: false,
111
+ message: `it holds more than ${String(itemsMax)} ${what}. Split it into smaller files.`,
112
+ };
113
+ const items = [];
114
+ const skipped = [];
115
+ const schema = itemSchemas[section];
116
+ for (const [index, each] of list.entries()) {
117
+ const parsed = schema.safeParse(each);
118
+ if (parsed.success)
119
+ items.push(parsed.data);
120
+ else
121
+ skipped.push({ item: labelOf(each, index), reason: reasonOf(parsed.error) });
122
+ }
123
+ return { ok: true, items, skipped };
124
+ }
125
+ function labelOf(item, index) {
126
+ const named = z.object({ name: z.string().trim().min(1) }).safeParse(item);
127
+ if (named.success)
128
+ return named.data.name;
129
+ const written = z.object({ written: z.string().trim().min(1) }).safeParse(item);
130
+ return written.success ? written.data.written : `Item ${String(index + 1)}`;
131
+ }
132
+ function reasonOf(error) {
133
+ const issue = error.issues[0];
134
+ if (issue === undefined)
135
+ return "it doesn't fit.";
136
+ const [first] = issue.path;
137
+ if (first === "values")
138
+ return `its setting ${issue.path.slice(1).join(".")}: ${issue.message}`;
139
+ if (first === "written" || first === "spoken")
140
+ return `its ${first === "written" ? "written form" : "spoken form"} is empty or too long.`;
141
+ if (typeof first === "string" && /^it/u.test(issue.message))
142
+ return issue.message;
143
+ return first === undefined ? "it isn't an item." : `its ${String(first)} is missing or wrong.`;
144
+ }
145
+ // A name nobody uses yet in `taken` (lower-cased): the name itself, else "<name> (imported)",
146
+ // "<name> (imported 2)"… The base is cut so the suffix still fits `max`.
147
+ export function importedName(wanted, taken, max) {
148
+ return freeName(wanted, taken, max, (n) => n === 1 ? " (imported)" : ` (imported ${String(n)})`);
149
+ }
150
+ // Duplicate selected names each copy "<name> copy", then "<name> copy 2"…
151
+ export function copyName(wanted, taken, max) {
152
+ return freeName(wanted, taken, max, (n) => (n === 1 ? " copy" : ` copy ${String(n)}`), false);
153
+ }
154
+ function freeName(wanted, taken, max, suffix, keepIfFree = true) {
155
+ const base = wanted.trim();
156
+ if (keepIfFree && !taken.has(base.toLowerCase()))
157
+ return base;
158
+ for (let n = 1;; n += 1) {
159
+ const end = suffix(n);
160
+ const candidate = `${base.slice(0, Math.max(0, max - end.length)).trimEnd()}${end}`;
161
+ if (!taken.has(candidate.toLowerCase()))
162
+ return candidate;
163
+ }
164
+ }
@@ -0,0 +1,82 @@
1
+ // Tab-separated when the file says so (or its first line holds a tab), comma-separated else.
2
+ export function readAliasTable(text, fileName = "") {
3
+ const firstLine = text.split(/\r?\n/u, 1)[0] ?? "";
4
+ const separator = /\.tsv$/iu.test(fileName) || firstLine.includes("\t") ? "\t" : ",";
5
+ const aliases = [];
6
+ const skipped = [];
7
+ for (const { line, cells } of rowsOf(text.replace(/^/u, ""), separator)) {
8
+ if (cells.every((cell) => cell.trim() === ""))
9
+ continue;
10
+ const written = (cells[0] ?? "").trim();
11
+ const spoken = (cells[1] ?? "").trim();
12
+ // A header row the spreadsheet kept: "Written,Say it as".
13
+ if (line === 1 && /^written\b/iu.test(written))
14
+ continue;
15
+ if (written === "" || spoken === "") {
16
+ skipped.push(line);
17
+ continue;
18
+ }
19
+ aliases.push({ written, spoken, wholeWord: true, caseSensitive: false });
20
+ }
21
+ return { aliases, skipped };
22
+ }
23
+ export function aliasTable(aliases, separator) {
24
+ const cell = (value) => separator === "\t"
25
+ ? value.replace(/[\t\r\n]+/gu, " ")
26
+ : /[",\r\n]/u.test(value)
27
+ ? `"${value.replace(/"/gu, '""')}"`
28
+ : value;
29
+ return `${[["Written", "Say it as"], ...aliases.map((alias) => [alias.written, alias.spoken])]
30
+ .map((row) => row.map(cell).join(separator))
31
+ .join("\n")}\n`;
32
+ }
33
+ // RFC 4180 fields: a quoted field may hold the separator, "" for a quote, and line breaks.
34
+ function rowsOf(text, separator) {
35
+ const rows = [];
36
+ let cells = [];
37
+ let cell = "";
38
+ let quoted = false;
39
+ let line = 1;
40
+ let rowLine = 1;
41
+ for (let at = 0; at < text.length; at += 1) {
42
+ const char = text[at];
43
+ if (quoted) {
44
+ if (char === '"' && text[at + 1] === '"') {
45
+ cell += '"';
46
+ at += 1;
47
+ }
48
+ else if (char === '"')
49
+ quoted = false;
50
+ else {
51
+ if (char === "\n")
52
+ line += 1;
53
+ cell += char;
54
+ }
55
+ }
56
+ else if (char === '"' && cell.trim() === "") {
57
+ quoted = true;
58
+ cell = "";
59
+ }
60
+ else if (char === separator) {
61
+ cells.push(cell);
62
+ cell = "";
63
+ }
64
+ else if (char === "\n" || char === "\r") {
65
+ if (char === "\r" && text[at + 1] === "\n")
66
+ at += 1;
67
+ cells.push(cell);
68
+ rows.push({ line: rowLine, cells });
69
+ cells = [];
70
+ cell = "";
71
+ line += 1;
72
+ rowLine = line;
73
+ }
74
+ else
75
+ cell += char;
76
+ }
77
+ if (cell !== "" || cells.length > 0) {
78
+ cells.push(cell);
79
+ rows.push({ line: rowLine, cells });
80
+ }
81
+ return rows;
82
+ }
@@ -0,0 +1,192 @@
1
+ import { stageKinds } from "../../kernel/pipeline.js";
2
+ import { sourceOf } from "../admission/model.js";
3
+ import { defaultVoicesSettings } from "../voices/model.js";
4
+ // Another output made from a project that already exists: the same brief, sources and
5
+ // accepted text, with one more kind of result switched on. It is a new revision of the same
6
+ // project, so the results already accepted are reused by the rebuild and only the new stages
7
+ // run. A podcast is an adaptation: the text model rewrites the article as a two-host
8
+ // conversation for the narration (`voices` source `adapt`), the article stays as written and the
9
+ // conversation is kept beside it as the script. That rewrite is said in the plan (`adapts`) and
10
+ // has to be accepted explicitly. A project has one narration, so an audiobook or podcast is
11
+ // not added over narration it already has.
12
+ export const addableOutputs = [
13
+ "narration",
14
+ "audiobook",
15
+ "images",
16
+ "video",
17
+ "pdf",
18
+ "podcast",
19
+ ];
20
+ export const addableOutputLabels = {
21
+ narration: "Narration",
22
+ audiobook: "Audiobook (chaptered MP3 and M4B)",
23
+ images: "Images",
24
+ video: "Video",
25
+ pdf: "PDF",
26
+ podcast: "Podcast (adapts your text into a conversation)",
27
+ };
28
+ // What one stage's result is called where the plan lists it.
29
+ const stageResults = {
30
+ research: "Research notes and sources",
31
+ article: "Accepted article text",
32
+ audio: "Narration",
33
+ images: "Slideshow images",
34
+ thumbnail: "Thumbnail",
35
+ video: "Video",
36
+ document: "PDF",
37
+ };
38
+ export function planAddedOutput(config, kind) {
39
+ const on = (stage) => sourceOf(config.sources, stage) !== "off";
40
+ if (!on("article") && kind !== "images")
41
+ return {
42
+ ok: false,
43
+ reason: "needs-article",
44
+ message: `${addableOutputLabels[kind]} is made from the project's text, and this project has none. Open Settings in this project, set Article to Generate or Provide, save, then add it.`,
45
+ };
46
+ if (present(config, kind))
47
+ return {
48
+ ok: false,
49
+ reason: "already",
50
+ message: `This project already makes ${lowerLabel(kind)}. Find it in its section on the left.`,
51
+ };
52
+ if ((kind === "audiobook" || kind === "podcast") && on("audio"))
53
+ return {
54
+ ok: false,
55
+ reason: "narration-taken",
56
+ message: `This project already has narration, and a project has one: ${kind === "podcast" ? "a podcast" : "an audiobook"} would replace it. Keep it, or change its format under Speakers in this project's Settings → Providers.`,
57
+ };
58
+ const sources = switchedOn(config, kind);
59
+ const next = {
60
+ ...config,
61
+ sources,
62
+ ...(kind === "audiobook" ? { voices: audiobookVoices(config) } : {}),
63
+ ...(kind === "podcast" ? { voices: podcastVoices(config) } : {}),
64
+ // A description or shorts are parts of a video: switched on with one, never charged for
65
+ // when only narration or a PDF is added.
66
+ ...(kind === "video" || on("audio")
67
+ ? {}
68
+ : { youtubeDescription: undefined, shorts: undefined }),
69
+ };
70
+ const stages = stageKinds.filter((stage) => !on(stage) && sourceOf(sources, stage) !== "off");
71
+ const kept = stageKinds.filter((stage) => on(stage) && stage !== "thumbnail");
72
+ return {
73
+ ok: true,
74
+ plan: {
75
+ kind,
76
+ config: next,
77
+ stages,
78
+ reused: ["Brief: title, prompts and keywords", ...kept.map((stage) => stageResults[stage])],
79
+ created: createdWords(kind, stages),
80
+ textUse: kind === "podcast"
81
+ ? "Adapted: the text model rewrites your article as a conversation between two hosts for the narration. The article stays as written; the conversation is kept beside it as the script."
82
+ : kind === "images"
83
+ ? "Your text is not changed."
84
+ : "Your text is used as written: nothing is rewritten.",
85
+ adapts: kind === "podcast",
86
+ },
87
+ };
88
+ }
89
+ function present(config, kind) {
90
+ const on = (stage) => sourceOf(config.sources, stage) !== "off";
91
+ switch (kind) {
92
+ case "narration":
93
+ return on("audio");
94
+ case "audiobook":
95
+ return on("audio") && config.voices?.audioFiles === true;
96
+ case "images":
97
+ return on("images");
98
+ case "video":
99
+ return on("video");
100
+ case "pdf":
101
+ return on("document");
102
+ case "podcast":
103
+ return on("audio") && config.voices?.format === "podcast";
104
+ }
105
+ }
106
+ function switchedOn(config, kind) {
107
+ const sources = config.sources;
108
+ const audio = sources.audio === "off" ? "generate" : sources.audio;
109
+ switch (kind) {
110
+ case "narration":
111
+ case "audiobook":
112
+ case "podcast":
113
+ return { ...sources, audio };
114
+ case "images":
115
+ return { ...sources, images: "generate" };
116
+ case "video":
117
+ return {
118
+ ...sources,
119
+ audio,
120
+ images: sources.images === "off" ? "generate" : sources.images,
121
+ video: "generate",
122
+ };
123
+ case "pdf":
124
+ return { ...sources, document: "generate" };
125
+ }
126
+ }
127
+ // One narrator reading the text as written; the text model only hands any quoted dialogue to
128
+ // the speakers (`attribute`). An audiobook the project already sets up keeps its own speakers.
129
+ function audiobookVoices(config) {
130
+ const base = config.voices ?? {
131
+ ...defaultVoicesSettings("audiobook"),
132
+ source: "attribute",
133
+ };
134
+ const voice = config.audio;
135
+ return {
136
+ ...base,
137
+ audioFiles: true,
138
+ speakers: base.speakers.map((speaker) => speaker.voice.voice === "" && voice !== undefined
139
+ ? {
140
+ ...speaker,
141
+ voice: { provider: voice.provider, model: voice.model, voice: voice.voice },
142
+ }
143
+ : speaker),
144
+ };
145
+ }
146
+ // Two hosts talk the article through; the first speaks in the project's narration voice and
147
+ // the second needs a voice of its own, chosen in Settings.
148
+ function podcastVoices(config) {
149
+ const base = { ...defaultVoicesSettings("podcast"), source: "adapt", audioFiles: true };
150
+ const voice = config.audio;
151
+ return {
152
+ ...base,
153
+ speakers: base.speakers.map((speaker, at) => at === 0 && voice !== undefined
154
+ ? {
155
+ ...speaker,
156
+ voice: { provider: voice.provider, model: voice.model, voice: voice.voice },
157
+ }
158
+ : speaker),
159
+ };
160
+ }
161
+ function createdWords(kind, stages) {
162
+ const words = [];
163
+ if (kind === "podcast")
164
+ words.push("A conversation script adapted from your article");
165
+ if (stages.includes("audio"))
166
+ words.push(kind === "audiobook"
167
+ ? "Narration by the narrator voice"
168
+ : kind === "podcast"
169
+ ? "Narration by the two hosts"
170
+ : "Narration of the text");
171
+ if (kind === "podcast")
172
+ words.push("The episode as MP3 and M4B with chapters");
173
+ if (kind === "audiobook")
174
+ words.push("Chaptered MP3 and M4B files");
175
+ if (kind === "narration")
176
+ words.push("The narration as one audio file");
177
+ if (stages.includes("images"))
178
+ words.push("Slideshow images");
179
+ if (stages.includes("video"))
180
+ words.push("The rendered video");
181
+ if (stages.includes("document"))
182
+ words.push("The PDF, with a linked contents page");
183
+ return words;
184
+ }
185
+ function lowerLabel(kind) {
186
+ const label = addableOutputLabels[kind];
187
+ if (kind === "pdf")
188
+ return "a PDF";
189
+ if (kind === "podcast")
190
+ return "a podcast";
191
+ return label.charAt(0).toLowerCase() + label.slice(1);
192
+ }