@linxin666/dsh-client-ui-task-board 0.1.14 → 0.1.16
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/lib/client.js +368 -160
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/board/TaskCard.d.ts +4 -5
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/core/controller.d.ts +6 -1
- package/lib/types/core/scheduler.d.ts +13 -2
- package/lib/types/core/use-cases/task-create.d.ts +22 -0
- package/lib/types/core/use-cases/task-delete.d.ts +21 -0
- package/lib/types/core/use-cases/task-schedule.d.ts +34 -0
- package/lib/types/core/use-cases/task-update.d.ts +17 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/board/TaskBoard.tsx +14 -6
- package/src/client/board/TaskCard.tsx +10 -1
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/core/controller.ts +25 -26
- package/src/core/scheduler.ts +39 -7
- package/src/core/use-cases/task-create.ts +33 -0
- package/src/core/use-cases/task-delete.ts +33 -0
- package/src/core/use-cases/task-schedule.ts +74 -0
- package/src/core/use-cases/task-update.ts +26 -0
package/lib/client.js
CHANGED
|
@@ -8,107 +8,6 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
let react = require("react");
|
|
9
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
10
|
let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
|
|
11
|
-
//#region src/core/schedule.ts
|
|
12
|
-
/** Inclusive ranges per field, in cron order. */
|
|
13
|
-
const FIELD_RANGES = [
|
|
14
|
-
[0, 59],
|
|
15
|
-
[0, 23],
|
|
16
|
-
[1, 31],
|
|
17
|
-
[1, 12],
|
|
18
|
-
[0, 7]
|
|
19
|
-
];
|
|
20
|
-
/**
|
|
21
|
-
* Parse a 5-field cron expression.
|
|
22
|
-
* @returns the match sets, or null when the expression is invalid.
|
|
23
|
-
*/
|
|
24
|
-
function parseCron(expr) {
|
|
25
|
-
const fields = expr.trim().split(/\s+/);
|
|
26
|
-
if (fields.length !== 5) return null;
|
|
27
|
-
const sets = [];
|
|
28
|
-
for (let index = 0; index < 5; index++) {
|
|
29
|
-
const [min, max] = FIELD_RANGES[index];
|
|
30
|
-
const set = /* @__PURE__ */ new Set();
|
|
31
|
-
if (!parseField(fields[index], min, max, set)) return null;
|
|
32
|
-
sets.push(set);
|
|
33
|
-
}
|
|
34
|
-
const weekdays = /* @__PURE__ */ new Set();
|
|
35
|
-
for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
|
|
36
|
-
return {
|
|
37
|
-
minutes: sets[0],
|
|
38
|
-
hours: sets[1],
|
|
39
|
-
days: sets[2],
|
|
40
|
-
months: sets[3],
|
|
41
|
-
weekdays,
|
|
42
|
-
dayWildcard: fields[2] === "*",
|
|
43
|
-
weekdayWildcard: fields[4] === "*"
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
/** Whether the expression parses. */
|
|
47
|
-
function isValidCron(expr) {
|
|
48
|
-
return parseCron(expr) !== null;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Compute the next matching instant after `fromMs` (ms epoch), in local time,
|
|
52
|
-
* at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
|
|
53
|
-
* of the matching minute's start, or undefined when nothing matches within
|
|
54
|
-
* 366 days (e.g. `0 0 30 2 *`).
|
|
55
|
-
*/
|
|
56
|
-
function nextRunAtMs(expr, fromMs) {
|
|
57
|
-
const schedule = parseCron(expr);
|
|
58
|
-
if (schedule === null) return void 0;
|
|
59
|
-
const from = new Date(fromMs);
|
|
60
|
-
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
|
|
61
|
-
const limitMs = fromMs + 366 * 24 * 60 * 60 * 1e3;
|
|
62
|
-
while (scan.getTime() <= limitMs) {
|
|
63
|
-
if (matches(schedule, scan)) return scan.getTime();
|
|
64
|
-
scan.setMinutes(scan.getMinutes() + 1);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/** Parse one comma-list field into the match set. */
|
|
68
|
-
function parseField(field, min, max, out) {
|
|
69
|
-
if (field === "*") {
|
|
70
|
-
for (let value = min; value <= max; value++) out.add(value);
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
for (const part of field.split(",")) {
|
|
74
|
-
if (part === "") return false;
|
|
75
|
-
const [range, stepRaw] = part.split("/");
|
|
76
|
-
let low;
|
|
77
|
-
let high;
|
|
78
|
-
if (range === "*") {
|
|
79
|
-
low = min;
|
|
80
|
-
high = max;
|
|
81
|
-
} else if (range.includes("-")) {
|
|
82
|
-
const [a, b] = range.split("-");
|
|
83
|
-
if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
|
|
84
|
-
low = Number(a);
|
|
85
|
-
high = Number(b);
|
|
86
|
-
} else if (isDigits(range)) {
|
|
87
|
-
low = Number(range);
|
|
88
|
-
high = Number(range);
|
|
89
|
-
} else return false;
|
|
90
|
-
if (low < min || high > max || low > high) return false;
|
|
91
|
-
const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
|
|
92
|
-
if (!Number.isInteger(step) || step < 1) return false;
|
|
93
|
-
for (let value = low; value <= high; value += step) out.add(value);
|
|
94
|
-
}
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
98
|
-
function matches(schedule, date) {
|
|
99
|
-
if (!schedule.minutes.has(date.getMinutes())) return false;
|
|
100
|
-
if (!schedule.hours.has(date.getHours())) return false;
|
|
101
|
-
if (!schedule.months.has(date.getMonth() + 1)) return false;
|
|
102
|
-
const dayMatches = schedule.days.has(date.getDate());
|
|
103
|
-
const weekdayMatches = schedule.weekdays.has(date.getDay());
|
|
104
|
-
if (schedule.dayWildcard) return weekdayMatches;
|
|
105
|
-
if (schedule.weekdayWildcard) return dayMatches;
|
|
106
|
-
return dayMatches || weekdayMatches;
|
|
107
|
-
}
|
|
108
|
-
function isDigits(value) {
|
|
109
|
-
return /^\d+$/.test(value);
|
|
110
|
-
}
|
|
111
|
-
//#endregion
|
|
112
11
|
//#region src/core/tasks.ts
|
|
113
12
|
/** The five kanban columns, in display order. */
|
|
114
13
|
const COLUMNS = [
|
|
@@ -249,6 +148,221 @@ window.__ModuleLoader__.load({
|
|
|
249
148
|
return "running";
|
|
250
149
|
}
|
|
251
150
|
//#endregion
|
|
151
|
+
//#region src/core/use-cases/task-create.ts
|
|
152
|
+
/**
|
|
153
|
+
* Create-task use case: mint a new task from user input, rejecting a blank
|
|
154
|
+
* title. Pure ledger transition (no persistence or notify — the controller
|
|
155
|
+
* orchestrates those), so it is unit-testable without any runtime face.
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* Apply a create against the current ledger. Returns the new task and the
|
|
159
|
+
* appended ledger, or the unchanged ledger when the title is blank.
|
|
160
|
+
* @param tasks - current ledger.
|
|
161
|
+
* @param input - raw user input (title/description/prompt).
|
|
162
|
+
* @param now - clock instant (ms epoch).
|
|
163
|
+
* @param id - minted task id.
|
|
164
|
+
*/
|
|
165
|
+
function applyCreateTask(tasks, input, now, id) {
|
|
166
|
+
if (input.title.trim() === "") return {
|
|
167
|
+
task: void 0,
|
|
168
|
+
tasks
|
|
169
|
+
};
|
|
170
|
+
const task = createTask(input, now, id);
|
|
171
|
+
return {
|
|
172
|
+
task,
|
|
173
|
+
tasks: [...tasks, task]
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/core/use-cases/task-delete.ts
|
|
178
|
+
/**
|
|
179
|
+
* Apply a delete across the ledger. The selection (a task id) is cleared when
|
|
180
|
+
* it matches the removed task, so the UI never lingers on a vanished detail.
|
|
181
|
+
* @param tasks - current ledger.
|
|
182
|
+
* @param selectedTaskId - the currently selected task id (may be undefined).
|
|
183
|
+
* @param id - the task to remove.
|
|
184
|
+
*/
|
|
185
|
+
function applyDeleteTask(tasks, selectedTaskId, id) {
|
|
186
|
+
return {
|
|
187
|
+
tasks: tasks.filter((task) => task.id !== id),
|
|
188
|
+
selectionCleared: selectedTaskId === id
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/core/schedule.ts
|
|
193
|
+
/** Inclusive ranges per field, in cron order. */
|
|
194
|
+
const FIELD_RANGES = [
|
|
195
|
+
[0, 59],
|
|
196
|
+
[0, 23],
|
|
197
|
+
[1, 31],
|
|
198
|
+
[1, 12],
|
|
199
|
+
[0, 7]
|
|
200
|
+
];
|
|
201
|
+
/**
|
|
202
|
+
* Parse a 5-field cron expression.
|
|
203
|
+
* @returns the match sets, or null when the expression is invalid.
|
|
204
|
+
*/
|
|
205
|
+
function parseCron(expr) {
|
|
206
|
+
const fields = expr.trim().split(/\s+/);
|
|
207
|
+
if (fields.length !== 5) return null;
|
|
208
|
+
const sets = [];
|
|
209
|
+
for (let index = 0; index < 5; index++) {
|
|
210
|
+
const [min, max] = FIELD_RANGES[index];
|
|
211
|
+
const set = /* @__PURE__ */ new Set();
|
|
212
|
+
if (!parseField(fields[index], min, max, set)) return null;
|
|
213
|
+
sets.push(set);
|
|
214
|
+
}
|
|
215
|
+
const weekdays = /* @__PURE__ */ new Set();
|
|
216
|
+
for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
|
|
217
|
+
return {
|
|
218
|
+
minutes: sets[0],
|
|
219
|
+
hours: sets[1],
|
|
220
|
+
days: sets[2],
|
|
221
|
+
months: sets[3],
|
|
222
|
+
weekdays,
|
|
223
|
+
dayWildcard: fields[2] === "*",
|
|
224
|
+
weekdayWildcard: fields[4] === "*"
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** Whether the expression parses. */
|
|
228
|
+
function isValidCron(expr) {
|
|
229
|
+
return parseCron(expr) !== null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Compute the next matching instant after `fromMs` (ms epoch), in local time,
|
|
233
|
+
* at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
|
|
234
|
+
* of the matching minute's start, or undefined when nothing matches within
|
|
235
|
+
* 366 days (e.g. `0 0 30 2 *`).
|
|
236
|
+
*/
|
|
237
|
+
function nextRunAtMs(expr, fromMs) {
|
|
238
|
+
const schedule = parseCron(expr);
|
|
239
|
+
if (schedule === null) return void 0;
|
|
240
|
+
const from = new Date(fromMs);
|
|
241
|
+
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
|
|
242
|
+
const limitMs = fromMs + 366 * 24 * 60 * 60 * 1e3;
|
|
243
|
+
while (scan.getTime() <= limitMs) {
|
|
244
|
+
if (matches(schedule, scan)) return scan.getTime();
|
|
245
|
+
scan.setMinutes(scan.getMinutes() + 1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Parse one comma-list field into the match set. */
|
|
249
|
+
function parseField(field, min, max, out) {
|
|
250
|
+
if (field === "*") {
|
|
251
|
+
for (let value = min; value <= max; value++) out.add(value);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
for (const part of field.split(",")) {
|
|
255
|
+
if (part === "") return false;
|
|
256
|
+
const [range, stepRaw] = part.split("/");
|
|
257
|
+
let low;
|
|
258
|
+
let high;
|
|
259
|
+
if (range === "*") {
|
|
260
|
+
low = min;
|
|
261
|
+
high = max;
|
|
262
|
+
} else if (range.includes("-")) {
|
|
263
|
+
const [a, b] = range.split("-");
|
|
264
|
+
if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
|
|
265
|
+
low = Number(a);
|
|
266
|
+
high = Number(b);
|
|
267
|
+
} else if (isDigits(range)) {
|
|
268
|
+
low = Number(range);
|
|
269
|
+
high = Number(range);
|
|
270
|
+
} else return false;
|
|
271
|
+
if (low < min || high > max || low > high) return false;
|
|
272
|
+
const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
|
|
273
|
+
if (!Number.isInteger(step) || step < 1) return false;
|
|
274
|
+
for (let value = low; value <= high; value += step) out.add(value);
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
279
|
+
function matches(schedule, date) {
|
|
280
|
+
if (!schedule.minutes.has(date.getMinutes())) return false;
|
|
281
|
+
if (!schedule.hours.has(date.getHours())) return false;
|
|
282
|
+
if (!schedule.months.has(date.getMonth() + 1)) return false;
|
|
283
|
+
const dayMatches = schedule.days.has(date.getDate());
|
|
284
|
+
const weekdayMatches = schedule.weekdays.has(date.getDay());
|
|
285
|
+
if (schedule.dayWildcard) return weekdayMatches;
|
|
286
|
+
if (schedule.weekdayWildcard) return dayMatches;
|
|
287
|
+
return dayMatches || weekdayMatches;
|
|
288
|
+
}
|
|
289
|
+
function isDigits(value) {
|
|
290
|
+
return /^\d+$/.test(value);
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/core/use-cases/task-schedule.ts
|
|
294
|
+
/**
|
|
295
|
+
* Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
|
|
296
|
+
* Pure ledger transitions (no persistence or notify — the controller
|
|
297
|
+
* orchestrates those). Validation and next-run computation live here, sharing
|
|
298
|
+
* the core cron parser (schedule.ts) and the withSchedule transition.
|
|
299
|
+
*/
|
|
300
|
+
/**
|
|
301
|
+
* Set a task's schedule rule. A blank or invalid cron is rejected (state
|
|
302
|
+
* untouched); an enabled rule computes the next run instant immediately, a
|
|
303
|
+
* disabled or invalid one carries no next-run instant.
|
|
304
|
+
* @param tasks - current ledger.
|
|
305
|
+
* @param id - the task to schedule.
|
|
306
|
+
* @param patch - rule fields to change (absent fields keep their current value).
|
|
307
|
+
* @param now - clock instant (ms epoch).
|
|
308
|
+
*/
|
|
309
|
+
function applySetSchedule(tasks, id, patch, now) {
|
|
310
|
+
const task = tasks.find((candidate) => candidate.id === id);
|
|
311
|
+
if (task === void 0) return {
|
|
312
|
+
tasks,
|
|
313
|
+
applied: false
|
|
314
|
+
};
|
|
315
|
+
const current = task.schedule;
|
|
316
|
+
const cron = (patch.cron ?? current?.cron ?? "").trim();
|
|
317
|
+
if (cron === "" || !isValidCron(cron)) return {
|
|
318
|
+
tasks,
|
|
319
|
+
applied: false
|
|
320
|
+
};
|
|
321
|
+
const enabled = patch.enabled ?? current?.enabled ?? false;
|
|
322
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
|
|
323
|
+
return {
|
|
324
|
+
tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
|
|
325
|
+
enabled,
|
|
326
|
+
cron,
|
|
327
|
+
nextRunAt
|
|
328
|
+
}, now) : candidate),
|
|
329
|
+
applied: true
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Roll a task's schedule rule forward (scheduler callback): persist the next
|
|
334
|
+
* due instant and the trigger instant. No-op for tasks without a rule (deleted
|
|
335
|
+
* mid-tick, for example).
|
|
336
|
+
* @param tasks - current ledger.
|
|
337
|
+
* @param id - the task to roll forward.
|
|
338
|
+
* @param nextRunAt - next due instant (may be undefined to clear).
|
|
339
|
+
* @param lastTriggeredAt - the trigger instant of this run.
|
|
340
|
+
* @param now - clock instant (ms epoch).
|
|
341
|
+
*/
|
|
342
|
+
function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
|
|
343
|
+
return tasks.map((task) => task.id === id && task.schedule !== void 0 ? withSchedule(task, {
|
|
344
|
+
nextRunAt,
|
|
345
|
+
lastTriggeredAt
|
|
346
|
+
}, now) : task);
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/core/use-cases/task-update.ts
|
|
350
|
+
/**
|
|
351
|
+
* Apply an update across the ledger. Tasks that do not match the id are left
|
|
352
|
+
* untouched; the matched task receives the patch plus a fresh updatedAt.
|
|
353
|
+
* @param tasks - current ledger.
|
|
354
|
+
* @param id - the task to update.
|
|
355
|
+
* @param patch - editable-field changes.
|
|
356
|
+
* @param now - clock instant (ms epoch).
|
|
357
|
+
*/
|
|
358
|
+
function applyUpdateTask(tasks, id, patch, now) {
|
|
359
|
+
return tasks.map((task) => task.id === id ? {
|
|
360
|
+
...task,
|
|
361
|
+
...patch,
|
|
362
|
+
updatedAt: now
|
|
363
|
+
} : task);
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
252
366
|
//#region src/core/controller.ts
|
|
253
367
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
254
368
|
function selectedTaskOf(snapshot) {
|
|
@@ -347,18 +461,14 @@ window.__ModuleLoader__.load({
|
|
|
347
461
|
this.notify();
|
|
348
462
|
}
|
|
349
463
|
createTask(input) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
this.tasks = [...
|
|
464
|
+
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid());
|
|
465
|
+
if (task === void 0) return void 0;
|
|
466
|
+
this.tasks = [...tasks];
|
|
353
467
|
this.persistAndNotify();
|
|
354
468
|
return task;
|
|
355
469
|
}
|
|
356
470
|
updateTask(id, patch) {
|
|
357
|
-
this.tasks = this.tasks
|
|
358
|
-
...task,
|
|
359
|
-
...patch,
|
|
360
|
-
updatedAt: this.now()
|
|
361
|
-
} : task);
|
|
471
|
+
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())];
|
|
362
472
|
this.persistAndNotify();
|
|
363
473
|
}
|
|
364
474
|
moveTask(id, status) {
|
|
@@ -366,32 +476,24 @@ window.__ModuleLoader__.load({
|
|
|
366
476
|
this.persistAndNotify();
|
|
367
477
|
}
|
|
368
478
|
deleteTask(id) {
|
|
369
|
-
|
|
370
|
-
|
|
479
|
+
const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id);
|
|
480
|
+
this.tasks = [...tasks];
|
|
481
|
+
if (selectionCleared) this.selectedTaskId = void 0;
|
|
371
482
|
this.persistAndNotify();
|
|
372
483
|
}
|
|
373
484
|
/**
|
|
374
485
|
* Update a task's schedule rule. A blank or invalid cron expression is
|
|
375
486
|
* rejected (returns false, state untouched). When the rule ends up enabled
|
|
376
487
|
* the next run instant is computed immediately; a disabled rule carries no
|
|
377
|
-
* next-run instant.
|
|
488
|
+
* next-run instant. Delegates the domain transition to the schedule use case.
|
|
378
489
|
* @param id - the task to schedule.
|
|
379
490
|
* @param patch - fields to change (absent fields keep their current value).
|
|
380
491
|
* @returns true when applied, false when rejected (invalid cron / unknown task).
|
|
381
492
|
*/
|
|
382
493
|
setSchedule(id, patch) {
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
const cron = (patch.cron ?? current?.cron ?? "").trim();
|
|
387
|
-
if (cron === "" || !isValidCron(cron)) return false;
|
|
388
|
-
const enabled = patch.enabled ?? current?.enabled ?? false;
|
|
389
|
-
const nextRunAt = enabled ? nextRunAtMs(cron, this.now()) : void 0;
|
|
390
|
-
this.tasks = this.tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
|
|
391
|
-
enabled,
|
|
392
|
-
cron,
|
|
393
|
-
nextRunAt
|
|
394
|
-
}, this.now()) : candidate);
|
|
494
|
+
const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now());
|
|
495
|
+
if (!applied) return false;
|
|
496
|
+
this.tasks = [...tasks];
|
|
395
497
|
this.persistAndNotify();
|
|
396
498
|
return true;
|
|
397
499
|
}
|
|
@@ -401,10 +503,8 @@ window.__ModuleLoader__.load({
|
|
|
401
503
|
* schedule rule (it was deleted mid-tick, for example).
|
|
402
504
|
*/
|
|
403
505
|
applyScheduleNextRun(id, nextRunAt, lastTriggeredAt) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
lastTriggeredAt
|
|
407
|
-
}, this.now()) : task);
|
|
506
|
+
const next = applyScheduleNextRun(this.tasks, id, nextRunAt, lastTriggeredAt, this.now());
|
|
507
|
+
this.tasks = [...next];
|
|
408
508
|
this.persistAndNotify();
|
|
409
509
|
}
|
|
410
510
|
/**
|
|
@@ -736,6 +836,12 @@ window.__ModuleLoader__.load({
|
|
|
736
836
|
* a task still running at its due instant is skipped by the controller's
|
|
737
837
|
* runTask guard and simply waits for the next cron match.
|
|
738
838
|
*
|
|
839
|
+
* The ticker is controlled: `start` arms a single interval guarded by an
|
|
840
|
+
* idempotence check (a second start while running is a no-op, so wiring can
|
|
841
|
+
* never leak a duplicate timer), and `stop`/`dispose` clear the timer and
|
|
842
|
+
* drop the environment listeners. The board unmount path calls stop/dispose,
|
|
843
|
+
* so no interval or listener outlives the board.
|
|
844
|
+
*
|
|
739
845
|
* Framework-free: all runtime access flows through the injected deps
|
|
740
846
|
* (structural faces), so tests drive ticks directly without timers.
|
|
741
847
|
*/
|
|
@@ -746,26 +852,54 @@ window.__ModuleLoader__.load({
|
|
|
746
852
|
var SchedulerService = class {
|
|
747
853
|
deps;
|
|
748
854
|
timer;
|
|
855
|
+
environmentListener;
|
|
749
856
|
disposed = false;
|
|
857
|
+
started = false;
|
|
750
858
|
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
751
859
|
constructor(deps) {
|
|
752
860
|
this.deps = deps;
|
|
753
861
|
}
|
|
754
|
-
/**
|
|
862
|
+
/**
|
|
863
|
+
* Start ticking: one immediate check (catch-up after reload) + the interval.
|
|
864
|
+
* Single-instance: a second start while already armed is a no-op, so a
|
|
865
|
+
* re-entrant mount can never stack a duplicate timer.
|
|
866
|
+
*/
|
|
755
867
|
start() {
|
|
756
868
|
if (this.disposed) return;
|
|
869
|
+
if (this.started) return;
|
|
870
|
+
this.started = true;
|
|
757
871
|
this.tick();
|
|
758
872
|
this.timer = setInterval(() => {
|
|
759
873
|
this.tick();
|
|
760
874
|
}, this.deps.tickMs ?? 6e4);
|
|
761
|
-
this.deps.environment
|
|
875
|
+
if (this.deps.environment !== void 0) {
|
|
876
|
+
this.environmentListener = () => {
|
|
877
|
+
this.tick();
|
|
878
|
+
};
|
|
879
|
+
this.deps.environment.addEventListener("visibilitychange", this.environmentListener);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
|
|
884
|
+
* callers that treat the scheduler as a controlled ticker; `dispose` is an
|
|
885
|
+
* alias.
|
|
886
|
+
*/
|
|
887
|
+
stop() {
|
|
888
|
+
this.dispose();
|
|
762
889
|
}
|
|
763
890
|
/** Stop ticking and drop listeners (idempotent). */
|
|
764
891
|
dispose() {
|
|
892
|
+
if (this.disposed && this.timer === void 0 && this.environmentListener === void 0) return;
|
|
765
893
|
this.disposed = true;
|
|
766
|
-
|
|
767
|
-
this.timer
|
|
768
|
-
|
|
894
|
+
this.started = false;
|
|
895
|
+
if (this.timer !== void 0) {
|
|
896
|
+
clearInterval(this.timer);
|
|
897
|
+
this.timer = void 0;
|
|
898
|
+
}
|
|
899
|
+
if (this.environmentListener !== void 0 && this.deps.environment !== void 0) {
|
|
900
|
+
this.deps.environment.removeEventListener("visibilitychange", this.environmentListener);
|
|
901
|
+
this.environmentListener = void 0;
|
|
902
|
+
}
|
|
769
903
|
}
|
|
770
904
|
/**
|
|
771
905
|
* Check every enabled schedule and trigger the due ones. Idempotent per
|
|
@@ -791,9 +925,6 @@ window.__ModuleLoader__.load({
|
|
|
791
925
|
if (await this.deps.runTask(task.id)) this.deps.applySchedule(task.id, next, now);
|
|
792
926
|
}
|
|
793
927
|
}
|
|
794
|
-
onVisibility = () => {
|
|
795
|
-
this.tick();
|
|
796
|
-
};
|
|
797
928
|
};
|
|
798
929
|
//#endregion
|
|
799
930
|
//#region src/core/store.ts
|
|
@@ -1352,6 +1483,15 @@ window.__ModuleLoader__.load({
|
|
|
1352
1483
|
}
|
|
1353
1484
|
//#endregion
|
|
1354
1485
|
//#region src/client/board/TaskCard.tsx
|
|
1486
|
+
/**
|
|
1487
|
+
* Task card: the board's column item. Clicking opens the task detail — it
|
|
1488
|
+
* never executes anything directly (detail holds the Run button).
|
|
1489
|
+
*
|
|
1490
|
+
* Memoized: the card re-renders only when its own task record changes, so a
|
|
1491
|
+
* status/filter update on one card (or scrolling) never re-renders every
|
|
1492
|
+
* card on the board. The per-card onClick is built with a stable task reference
|
|
1493
|
+
* by the board, so the memo boundary is effective.
|
|
1494
|
+
*/
|
|
1355
1495
|
/** Compact relative/absolute time label. */
|
|
1356
1496
|
function formatTime(ms) {
|
|
1357
1497
|
const date = new Date(ms);
|
|
@@ -1362,7 +1502,7 @@ window.__ModuleLoader__.load({
|
|
|
1362
1502
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
1363
1503
|
}
|
|
1364
1504
|
/** One card in a column. */
|
|
1365
|
-
function
|
|
1505
|
+
function TaskCardInner({ task, onClick }) {
|
|
1366
1506
|
const latest = task.executions[task.executions.length - 1];
|
|
1367
1507
|
const runs = task.executions.length;
|
|
1368
1508
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
@@ -1423,6 +1563,8 @@ window.__ModuleLoader__.load({
|
|
|
1423
1563
|
]
|
|
1424
1564
|
});
|
|
1425
1565
|
}
|
|
1566
|
+
/** Memoized card: re-renders only when the card's own task record changes. */
|
|
1567
|
+
const TaskCard = (0, react.memo)(TaskCardInner);
|
|
1426
1568
|
//#endregion
|
|
1427
1569
|
//#region src/client/board/ConfirmDialog.tsx
|
|
1428
1570
|
/**
|
|
@@ -1826,6 +1968,20 @@ window.__ModuleLoader__.load({
|
|
|
1826
1968
|
const needle = filter.trim().toLowerCase();
|
|
1827
1969
|
return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle);
|
|
1828
1970
|
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Memoized per-card adapter: with a stable `onOpen` from the board and an
|
|
1973
|
+
* immutable task record (only the changed card gets a new object ref), a card
|
|
1974
|
+
* re-renders only when its own task changes — not when a sibling card status,
|
|
1975
|
+
* the filter, or the selection moves.
|
|
1976
|
+
*/
|
|
1977
|
+
const MemoTaskCard = (0, react.memo)(function MemoTaskCard({ task, onOpen }) {
|
|
1978
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
|
|
1979
|
+
task,
|
|
1980
|
+
onClick: (0, react.useCallback)(() => {
|
|
1981
|
+
onOpen(task.id);
|
|
1982
|
+
}, [task.id, onOpen])
|
|
1983
|
+
});
|
|
1984
|
+
});
|
|
1829
1985
|
/** Board component; subscribes to the controller snapshot. */
|
|
1830
1986
|
function TaskBoard({ controller }) {
|
|
1831
1987
|
const [snapshot, setSnapshot] = (0, react.useState)(controller.getSnapshot());
|
|
@@ -1834,6 +1990,9 @@ window.__ModuleLoader__.load({
|
|
|
1834
1990
|
const [showNew, setShowNew] = (0, react.useState)(false);
|
|
1835
1991
|
const selected = selectedTaskOf(snapshot);
|
|
1836
1992
|
const visible = snapshot.tasks.filter((task) => matchesFilter(task, filter));
|
|
1993
|
+
const openTask = (0, react.useCallback)((id) => {
|
|
1994
|
+
controller.openTask(id);
|
|
1995
|
+
}, [controller]);
|
|
1837
1996
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1838
1997
|
className: board_module_css_default.board,
|
|
1839
1998
|
"data-dsh-taskboard-board": "",
|
|
@@ -1899,11 +2058,9 @@ window.__ModuleLoader__.load({
|
|
|
1899
2058
|
]
|
|
1900
2059
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1901
2060
|
className: board_module_css_default.cards,
|
|
1902
|
-
children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
2061
|
+
children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemoTaskCard, {
|
|
1903
2062
|
task,
|
|
1904
|
-
|
|
1905
|
-
controller.openTask(task.id);
|
|
1906
|
-
}
|
|
2063
|
+
onOpen: openTask
|
|
1907
2064
|
}, task.id)), tasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1908
2065
|
className: board_module_css_default.columnEmpty,
|
|
1909
2066
|
children: t("board.empty")
|
|
@@ -2117,7 +2274,7 @@ window.__ModuleLoader__.load({
|
|
|
2117
2274
|
}
|
|
2118
2275
|
//#endregion
|
|
2119
2276
|
//#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
|
|
2120
|
-
const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save{transition:none}}";
|
|
2277
|
+
const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save{transition:none}}";
|
|
2121
2278
|
const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
|
|
2122
2279
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
2123
2280
|
const tag = document.createElement("style");
|
|
@@ -2158,12 +2315,12 @@ window.__ModuleLoader__.load({
|
|
|
2158
2315
|
//#endregion
|
|
2159
2316
|
//#region src/client/PluginSettingsCard.tsx
|
|
2160
2317
|
/**
|
|
2161
|
-
*
|
|
2162
|
-
* plugin and what its settings govern, the controls inside, and the save
|
|
2163
|
-
* writes them. Renders nothing while the namespace is unavailable — a
|
|
2318
|
+
* Family-shared chrome for plugin settings cards: a disclosure header naming
|
|
2319
|
+
* the plugin and what its settings govern, the controls inside, and the save
|
|
2320
|
+
* that writes them. Renders nothing while the namespace is unavailable — a
|
|
2164
2321
|
* deployment that does not compose the owning plugin should show no trace of
|
|
2165
|
-
* it.
|
|
2166
|
-
*
|
|
2322
|
+
* it. Inlined into each consumer's client bundle; mirrors the official
|
|
2323
|
+
* ui-plugin-config PluginCard in a self-contained slice.
|
|
2167
2324
|
*/
|
|
2168
2325
|
/**
|
|
2169
2326
|
* Render one plugin settings card.
|
|
@@ -2175,6 +2332,7 @@ window.__ModuleLoader__.load({
|
|
|
2175
2332
|
const { state } = props;
|
|
2176
2333
|
if (!state.available) return null;
|
|
2177
2334
|
const title = props.t(props.titleKey);
|
|
2335
|
+
const description = props.t(props.descriptionKey);
|
|
2178
2336
|
const blocked = !state.dirty || state.invalid || state.saving;
|
|
2179
2337
|
const cardClass = open ? `${settings_card_module_css_default.cardOpen} ${settings_card_module_css_default.card}` : settings_card_module_css_default.card;
|
|
2180
2338
|
if (!state.exposed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
@@ -2191,10 +2349,12 @@ window.__ModuleLoader__.load({
|
|
|
2191
2349
|
className: settings_card_module_css_default.headText,
|
|
2192
2350
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2193
2351
|
className: settings_card_module_css_default.name,
|
|
2352
|
+
title,
|
|
2194
2353
|
children: title
|
|
2195
2354
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2196
2355
|
className: settings_card_module_css_default.description,
|
|
2197
|
-
|
|
2356
|
+
title: description,
|
|
2357
|
+
children: description
|
|
2198
2358
|
})]
|
|
2199
2359
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
2200
2360
|
width: "14",
|
|
@@ -2232,14 +2392,17 @@ window.__ModuleLoader__.load({
|
|
|
2232
2392
|
className: settings_card_module_css_default.headText,
|
|
2233
2393
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2234
2394
|
className: settings_card_module_css_default.name,
|
|
2395
|
+
title,
|
|
2235
2396
|
children: title
|
|
2236
2397
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2237
2398
|
className: settings_card_module_css_default.description,
|
|
2238
|
-
|
|
2399
|
+
title: description,
|
|
2400
|
+
children: description
|
|
2239
2401
|
})]
|
|
2240
2402
|
}),
|
|
2241
2403
|
state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2242
2404
|
className: settings_card_module_css_default.pending,
|
|
2405
|
+
title: props.t("settings.unsaved"),
|
|
2243
2406
|
children: props.t("settings.unsaved")
|
|
2244
2407
|
}) : null,
|
|
2245
2408
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
@@ -2267,10 +2430,10 @@ window.__ModuleLoader__.load({
|
|
|
2267
2430
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2268
2431
|
className: settings_card_module_css_default.footer,
|
|
2269
2432
|
children: [
|
|
2270
|
-
state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.
|
|
2433
|
+
state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
2271
2434
|
className: settings_card_module_css_default.failed,
|
|
2272
2435
|
role: "status",
|
|
2273
|
-
children: props.t("settings.saveFailed")
|
|
2436
|
+
children: [props.t("settings.saveFailed"), state.failedReason ? " - " + state.failedReason : ""]
|
|
2274
2437
|
}) : null,
|
|
2275
2438
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2276
2439
|
type: "button",
|
|
@@ -2355,11 +2518,13 @@ window.__ModuleLoader__.load({
|
|
|
2355
2518
|
field,
|
|
2356
2519
|
format: (value) => typeof value === "boolean" ? String(value) : "",
|
|
2357
2520
|
parse: (text) => {
|
|
2358
|
-
|
|
2521
|
+
const trimmed = text.trim();
|
|
2522
|
+
if (trimmed === "") return { kind: "clear" };
|
|
2523
|
+
if (trimmed === "true") return {
|
|
2359
2524
|
kind: "set",
|
|
2360
2525
|
value: true
|
|
2361
2526
|
};
|
|
2362
|
-
if (
|
|
2527
|
+
if (trimmed === "false") return {
|
|
2363
2528
|
kind: "set",
|
|
2364
2529
|
value: false
|
|
2365
2530
|
};
|
|
@@ -2381,6 +2546,7 @@ window.__ModuleLoader__.load({
|
|
|
2381
2546
|
listeners = /* @__PURE__ */ new Set();
|
|
2382
2547
|
saving = false;
|
|
2383
2548
|
failed = false;
|
|
2549
|
+
failedReason;
|
|
2384
2550
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
2385
2551
|
constructor(scope, specs) {
|
|
2386
2552
|
this.scope = scope;
|
|
@@ -2408,7 +2574,8 @@ window.__ModuleLoader__.load({
|
|
|
2408
2574
|
dirty: plan.length > 0,
|
|
2409
2575
|
invalid: plan.some((item) => item.run === void 0),
|
|
2410
2576
|
saving: this.saving,
|
|
2411
|
-
failed: this.failed
|
|
2577
|
+
failed: this.failed,
|
|
2578
|
+
...this.failedReason === void 0 ? {} : { failedReason: this.failedReason }
|
|
2412
2579
|
};
|
|
2413
2580
|
}
|
|
2414
2581
|
/** Read one field's state from the effective section and its staged draft. */
|
|
@@ -2449,28 +2616,50 @@ window.__ModuleLoader__.load({
|
|
|
2449
2616
|
if (this.staged.size === 0 && !this.failed) return;
|
|
2450
2617
|
this.staged.clear();
|
|
2451
2618
|
this.failed = false;
|
|
2619
|
+
this.failedReason = void 0;
|
|
2452
2620
|
this.publish();
|
|
2453
2621
|
}
|
|
2454
2622
|
};
|
|
2455
2623
|
}
|
|
2456
2624
|
/**
|
|
2457
2625
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
2626
|
+
*
|
|
2627
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
2628
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
2629
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
2630
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
2631
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
2632
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
2458
2633
|
* @returns settlement after every write and the read-back.
|
|
2459
2634
|
*/
|
|
2460
2635
|
async save() {
|
|
2461
2636
|
const plan = this.plan();
|
|
2462
|
-
const
|
|
2463
|
-
if (plan.length === 0 || this.saving ||
|
|
2637
|
+
const valid = plan.filter((item) => item.run !== void 0);
|
|
2638
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length) return;
|
|
2639
|
+
const plannedWrites = valid.map((item) => item.op);
|
|
2640
|
+
const fields = new Set(plan.map((item) => item.field));
|
|
2464
2641
|
this.saving = true;
|
|
2465
2642
|
this.failed = false;
|
|
2643
|
+
this.failedReason = void 0;
|
|
2466
2644
|
this.publish();
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
if (
|
|
2645
|
+
const landed = /* @__PURE__ */ new Set();
|
|
2646
|
+
const batch = this.batchedScope();
|
|
2647
|
+
if (batch !== void 0) {
|
|
2648
|
+
const result = await batch.mutate(plannedWrites);
|
|
2649
|
+
if (result.ok) {
|
|
2650
|
+
for (const field of result.fields) if (field.landed) landed.add(field.field);
|
|
2651
|
+
} else this.failedReason = result.message;
|
|
2652
|
+
} else for (const item of valid) if (await item.run()) landed.add(item.field);
|
|
2653
|
+
for (const field of fields) if (landed.has(field)) this.staged.delete(field);
|
|
2470
2654
|
this.saving = false;
|
|
2471
|
-
this.failed =
|
|
2655
|
+
this.failed = landed.size !== fields.size;
|
|
2472
2656
|
this.publish();
|
|
2473
2657
|
}
|
|
2658
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
2659
|
+
batchedScope() {
|
|
2660
|
+
const candidate = this.scope;
|
|
2661
|
+
return typeof candidate?.mutate === "function" ? candidate : void 0;
|
|
2662
|
+
}
|
|
2474
2663
|
/**
|
|
2475
2664
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
2476
2665
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -2485,6 +2674,10 @@ window.__ModuleLoader__.load({
|
|
|
2485
2674
|
if (staged.clear) {
|
|
2486
2675
|
if (this.stored(field)) plan.push({
|
|
2487
2676
|
field,
|
|
2677
|
+
op: {
|
|
2678
|
+
field,
|
|
2679
|
+
op: "unset"
|
|
2680
|
+
},
|
|
2488
2681
|
run: () => this.clear(field)
|
|
2489
2682
|
});
|
|
2490
2683
|
continue;
|
|
@@ -2493,14 +2686,27 @@ window.__ModuleLoader__.load({
|
|
|
2493
2686
|
const write = spec.parse(staged.text);
|
|
2494
2687
|
if (write === void 0) plan.push({
|
|
2495
2688
|
field,
|
|
2689
|
+
op: {
|
|
2690
|
+
field,
|
|
2691
|
+
op: "unset"
|
|
2692
|
+
},
|
|
2496
2693
|
run: void 0
|
|
2497
2694
|
});
|
|
2498
2695
|
else if (write.kind === "clear") plan.push({
|
|
2499
2696
|
field,
|
|
2697
|
+
op: {
|
|
2698
|
+
field,
|
|
2699
|
+
op: "unset"
|
|
2700
|
+
},
|
|
2500
2701
|
run: () => this.clear(field)
|
|
2501
2702
|
});
|
|
2502
2703
|
else plan.push({
|
|
2503
2704
|
field,
|
|
2705
|
+
op: {
|
|
2706
|
+
field,
|
|
2707
|
+
op: "set",
|
|
2708
|
+
value: write.value
|
|
2709
|
+
},
|
|
2504
2710
|
run: () => this.store(field, write.value)
|
|
2505
2711
|
});
|
|
2506
2712
|
}
|
|
@@ -2512,11 +2718,13 @@ window.__ModuleLoader__.load({
|
|
|
2512
2718
|
}
|
|
2513
2719
|
async store(field, value) {
|
|
2514
2720
|
await this.scope.set(field, value);
|
|
2721
|
+
if (this.specOf(field).secret) return true;
|
|
2515
2722
|
return this.userLayer()?.[field] === value;
|
|
2516
2723
|
}
|
|
2517
2724
|
stage(field, edit) {
|
|
2518
2725
|
this.staged.set(field, edit);
|
|
2519
2726
|
this.failed = false;
|
|
2727
|
+
this.failedReason = void 0;
|
|
2520
2728
|
this.publish();
|
|
2521
2729
|
}
|
|
2522
2730
|
specOf(field) {
|