@gentbajko/slopify 3.9.1 → 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.
- package/dist/catalog/model-info.js +33 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/channels.js +6 -1
- package/dist/edge/http/document-themes.js +2 -2
- package/dist/edge/http/files.js +6 -2
- package/dist/edge/http/outputs.js +67 -0
- package/dist/edge/http/planning.js +18 -3
- package/dist/edge/http/projects.js +7 -4
- package/dist/edge/http/providers.js +4 -6
- package/dist/edge/http/revision-files.js +25 -7
- package/dist/edge/http/schedules.js +30 -0
- package/dist/edge/http/trash.js +51 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
- package/dist/patch-notes/3.10.0.md +79 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/heads.js +59 -0
- package/dist/slices/admission/rules.js +6 -1
- package/dist/slices/batch/index.js +22 -0
- package/dist/slices/channels/rebrand.js +7 -1
- package/dist/slices/channels/repo.js +2 -1
- package/dist/slices/channels/schema.js +5 -0
- package/dist/slices/channels/service.js +23 -3
- package/dist/slices/estimate/index.js +2 -0
- package/dist/slices/library/transfer.js +164 -0
- package/dist/slices/narration/aliases-table.js +82 -0
- package/dist/slices/outputs/add.js +192 -0
- package/dist/slices/outputs/model.js +36 -0
- package/dist/slices/outputs/service.js +136 -0
- package/dist/slices/rebuild/model.js +1 -1
- package/dist/slices/rebuild/recipe-text.js +9 -4
- package/dist/slices/rebuild/recipe-visual.js +6 -0
- package/dist/slices/rebuild/runtime-local.js +10 -3
- package/dist/slices/rebuild/runtime-provider.js +7 -0
- package/dist/slices/revisions/downloads.js +38 -17
- package/dist/slices/revisions/model.js +3 -1
- package/dist/slices/revisions/mutation-assets.js +29 -2
- package/dist/slices/revisions/mutation-prepare.js +44 -9
- package/dist/slices/revisions/schema.js +4 -0
- package/dist/slices/schedules/agenda.js +21 -16
- package/dist/slices/schedules/held-bulk.js +66 -0
- package/dist/slices/schedules/topics.js +1 -1
- package/dist/slices/storage/backup-import.js +8 -4
- package/dist/slices/storage/downloads.js +42 -4
- package/dist/slices/voices/adaptation.js +19 -0
- package/dist/slices/voices/model.js +4 -2
- package/dist/slices/youtube/model.js +33 -2
- package/dist/tutorials/AI-CLIs.md +1 -1
- package/dist/tutorials/Backups.md +5 -5
- package/dist/tutorials/Calendar.md +2 -2
- package/dist/tutorials/Channels.md +1 -1
- package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
- package/dist/tutorials/Document-Themes.md +2 -2
- package/dist/tutorials/Editing-a-Project.md +2 -2
- package/dist/tutorials/FAQ.md +1 -1
- package/dist/tutorials/Home-and-Projects.md +33 -22
- package/dist/tutorials/Intros-and-Outros.md +4 -4
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
- package/dist/tutorials/Library-Overview.md +16 -15
- package/dist/tutorials/Multiple-Voices.md +3 -3
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
- package/dist/tutorials/PDF-Documents.md +5 -5
- package/dist/tutorials/Play-Narration.md +3 -3
- package/dist/tutorials/Play-Outputs.md +1 -1
- package/dist/tutorials/Play-Overview.md +4 -4
- package/dist/tutorials/Play-Title-and-Article.md +3 -3
- package/dist/tutorials/Project-Page.md +7 -6
- package/dist/tutorials/Prompts.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +2 -2
- package/dist/tutorials/Recovery-and-Retries.md +1 -1
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +35 -31
- package/dist/tutorials/Templates.md +1 -1
- package/dist/tutorials/Trash.md +5 -5
- package/dist/tutorials/Troubleshooting.md +2 -2
- package/dist/tutorials/Where-Your-Files-Live.md +4 -4
- package/dist/web/assets/index-CW_6Ho-M.js +325 -0
- package/dist/web/assets/index-DmPZKDb1.css +1 -0
- package/dist/web/assets/{pdf-D04vvdOD.js → pdf-C4K73cRQ.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-IWb_SctN.js +0 -310
- 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({
|
|
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
|
|
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
|
+
}
|