updateday 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +109 -2
- package/assets/hourglass/10m.png +0 -0
- package/assets/hourglass/12h.png +0 -0
- package/assets/hourglass/15m.png +0 -0
- package/assets/hourglass/1h.png +0 -0
- package/assets/hourglass/24h.png +0 -0
- package/assets/hourglass/2h.png +0 -0
- package/assets/hourglass/30m.png +0 -0
- package/assets/hourglass/3h.png +0 -0
- package/assets/hourglass/45m.png +0 -0
- package/assets/hourglass/4h.png +0 -0
- package/assets/hourglass/5h.png +0 -0
- package/assets/hourglass/5m.png +0 -0
- package/assets/hourglass/6h.png +0 -0
- package/assets/hourglass/default.png +0 -0
- package/assets/hourglass/now.png +0 -0
- package/assets/hourglass-styles/blocky.png +0 -0
- package/assets/hourglass-styles/classic.png +0 -0
- package/assets/hourglass-styles/glow.png +0 -0
- package/assets/hourglass-styles/outline.png +0 -0
- package/assets/hourglass-styles/pixel.png +0 -0
- package/assets/hourglass-styles/rounded.png +0 -0
- package/bin/updateday.js +5 -0
- package/dist/bundle.js +54 -0
- package/dist/cli.js +254 -0
- package/dist/config.js +261 -0
- package/dist/doctor.js +119 -0
- package/dist/experiments.js +349 -0
- package/dist/gamePage.js +143 -0
- package/dist/gifts.js +55 -0
- package/dist/history.js +68 -0
- package/dist/icons.js +216 -0
- package/dist/labels.js +64 -0
- package/dist/mcp.js +302 -0
- package/dist/moderation.js +86 -0
- package/dist/notify.js +72 -0
- package/dist/openCloud.js +211 -0
- package/dist/playerData.js +109 -0
- package/dist/preflight.js +202 -0
- package/dist/runner.js +360 -0
- package/dist/schedule.js +162 -0
- package/dist/server.js +929 -0
- package/dist/state.js +29 -0
- package/dist/swap.js +196 -0
- package/dist/zip.js +82 -0
- package/docs/AGENTS.md +123 -0
- package/docs/COVERAGE.md +50 -0
- package/docs/GUIDE.md +288 -0
- package/docs/hourglass-styles.png +0 -0
- package/docs/screenshot.png +0 -0
- package/example/multi-game.json +16 -0
- package/example/updateday.json +37 -0
- package/package.json +63 -5
- package/roblox/UpdateDayBanner.client.luau +159 -0
- package/roblox/UpdateDayCountdown.server.luau +48 -0
- package/roblox/UpdateDayGifts.luau +119 -0
- package/roblox/UpdateDayListener.luau +43 -0
- package/templates/github-actions/updateday.yml +35 -0
- package/ui/core.js +325 -0
- package/ui/countdown.js +775 -0
- package/ui/events.js +249 -0
- package/ui/gifts.js +215 -0
- package/ui/history.js +156 -0
- package/ui/index.html +530 -0
- package/ui/jsonEditor.js +417 -0
- package/ui/page.js +94 -0
- package/ui/players.js +256 -0
- package/ui/run.js +391 -0
- package/ui/sale.js +366 -0
- package/ui/settings.js +414 -0
- package/ui/shell.js +193 -0
package/ui/players.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
const DEFAULT_KEY_FORMAT = "{userId}";
|
|
2
|
+
const MAX_DIFF_LINES = 12;
|
|
3
|
+
|
|
4
|
+
let currentPlayer = null;
|
|
5
|
+
let currentEntry = null;
|
|
6
|
+
let playerEditor = null;
|
|
7
|
+
|
|
8
|
+
function playerHtml(player, extra) {
|
|
9
|
+
return `
|
|
10
|
+
<div class="player">
|
|
11
|
+
${player.avatarUrl ? `<img src="${h(player.avatarUrl)}" alt="">` : `<img alt="">`}
|
|
12
|
+
<div class="grow"><b>${h(player.displayName)}</b><div class="hint">@${h(player.name)} · ID ${player.id}</div></div>
|
|
13
|
+
${extra ?? ""}
|
|
14
|
+
</div>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function lookupHtml(id) {
|
|
18
|
+
return `
|
|
19
|
+
<form class="row" id="${id}Form" style="align-items:flex-end">
|
|
20
|
+
<label class="field" style="flex:1"><span>Player</span><input type="search" id="${id}" placeholder="Username or user ID" value="${h(currentPlayer?.name ?? "")}" autocomplete="off"></label>
|
|
21
|
+
<div class="field" style="flex:none"><button class="btn btn-primary" type="submit">Look up</button></div>
|
|
22
|
+
</form>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bindLookup(id, onFound) {
|
|
26
|
+
const input = $(id);
|
|
27
|
+
$(`${id}Form`).addEventListener("submit", async (event) => {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
const query = input.value.trim();
|
|
30
|
+
if (!query) {
|
|
31
|
+
input.focus();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const button = event.submitter;
|
|
35
|
+
button.disabled = true;
|
|
36
|
+
try {
|
|
37
|
+
const result = await api("GET", `/api/player?${gameQuery()}&q=${encodeURIComponent(query)}`);
|
|
38
|
+
currentPlayer = result.player;
|
|
39
|
+
onFound(result);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
toast(error.message, "danger");
|
|
42
|
+
} finally {
|
|
43
|
+
button.disabled = false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function playerDataSettings() {
|
|
49
|
+
const game = currentGame();
|
|
50
|
+
game.playerData = game.playerData || { dataStore: "", keyFormat: DEFAULT_KEY_FORMAT };
|
|
51
|
+
return game.playerData;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function playerKey() {
|
|
55
|
+
const format = $("pdKey").value.trim() || DEFAULT_KEY_FORMAT;
|
|
56
|
+
return format.replace(/\{userId\}/g, String(currentPlayer.id));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function changedPaths(before, after, prefix = "") {
|
|
60
|
+
if (JSON.stringify(before) === JSON.stringify(after)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const isObject = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
64
|
+
const bothArrays = Array.isArray(before) && Array.isArray(after);
|
|
65
|
+
if (!bothArrays && (!isObject(before) || !isObject(after))) {
|
|
66
|
+
return [{ path: prefix || "(whole value)", before, after }];
|
|
67
|
+
}
|
|
68
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
69
|
+
const childPath = (key) => (bothArrays ? `${prefix}[${key}]` : prefix ? `${prefix}.${key}` : key);
|
|
70
|
+
return [...keys].flatMap((key) => changedPaths(before[key], after[key], childPath(key)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entryQuery(store, key) {
|
|
74
|
+
return `${gameQuery()}&ds=${encodeURIComponent(store)}&key=${encodeURIComponent(key)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function savePlayerData(store, key) {
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = playerEditor.getValue();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
$("pdError").innerHTML = errorBox(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
$("pdError").innerHTML = "";
|
|
86
|
+
const changes = changedPaths(currentEntry?.value ?? null, value);
|
|
87
|
+
if (changes.length === 0) {
|
|
88
|
+
toast("Nothing changed");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const shown = (value, missing) => (value === undefined ? missing : JSON.stringify(value));
|
|
92
|
+
const lines = changes.slice(0, MAX_DIFF_LINES).map((change) => `${change.path}: ${shown(change.before, "(new)")} → ${shown(change.after, "(removed)")}`);
|
|
93
|
+
if (changes.length > MAX_DIFF_LINES) {
|
|
94
|
+
lines.push(`…and ${changes.length - MAX_DIFF_LINES} more`);
|
|
95
|
+
}
|
|
96
|
+
const sure = await dialog({
|
|
97
|
+
title: `Save ${currentPlayer.name}'s data?`,
|
|
98
|
+
body: `<p>A backup is saved first.</p><pre>${h(lines.join("\n"))}</pre>`,
|
|
99
|
+
html: true,
|
|
100
|
+
confirmText: "Save changes",
|
|
101
|
+
});
|
|
102
|
+
if (!sure) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await api("PUT", `/api/entry?${entryQuery(store, key)}`, { value, etag: currentEntry?.etag ?? null });
|
|
107
|
+
toast("Saved. The old data is in the backups.", "success");
|
|
108
|
+
showPlayerData();
|
|
109
|
+
} catch (error) {
|
|
110
|
+
$("pdError").innerHTML = errorBox(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pendingChanges() {
|
|
115
|
+
try {
|
|
116
|
+
return changedPaths(currentEntry?.value ?? null, playerEditor.getValue()).length;
|
|
117
|
+
} catch {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function showChangeCount() {
|
|
123
|
+
const count = pendingChanges();
|
|
124
|
+
$("pdChanges").textContent = count ? `${count} unsaved change${count === 1 ? "" : "s"}` : "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function reloadPlayerData() {
|
|
128
|
+
if (pendingChanges() > 0) {
|
|
129
|
+
const sure = await dialog({ title: "Discard your edits?", body: "Reloading reads the player's data from Roblox again and drops the changes you haven't saved.", confirmText: "Discard and reload", danger: true });
|
|
130
|
+
if (!sure) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
showPlayerData();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function backupsHtml(backups) {
|
|
138
|
+
if (!backups.length) {
|
|
139
|
+
return `<div class="hint">None yet. One is made before every change.</div>`;
|
|
140
|
+
}
|
|
141
|
+
return `<div class="list">${backups.map((backup) => `
|
|
142
|
+
<div class="list-row">
|
|
143
|
+
<span class="hint">${h(formatWhen(backup.savedAt))}</span>
|
|
144
|
+
<span>${h(backup.reason)}</span>
|
|
145
|
+
<button class="btn btn-sm" data-file="${h(backup.file)}">Restore</button>
|
|
146
|
+
</div>`).join("")}</div>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function showPlayerData() {
|
|
150
|
+
const result = $("pdResult");
|
|
151
|
+
const store = $("pdStore").value;
|
|
152
|
+
if (!store) {
|
|
153
|
+
result.innerHTML = playerHtml(currentPlayer) + `<div class="callout callout-info">Pick the data store your game saves players in.</div>`;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const key = playerKey();
|
|
157
|
+
result.innerHTML = playerHtml(currentPlayer) + `<div class="skeleton" style="height:220px"></div>`;
|
|
158
|
+
try {
|
|
159
|
+
const { entry, backups } = await api("GET", `/api/entry?${entryQuery(store, key)}`);
|
|
160
|
+
currentEntry = entry;
|
|
161
|
+
result.innerHTML = `
|
|
162
|
+
${playerHtml(currentPlayer, `<span class="badge">${h(store)} / ${h(key)}</span>`)}
|
|
163
|
+
${entry ? "" : `<div class="callout callout-warning" style="margin-bottom:12px">No saved data for this player under that key yet.</div>`}
|
|
164
|
+
<div class="field"><span>Saved data</span><div id="pdEditor"></div></div>
|
|
165
|
+
<div id="pdError"></div>
|
|
166
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-bottom:18px">
|
|
167
|
+
<span class="change-count" id="pdChanges" aria-live="polite"></span>
|
|
168
|
+
<span style="flex:1"></span>
|
|
169
|
+
<button class="btn" id="pdReload">Reload</button>
|
|
170
|
+
<button class="btn btn-primary" id="pdSave">Save changes</button>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="section-title">Backups, newest first</div>
|
|
173
|
+
${backupsHtml(backups)}`;
|
|
174
|
+
playerEditor = createJsonEditor($("pdEditor"), entry ? entry.value : null, showChangeCount);
|
|
175
|
+
$("pdSave").addEventListener("click", () => savePlayerData(store, key));
|
|
176
|
+
$("pdReload").addEventListener("click", reloadPlayerData);
|
|
177
|
+
for (const button of result.querySelectorAll("[data-file]")) {
|
|
178
|
+
button.addEventListener("click", async () => {
|
|
179
|
+
const sure = await dialog({ title: "Restore this backup?", body: "The player's current data is backed up first, so you can undo this too.", confirmText: "Restore" });
|
|
180
|
+
if (!sure) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await api("POST", `/api/entry?${entryQuery(store, key)}`, { restore: button.dataset.file });
|
|
185
|
+
toast("Backup restored", "success");
|
|
186
|
+
showPlayerData();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
toast(error.message, "danger");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
result.innerHTML = playerHtml(currentPlayer) + errorBox(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function loadDataStores(settings) {
|
|
198
|
+
const select = $("pdStore");
|
|
199
|
+
try {
|
|
200
|
+
const { dataStores } = await api("GET", `/api/datastores?${gameQuery()}`);
|
|
201
|
+
select.innerHTML = `<option value="">Pick a data store</option>` + dataStores.map((name) => `<option ${name === settings.dataStore ? "selected" : ""}>${h(name)}</option>`).join("");
|
|
202
|
+
select.disabled = false;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
select.innerHTML = `<option value="">Couldn't load data stores</option>`;
|
|
205
|
+
$("pdStoreError").innerHTML = errorBox(error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderPlayers(root) {
|
|
210
|
+
if (!currentGame()?.universeId) {
|
|
211
|
+
root.innerHTML = `<div class="card">${emptyState("Add the game link first", "Player data is read from the game's data stores.")}</div>`;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const settings = currentGame().playerData ?? { dataStore: "", keyFormat: DEFAULT_KEY_FORMAT };
|
|
215
|
+
root.innerHTML = `
|
|
216
|
+
<div class="grid-2">
|
|
217
|
+
<div class="stack">
|
|
218
|
+
<div class="card">
|
|
219
|
+
<div class="card-head"><div><h2>Find a player</h2><div class="hint">Look up a player's saved data, change it, and put it back.</div></div></div>
|
|
220
|
+
${lookupHtml("pdLookup")}
|
|
221
|
+
<div id="pdResult">${currentPlayer ? "" : emptyState("No player picked", "Search by username or user ID.")}</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="stack">
|
|
225
|
+
<div class="card">
|
|
226
|
+
<div class="card-head"><div><h2>Where players are saved</h2><div class="hint">Remembered for this game.</div></div></div>
|
|
227
|
+
<label class="field"><span>Data store</span><select id="pdStore" disabled><option>Loading…</option></select></label>
|
|
228
|
+
<div id="pdStoreError"></div>
|
|
229
|
+
<label class="field"><span>Key</span><input type="text" id="pdKey" class="mono" value="${h(settings.keyFormat)}"><span class="hint">{userId} becomes the player's ID, e.g. Player_{userId}.</span></label>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="callout callout-info">A backup is saved before every change, and saves check nobody else wrote in between.</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>`;
|
|
234
|
+
$("pdKey").addEventListener("change", (event) => {
|
|
235
|
+
playerDataSettings().keyFormat = event.target.value.trim() || DEFAULT_KEY_FORMAT;
|
|
236
|
+
markDirty();
|
|
237
|
+
});
|
|
238
|
+
$("pdStore").addEventListener("change", (event) => {
|
|
239
|
+
playerDataSettings().dataStore = event.target.value;
|
|
240
|
+
markDirty();
|
|
241
|
+
if (currentPlayer) {
|
|
242
|
+
showPlayerData();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
bindLookup("pdLookup", showPlayerData);
|
|
246
|
+
loadDataStores(settings).then(() => {
|
|
247
|
+
if (currentPlayer && UD.view === "players") {
|
|
248
|
+
showPlayerData();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
UD.views.players = {
|
|
254
|
+
label: "Player data",
|
|
255
|
+
render: renderPlayers,
|
|
256
|
+
};
|
package/ui/run.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
const POLL_RUNNING_MS = 2000;
|
|
2
|
+
const POLL_IDLE_MS = 6000;
|
|
3
|
+
const DOCTOR_SYMBOLS = { ok: "✓", missing: "✗", error: "!", untested: "–" };
|
|
4
|
+
const LOG_LINE = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+(.*)$/;
|
|
5
|
+
const ALERT_LINE = /REMINDER|FAILED|^ERROR|update day is done|Every schedule is complete/i;
|
|
6
|
+
const CHECK_SYMBOLS_RUN = { ok: "✓", warn: "!", bad: "✗", info: "i" };
|
|
7
|
+
|
|
8
|
+
let runMode = "idle";
|
|
9
|
+
let pollTimer = null;
|
|
10
|
+
let drawerOpen = false;
|
|
11
|
+
let lastLog = "";
|
|
12
|
+
let lastLines = null;
|
|
13
|
+
|
|
14
|
+
function nextStep() {
|
|
15
|
+
const steps = (UD.schedule?.games ?? []).flatMap((game) => (game?.steps ?? []).filter((step) => !step.done).map((step) => ({ ...step, game: game.name })));
|
|
16
|
+
steps.sort((a, b) => (a.at ? Date.parse(a.at) : 0) - (b.at ? Date.parse(b.at) : 0));
|
|
17
|
+
return steps[0] ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function keyEnvs() {
|
|
21
|
+
const games = (UD.schedule?.games ?? []).filter(Boolean);
|
|
22
|
+
return [...new Map(games.map((game) => [game.apiKeyEnv, game])).values()];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runbarHtml() {
|
|
26
|
+
return `
|
|
27
|
+
<div class="runbar-main">
|
|
28
|
+
<div class="run-state">
|
|
29
|
+
<span class="pulse" id="runPulse" aria-hidden="true"></span>
|
|
30
|
+
<div class="run-text"><b id="runTitle">Not running</b><span id="runNext"></span></div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="run-actions">
|
|
33
|
+
<span id="runKey"></span>
|
|
34
|
+
<button class="btn btn-ghost btn-sm" id="runCheck" title="Check the key, times, icons, sale items and alerts">Check plan</button>
|
|
35
|
+
<button class="btn btn-ghost btn-sm" id="runDry" title="Show what would happen right now, without changing anything">Dry run</button>
|
|
36
|
+
<button class="btn btn-ghost btn-sm" id="runUndo" title="Put prices, names and the icon back">Undo all</button>
|
|
37
|
+
<button class="btn btn-primary" id="runStart">Start</button>
|
|
38
|
+
<button class="btn btn-danger" id="runStop" hidden>Stop</button>
|
|
39
|
+
<button class="icon-btn" id="runDrawer" aria-expanded="false" aria-controls="drawer" aria-label="Show log and API key">▴</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="drawer" id="drawer">
|
|
43
|
+
<div class="drawer-inner">
|
|
44
|
+
<div>
|
|
45
|
+
<div class="section-title">API key</div>
|
|
46
|
+
<div id="runKeys"></div>
|
|
47
|
+
<div class="hint" style="margin-bottom:12px">Pasted keys stay in memory until UpdateDay closes and are never written to disk. To keep one, set it as an environment variable.</div>
|
|
48
|
+
<button class="btn btn-sm" id="runDoctor">Check key permissions</button>
|
|
49
|
+
<div id="runDoctorResult" style="margin-top:12px"></div>
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<div class="section-title">Log</div>
|
|
53
|
+
<pre class="log" id="runLog" tabindex="0" aria-label="Run log"></pre>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function logHtml(lines) {
|
|
60
|
+
if (!lines.length) {
|
|
61
|
+
return `<span class="t">Nothing has run yet. Start runs the plan on time; Dry run shows what would happen right now.</span>`;
|
|
62
|
+
}
|
|
63
|
+
return lines.map((line) => {
|
|
64
|
+
const match = LOG_LINE.exec(line);
|
|
65
|
+
const text = match ? match[2] : line;
|
|
66
|
+
const kind = /error|failed|refused|couldn't/i.test(text) ? "err" : /^\s*RUN\b/.test(text) ? "run" : /REMINDER|warn|skipp/i.test(text) ? "warn" : "";
|
|
67
|
+
const time = match ? `<span class="t">${h(formatTime(match[1]))}</span> ` : "";
|
|
68
|
+
return `${time}<span class="${kind}">${h(text.replace(/^\s*RUN\s+/, "▸ "))}</span>`;
|
|
69
|
+
}).join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderRunState() {
|
|
73
|
+
const running = runMode !== "idle";
|
|
74
|
+
$("runPulse").classList.toggle("on", running);
|
|
75
|
+
$("runTitle").textContent = runMode === "watching" ? "Running" : running ? "Busy" : "Not running";
|
|
76
|
+
const next = nextStep();
|
|
77
|
+
const multi = (UD.schedule?.games ?? []).filter(Boolean).length > 1;
|
|
78
|
+
const nextText = next
|
|
79
|
+
? `Next: ${next.label}${multi ? ` (${next.game})` : ""} · ${next.at ? (Date.parse(next.at) <= Date.now() ? "due now" : `in ${formatSpan(Date.parse(next.at) - Date.now())}`) : "right away"}`
|
|
80
|
+
: "Everything is done";
|
|
81
|
+
$("runNext").textContent = nextText;
|
|
82
|
+
$("runNext").title = nextText;
|
|
83
|
+
$("runStart").hidden = running;
|
|
84
|
+
$("runStop").hidden = !running;
|
|
85
|
+
$("runDry").disabled = running;
|
|
86
|
+
$("runUndo").disabled = running;
|
|
87
|
+
const envs = keyEnvs();
|
|
88
|
+
const missing = envs.filter((game) => !game.hasKey).length;
|
|
89
|
+
$("runKey").innerHTML = envs.length
|
|
90
|
+
? `<button class="badge ${missing ? "badge-warning" : "badge-success"}" id="runKeyBadge" style="cursor:pointer;border:0">${missing ? "No API key" : "Key ready"}</button>`
|
|
91
|
+
: "";
|
|
92
|
+
$("runKeyBadge")?.addEventListener("click", () => setDrawer(true));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderKeys() {
|
|
96
|
+
const box = $("runKeys");
|
|
97
|
+
const envs = keyEnvs();
|
|
98
|
+
if (!envs.length) {
|
|
99
|
+
box.innerHTML = `<div class="hint" style="margin-bottom:10px">Add a game first.</div>`;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
box.innerHTML = envs.map((game) => `
|
|
103
|
+
<form class="key-row" data-env="${h(game.apiKeyEnv)}">
|
|
104
|
+
<label class="field" style="margin:0">
|
|
105
|
+
<span>${h(game.apiKeyEnv)} <span class="badge ${game.hasKey ? "badge-success" : "badge-warning"}">${game.hasKey ? "Set" : "Missing"}</span></span>
|
|
106
|
+
<input type="password" placeholder="${game.hasKey ? "Paste to replace" : "Paste your Open Cloud API key"}" autocomplete="off">
|
|
107
|
+
</label>
|
|
108
|
+
<div class="field" style="margin:0;justify-content:flex-end"><button class="btn btn-sm" type="submit">Use key</button></div>
|
|
109
|
+
</form>`).join("");
|
|
110
|
+
for (const form of box.querySelectorAll("form")) {
|
|
111
|
+
form.addEventListener("submit", async (event) => {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
const input = form.querySelector("input");
|
|
114
|
+
const value = input.value.trim();
|
|
115
|
+
if (!value) {
|
|
116
|
+
input.focus();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await api("POST", "/api/key", { env: form.dataset.env, value });
|
|
121
|
+
input.value = "";
|
|
122
|
+
await refreshSchedule();
|
|
123
|
+
renderKeys();
|
|
124
|
+
renderRunState();
|
|
125
|
+
saleItems.game = null;
|
|
126
|
+
renderView();
|
|
127
|
+
toast("Key added for this session", "success");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
toast(error.message, "danger");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function doctorRowHtml(probe) {
|
|
136
|
+
const level = probe.status === "ok" ? "ok" : probe.status === "untested" ? "info" : probe.used ? "bad" : "warn";
|
|
137
|
+
return `
|
|
138
|
+
<li class="check ${level}">
|
|
139
|
+
<span class="dot" aria-hidden="true">${DOCTOR_SYMBOLS[probe.status]}</span>
|
|
140
|
+
<div><b>${h(probe.feature)}</b>${probe.used ? "" : ` <span class="hint">not in your plan</span>`}
|
|
141
|
+
<div class="hint mono">${h(probe.permission)}</div>
|
|
142
|
+
${probe.detail ? `<div class="hint">${h(probe.detail)}</div>` : ""}
|
|
143
|
+
</div>
|
|
144
|
+
</li>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runDoctor() {
|
|
148
|
+
const box = $("runDoctorResult");
|
|
149
|
+
const button = $("runDoctor");
|
|
150
|
+
button.disabled = true;
|
|
151
|
+
box.innerHTML = `<div class="skeleton" style="height:120px"></div>`;
|
|
152
|
+
try {
|
|
153
|
+
const { probes } = await api("GET", `/api/doctor?${gameQuery()}`);
|
|
154
|
+
const blocking = probes.filter((probe) => probe.used && (probe.status === "missing" || probe.status === "error"));
|
|
155
|
+
const heading = blocking.length
|
|
156
|
+
? `<div class="callout callout-danger">${blocking.length} permission${blocking.length === 1 ? "" : "s"} missing for your plan. Edit the key in Creator Hub (Open Cloud → API Keys) and add them for this game.</div>`
|
|
157
|
+
: `<div class="callout callout-info">The key has everything your plan needs.</div>`;
|
|
158
|
+
box.innerHTML = `${heading}<ul class="checks">${probes.map(doctorRowHtml).join("")}</ul>`;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
box.innerHTML = errorBox(error);
|
|
161
|
+
} finally {
|
|
162
|
+
button.disabled = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setDrawer(open) {
|
|
167
|
+
drawerOpen = open;
|
|
168
|
+
$("drawer").classList.toggle("open", open);
|
|
169
|
+
$("runDrawer").setAttribute("aria-expanded", String(open));
|
|
170
|
+
$("runDrawer").textContent = open ? "▾" : "▴";
|
|
171
|
+
if (open) {
|
|
172
|
+
renderKeys();
|
|
173
|
+
const log = $("runLog");
|
|
174
|
+
log.scrollTop = log.scrollHeight;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function preflightHtml(reports) {
|
|
179
|
+
const multi = reports.length > 1;
|
|
180
|
+
return reports.map((report) => `
|
|
181
|
+
${multi ? `<div class="section-title" style="margin-top:10px">${h(report.game)} ${report.ready ? `<span class="badge badge-success">Ready</span>` : `<span class="badge badge-danger">Needs fixing</span>`}</div>` : ""}
|
|
182
|
+
<ul class="checks">${report.checks.map((check) => `
|
|
183
|
+
<li class="check ${check.level}">
|
|
184
|
+
<span class="dot" aria-hidden="true">${CHECK_SYMBOLS_RUN[check.level]}</span>
|
|
185
|
+
<div><b>${h(check.title)}</b>${check.detail ? `<div class="hint">${h(check.detail)}</div>` : ""}</div>
|
|
186
|
+
</li>`).join("")}</ul>`).join("");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function fetchPreflight() {
|
|
190
|
+
const { reports, error } = await api("GET", "/api/preflight");
|
|
191
|
+
if (error) {
|
|
192
|
+
throw new Error(error);
|
|
193
|
+
}
|
|
194
|
+
return reports;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function checkPlan() {
|
|
198
|
+
const button = $("runCheck");
|
|
199
|
+
button.disabled = true;
|
|
200
|
+
button.textContent = "Checking…";
|
|
201
|
+
try {
|
|
202
|
+
const reports = await fetchPreflight();
|
|
203
|
+
const problems = reports.flatMap((report) => report.checks).filter((check) => check.level === "bad").length;
|
|
204
|
+
await dialog({
|
|
205
|
+
title: problems ? `${problems} thing${problems === 1 ? "" : "s"} to fix first` : "Ready for the update",
|
|
206
|
+
body: preflightHtml(reports),
|
|
207
|
+
html: true,
|
|
208
|
+
confirmText: "Close",
|
|
209
|
+
cancelText: null,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
toast(error.message, "danger");
|
|
213
|
+
} finally {
|
|
214
|
+
button.disabled = false;
|
|
215
|
+
button.textContent = "Check plan";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function desktopAlert(line) {
|
|
220
|
+
if (readSetting(DESKTOP_ALERTS_KEY) !== "on" || typeof Notification === "undefined" || Notification.permission !== "granted") {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const match = LOG_LINE.exec(line);
|
|
224
|
+
const text = (match ? match[2] : line).replace(/^\s*/, "");
|
|
225
|
+
const title = /REMINDER/.test(text) ? "Upload the next countdown icon" : /FAILED|ERROR/.test(text) ? "UpdateDay hit a problem" : "UpdateDay";
|
|
226
|
+
try {
|
|
227
|
+
new Notification(title, { body: text.replace(/^REMINDER [^:]+: /, "").slice(0, 180), tag: "updateday" });
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function alertNewLines(lines) {
|
|
233
|
+
if (lastLines === null) {
|
|
234
|
+
lastLines = lines;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const previous = lastLines[lastLines.length - 1];
|
|
238
|
+
const start = previous === undefined ? 0 : lines.lastIndexOf(previous) + 1;
|
|
239
|
+
for (const line of lines.slice(start)) {
|
|
240
|
+
if (ALERT_LINE.test(line)) {
|
|
241
|
+
desktopAlert(line);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
lastLines = lines;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function pollRun() {
|
|
248
|
+
clearTimeout(pollTimer);
|
|
249
|
+
try {
|
|
250
|
+
const status = await api("GET", "/api/status");
|
|
251
|
+
const changed = status.mode !== runMode;
|
|
252
|
+
runMode = status.mode;
|
|
253
|
+
const text = status.log.join("\n");
|
|
254
|
+
if (text !== lastLog) {
|
|
255
|
+
lastLog = text;
|
|
256
|
+
alertNewLines(status.log);
|
|
257
|
+
const log = $("runLog");
|
|
258
|
+
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 8;
|
|
259
|
+
log.innerHTML = logHtml(status.log);
|
|
260
|
+
if (atBottom) {
|
|
261
|
+
log.scrollTop = log.scrollHeight;
|
|
262
|
+
}
|
|
263
|
+
if (runMode !== "idle") {
|
|
264
|
+
await refreshSchedule();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (changed) {
|
|
268
|
+
await refreshSchedule();
|
|
269
|
+
}
|
|
270
|
+
renderRunState();
|
|
271
|
+
} catch {
|
|
272
|
+
}
|
|
273
|
+
pollTimer = setTimeout(pollRun, runMode === "idle" ? POLL_IDLE_MS : POLL_RUNNING_MS);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function ensureSaved(verb) {
|
|
277
|
+
if (!UD.dirty) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
const sure = await dialog({ title: "Save first?", body: `${verb} uses the saved plan. Save your changes and continue?`, confirmText: "Save and continue" });
|
|
281
|
+
if (!sure) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
await save();
|
|
285
|
+
return !UD.dirty;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function runAction(action) {
|
|
289
|
+
try {
|
|
290
|
+
await api("POST", `/api/run/${action}`);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
toast(error.message, "danger");
|
|
293
|
+
}
|
|
294
|
+
pollRun();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function startRun() {
|
|
298
|
+
if (!(await ensureSaved("Starting"))) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const missing = keyEnvs().filter((game) => !game.hasKey);
|
|
302
|
+
if (missing.length) {
|
|
303
|
+
setDrawer(true);
|
|
304
|
+
toast(`Add the API key (${missing[0].apiKeyEnv}) first`, "danger");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const button = $("runStart");
|
|
308
|
+
button.disabled = true;
|
|
309
|
+
button.textContent = "Checking…";
|
|
310
|
+
let reports = [];
|
|
311
|
+
try {
|
|
312
|
+
reports = await fetchPreflight();
|
|
313
|
+
} catch (error) {
|
|
314
|
+
toast(`Couldn't check the plan: ${error.message}`, "danger");
|
|
315
|
+
} finally {
|
|
316
|
+
button.disabled = false;
|
|
317
|
+
button.textContent = "Start";
|
|
318
|
+
}
|
|
319
|
+
const problems = reports.flatMap((report) => report.checks).filter((check) => check.level === "bad").length;
|
|
320
|
+
if (problems) {
|
|
321
|
+
const anyway = await dialog({
|
|
322
|
+
title: `${problems} thing${problems === 1 ? "" : "s"} to fix first`,
|
|
323
|
+
body: preflightHtml(reports),
|
|
324
|
+
html: true,
|
|
325
|
+
confirmText: "Start anyway",
|
|
326
|
+
cancelText: "Fix first",
|
|
327
|
+
danger: true,
|
|
328
|
+
});
|
|
329
|
+
if (!anyway) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
await runAction("start");
|
|
334
|
+
toast("Running. Keep UpdateDay open until the update is done.", "success");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function dryRun() {
|
|
338
|
+
if (!(await ensureSaved("A dry run"))) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
setDrawer(true);
|
|
342
|
+
runAction("dryrun");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function undoAll() {
|
|
346
|
+
const sure = await dialog({
|
|
347
|
+
title: "Undo everything?",
|
|
348
|
+
body: "Puts every saved price and name back, removes the countdown icons and restores the game's language settings. Use this if an update is cancelled.",
|
|
349
|
+
confirmText: "Undo everything",
|
|
350
|
+
danger: true,
|
|
351
|
+
});
|
|
352
|
+
if (!sure) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
setDrawer(true);
|
|
356
|
+
await runAction("restore");
|
|
357
|
+
toast("Undo started. Watch the log for each item.", "success");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function stopRun() {
|
|
361
|
+
const sure = await dialog({ title: "Stop the run?", body: "Nothing more happens until you start again. Anything already changed stays as it is; use Undo all to put it back.", confirmText: "Stop", danger: true });
|
|
362
|
+
if (sure) {
|
|
363
|
+
runAction("stop");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function initRunbar() {
|
|
368
|
+
$("runbar").innerHTML = runbarHtml();
|
|
369
|
+
$("runStart").addEventListener("click", startRun);
|
|
370
|
+
$("runStop").addEventListener("click", stopRun);
|
|
371
|
+
$("runDry").addEventListener("click", dryRun);
|
|
372
|
+
$("runCheck").addEventListener("click", checkPlan);
|
|
373
|
+
$("runUndo").addEventListener("click", undoAll);
|
|
374
|
+
$("runDoctor").addEventListener("click", runDoctor);
|
|
375
|
+
$("runDrawer").addEventListener("click", () => setDrawer(!drawerOpen));
|
|
376
|
+
document.addEventListener("click", (event) => {
|
|
377
|
+
if (event.target.closest(".js-add-key")) {
|
|
378
|
+
setDrawer(true);
|
|
379
|
+
document.querySelector("#runKeys input")?.focus();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
onChange(() => {
|
|
383
|
+
renderRunState();
|
|
384
|
+
const typing = [...document.querySelectorAll("#runKeys input")].some((input) => input.value);
|
|
385
|
+
if (drawerOpen && !typing) {
|
|
386
|
+
renderKeys();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
renderRunState();
|
|
390
|
+
pollRun();
|
|
391
|
+
}
|