@gentbajko/slopify 3.3.1 → 3.4.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/edge/http/studio.js +119 -32
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0048-releases.sql +20 -0
- package/dist/patch-notes/3.4.0.md +28 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/storage/backup-format.js +34 -3
- package/dist/slices/storage/portable.js +4 -0
- package/dist/slices/studio/backfill.js +0 -4
- package/dist/slices/studio/calendar.js +85 -0
- package/dist/slices/studio/pack.js +7 -13
- package/dist/slices/studio/plan-model.js +37 -0
- package/dist/slices/studio/plan.js +19 -102
- package/dist/slices/studio/releases.js +140 -0
- package/dist/slices/studio/videos.js +5 -0
- package/dist/web/assets/index-B4HZ24q0.js +310 -0
- package/dist/web/assets/index-GnlzADIB.css +1 -0
- package/dist/web/assets/{pdf-BI8cHr9G.js → pdf-Dsbuwxiz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-6wMtRXOo.css +0 -1
- package/dist/web/assets/index-DmLX0oKs.js +0 -310
|
@@ -5,45 +5,13 @@ import { readSetting, writeSetting } from "../settings/repo.js";
|
|
|
5
5
|
// upload is prepared; each of its shorts goes out at the first time its day and hour come round
|
|
6
6
|
// after its own long video, so a plan never needs "next week" notes. The extension types the
|
|
7
7
|
// times into Studio's schedule, in the browser's own time zone.
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
day: z.number().int().min(0).max(6),
|
|
11
|
-
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
|
12
|
-
});
|
|
13
|
-
const rowSchema = z.object({
|
|
14
|
-
name: z.string().trim().min(1).max(20),
|
|
15
|
-
long: slotSchema,
|
|
16
|
-
shorts: z.array(slotSchema).max(10),
|
|
17
|
-
});
|
|
18
|
-
export const postingPlanSchema = z.object({
|
|
19
|
-
timeZone: z.string().min(1).max(100),
|
|
20
|
-
rows: z.array(rowSchema).max(14),
|
|
21
|
-
});
|
|
8
|
+
export { postingPlanSchema, weekdays, } from "./plan-model.js";
|
|
9
|
+
import { postingPlanSchema } from "./plan-model.js";
|
|
22
10
|
export const postingPlanKey = "studio.postingPlan";
|
|
23
11
|
const key = postingPlanKey;
|
|
24
|
-
//
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
timeZone,
|
|
29
|
-
rows: [
|
|
30
|
-
{
|
|
31
|
-
name: "A",
|
|
32
|
-
long: at(0, "20:00"),
|
|
33
|
-
shorts: [at(1, "00:00"), at(1, "17:00"), at(2, "00:00"), at(2, "17:00"), at(3, "17:00")],
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: "B",
|
|
37
|
-
long: at(2, "20:00"),
|
|
38
|
-
shorts: [at(3, "00:00"), at(4, "00:00"), at(4, "17:00"), at(5, "17:00"), at(6, "17:00")],
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: "C",
|
|
42
|
-
long: at(4, "20:00"),
|
|
43
|
-
shorts: [at(5, "00:00"), at(6, "00:00"), at(6, "21:00"), at(0, "00:00"), at(0, "17:00")],
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
};
|
|
12
|
+
// No plan until one is made: nothing is scheduled, and Prepare upload says so.
|
|
13
|
+
export function emptyPlan(timeZone) {
|
|
14
|
+
return { timeZone, rows: [] };
|
|
47
15
|
}
|
|
48
16
|
export function localTimeZone() {
|
|
49
17
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
@@ -57,9 +25,9 @@ export function readPlan(db) {
|
|
|
57
25
|
return parsed.data;
|
|
58
26
|
}
|
|
59
27
|
catch {
|
|
60
|
-
// A broken setting reads as
|
|
28
|
+
// A broken setting reads as no plan.
|
|
61
29
|
}
|
|
62
|
-
return
|
|
30
|
+
return emptyPlan(localTimeZone());
|
|
63
31
|
}
|
|
64
32
|
export function writePlan(db, plan) {
|
|
65
33
|
writeSetting(db, key, JSON.stringify(postingPlanSchema.parse(plan)));
|
|
@@ -123,68 +91,17 @@ export function nextOccurrence(timeZone, slot, after) {
|
|
|
123
91
|
}
|
|
124
92
|
return new Date(after.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
125
93
|
}
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (at.getTime() >= soonest)
|
|
139
|
-
slots.push({ row: row.name, at });
|
|
140
|
-
}
|
|
94
|
+
// Every time the slot's day and hour come round from `from` (inclusive) to `until`.
|
|
95
|
+
export function zonedTimes(timeZone, slot, from, until) {
|
|
96
|
+
const times = [];
|
|
97
|
+
let date = localDate(timeZone, new Date(from.getTime() - 24 * 60 * 60 * 1000));
|
|
98
|
+
for (;; date = addDays(date, 1)) {
|
|
99
|
+
if (date.weekday !== slot.day)
|
|
100
|
+
continue;
|
|
101
|
+
const at = zonedInstant(timeZone, date, slot.time);
|
|
102
|
+
if (at.getTime() >= until.getTime())
|
|
103
|
+
return times;
|
|
104
|
+
if (at.getTime() >= from.getTime())
|
|
105
|
+
times.push(at);
|
|
141
106
|
}
|
|
142
|
-
return slots
|
|
143
|
-
.toSorted((left, right) => left.at.getTime() - right.at.getTime())
|
|
144
|
-
.map((slot) => ({ row: slot.row, longAt: slot.at.toISOString() }));
|
|
145
|
-
}
|
|
146
|
-
export function scheduleOf(plan, slot) {
|
|
147
|
-
const row = plan.rows.find((one) => one.name === slot.row);
|
|
148
|
-
const longAt = new Date(slot.longAt);
|
|
149
|
-
return {
|
|
150
|
-
...slot,
|
|
151
|
-
shortsAt: (row?.shorts ?? []).map((short) => nextOccurrence(plan.timeZone, short, longAt).toISOString()),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
// The project's slot: the one it was given, else the next free one, kept from now on. A project
|
|
155
|
-
// set to "Not scheduled" keeps an empty row, so it isn't given one again.
|
|
156
|
-
export function assignedSlot(db, plan, projectId, now) {
|
|
157
|
-
const row = db
|
|
158
|
-
.prepare("SELECT row_name, long_at FROM upload_slots WHERE project_id=?")
|
|
159
|
-
.get(projectId);
|
|
160
|
-
if (row !== undefined)
|
|
161
|
-
return String(row.long_at) === ""
|
|
162
|
-
? undefined
|
|
163
|
-
: { row: String(row.row_name), longAt: String(row.long_at) };
|
|
164
|
-
const free = freeSlots(db, plan, now, 1)[0];
|
|
165
|
-
if (free === undefined)
|
|
166
|
-
return undefined;
|
|
167
|
-
setSlot(db, projectId, free, now);
|
|
168
|
-
return free;
|
|
169
|
-
}
|
|
170
|
-
export function freeSlots(db, plan, now, count) {
|
|
171
|
-
const taken = new Set(db
|
|
172
|
-
.prepare("SELECT long_at FROM upload_slots")
|
|
173
|
-
.all()
|
|
174
|
-
.map((row) => String(row.long_at)));
|
|
175
|
-
return comingSlots(plan, now)
|
|
176
|
-
.filter((slot) => !taken.has(slot.longAt))
|
|
177
|
-
.slice(0, count);
|
|
178
|
-
}
|
|
179
|
-
export function setSlot(db, projectId, slot, now) {
|
|
180
|
-
db.prepare(`INSERT INTO upload_slots(project_id,row_name,long_at,assigned_at) VALUES (?,?,?,?)
|
|
181
|
-
ON CONFLICT(project_id) DO UPDATE SET row_name=excluded.row_name, long_at=excluded.long_at,
|
|
182
|
-
assigned_at=excluded.assigned_at`).run(projectId, slot.row, slot.longAt, now.toISOString());
|
|
183
|
-
}
|
|
184
|
-
// "Not scheduled": the project takes no slot until one is chosen for it.
|
|
185
|
-
export function unschedule(db, projectId, now) {
|
|
186
|
-
setSlot(db, projectId, { row: "", longAt: "" }, now);
|
|
187
|
-
}
|
|
188
|
-
export function clearSlot(db, projectId) {
|
|
189
|
-
db.prepare("DELETE FROM upload_slots WHERE project_id=?").run(projectId);
|
|
190
107
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
2
|
+
import { zonedTimes } from "./plan.js";
|
|
3
|
+
import { leadHoursDefault, leadHoursMax } from "./plan-model.js";
|
|
4
|
+
const hourMs = 60 * 60 * 1000;
|
|
5
|
+
export const leadHoursKey = "studio.leadHours";
|
|
6
|
+
const leadKey = leadHoursKey;
|
|
7
|
+
export function readLeadHours(db) {
|
|
8
|
+
const stored = Number(readSetting(db, leadKey));
|
|
9
|
+
return Number.isInteger(stored) && stored >= 1 && stored <= leadHoursMax
|
|
10
|
+
? stored
|
|
11
|
+
: leadHoursDefault;
|
|
12
|
+
}
|
|
13
|
+
export function writeLeadHours(db, hours) {
|
|
14
|
+
writeSetting(db, leadKey, String(hours));
|
|
15
|
+
}
|
|
16
|
+
export function releasesOf(db, projectId) {
|
|
17
|
+
return db
|
|
18
|
+
.prepare("SELECT short, release_at, line, by FROM releases WHERE project_id=? ORDER BY short")
|
|
19
|
+
.all(projectId)
|
|
20
|
+
.map((row) => ({
|
|
21
|
+
short: Number(row.short),
|
|
22
|
+
at: String(row.release_at),
|
|
23
|
+
line: row.line === null ? null : String(row.line),
|
|
24
|
+
by: row.by === "person" ? "person" : "plan",
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
export function allReleases(db) {
|
|
28
|
+
return db
|
|
29
|
+
.prepare("SELECT project_id, short, release_at, line, by FROM releases WHERE release_at != '' ORDER BY release_at")
|
|
30
|
+
.all()
|
|
31
|
+
.map((row) => ({
|
|
32
|
+
projectId: String(row.project_id),
|
|
33
|
+
short: Number(row.short),
|
|
34
|
+
at: String(row.release_at),
|
|
35
|
+
line: row.line === null ? null : String(row.line),
|
|
36
|
+
by: row.by === "person" ? "person" : "plan",
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
function write(db, projectId, release, now) {
|
|
40
|
+
db.prepare(`INSERT INTO releases(project_id,short,release_at,line,by,set_at) VALUES (?,?,?,?,?,?)
|
|
41
|
+
ON CONFLICT(project_id,short) DO UPDATE SET release_at=excluded.release_at,
|
|
42
|
+
line=excluded.line, by=excluded.by, set_at=excluded.set_at`).run(projectId, release.short, release.at, release.line, release.by, now.toISOString());
|
|
43
|
+
}
|
|
44
|
+
// The hours other releases already take: two never go out in the same hour.
|
|
45
|
+
function takenHours(db, exceptProject) {
|
|
46
|
+
return new Set(allReleases(db)
|
|
47
|
+
.filter((release) => release.projectId !== exceptProject)
|
|
48
|
+
.map((release) => Math.floor(Date.parse(release.at) / hourMs)));
|
|
49
|
+
}
|
|
50
|
+
const fits = (line, series) => line.series === "" || line.series === series;
|
|
51
|
+
// The plan's long-video times from `from` to `until`, in order, each with its line.
|
|
52
|
+
export function lineSlots(plan, from, until) {
|
|
53
|
+
return plan.rows
|
|
54
|
+
.flatMap((line) => zonedTimes(plan.timeZone, line.long, from, until).map((at) => ({
|
|
55
|
+
row: line.name,
|
|
56
|
+
series: line.series,
|
|
57
|
+
longAt: at.toISOString(),
|
|
58
|
+
})))
|
|
59
|
+
.toSorted((left, right) => Date.parse(left.longAt) - Date.parse(right.longAt));
|
|
60
|
+
}
|
|
61
|
+
// The coming long-video times a project of `series` may take: far enough ahead to upload in
|
|
62
|
+
// time, of a line that takes its series, in an hour no other release has.
|
|
63
|
+
export function freeSlots(db, plan, series, now, count, exceptProject) {
|
|
64
|
+
const taken = takenHours(db, exceptProject);
|
|
65
|
+
const from = new Date(now.getTime() + readLeadHours(db) * hourMs);
|
|
66
|
+
return lineSlots(plan, from, new Date(from.getTime() + 8 * 7 * 24 * hourMs))
|
|
67
|
+
.filter((slot) => {
|
|
68
|
+
const line = plan.rows.find((one) => one.name === slot.row);
|
|
69
|
+
return (line !== undefined &&
|
|
70
|
+
fits(line, series) &&
|
|
71
|
+
!taken.has(Math.floor(Date.parse(slot.longAt) / hourMs)));
|
|
72
|
+
})
|
|
73
|
+
.slice(0, count)
|
|
74
|
+
.map(({ row, longAt }) => ({ row, longAt }));
|
|
75
|
+
}
|
|
76
|
+
// Gives a finished project its release times where it has none: the long video the next free
|
|
77
|
+
// slot of its series, each short the line's first short time after the one before it in an
|
|
78
|
+
// hour no other release has. A project set to "not scheduled" is left so.
|
|
79
|
+
export function planReleases(db, plan, project, now) {
|
|
80
|
+
let releases = releasesOf(db, project.id);
|
|
81
|
+
let long = releases.find((release) => release.short === 0);
|
|
82
|
+
if (long === undefined) {
|
|
83
|
+
const slot = freeSlots(db, plan, project.series, now, 1, project.id)[0];
|
|
84
|
+
if (slot === undefined)
|
|
85
|
+
return;
|
|
86
|
+
long = { short: 0, at: slot.longAt, line: slot.row, by: "plan" };
|
|
87
|
+
write(db, project.id, long, now);
|
|
88
|
+
releases = releasesOf(db, project.id);
|
|
89
|
+
}
|
|
90
|
+
if (long.at === "")
|
|
91
|
+
return;
|
|
92
|
+
const line = plan.rows.find((one) => one.name === long?.line);
|
|
93
|
+
if (line === undefined || line.shorts.length === 0)
|
|
94
|
+
return;
|
|
95
|
+
const taken = takenHours(db, project.id);
|
|
96
|
+
for (const release of releases)
|
|
97
|
+
if (release.at !== "")
|
|
98
|
+
taken.add(Math.floor(Date.parse(release.at) / hourMs));
|
|
99
|
+
const longAt = new Date(long.at);
|
|
100
|
+
const times = line.shorts
|
|
101
|
+
.flatMap((slot) => zonedTimes(plan.timeZone, slot, longAt, new Date(longAt.getTime() + 5 * 7 * 24 * hourMs)))
|
|
102
|
+
.toSorted((left, right) => left.getTime() - right.getTime());
|
|
103
|
+
let after = longAt.getTime();
|
|
104
|
+
for (let short = 1; short <= project.shorts; short += 1) {
|
|
105
|
+
const kept = releases.find((release) => release.short === short);
|
|
106
|
+
if (kept !== undefined) {
|
|
107
|
+
if (kept.at !== "")
|
|
108
|
+
after = Math.max(after, Date.parse(kept.at));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const at = times.find((time) => time.getTime() > after && !taken.has(Math.floor(time.getTime() / hourMs)));
|
|
112
|
+
if (at === undefined)
|
|
113
|
+
return;
|
|
114
|
+
taken.add(Math.floor(at.getTime() / hourMs));
|
|
115
|
+
after = at.getTime();
|
|
116
|
+
write(db, project.id, { short, at: at.toISOString(), line: line.name, by: "plan" }, now);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// The person sets one release: an ISO time, or "" for "not scheduled". Moving the long video
|
|
120
|
+
// drops the shorts' plan-placed times, so they are planned again after it.
|
|
121
|
+
export function setRelease(db, projectId, short, at, line, now) {
|
|
122
|
+
write(db, projectId, { short, at, line, by: "person" }, now);
|
|
123
|
+
if (short === 0)
|
|
124
|
+
db.prepare("DELETE FROM releases WHERE project_id=? AND short>0 AND by='plan'").run(projectId);
|
|
125
|
+
}
|
|
126
|
+
export function scheduleOf(db, projectId) {
|
|
127
|
+
const releases = releasesOf(db, projectId);
|
|
128
|
+
const long = releases.find((release) => release.short === 0);
|
|
129
|
+
if (long === undefined || long.at === "")
|
|
130
|
+
return undefined;
|
|
131
|
+
const last = Math.max(0, ...releases.map((release) => release.short));
|
|
132
|
+
return {
|
|
133
|
+
row: long.line ?? "",
|
|
134
|
+
longAt: long.at,
|
|
135
|
+
shortsAt: Array.from({ length: last }, (_, index) => {
|
|
136
|
+
const at = releases.find((release) => release.short === index + 1)?.at;
|
|
137
|
+
return at === undefined || at === "" ? null : at;
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -26,6 +26,7 @@ function rowOf(row) {
|
|
|
26
26
|
commentMessage: typeof row.comment_message === "string" ? row.comment_message : null,
|
|
27
27
|
abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
|
|
28
28
|
abAt: typeof row.ab_at === "string" ? row.ab_at : null,
|
|
29
|
+
checks: typeof row.checks === "string" ? row.checks : null,
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
// Records (or replaces) the video an upload became: "filled" from the upload dialog, "done"
|
|
@@ -92,3 +93,7 @@ export function previousLongVideo(db, projectId) {
|
|
|
92
93
|
.get(projectId, own?.recorded_at ?? null, own?.recorded_at ?? null);
|
|
93
94
|
return typeof row?.video_id === "string" ? row.video_id : undefined;
|
|
94
95
|
}
|
|
96
|
+
// Studio's word on a known video's checks, read from its Content list row.
|
|
97
|
+
export function setChecks(db, videoId, checks) {
|
|
98
|
+
db.prepare("UPDATE youtube_videos SET checks=? WHERE video_id=?").run(checks, videoId);
|
|
99
|
+
}
|