@gentbajko/slopify 3.0.12 → 3.0.14
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/edge/events/hub.js +35 -15
- package/dist/edge/events/preview-cache.js +1 -0
- package/dist/edge/events/visibility.js +42 -8
- package/dist/edge/http/entries.js +2 -1
- package/dist/edge/http/prompts.js +2 -1
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/main.js +2 -2
- package/dist/patch-notes/3.0.13.md +43 -0
- package/dist/patch-notes/3.0.14.md +24 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/library/rename.js +94 -0
- package/dist/slices/library/save.js +5 -0
- package/dist/slices/library/used-by.js +95 -68
- package/dist/slices/rebuild/recipe-build.js +6 -3
- package/dist/slices/rebuild/recipe-model.js +1 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +1 -0
- package/dist/slices/rebuild/recipe-scenes.js +33 -20
- package/dist/slices/rebuild/recipe-visual.js +2 -4
- package/dist/slices/rebuild/recipe-work.js +1 -0
- package/dist/slices/rebuild/runtime-export.js +2 -1
- package/dist/slices/rebuild/runtime-provider.js +5 -3
- package/dist/slices/rebuild/transition-repo.js +13 -5
- package/dist/slices/revisions/restore.js +6 -1
- package/dist/slices/run-cost/timing.js +17 -5
- package/dist/tutorials/Notifications.md +9 -0
- package/dist/tutorials/Play-Images.md +1 -1
- package/dist/web/assets/{index-BbbbM56z.css → index-CcJ6VLvA.css} +1 -1
- package/dist/web/assets/{index-BPstEgjc.js → index-aN9pfG3k.js} +120 -120
- package/dist/web/assets/{pdf-DVDjUG4j.js → pdf-D0quBJqy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
package/dist/edge/events/hub.js
CHANGED
|
@@ -7,6 +7,12 @@ export function createHub(deps) {
|
|
|
7
7
|
// changes, so the newest one is kept here and replayed to whoever opens next; a page
|
|
8
8
|
// that loads mid-render would otherwise be told nothing is running.
|
|
9
9
|
let tally = { type: "running.count", count: 0 };
|
|
10
|
+
const present = (event) => deps.presentEvent !== undefined
|
|
11
|
+
? deps.presentEvent(event)
|
|
12
|
+
: deps.acceptEvent?.(event) === false
|
|
13
|
+
? undefined
|
|
14
|
+
: event;
|
|
15
|
+
const heartbeatMs = deps.heartbeatMs ?? 20_000;
|
|
10
16
|
const send = (subscriber, event) => {
|
|
11
17
|
// writeSSE rejects on a socket that is already gone, asynchronously and long after
|
|
12
18
|
// emit() returned, so a dead subscriber is dropped here instead of at the call site.
|
|
@@ -38,6 +44,15 @@ export function createHub(deps) {
|
|
|
38
44
|
}
|
|
39
45
|
else {
|
|
40
46
|
signal.addEventListener("abort", subscriber.drop, { once: true });
|
|
47
|
+
if (heartbeatMs > 0) {
|
|
48
|
+
const beat = setInterval(() => {
|
|
49
|
+
subscriber.stream
|
|
50
|
+
.writeSSE({ event: "ping", data: "", id: deps.ids.next() })
|
|
51
|
+
.catch(() => subscriber.drop());
|
|
52
|
+
}, heartbeatMs);
|
|
53
|
+
beat.unref?.();
|
|
54
|
+
void done.then(() => clearInterval(beat));
|
|
55
|
+
}
|
|
41
56
|
}
|
|
42
57
|
return subscriber;
|
|
43
58
|
};
|
|
@@ -51,33 +66,38 @@ export function createHub(deps) {
|
|
|
51
66
|
}
|
|
52
67
|
});
|
|
53
68
|
if (set.has(subscriber))
|
|
54
|
-
for (const event of previews.snapshot(projectId))
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
for (const event of previews.snapshot(projectId)) {
|
|
70
|
+
const shown = present(event);
|
|
71
|
+
if (shown !== undefined)
|
|
72
|
+
send(subscriber, shown);
|
|
73
|
+
}
|
|
57
74
|
return subscriber.done;
|
|
58
75
|
},
|
|
59
76
|
subscribeGlobal: (stream, signal) => {
|
|
60
77
|
const subscriber = join(globals, stream, signal, () => { });
|
|
61
78
|
if (globals.has(subscriber)) {
|
|
62
79
|
send(subscriber, tally);
|
|
80
|
+
// What the AI models are writing right now, so a page opened mid-call shows it.
|
|
81
|
+
for (const event of previews.snapshotAll()) {
|
|
82
|
+
const shown = present(event);
|
|
83
|
+
if (shown !== undefined)
|
|
84
|
+
send(subscriber, shown);
|
|
85
|
+
}
|
|
63
86
|
}
|
|
64
87
|
return subscriber.done;
|
|
65
88
|
},
|
|
66
89
|
emit: (projectId, event) => {
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
90
|
+
const shown = present(event);
|
|
91
|
+
if (shown !== undefined)
|
|
92
|
+
previews.observe(shown);
|
|
93
|
+
else if (event.type === "stage.state")
|
|
69
94
|
previews.observe(event);
|
|
70
|
-
if (
|
|
95
|
+
if (shown === undefined)
|
|
71
96
|
return;
|
|
72
|
-
for (const subscriber of projects.get(projectId) ?? [])
|
|
73
|
-
send(subscriber,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
event.type === "project.updated" ||
|
|
77
|
-
event.type === "review.flagged") {
|
|
78
|
-
for (const subscriber of globals)
|
|
79
|
-
send(subscriber, event);
|
|
80
|
-
}
|
|
97
|
+
for (const subscriber of projects.get(projectId) ?? [])
|
|
98
|
+
send(subscriber, shown);
|
|
99
|
+
for (const subscriber of globals)
|
|
100
|
+
send(subscriber, shown);
|
|
81
101
|
},
|
|
82
102
|
emitGlobal: (event) => {
|
|
83
103
|
if (event.type === "running.count") {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { currentRevisionId } from "../../slices/revisions/repo.js";
|
|
2
|
+
// Whether the work an event came from is the current revision's: made for it, or carried to it
|
|
3
|
+
// from an older revision by an edit that left it unchanged (a video still rendering after a
|
|
4
|
+
// thumbnail redo is still the project's video).
|
|
2
5
|
export function currentWorkOrigin(db, projectId, origin) {
|
|
3
6
|
if (currentRevisionId(db, projectId) === undefined)
|
|
4
7
|
return origin.revisionId === undefined && origin.workId === undefined;
|
|
@@ -13,21 +16,52 @@ export function currentWorkOrigin(db, projectId, origin) {
|
|
|
13
16
|
JOIN json_each(v.fingerprints) f ON f.key=COALESCE(r.logical_key,r.work_key)
|
|
14
17
|
AND f.value=COALESCE(r.desired_fingerprint,r.fingerprint)
|
|
15
18
|
LEFT JOIN revision_work_pieces p ON p.id=r.piece_id AND p.work_id=w.id
|
|
16
|
-
WHERE w.project_id=? AND w.
|
|
19
|
+
WHERE w.project_id=? AND w.id=?
|
|
17
20
|
AND (? IS NULL OR r.piece_id=?)
|
|
18
21
|
AND (r.piece_id IS NULL OR (p.work_key=r.work_key AND p.fingerprint=r.fingerprint))
|
|
19
22
|
`)
|
|
20
|
-
.get(projectId, origin.
|
|
23
|
+
.get(projectId, origin.workId, origin.workPieceId ?? null, origin.workPieceId ?? null) !==
|
|
24
|
+
undefined);
|
|
21
25
|
}
|
|
22
|
-
|
|
26
|
+
// The event as the project's pages should see it, or undefined for one they must not: work the
|
|
27
|
+
// current revision no longer wants. Carried work is told under the current revision, since
|
|
28
|
+
// that is the revision the pages show.
|
|
29
|
+
export function currentEvent(db, event) {
|
|
23
30
|
if (event.type === "project.updated")
|
|
24
|
-
return
|
|
31
|
+
return event;
|
|
25
32
|
if (event.type === "project.state" &&
|
|
26
33
|
event.revisionId === undefined &&
|
|
27
34
|
event.workId === undefined)
|
|
28
|
-
return
|
|
35
|
+
return event;
|
|
29
36
|
const head = currentRevisionId(db, event.projectId);
|
|
30
|
-
if (head
|
|
31
|
-
return
|
|
32
|
-
|
|
37
|
+
if (head === undefined)
|
|
38
|
+
return currentWorkOrigin(db, event.projectId, event) ? event : undefined;
|
|
39
|
+
if (!currentWorkOrigin(db, event.projectId, event))
|
|
40
|
+
return undefined;
|
|
41
|
+
return event.revisionId === head ? event : { ...event, revisionId: head };
|
|
42
|
+
}
|
|
43
|
+
export function currentProjectEvent(db, event) {
|
|
44
|
+
return currentEvent(db, event) !== undefined;
|
|
45
|
+
}
|
|
46
|
+
// `currentEvent` for the hub. The streamed text (a token at a time) asks the same question many
|
|
47
|
+
// times a second, so its answer is kept for a second per piece of work.
|
|
48
|
+
export function eventPresenter(db, now = Date.now) {
|
|
49
|
+
const seen = new Map();
|
|
50
|
+
return (event) => {
|
|
51
|
+
if (event.type !== "llm.preview" && event.type !== "article.delta")
|
|
52
|
+
return currentEvent(db, event);
|
|
53
|
+
const key = [event.projectId, event.revisionId, event.workId, event.workPieceId].join("|");
|
|
54
|
+
const hit = seen.get(key);
|
|
55
|
+
if (hit !== undefined && now() - hit.at < 1000)
|
|
56
|
+
return !hit.shown
|
|
57
|
+
? undefined
|
|
58
|
+
: hit.revisionId === undefined || hit.revisionId === event.revisionId
|
|
59
|
+
? event
|
|
60
|
+
: { ...event, revisionId: hit.revisionId };
|
|
61
|
+
const shown = currentEvent(db, event);
|
|
62
|
+
if (seen.size > 512)
|
|
63
|
+
seen.clear();
|
|
64
|
+
seen.set(key, { at: now(), shown: shown !== undefined, revisionId: shown?.revisionId });
|
|
65
|
+
return shown;
|
|
66
|
+
};
|
|
33
67
|
}
|
|
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { versionsOrCurrent } from "../../slices/library/history.js";
|
|
5
5
|
import { entryCategories, entryModes } from "../../slices/library/model.js";
|
|
6
|
+
import { formerNames } from "../../slices/library/rename.js";
|
|
6
7
|
import { entryById, listEntries } from "../../slices/library/repo.js";
|
|
7
8
|
import { createEntry, removeEntry, restoreEntry, updateEntry } from "../../slices/library/save.js";
|
|
8
9
|
import { usedBy } from "../../slices/library/used-by.js";
|
|
@@ -51,7 +52,7 @@ export function entryRoutes(deps) {
|
|
|
51
52
|
const entry = entryById(deps.db, c.req.valid("param").id);
|
|
52
53
|
return entry === undefined
|
|
53
54
|
? refused(c, { ok: false, reason: "not-found" }, "entry")
|
|
54
|
-
: c.json(usedBy(deps.db, { item: "entry", category: entry.category, name: entry.name }));
|
|
55
|
+
: c.json(usedBy(deps.db, { item: "entry", category: entry.category, name: entry.name }, formerNames(deps.db, "entry", entry.id, entry.name)));
|
|
55
56
|
})
|
|
56
57
|
.delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
57
58
|
const result = removeEntry(library, c.req.valid("param").id);
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { versionsOrCurrent } from "../../slices/library/history.js";
|
|
5
5
|
import { promptKinds } from "../../slices/library/model.js";
|
|
6
6
|
import { setPromptPhotorealistic, withPhotorealistic, } from "../../slices/library/photorealistic.js";
|
|
7
|
+
import { formerNames } from "../../slices/library/rename.js";
|
|
7
8
|
import { listPrompts, promptById } from "../../slices/library/repo.js";
|
|
8
9
|
import { createPrompt, removePrompt, restorePrompt, updatePrompt, } from "../../slices/library/save.js";
|
|
9
10
|
import { usedBy } from "../../slices/library/used-by.js";
|
|
@@ -70,7 +71,7 @@ export function promptRoutes(deps) {
|
|
|
70
71
|
const prompt = promptById(deps.db, c.req.valid("param").id);
|
|
71
72
|
return prompt === undefined
|
|
72
73
|
? refused(c, { ok: false, reason: "not-found" }, "prompt")
|
|
73
|
-
: c.json(usedBy(deps.db, { item: "prompt", kind: prompt.kind, name: prompt.name }));
|
|
74
|
+
: c.json(usedBy(deps.db, { item: "prompt", kind: prompt.kind, name: prompt.name }, formerNames(deps.db, "prompt", prompt.id, prompt.name)));
|
|
74
75
|
})
|
|
75
76
|
// A project holds its own rendered text, so nothing cascades and a
|
|
76
77
|
// template used by past projects is deleted like any other.
|
|
Binary file
|
|
Binary file
|
package/dist/main.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createCatalogueStore } from "./catalog/store.js";
|
|
|
14
14
|
import { createAutostart } from "./edge/autostart/index.js";
|
|
15
15
|
import { dockerActivationCommitted, dockerFilesLayout, dockerFolderConfiguration, } from "./edge/docker-install/activation.js";
|
|
16
16
|
import { createHub, observedHub } from "./edge/events/hub.js";
|
|
17
|
-
import {
|
|
17
|
+
import { eventPresenter } from "./edge/events/visibility.js";
|
|
18
18
|
import { createApp } from "./edge/http/app.js";
|
|
19
19
|
import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
|
|
20
20
|
import { limitRequestTimes } from "./edge/http/timeouts.js";
|
|
@@ -219,7 +219,7 @@ export async function boot(config, options = {}) {
|
|
|
219
219
|
const hub = observedHub(createHub({
|
|
220
220
|
ids,
|
|
221
221
|
log,
|
|
222
|
-
|
|
222
|
+
presentEvent: eventPresenter(eventDb),
|
|
223
223
|
}), (event) => {
|
|
224
224
|
notifier.observe(event);
|
|
225
225
|
episodes?.observe(event);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Slopify 3.0.13
|
|
2
|
+
|
|
3
|
+
Released 30 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.13 makes every page update live again, tightens the project page, adds run sounds, and fixes Restore and a thumbnail change that remade every image.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Pages update live again.** Steps, images, progress and finished runs show up without refreshing, on every page and in every tab.
|
|
10
|
+
- **Run sounds.** A chime when a run starts and a different one when it ends.
|
|
11
|
+
- **History → Restore works again** on projects with scenes, looks, shorts or a YouTube description.
|
|
12
|
+
|
|
13
|
+
## Live updates
|
|
14
|
+
|
|
15
|
+
- Each page now keeps one live connection instead of one per project it shows. With a few tabs open, the connections ran out and the page's own requests waited forever: updates arrived but nothing could reload.
|
|
16
|
+
- A run that goes on after an edit (a video still rendering after a thumbnail redo) shows its progress again.
|
|
17
|
+
- The projects list, home and calendar move when steps start and finish, not only when a project changes.
|
|
18
|
+
- The video render's progress moves in tenths of a percent, so a two-hour render moves every few seconds.
|
|
19
|
+
- Pages showing a running project also look again every 15 seconds, in case an update was missed.
|
|
20
|
+
|
|
21
|
+
## Project page
|
|
22
|
+
|
|
23
|
+
- The header is one row: the way back, the title, the meta line and the buttons. Each stage's summary sits beside its title, and the run clock sits in the meta line instead of a row of its own, so the content starts much higher.
|
|
24
|
+
- The article shows its word and character count beside its title (the narrated text, without its sources and glossary).
|
|
25
|
+
- **Live** plays the finished narration, with its waveform, and plays the parts so far while it is spoken. Its length reads in hours and minutes.
|
|
26
|
+
|
|
27
|
+
## Run clock and cost
|
|
28
|
+
|
|
29
|
+
- The run clock keeps counting through work carried over from before an edit, instead of freezing.
|
|
30
|
+
- **Cost so far** shows the run's working time once it stops, for example "2 h 14 min of work".
|
|
31
|
+
|
|
32
|
+
## Notifications
|
|
33
|
+
|
|
34
|
+
- **Run sounds** (Settings → Notifications, on by default): two rising notes when a run starts, three brighter ones when it finishes, fails or waits for you. **Play start** and **Play end** let you hear them. With browser notifications on, a start also shows "Started: *title*".
|
|
35
|
+
|
|
36
|
+
## Fixes
|
|
37
|
+
|
|
38
|
+
- **History → Restore this revision** stopped with "unknown kind of step" on projects with Scenes from the article, `{{Appearance}}`, shorts, a YouTube description, levelled narration or animated images.
|
|
39
|
+
- Changing a project's thumbnail settings marked every image outdated. The thumbnails' scenes are now written by a step of their own in the Thumbnail stage.
|
|
40
|
+
|
|
41
|
+
## Upgrading from 3.0.12
|
|
42
|
+
|
|
43
|
+
- **Nothing becomes outdated.** A project whose thumbnail prompt uses `{{Scene}}` writes its thumbnail scenes in the new step the next time the thumbnail is made.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Slopify 3.0.14
|
|
2
|
+
|
|
3
|
+
Released 30 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.14 fixes two things in the Library: **Used by** on Review and Script prompts, and renaming a prompt or an intro/outro that templates and drafts still use.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Renaming a prompt no longer breaks what uses it.** Templates, schedules and open Play drafts take the new name.
|
|
10
|
+
- **Used by is right for Review and Script prompts.**
|
|
11
|
+
|
|
12
|
+
## Library
|
|
13
|
+
|
|
14
|
+
- Renaming a prompt or an intro/outro now renames it in every template and every Play draft you are still editing, so they keep using it. Schedules follow their template. Before, they kept the old name and fell back to the copy saved with the template.
|
|
15
|
+
- A project keeps the name its run used. **Used by** still lists it after a rename.
|
|
16
|
+
|
|
17
|
+
## Fixes
|
|
18
|
+
|
|
19
|
+
- **Used by** on a Review or Script prompt listed the templates and projects that used *image* prompts of the same name. It now lists the automatic reviews that pick the Review prompt, and the multi-voice script runs that pick the Script prompt.
|
|
20
|
+
- **Used by** now lists a schedule by the template as it is now, which is what the schedule runs, instead of the version it was saved with.
|
|
21
|
+
|
|
22
|
+
## Upgrading from 3.0.13
|
|
23
|
+
|
|
24
|
+
- **Nothing becomes outdated.** Templates and drafts that already name an old name keep working as before; the next rename carries them along.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.0.14",
|
|
4
|
+
"title": "Slopify 3.0.14",
|
|
5
|
+
"version": "3.0.14",
|
|
6
|
+
"date": "2026-09-30"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.0.13",
|
|
10
|
+
"title": "Slopify 3.0.13",
|
|
11
|
+
"version": "3.0.13",
|
|
12
|
+
"date": "2026-09-30"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.0.12",
|
|
4
16
|
"title": "Slopify 3.0.12",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Renaming a Library prompt or intro/outro. Everything names the Library by name, so a rename
|
|
2
|
+
// rewrites the name where a future run reads it: each live template's current version (its
|
|
3
|
+
// form and the copy it saved of the Library) and every Play draft still being edited.
|
|
4
|
+
// Schedules run their template as it is now, so they follow. A project revision is the record
|
|
5
|
+
// of what its run used and keeps the old name; "Used by" finds it through `formerNames`.
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { renamedIn } from "./used-by.js";
|
|
8
|
+
const documentRow = z.object({ key: z.string(), version: z.number(), document_json: z.string() });
|
|
9
|
+
// The template document or Play draft document with the form, and the Library copy it saved,
|
|
10
|
+
// naming `to`; undefined when neither named the old name.
|
|
11
|
+
function renamedDocument(json, ref, id, to) {
|
|
12
|
+
const document = JSON.parse(json);
|
|
13
|
+
if (typeof document !== "object" || document === null)
|
|
14
|
+
return undefined;
|
|
15
|
+
const next = { ...document };
|
|
16
|
+
let changed = false;
|
|
17
|
+
const form = renamedIn(next.form, ref, to);
|
|
18
|
+
if (form !== undefined) {
|
|
19
|
+
next.form = form;
|
|
20
|
+
changed = true;
|
|
21
|
+
}
|
|
22
|
+
const snapshot = renamedSnapshot(next.librarySnapshot, ref, id, to);
|
|
23
|
+
if (snapshot !== undefined) {
|
|
24
|
+
next.librarySnapshot = snapshot;
|
|
25
|
+
changed = true;
|
|
26
|
+
}
|
|
27
|
+
return changed ? JSON.stringify(next) : undefined;
|
|
28
|
+
}
|
|
29
|
+
// The saved copy of a renamed item is found by the new name once the Library row is gone.
|
|
30
|
+
function renamedSnapshot(value, ref, id, to) {
|
|
31
|
+
if (typeof value !== "object" || value === null)
|
|
32
|
+
return undefined;
|
|
33
|
+
const snapshot = value;
|
|
34
|
+
const listKey = ref.item === "prompt" ? "prompts" : "entries";
|
|
35
|
+
const rows = snapshot[listKey];
|
|
36
|
+
if (!Array.isArray(rows))
|
|
37
|
+
return undefined;
|
|
38
|
+
const from = ref.name.trim().toLowerCase();
|
|
39
|
+
let changed = false;
|
|
40
|
+
const next = rows.map((row) => {
|
|
41
|
+
if (typeof row !== "object" || row === null)
|
|
42
|
+
return row;
|
|
43
|
+
const one = row;
|
|
44
|
+
const sameKind = ref.item === "prompt" ? one.kind === ref.kind : one.category === ref.category;
|
|
45
|
+
const named = typeof one.name === "string" && one.name.trim().toLowerCase() === from;
|
|
46
|
+
if (!sameKind || one.name === to || (one.id !== id && !named))
|
|
47
|
+
return row;
|
|
48
|
+
changed = true;
|
|
49
|
+
return { ...one, name: to };
|
|
50
|
+
});
|
|
51
|
+
return changed ? { ...snapshot, [listKey]: next } : undefined;
|
|
52
|
+
}
|
|
53
|
+
// Runs inside the save's transaction, so the rename and the rewritten references land
|
|
54
|
+
// together or not at all.
|
|
55
|
+
export function renameReferences(db, ref, id, to) {
|
|
56
|
+
if (ref.name === to)
|
|
57
|
+
return;
|
|
58
|
+
const templates = db
|
|
59
|
+
.prepare(`SELECT t.id AS key,r.version,r.document_json FROM project_templates t
|
|
60
|
+
JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version
|
|
61
|
+
WHERE t.deleted_at IS NULL`)
|
|
62
|
+
.all()
|
|
63
|
+
.map((row) => documentRow.parse(row));
|
|
64
|
+
const updateTemplate = db.prepare("UPDATE project_template_revisions SET document_json=? WHERE template_id=? AND version=?");
|
|
65
|
+
for (const row of templates) {
|
|
66
|
+
const next = renamedDocument(row.document_json, ref, id, to);
|
|
67
|
+
if (next !== undefined)
|
|
68
|
+
updateTemplate.run(next, row.key, row.version);
|
|
69
|
+
}
|
|
70
|
+
const drafts = db
|
|
71
|
+
.prepare("SELECT id AS key,version,document_json FROM play_drafts WHERE state='active'")
|
|
72
|
+
.all()
|
|
73
|
+
.map((row) => documentRow.parse(row));
|
|
74
|
+
const updateDraft = db.prepare("UPDATE play_drafts SET document_json=? WHERE id=?");
|
|
75
|
+
for (const row of drafts) {
|
|
76
|
+
const next = renamedDocument(row.document_json, ref, id, to);
|
|
77
|
+
if (next !== undefined)
|
|
78
|
+
updateDraft.run(next, row.key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// The names an item had before, other than its current one, that no other live item of the
|
|
82
|
+
// same kind or category holds now.
|
|
83
|
+
export function formerNames(db, itemKind, id, current) {
|
|
84
|
+
const table = itemKind === "prompt" ? "prompts" : "entries";
|
|
85
|
+
const group = itemKind === "prompt" ? "kind" : "category";
|
|
86
|
+
const rows = db
|
|
87
|
+
.prepare(`SELECT DISTINCT v.name FROM library_versions v
|
|
88
|
+
WHERE v.item_kind=? AND v.item_id=? AND lower(v.name)<>lower(?)
|
|
89
|
+
AND NOT EXISTS (
|
|
90
|
+
SELECT 1 FROM ${table} o WHERE o.id<>v.item_id AND o.${group}=v.kind
|
|
91
|
+
AND lower(o.name)=lower(v.name) AND o.deleted_at IS NULL)`)
|
|
92
|
+
.all(itemKind, id, current);
|
|
93
|
+
return rows.map((row) => z.object({ name: z.string() }).parse(row).name);
|
|
94
|
+
}
|
|
@@ -6,6 +6,7 @@ import { detectSlots } from "../admission/substitute.js";
|
|
|
6
6
|
import { recordVersion, versionOf } from "./history.js";
|
|
7
7
|
import { lintEntry, lintPrompt } from "./lint.js";
|
|
8
8
|
import { entryCategories, promptKinds } from "./model.js";
|
|
9
|
+
import { renameReferences } from "./rename.js";
|
|
9
10
|
import { entryById, insertEntry, insertPrompt, promptById, replaceEntry, replacePrompt, trashEntry, trashPrompt, } from "./repo.js";
|
|
10
11
|
export function createPrompt(deps, draft) {
|
|
11
12
|
const fields = lintPrompt(draft);
|
|
@@ -30,6 +31,8 @@ export function updatePrompt(deps, id, draft, restoredFrom) {
|
|
|
30
31
|
const previous = promptById(deps.db, id);
|
|
31
32
|
if (!replacePrompt(deps.db, prompt))
|
|
32
33
|
return false;
|
|
34
|
+
if (previous !== undefined)
|
|
35
|
+
renameReferences(deps.db, { item: "prompt", kind: previous.kind, name: previous.name }, id, prompt.name);
|
|
33
36
|
recordVersion(deps.db, "prompt", prompt, {
|
|
34
37
|
previous,
|
|
35
38
|
...(restoredFrom === undefined ? {} : { restoredFrom }),
|
|
@@ -75,6 +78,8 @@ export function updateEntry(deps, id, draft, restoredFrom) {
|
|
|
75
78
|
const previous = entryById(deps.db, id);
|
|
76
79
|
if (!replaceEntry(deps.db, entry))
|
|
77
80
|
return false;
|
|
81
|
+
if (previous !== undefined)
|
|
82
|
+
renameReferences(deps.db, { item: "entry", category: previous.category, name: previous.name }, id, entry.name);
|
|
78
83
|
recordVersion(deps.db, "entry", entry, {
|
|
79
84
|
previous,
|
|
80
85
|
...(restoredFrom === undefined ? {} : { restoredFrom }),
|
|
@@ -5,92 +5,119 @@
|
|
|
5
5
|
// `snapshot.ts` use.
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { liveProject } from "../admission/repo.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
8
|
+
function record(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
10
|
+
? value
|
|
11
|
+
: undefined;
|
|
12
|
+
}
|
|
13
|
+
function field(owner, key) {
|
|
14
|
+
return owner === undefined ? [] : [{ get: () => owner[key], set: (name) => (owner[key] = name) }];
|
|
15
|
+
}
|
|
16
|
+
// An intro or outro: the name in a form, `{ name, mode }` in a run config.
|
|
17
|
+
function entrySlot(owner, key) {
|
|
18
|
+
const held = record(owner[key]);
|
|
19
|
+
return held === undefined ? field(owner, key) : field(held, "name");
|
|
20
|
+
}
|
|
21
|
+
function scriptRun(value) {
|
|
22
|
+
return record(value.sources)?.audio === "generate" && record(value.voices)?.source === "script";
|
|
23
|
+
}
|
|
24
|
+
function slotsIn(value, ref) {
|
|
25
|
+
if (ref.item === "entry")
|
|
26
|
+
return entrySlot(value, ref.category);
|
|
27
|
+
const shorts = record(value.shorts);
|
|
28
|
+
switch (ref.kind) {
|
|
29
|
+
case "article":
|
|
30
|
+
return scriptRun(value) ? [] : field(value, "articlePrompt");
|
|
31
|
+
case "script":
|
|
32
|
+
return scriptRun(value) ? field(value, "articlePrompt") : [];
|
|
33
|
+
case "narration":
|
|
34
|
+
return field(value, "narrationPrompt");
|
|
35
|
+
case "thumbnail":
|
|
36
|
+
return field(value, "thumbnailPrompt");
|
|
37
|
+
case "description":
|
|
38
|
+
return field(value, "descriptionPrompt");
|
|
39
|
+
case "shorts":
|
|
40
|
+
return field(shorts, "prompt");
|
|
41
|
+
case "review":
|
|
42
|
+
return Object.values(record(record(value.reviews)?.stages) ?? {}).flatMap((stage) => field(record(stage), "prompt"));
|
|
43
|
+
case "image":
|
|
44
|
+
return [
|
|
45
|
+
...(Array.isArray(value.imagePrompts) ? value.imagePrompts : []).flatMap((one) => field(record(one), "name")),
|
|
46
|
+
...field(record(value.reference), "prompt"),
|
|
47
|
+
...field(shorts, "imagePrompt"),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
33
51
|
// The names a form or config holds for one kind of prompt or category of entry.
|
|
34
52
|
export function namesIn(value, ref) {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
53
|
+
const form = record(value);
|
|
54
|
+
if (form === undefined)
|
|
37
55
|
return [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
? [ref.category === "intro" ? entryName(form.intro) : entryName(form.outro)]
|
|
42
|
-
: ref.kind === "article"
|
|
43
|
-
? [form.articlePrompt]
|
|
44
|
-
: ref.kind === "narration"
|
|
45
|
-
? [form.narrationPrompt]
|
|
46
|
-
: ref.kind === "thumbnail"
|
|
47
|
-
? [form.thumbnailPrompt]
|
|
48
|
-
: ref.kind === "description"
|
|
49
|
-
? [form.descriptionPrompt]
|
|
50
|
-
: ref.kind === "shorts"
|
|
51
|
-
? [form.shorts?.prompt]
|
|
52
|
-
: [
|
|
53
|
-
...(form.imagePrompts ?? []).map((one) => one.name),
|
|
54
|
-
form.reference?.prompt,
|
|
55
|
-
form.shorts?.imagePrompt,
|
|
56
|
-
];
|
|
57
|
-
return names.filter((name) => name !== undefined && name.trim() !== "");
|
|
56
|
+
return slotsIn(form, ref)
|
|
57
|
+
.map((slot) => slot.get())
|
|
58
|
+
.filter((name) => typeof name === "string" && name.trim() !== "");
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// A copy of the form or config with every place that names `from` naming `to` instead, or
|
|
61
|
+
// undefined when nothing names it.
|
|
62
|
+
export function renamedIn(value, ref, to) {
|
|
63
|
+
const form = record(value);
|
|
64
|
+
if (form === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const copy = structuredClone(form);
|
|
67
|
+
const from = ref.name.trim().toLowerCase();
|
|
68
|
+
let changed = false;
|
|
69
|
+
for (const slot of slotsIn(copy, ref)) {
|
|
70
|
+
const name = slot.get();
|
|
71
|
+
if (typeof name !== "string" || name.trim().toLowerCase() !== from || name === to)
|
|
72
|
+
continue;
|
|
73
|
+
slot.set(to);
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
return changed ? copy : undefined;
|
|
77
|
+
}
|
|
78
|
+
export function uses(value, ref, formerNames = []) {
|
|
79
|
+
const wanted = new Set([ref.name, ...formerNames].map((name) => name.trim().toLowerCase()));
|
|
80
|
+
return namesIn(value, ref).some((name) => wanted.has(name.trim().toLowerCase()));
|
|
62
81
|
}
|
|
63
82
|
const documentShape = z.object({ form: z.unknown() }).loose();
|
|
64
83
|
function formOf(json) {
|
|
65
84
|
const parsed = documentShape.safeParse(JSON.parse(json));
|
|
66
85
|
return parsed.success ? parsed.data.form : undefined;
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
// `formerNames` are names the item had before a rename that no other item holds now: a
|
|
88
|
+
// project revision keeps the name its run used (`rename.ts` leaves revisions alone), so a
|
|
89
|
+
// renamed prompt still finds the projects that ran it.
|
|
90
|
+
export function usedBy(db, ref, formerNames = []) {
|
|
69
91
|
// A name that appears nowhere in the JSON cannot match, so SQL skips those rows before any
|
|
70
92
|
// is parsed. JSON escapes quotes and backslashes, so only a plain name is pre-filtered.
|
|
71
93
|
// SQLite's lower() folds ASCII only, so a name with anything else is not pre-filtered.
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const names = [ref.name, ...formerNames];
|
|
95
|
+
const plain = names.every((name) => /^[\x20-\x7e]*$/u.test(name) && !/["\\]/u.test(name))
|
|
96
|
+
? names.map((name) => name.trim().toLowerCase())
|
|
97
|
+
: [""];
|
|
98
|
+
const mentions = (column) => `(${plain.map(() => `instr(lower(${column}),?)>0`).join(" OR ")})`;
|
|
99
|
+
const matches = (value) => uses(value, ref, formerNames);
|
|
75
100
|
const templates = db
|
|
76
101
|
.prepare(`SELECT t.id,r.name,r.document_json FROM project_templates t
|
|
77
102
|
JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version
|
|
78
|
-
WHERE t.deleted_at IS NULL AND
|
|
79
|
-
.all(plain)
|
|
103
|
+
WHERE t.deleted_at IS NULL AND ${mentions("r.document_json")} ORDER BY lower(r.name),t.id`)
|
|
104
|
+
.all(...plain)
|
|
80
105
|
.flatMap((row) => {
|
|
81
106
|
const parsed = templateRow.parse(row);
|
|
82
|
-
return
|
|
107
|
+
return matches(formOf(parsed.document_json)) ? [{ id: parsed.id, name: parsed.name }] : [];
|
|
83
108
|
});
|
|
84
|
-
// A schedule runs
|
|
109
|
+
// A schedule runs its template as it is now (`schedules/scheduler.ts`), so the template's
|
|
110
|
+
// current version is what it uses.
|
|
85
111
|
const schedules = db
|
|
86
112
|
.prepare(`SELECT s.id,s.name,s.status,r.document_json FROM schedules s
|
|
87
|
-
JOIN
|
|
88
|
-
|
|
113
|
+
JOIN project_templates t ON t.id=s.template_id
|
|
114
|
+
JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version
|
|
115
|
+
WHERE s.deleted_at IS NULL AND s.status IN ('active','paused') AND ${mentions("r.document_json")}
|
|
89
116
|
ORDER BY lower(s.name),s.id`)
|
|
90
|
-
.all(plain)
|
|
117
|
+
.all(...plain)
|
|
91
118
|
.flatMap((row) => {
|
|
92
119
|
const parsed = scheduleRow.parse(row);
|
|
93
|
-
return
|
|
120
|
+
return matches(formOf(parsed.document_json))
|
|
94
121
|
? [{ id: parsed.id, name: parsed.name, status: parsed.status }]
|
|
95
122
|
: [];
|
|
96
123
|
});
|
|
@@ -99,13 +126,13 @@ export function usedBy(db, ref) {
|
|
|
99
126
|
(SELECT count(*) FROM project_revisions x WHERE x.project_id=r.project_id) AS total
|
|
100
127
|
FROM project_revisions r JOIN projects p ON p.id=r.project_id
|
|
101
128
|
LEFT JOIN project_heads h ON h.project_id=r.project_id
|
|
102
|
-
WHERE ${liveProject("p")} AND
|
|
129
|
+
WHERE ${liveProject("p")} AND ${mentions("r.config")}
|
|
103
130
|
ORDER BY p.created_at DESC,p.id,r.created_at`)
|
|
104
|
-
.all(plain)
|
|
131
|
+
.all(...plain)
|
|
105
132
|
.map((row) => revisionRow.parse(row));
|
|
106
133
|
const projects = new Map();
|
|
107
134
|
for (const row of revisionRows) {
|
|
108
|
-
if (!
|
|
135
|
+
if (!matches(JSON.parse(row.config)))
|
|
109
136
|
continue;
|
|
110
137
|
const seen = projects.get(row.project_id);
|
|
111
138
|
projects.set(row.project_id, {
|
|
@@ -121,10 +148,10 @@ export function usedBy(db, ref) {
|
|
|
121
148
|
.prepare(`SELECT p.id,p.title,p.config FROM projects p
|
|
122
149
|
WHERE ${liveProject("p")}
|
|
123
150
|
AND NOT EXISTS (SELECT 1 FROM project_revisions r WHERE r.project_id=p.id)
|
|
124
|
-
AND
|
|
125
|
-
.all(plain)
|
|
151
|
+
AND ${mentions("p.config")} ORDER BY p.created_at DESC,p.id`)
|
|
152
|
+
.all(...plain)
|
|
126
153
|
.map((row) => legacyRow.parse(row))) {
|
|
127
|
-
if (
|
|
154
|
+
if (matches(JSON.parse(row.config)))
|
|
128
155
|
projects.set(row.id, {
|
|
129
156
|
id: row.id,
|
|
130
157
|
title: row.title,
|