@iasiv5/dsh-skins 1.5.11 → 1.5.12

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.
Files changed (3) hide show
  1. package/lib/client.js +145 -26
  2. package/lib/index.js +95 -26
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -940,9 +940,11 @@ ${darkSelector}{${dark.join(";")}}`;
940
940
  "personalization.panelLabel": "个性化设置",
941
941
  "personalization.saveFailed": "保存失败,请重试",
942
942
  "personalization.saveConflict": "配置已在其他窗口更新,请重试",
943
+ "personalization.fieldReverted": "壁纸已失效,已回退默认",
944
+ "personalization.library.previewRef": "未保存引用",
943
945
  "personalization.library.clear": "清空图库",
944
946
  "personalization.library.clearConfirm": "将永久删除全部 {count} 张上传图片,且不可恢复。以下壁纸设置将回退为默认:{names}",
945
- "personalization.library.clearFailed": "部分图片删除失败,已刷新剩余列表",
947
+ "personalization.library.clearFailed": "清空图库失败,已刷新剩余列表",
946
948
  "personalization.library.uploading": "正在上传…",
947
949
  "personalization.library.uploadingBatch": "正在上传 {done}/{total}…",
948
950
  "personalization.library.uploadSomeFailed": "已上传 {ok} 张,{failed} 张失败({reason})",
@@ -1013,9 +1015,11 @@ ${darkSelector}{${dark.join(";")}}`;
1013
1015
  "personalization.panelLabel": "Personalization settings",
1014
1016
  "personalization.saveFailed": "Save failed; try again",
1015
1017
  "personalization.saveConflict": "Settings changed in another window; try again",
1018
+ "personalization.fieldReverted": "Wallpaper no longer exists; reset to default",
1019
+ "personalization.library.previewRef": "unsaved reference",
1016
1020
  "personalization.library.clear": "Clear library",
1017
1021
  "personalization.library.clearConfirm": "This permanently deletes all {count} uploaded images. These wallpaper settings fall back to defaults: {names}",
1018
- "personalization.library.clearFailed": "Some deletions failed; the remaining list was refreshed",
1022
+ "personalization.library.clearFailed": "Clear library failed; the remaining list was refreshed",
1019
1023
  "personalization.library.uploading": "Uploading…",
1020
1024
  "personalization.library.uploadingBatch": "Uploading {done}/{total}…",
1021
1025
  "personalization.library.uploadSomeFailed": "Uploaded {ok}; {failed} failed ({reason})",
@@ -1260,11 +1264,16 @@ ${darkSelector}{${dark.join(";")}}`;
1260
1264
  setDeletingId(null);
1261
1265
  }
1262
1266
  };
1267
+ const previewRefsFor = (assetId) => (configClient.dirtyFields?.() ?? []).filter((entry) => entry.value === assetId).map((entry) => `${labelFor(entry.skinId)} · ${entry.key} · ${tr("personalization.library.previewRef")}`);
1263
1268
  const removeAsset = (asset) => {
1264
1269
  if (deletingId !== null || clearing) return;
1265
1270
  const references = state.references[asset.id] ?? [];
1266
- if (references.length === 0) return deleteAsset(asset);
1267
- const names = references.map((entry) => `${labelFor(entry.skinId)} · ${entry.key}`).join("\n");
1271
+ const previewRefs = previewRefsFor(asset.id);
1272
+ if (references.length === 0 && previewRefs.length === 0) return deleteAsset(asset);
1273
+ const names = [
1274
+ ...references.map((entry) => `${labelFor(entry.skinId)} · ${entry.key}`),
1275
+ ...previewRefs
1276
+ ].join("\n");
1268
1277
  requestConfirm(
1269
1278
  `${tr("personalization.library.deleteConfirm")}
1270
1279
  ${names}`,
@@ -1273,26 +1282,26 @@ ${names}`,
1273
1282
  };
1274
1283
  const clearLibrary = () => {
1275
1284
  if (clearing || deletingId !== null || library.length === 0) return;
1276
- const pairs = /* @__PURE__ */ new Map();
1285
+ const names = /* @__PURE__ */ new Map();
1277
1286
  for (const asset of library) {
1278
1287
  for (const entry of state.references[asset.id] ?? []) {
1279
- pairs.set(`${labelFor(entry.skinId)} · ${entry.key}`, true);
1288
+ names.set(`${labelFor(entry.skinId)} · ${entry.key}`, true);
1289
+ }
1290
+ for (const label of previewRefsFor(asset.id)) {
1291
+ names.set(label, true);
1280
1292
  }
1281
1293
  }
1282
- const names = [...pairs.keys()].join("、");
1294
+ const listed = [...names.keys()].join("、");
1283
1295
  requestConfirm(tr("personalization.library.clearConfirm", {
1284
1296
  count: library.length,
1285
- names: names === "" ? "—" : names
1297
+ names: listed === "" ? "—" : listed
1286
1298
  }), async () => {
1287
1299
  setClearing(true);
1288
1300
  try {
1289
- for (const asset of [...library]) {
1290
- const result = await configClient.deleteImage(asset.id);
1291
- if (result.error) {
1292
- setMessage(tr("personalization.library.clearFailed"));
1293
- void configClient.refetch();
1294
- return;
1295
- }
1301
+ const result = await configClient.clearLibrary();
1302
+ if (result.error) {
1303
+ setMessage(tr("personalization.library.clearFailed"));
1304
+ void configClient.refetch();
1296
1305
  }
1297
1306
  } catch {
1298
1307
  setMessage(tr("personalization.library.clearFailed"));
@@ -1405,7 +1414,10 @@ ${names}`,
1405
1414
  );
1406
1415
  }
1407
1416
  }
1408
- if (lastAssetId !== null) onValue(lastAssetId);
1417
+ if (lastAssetId !== null) {
1418
+ onValue(lastAssetId);
1419
+ await configClient.flushNow();
1420
+ }
1409
1421
  setMessage(firstError === null ? null : ok === 0 ? firstError : tr("personalization.library.uploadSomeFailed", {
1410
1422
  ok,
1411
1423
  failed: files.length - ok,
@@ -1417,6 +1429,7 @@ ${names}`,
1417
1429
  if (result.asset) {
1418
1430
  setMessage(null);
1419
1431
  onValue(result.asset.id);
1432
+ await configClient.flushNow();
1420
1433
  } else {
1421
1434
  setMessage(resolveHostErrorText(
1422
1435
  { code: result.error, message: tr("personalization.library.uploadFailed") },
@@ -1554,6 +1567,15 @@ ${names}`,
1554
1567
  jsx("div", { className: "dsh-skins-pz-muted", children: state.lastFlushError })
1555
1568
  ] }));
1556
1569
  }
1570
+ if (Array.isArray(state.revertedFields) && state.revertedFields.length > 0) {
1571
+ statusCluster.push(jsx("div", { key: "reverted", className: "dsh-skins-pz-status", children: [
1572
+ jsx("div", { children: tr("personalization.fieldReverted") }),
1573
+ jsx("div", {
1574
+ className: "dsh-skins-pz-muted",
1575
+ children: state.revertedFields.map((entry) => `${labelFor(entry.skinId)} · ${entry.key}`).join("、")
1576
+ })
1577
+ ] }));
1578
+ }
1557
1579
  return jsx("div", { className: "dsh-skins-pz", children: [
1558
1580
  // Header row (`.dsh-skins-pz-head`): the heading stays the focus
1559
1581
  // target (issue #12); the reset and collapse controls pack at its
@@ -2193,6 +2215,7 @@ ${names}`,
2193
2215
  let writeChain = Promise.resolve();
2194
2216
  let lastFlushCode = null;
2195
2217
  let lastFlushError = null;
2218
+ let revertedFields = [];
2196
2219
  let channel = null;
2197
2220
  function setFlushFailure(code, message) {
2198
2221
  lastFlushCode = code;
@@ -2218,6 +2241,7 @@ ${names}`,
2218
2241
  quota: snapshot.quota,
2219
2242
  lastFlushCode,
2220
2243
  lastFlushError,
2244
+ revertedFields: revertedFields.map((entry) => ({ ...entry })),
2221
2245
  dirtyCount: previews.size
2222
2246
  };
2223
2247
  }
@@ -2374,8 +2398,31 @@ ${names}`,
2374
2398
  if (!response.ok) {
2375
2399
  const errorBody = await response.json().catch(() => null);
2376
2400
  const errorMessage = errorBody?.error ?? `HTTP ${response.status}`;
2377
- setFlushFailure(null, errorMessage);
2401
+ const issues = Array.isArray(errorBody?.params?.issues) ? errorBody.params.issues : null;
2402
+ let excised = false;
2403
+ let onlyDangling = issues !== null && issues.length > 0;
2404
+ if (issues !== null) {
2405
+ for (const issue of issues) {
2406
+ const dangling = issue.code === "MISSING_ASSET" || issue.code === "BAD_ASSET";
2407
+ if (!dangling) onlyDangling = false;
2408
+ if (dangling && typeof issue.skinId === "string" && typeof issue.key === "string") {
2409
+ const composite = `${issue.skinId} ${issue.key}`;
2410
+ if (previews.has(composite)) {
2411
+ previews.delete(composite);
2412
+ excised = true;
2413
+ const alreadyNoted = revertedFields.some(
2414
+ (entry) => entry.skinId === issue.skinId && entry.key === issue.key
2415
+ );
2416
+ if (!alreadyNoted) revertedFields.push({ skinId: issue.skinId, key: issue.key });
2417
+ }
2418
+ }
2419
+ }
2420
+ }
2421
+ const healed = onlyDangling && excised;
2422
+ setFlushFailure(null, healed ? null : errorMessage);
2378
2423
  emit();
2424
+ if (excised && previews.size > 0) void flushNow();
2425
+ if (healed) return { flushed: 0, blocked: null, selfHealed: true };
2379
2426
  return { flushed: 0, blocked: "error", errorMessage };
2380
2427
  }
2381
2428
  await response.json().catch(() => null);
@@ -2383,6 +2430,7 @@ ${names}`,
2383
2430
  if (previews.get(composite) === value) previews.delete(composite);
2384
2431
  }
2385
2432
  setFlushFailure(null, null);
2433
+ revertedFields = [];
2386
2434
  await refetch();
2387
2435
  announce();
2388
2436
  return { flushed: count };
@@ -2508,22 +2556,91 @@ ${names}`,
2508
2556
  async function concludeUpload(outcome) {
2509
2557
  if (outcome.error !== void 0) return { error: outcome.error };
2510
2558
  const body = await outcome.response.json();
2511
- await refetch();
2559
+ if (body?.asset?.id !== void 0) {
2560
+ snapshot = {
2561
+ ...snapshot,
2562
+ revision: typeof body.revision === "number" ? body.revision : snapshot.revision,
2563
+ library: [...snapshot.library, body.asset],
2564
+ quota: {
2565
+ count: (snapshot.quota?.count ?? 0) + 1,
2566
+ totalBytes: (snapshot.quota?.totalBytes ?? 0) + (body.asset.byteLength ?? 0)
2567
+ }
2568
+ };
2569
+ emit();
2570
+ }
2512
2571
  announce();
2513
2572
  return { asset: body.asset };
2514
2573
  }
2574
+ function runGalleryOp(operations, goalAchieved) {
2575
+ writeChain = writeChain.then(async () => {
2576
+ if (disposed) return { error: "disposed" };
2577
+ const blocked = gateWrites();
2578
+ if (blocked !== null) return { error: blocked };
2579
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2580
+ let response;
2581
+ try {
2582
+ response = await request("/config", {
2583
+ method: "PATCH",
2584
+ headers: { "content-type": "application/json" },
2585
+ body: JSON.stringify({ baseRevision: snapshot.revision, operations })
2586
+ });
2587
+ } catch {
2588
+ return { error: "OFFLINE" };
2589
+ }
2590
+ if (disposed) return { error: "disposed" };
2591
+ if (response.status === 409) {
2592
+ const conflictBody = await response.json().catch(() => null);
2593
+ if (conflictBody?.code === "STORE_READONLY") {
2594
+ setFlushFailure(null, null);
2595
+ setStatus("unsupported-readonly");
2596
+ return { error: "conflict" };
2597
+ }
2598
+ await refetch();
2599
+ if (goalAchieved()) return { affectedSkins: [] };
2600
+ if (attempt === 1) {
2601
+ setFlushFailure(null, null);
2602
+ emit();
2603
+ return { error: "REVISION_CONFLICT" };
2604
+ }
2605
+ continue;
2606
+ }
2607
+ if (!response.ok) {
2608
+ const body2 = await response.json().catch(() => null);
2609
+ if (body2?.code === "INVALID_CONFIG" && body2.params?.issues?.some((issue) => issue.code === "ASSET_NOT_FOUND")) {
2610
+ await refetch();
2611
+ if (goalAchieved()) return { affectedSkins: [] };
2612
+ return { error: "ASSET_NOT_FOUND" };
2613
+ }
2614
+ return { error: body2?.code ?? `HTTP ${response.status}` };
2615
+ }
2616
+ const body = await response.json().catch(() => null);
2617
+ await refetch();
2618
+ announce();
2619
+ return { affectedSkins: body?.affectedSkins ?? [], revision: body?.revision };
2620
+ }
2621
+ return { error: "REVISION_CONFLICT" };
2622
+ });
2623
+ return writeChain;
2624
+ }
2515
2625
  async function deleteImage(id) {
2516
2626
  const blocked = gateWrites();
2517
2627
  if (blocked !== null) return { error: blocked };
2518
- const response = await request(`/library/${id}`, { method: "DELETE" });
2519
- if (!response.ok) {
2520
- const body2 = await response.json().catch(() => null);
2521
- return { error: body2?.code ?? `HTTP ${response.status}` };
2628
+ return runGalleryOp(
2629
+ [{ op: "deleteAsset", assetId: id }],
2630
+ () => !snapshot.library.some((entry) => entry.id === id)
2631
+ );
2632
+ }
2633
+ function clearLibrary() {
2634
+ return runGalleryOp([{ op: "clearLibrary" }], () => snapshot.library.length === 0);
2635
+ }
2636
+ function dirtyFields() {
2637
+ const fields = [];
2638
+ for (const [composite, value] of previews) {
2639
+ if (value === null) continue;
2640
+ const [skinId, key] = composite.split(" ");
2641
+ fields.push({ skinId, key, value });
2522
2642
  }
2523
- const body = await response.json();
2524
- await refetch();
2525
- announce();
2526
- return { affectedSkins: body.affectedSkins ?? [], revision: body.revision };
2643
+ return fields;
2527
2644
  }
2528
2645
  async function confirmRecovery() {
2529
2646
  const response = await request("/recovery", { method: "POST" });
@@ -2548,6 +2665,8 @@ ${names}`,
2548
2665
  effectiveOverrides,
2549
2666
  uploadImage,
2550
2667
  deleteImage,
2668
+ clearLibrary,
2669
+ dirtyFields,
2551
2670
  confirmRecovery,
2552
2671
  assetUrl,
2553
2672
  getState: publicState,
package/lib/index.js CHANGED
@@ -693,6 +693,20 @@ function createPersonalizationStore(options = {}) {
693
693
  if (mode === "recovery") base.recovery = recovery;
694
694
  return base;
695
695
  }
696
+ function applyGalleryDelete(draft, id, affectedSkins) {
697
+ const meta = draft.library[id];
698
+ for (const [skinId, section] of Object.entries(draft.skins)) {
699
+ for (const field of listAssetFields(skinId)) {
700
+ if (section?.[field.key] === id) {
701
+ delete section[field.key];
702
+ affectedSkins.push({ skinId, key: field.key });
703
+ }
704
+ }
705
+ if (Object.keys(section).length === 0) delete draft.skins[skinId];
706
+ }
707
+ delete draft.library[id];
708
+ return meta;
709
+ }
696
710
  async function applyOperations({ baseRevision, operations }) {
697
711
  init();
698
712
  requireNormal("配置写入");
@@ -703,36 +717,100 @@ function createPersonalizationStore(options = {}) {
703
717
  if (!Array.isArray(operations) || operations.length === 0 || operations.length > 64) {
704
718
  throw codedError("INVALID_CONFIG", "operations 必须是 1–64 个元素的数组");
705
719
  }
706
- for (const operation of operations) {
720
+ const issues = [];
721
+ for (let index = 0; index < operations.length; index += 1) {
722
+ const operation = operations[index];
707
723
  if (operation === null || typeof operation !== "object") {
708
- throw codedError("INVALID_CONFIG", "operation 必须是对象");
724
+ issues.push({ opIndex: index, code: "INVALID_OP" });
725
+ continue;
709
726
  }
710
727
  const { op, skinId, key } = operation;
711
- if (op !== "set" && op !== "delete") throw codedError("INVALID_CONFIG", "op 必须是 set 或 delete");
712
- if (getField(skinId, key) === null) {
713
- throw codedError("INVALID_CONFIG", `未知字段 ${skinId}.${key}`);
728
+ if (op === "set" || op === "delete") {
729
+ if (getField(skinId, key) === null) {
730
+ issues.push({ opIndex: index, skinId, key, code: "UNKNOWN_FIELD" });
731
+ }
732
+ } else if (op === "deleteAsset") {
733
+ const assetId = operation.assetId;
734
+ if (typeof assetId !== "string" || !ASSET_ID_PATTERN.test(assetId)) {
735
+ issues.push({ opIndex: index, assetId, code: "INVALID_ASSET_ID" });
736
+ } else if (state.library[assetId] === void 0) {
737
+ issues.push({ opIndex: index, assetId, code: "ASSET_NOT_FOUND" });
738
+ }
739
+ } else if (op !== "clearLibrary") {
740
+ issues.push({ opIndex: index, code: "INVALID_OP" });
714
741
  }
715
742
  }
716
743
  const draft = structuredClone(state);
717
744
  const provider = metaProviderFactory(draft.library);
745
+ for (let index = 0; index < operations.length; index += 1) {
746
+ const operation = operations[index];
747
+ if (operation?.op !== "set") continue;
748
+ const verdict = validateOverride(operation.skinId, operation.key, operation.value, provider);
749
+ if (!verdict.ok) {
750
+ issues.push({ opIndex: index, skinId: operation.skinId, key: operation.key, code: verdict.code });
751
+ }
752
+ }
753
+ if (issues.length > 0) {
754
+ issues.sort((a, b) => a.opIndex - b.opIndex);
755
+ throw codedError("INVALID_CONFIG", `operations 校验失败(${issues[0].code})`, {
756
+ code: issues[0].code,
757
+ // retained for old readers (ADR-0015 Q&A R1-8)
758
+ issues
759
+ });
760
+ }
761
+ const affectedSkins = [];
762
+ const removedAssets = [];
763
+ let galleryOps = false;
764
+ const hasFieldOps = operations.some((operation) => operation?.op === "set" || operation?.op === "delete");
718
765
  for (const operation of operations) {
719
766
  const { op, skinId, key } = operation;
720
- if (op === "set") {
721
- const verdict = validateOverride(skinId, key, operation.value, provider);
722
- if (!verdict.ok) {
723
- throw codedError("INVALID_CONFIG", `${skinId}.${key} 校验失败(${verdict.code})`, { code: verdict.code });
767
+ if (op === "set" || op === "delete") {
768
+ draft.skins[skinId] = draft.skins[skinId] ?? {};
769
+ if (op === "set") draft.skins[skinId][key] = operation.value;
770
+ else delete draft.skins[skinId][key];
771
+ } else if (op === "deleteAsset") {
772
+ galleryOps = true;
773
+ const meta = applyGalleryDelete(draft, operation.assetId, affectedSkins);
774
+ if (meta !== void 0) removedAssets.push(meta);
775
+ } else if (op === "clearLibrary") {
776
+ galleryOps = true;
777
+ for (const id of Object.keys(draft.library)) {
778
+ const meta = applyGalleryDelete(draft, id, affectedSkins);
779
+ if (meta !== void 0) removedAssets.push(meta);
724
780
  }
725
781
  }
726
782
  }
727
- for (const { op, skinId, key, value } of operations) {
728
- draft.skins[skinId] = draft.skins[skinId] ?? {};
729
- if (op === "set") draft.skins[skinId][key] = value;
730
- else delete draft.skins[skinId][key];
783
+ if (galleryOps && !hasFieldOps && removedAssets.length === 0) {
784
+ return { revision: state.revision, affectedSkins };
785
+ }
786
+ if (galleryOps) {
787
+ const finalProvider = metaProviderFactory(draft.library);
788
+ for (const [skinId, section] of Object.entries(draft.skins)) {
789
+ for (const field of listAssetFields(skinId)) {
790
+ const value = section?.[field.key];
791
+ if (value === void 0) continue;
792
+ const verdict = validateOverride(skinId, field.key, value, finalProvider);
793
+ if (!verdict.ok) {
794
+ throw codedError("INVALID_CONFIG", `终态校验失败:${skinId}.${field.key} 引用已删除资产`, {
795
+ code: verdict.code,
796
+ issues: [{ skinId, key: field.key, code: verdict.code }]
797
+ });
798
+ }
799
+ }
800
+ }
731
801
  }
732
802
  draft.revision += 1;
733
803
  commitState(draft);
804
+ for (const meta of removedAssets) {
805
+ try {
806
+ fs.unlinkSync(blobFileFor(meta.id, meta.extension));
807
+ } catch {
808
+ }
809
+ }
734
810
  gcNow();
735
- return { revision: draft.revision };
811
+ const result = { revision: draft.revision };
812
+ if (galleryOps) result.affectedSkins = affectedSkins;
813
+ return result;
736
814
  });
737
815
  }
738
816
  function ingestAssetMeta(buffer, { declaredMime } = {}) {
@@ -829,23 +907,14 @@ function createPersonalizationStore(options = {}) {
829
907
  throw codedError("INVALID_ASSET_ID", "非法的图片 id");
830
908
  }
831
909
  return enqueue(() => {
832
- const meta = state.library[id];
833
- if (meta === void 0) throw codedError("ASSET_NOT_FOUND", "图片不存在");
910
+ if (state.library[id] === void 0) throw codedError("ASSET_NOT_FOUND", "图片不存在");
834
911
  const draft = structuredClone(state);
835
912
  const affectedSkins = [];
836
- for (const [skinId, section] of Object.entries(draft.skins)) {
837
- for (const field of listAssetFields(skinId)) {
838
- if (section?.[field.key] === id) {
839
- delete section[field.key];
840
- affectedSkins.push({ skinId, key: field.key });
841
- }
842
- }
843
- }
844
- delete draft.library[id];
913
+ const meta = applyGalleryDelete(draft, id, affectedSkins);
845
914
  draft.revision += 1;
846
915
  commitState(draft);
847
916
  try {
848
- fs.unlinkSync(blobFileFor(id, meta.extension));
917
+ fs.unlinkSync(blobFileFor(meta.id, meta.extension));
849
918
  } catch {
850
919
  }
851
920
  gcNow();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-skins",
3
3
  "description": "DSH Web 皮肤插件:多套品牌皮肤侧栏热切换。",
4
- "version": "1.5.11",
4
+ "version": "1.5.12",
5
5
  "homepage": "https://github.com/iasiv5/dsh-skins#readme",
6
6
  "repository": {
7
7
  "type": "git",