dsh-plugin-litsearch 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -118,6 +118,10 @@ function fetchHealth(signal) {
118
118
  function fetchEvents(sinceRev, signal) {
119
119
  return request(`/events?sinceRev=${encodeURIComponent(String(sinceRev))}`, { signal });
120
120
  }
121
+ /** 面板把「当前页 / 当前筛选」告诉 Host,模型工具才能准确解析下载范围。 */
122
+ function postViewSnapshot(body) {
123
+ return request("/view", writeInit("POST", body));
124
+ }
121
125
  function listTasks(signal) {
122
126
  return request("/tasks", { signal });
123
127
  }
@@ -223,6 +227,10 @@ function probeStorage() {
223
227
  function probeZotero() {
224
228
  return request("/settings/zotero", writeInit("POST", {}));
225
229
  }
230
+ /** 读取可写入的 Zotero 库与分类。 */
231
+ function fetchZoteroTargets() {
232
+ return request("/settings/zotero/targets", writeInit("POST", {}));
233
+ }
226
234
  /** 下载单篇 PDF 到用户目录。 */
227
235
  function downloadPaperPdf(taskId, paperId, signal) {
228
236
  return request(taskPath(taskId, `/papers/${encodeURIComponent(paperId)}/download`), {
@@ -230,8 +238,15 @@ function downloadPaperPdf(taskId, paperId, signal) {
230
238
  signal
231
239
  });
232
240
  }
233
- function syncZotero(taskId, paperIds) {
234
- return request(taskPath(taskId, "/zotero"), writeInit("POST", { paperIds }));
241
+ function syncZotero(taskId, paperIds, target) {
242
+ return request(taskPath(taskId, "/zotero"), writeInit("POST", {
243
+ paperIds,
244
+ ...target === void 0 ? {} : {
245
+ target: target.id,
246
+ ...target.name === void 0 ? {} : { targetName: target.name },
247
+ ...target.filesEditable === void 0 ? {} : { targetFilesEditable: target.filesEditable }
248
+ }
249
+ }));
235
250
  }
236
251
  /** 点击「查看已下载」前确认本地文件仍在(页面加载后可能被删除)。 */
237
252
  function recheckPaperPdf(taskId, paperId) {
@@ -258,6 +273,97 @@ function exportUrl(id, format, query = {}, ids) {
258
273
  const suffix = params.toString();
259
274
  return `${BASE}/export${suffix === "" ? "" : `?${suffix}`}`;
260
275
  }
276
+ // src/client/downloadCommandStore.ts
277
+ /**
278
+ * 模型下载指令的客户端暂存与分发。
279
+ *
280
+ * 指令由全局 `/events` 轮询写入,但真正执行下载的是某个任务 tab 里的
281
+ * `TaskPane`;tab 可能还没挂载(focus 正在拉起它),所以这里做模块级
282
+ * 待消费队列,并按 commandId 幂等去重 —— 刷新页面也不会重放旧指令。
283
+ */
284
+ const SEEN_KEY$1 = "dsh-litsearch.downloads.seen";
285
+ const SEEN_MAX$1 = 200;
286
+ /** 指令是否仍在有效期内(允许客户端时钟略慢 1 分钟)。 */
287
+ function isFreshDownload(command, now = Date.now()) {
288
+ return Number.isFinite(command.at) && command.at <= now + 6e4 && now - command.at <= 6e5;
289
+ }
290
+ function loadSeen$1() {
291
+ try {
292
+ const raw = globalThis.localStorage?.getItem(SEEN_KEY$1);
293
+ if (raw === null || raw === void 0 || raw === "") return [];
294
+ const parsed = JSON.parse(raw);
295
+ if (!Array.isArray(parsed)) return [];
296
+ return parsed.filter((item) => typeof item === "string").slice(-200);
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+ function rememberSeen$1(commandId) {
302
+ seen$1.add(commandId);
303
+ while (seen$1.size > SEEN_MAX$1) {
304
+ const oldest = seen$1.values().next().value;
305
+ if (oldest === void 0) break;
306
+ seen$1.delete(oldest);
307
+ }
308
+ try {
309
+ globalThis.localStorage?.setItem(SEEN_KEY$1, JSON.stringify([...seen$1]));
310
+ } catch {}
311
+ }
312
+ const seen$1 = new Set(loadSeen$1());
313
+ let pending$2 = [];
314
+ let version$2 = 0;
315
+ const listeners$3 = /* @__PURE__ */ new Set();
316
+ function subscribe$2(listener) {
317
+ listeners$3.add(listener);
318
+ return () => {
319
+ listeners$3.delete(listener);
320
+ };
321
+ }
322
+ function getVersion$1() {
323
+ return version$2;
324
+ }
325
+ function getServerVersion$1() {
326
+ return 0;
327
+ }
328
+ function bump$1() {
329
+ version$2 += 1;
330
+ for (const listener of listeners$3) listener();
331
+ }
332
+ /**
333
+ * 把 `/events` 增量里的下载指令写入待消费队列。
334
+ *
335
+ * 不受自动打开开关与首次轮询影响:下载指令总是入队,tab 由工具同时发出的
336
+ * focus 请求拉起,未挂载时指令在此等待。
337
+ */
338
+ function publishDownloadCommands(commands, screenSessionId) {
339
+ if (commands === void 0 || commands.length === 0) return;
340
+ let added = 0;
341
+ for (const command of commands) {
342
+ if (!isFreshDownload(command)) continue;
343
+ if (seen$1.has(command.commandId)) continue;
344
+ if (command.sessionId !== void 0 && screenSessionId !== void 0 && command.sessionId !== screenSessionId) continue;
345
+ if (pending$2.some((item) => item.commandId === command.commandId)) continue;
346
+ rememberSeen$1(command.commandId);
347
+ pending$2 = [...pending$2, command];
348
+ added += 1;
349
+ }
350
+ if (added > 0) bump$1();
351
+ }
352
+ /** 订阅某个任务的待消费指令;返回数组按到达顺序排列。 */
353
+ function usePendingDownloadCommands(taskId) {
354
+ const current = (0, react.useSyncExternalStore)(subscribe$2, getVersion$1, getServerVersion$1);
355
+ return (0, react.useMemo)(() => {
356
+ if (taskId === null) return [];
357
+ return pending$2.filter((command) => command.taskId === taskId);
358
+ }, [current, taskId]);
359
+ }
360
+ /** 指令已被某个 TaskPane 接走,避免别的 tab 或后续 effect 重复执行。 */
361
+ function markConsumed(commandId) {
362
+ const next = pending$2.filter((command) => command.commandId !== commandId);
363
+ if (next.length === pending$2.length) return;
364
+ pending$2 = next;
365
+ bump$1();
366
+ }
261
367
  // src/client/health.ts
262
368
  /**
263
369
  * `/health` 的共享快照。
@@ -266,17 +372,17 @@ function exportUrl(id, format, query = {}, ids) {
266
372
  * Host 侧设置改了不会主动推送,这里用一份模块级快照 + 定时刷新兜底。
267
373
  */
268
374
  let snapshot = null;
269
- const listeners$1 = /* @__PURE__ */ new Set();
375
+ const listeners$2 = /* @__PURE__ */ new Set();
270
376
  function emit() {
271
- for (const listener of listeners$1) listener();
377
+ for (const listener of listeners$2) listener();
272
378
  }
273
379
  function getHealth() {
274
380
  return snapshot;
275
381
  }
276
382
  function subscribeHealth(listener) {
277
- listeners$1.add(listener);
383
+ listeners$2.add(listener);
278
384
  return () => {
279
- listeners$1.delete(listener);
385
+ listeners$2.delete(listener);
280
386
  };
281
387
  }
282
388
  /** 拉取一次健康信息;失败时保留旧快照并返回 null。 */
@@ -300,28 +406,28 @@ function useHealth() {
300
406
  * 同一个 client bundle 里主页历史、任务列表弹窗可能挂在不同 tab 上;
301
407
  * 任一处新建 / 重命名 / 删除任务后 bump 一个版本号,其它处据此重拉列表。
302
408
  */
303
- let version = 0;
304
- const listeners = /* @__PURE__ */ new Set();
409
+ let version$1 = 0;
410
+ const listeners$1 = /* @__PURE__ */ new Set();
305
411
  const summaries = /* @__PURE__ */ new Map();
306
412
  const summaryListeners = /* @__PURE__ */ new Set();
307
413
  function notifyTaskListChanged() {
308
- version += 1;
309
- for (const listener of listeners) listener();
414
+ version$1 += 1;
415
+ for (const listener of listeners$1) listener();
310
416
  }
311
- function subscribe(listener) {
312
- listeners.add(listener);
417
+ function subscribe$1(listener) {
418
+ listeners$1.add(listener);
313
419
  return () => {
314
- listeners.delete(listener);
420
+ listeners$1.delete(listener);
315
421
  };
316
422
  }
317
423
  function getSnapshot() {
318
- return version;
424
+ return version$1;
319
425
  }
320
426
  function getServerSnapshot() {
321
427
  return 0;
322
428
  }
323
429
  function useTaskListVersion() {
324
- return (0, react.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot);
430
+ return (0, react.useSyncExternalStore)(subscribe$1, getSnapshot, getServerSnapshot);
325
431
  }
326
432
  /**
327
433
  * 把 `/events` 拿到的任务摘要广播给已挂载的任务 tab。
@@ -342,6 +448,93 @@ function subscribeTaskSummary(listener) {
342
448
  summaryListeners.delete(listener);
343
449
  };
344
450
  }
451
+ // src/client/zoteroCommandStore.ts
452
+ /**
453
+ * 模型「批量存入 Zotero」指令的客户端暂存与分发。
454
+ *
455
+ * 与 downloadCommandStore 同构但完全独立:两条链路各自幂等、各自排队,
456
+ * 下载指令的改动不会波及 Zotero,反之亦然。指令由全局 `/events` 轮询写入,
457
+ * 真正执行的是任务 tab 里的 `TaskPane`;tab 可能还没挂载(focus 正在拉起它),
458
+ * 所以这里做模块级待消费队列,并按 commandId 去重 —— 刷新页面也不会重放。
459
+ */
460
+ const SEEN_KEY = "dsh-litsearch.zotero.seen";
461
+ const SEEN_MAX = 200;
462
+ /** 指令是否仍在有效期内(允许客户端时钟略慢 1 分钟)。 */
463
+ function isFreshZotero(command, now = Date.now()) {
464
+ return Number.isFinite(command.at) && command.at <= now + 6e4 && now - command.at <= 6e5;
465
+ }
466
+ function loadSeen() {
467
+ try {
468
+ const raw = globalThis.localStorage?.getItem(SEEN_KEY);
469
+ if (raw === null || raw === void 0 || raw === "") return [];
470
+ const parsed = JSON.parse(raw);
471
+ if (!Array.isArray(parsed)) return [];
472
+ return parsed.filter((item) => typeof item === "string").slice(-200);
473
+ } catch {
474
+ return [];
475
+ }
476
+ }
477
+ function rememberSeen(commandId) {
478
+ seen.add(commandId);
479
+ while (seen.size > SEEN_MAX) {
480
+ const oldest = seen.values().next().value;
481
+ if (oldest === void 0) break;
482
+ seen.delete(oldest);
483
+ }
484
+ try {
485
+ globalThis.localStorage?.setItem(SEEN_KEY, JSON.stringify([...seen]));
486
+ } catch {}
487
+ }
488
+ const seen = new Set(loadSeen());
489
+ let pending$1 = [];
490
+ let version = 0;
491
+ const listeners = /* @__PURE__ */ new Set();
492
+ function subscribe(listener) {
493
+ listeners.add(listener);
494
+ return () => {
495
+ listeners.delete(listener);
496
+ };
497
+ }
498
+ function getVersion() {
499
+ return version;
500
+ }
501
+ function getServerVersion() {
502
+ return 0;
503
+ }
504
+ function bump() {
505
+ version += 1;
506
+ for (const listener of listeners) listener();
507
+ }
508
+ /** 把 `/events` 增量里的 Zotero 指令写入待消费队列。 */
509
+ function publishZoteroCommands(commands, screenSessionId) {
510
+ if (commands === void 0 || commands.length === 0) return;
511
+ let added = 0;
512
+ for (const command of commands) {
513
+ if (!isFreshZotero(command)) continue;
514
+ if (seen.has(command.commandId)) continue;
515
+ if (command.sessionId !== void 0 && screenSessionId !== void 0 && command.sessionId !== screenSessionId) continue;
516
+ if (pending$1.some((item) => item.commandId === command.commandId)) continue;
517
+ rememberSeen(command.commandId);
518
+ pending$1 = [...pending$1, command];
519
+ added += 1;
520
+ }
521
+ if (added > 0) bump();
522
+ }
523
+ /** 订阅某个任务的待消费指令;返回数组按到达顺序排列。 */
524
+ function usePendingZoteroCommands(taskId) {
525
+ const current = (0, react.useSyncExternalStore)(subscribe, getVersion, getServerVersion);
526
+ return (0, react.useMemo)(() => {
527
+ if (taskId === null) return [];
528
+ return pending$1.filter((command) => command.taskId === taskId);
529
+ }, [current, taskId]);
530
+ }
531
+ /** 指令已被某个 TaskPane 接走,避免别的 tab 或后续 effect 重复执行。 */
532
+ function markZoteroConsumed(commandId) {
533
+ const next = pending$1.filter((command) => command.commandId !== commandId);
534
+ if (next.length === pending$1.length) return;
535
+ pending$1 = next;
536
+ bump();
537
+ }
345
538
  // src/client/autoOpen.ts
346
539
  /**
347
540
  * 模型任务的自动打开 + 任务标题缓存 + 「待打开」队列。
@@ -559,6 +752,8 @@ function startAutoOpen(options) {
559
752
  seeded = true;
560
753
  rev = page.rev;
561
754
  publishTaskSummaries(page.tasks);
755
+ publishDownloadCommands(page.downloads, options.sessionId?.());
756
+ publishZoteroCommands(page.zotero, options.sessionId?.());
562
757
  const settings = getHealth()?.settings;
563
758
  const autoOpen = settings?.autoOpenTaskTab ?? true;
564
759
  if (autoOpen && page.focus !== void 0 && isFreshFocus(page.focus)) {
@@ -966,6 +1161,8 @@ const dicts = {
966
1161
  "pdf.downloadDirUnknown": "工作目录下的 dsh-litsearch 文件夹",
967
1162
  "pdf.fulltext": "HTML 全文",
968
1163
  "pdf.zotero": "存入 Zotero",
1164
+ "pdf.zoteroSynced": "Zotero 已同步",
1165
+ "pdf.zoteroRetry": "Zotero 同步失败",
969
1166
  "pdf.error": "全文获取失败:{message}",
970
1167
  "pdf.downloaded": "已保存:{name}",
971
1168
  "pdf.htmlFallback": "未找到可下载的 PDF,已切换为 HTML 全文;可点击 PDF 链接在浏览器打开。",
@@ -1005,6 +1202,14 @@ const dicts = {
1005
1202
  "batch.complete": "下载任务完成",
1006
1203
  "batch.untitled": "(未获取标题)",
1007
1204
  "batch.close": "关闭",
1205
+ "batch.modelTitle": "模型发起的批量下载",
1206
+ "batch.modelDesc": "模型已选出 {n} 篇文献,正在逐篇下载;可关闭弹窗或点「取消下载」中止。",
1207
+ "batch.originModel": "模型发起",
1208
+ "batch.source": "来源 {source}",
1209
+ "batch.allSkipped": "清单中的文献均已下载,已跳过",
1210
+ "batch.cancel": "取消下载",
1211
+ "batch.cancelled": "已取消,剩余文献未下载",
1212
+ "batch.empty": "模型指令里没有可下载的文献",
1008
1213
  "batch.status.pending": "未下载",
1009
1214
  "batch.status.downloading": "下载中…",
1010
1215
  "batch.status.downloaded": "已下载",
@@ -1303,6 +1508,45 @@ const dicts = {
1303
1508
  "zotero.probeOk": "连接正常(Zotero {version});当前分类:{name}",
1304
1509
  "zotero.probeOkNoCollection": "连接正常,但 Zotero 里还没有选中分类",
1305
1510
  "zotero.probeFail": "连接失败:{message}",
1511
+ "zotero.dialog.title": "存入 Zotero",
1512
+ "zotero.dialog.desc": "共 {n} 篇。选择要存入的 Zotero 目录,再点「同步」。",
1513
+ "zotero.dialog.modelTitle": "模型发起的批量存入 Zotero",
1514
+ "zotero.dialog.modelDescAuto": "模型已选出 {n} 篇并指定目录,正在自动同步;可随时取消。",
1515
+ "zotero.dialog.modelDescPick": "模型已选出 {n} 篇,请确认目标目录后点「同步」。",
1516
+ "zotero.dialog.originModel": "模型发起",
1517
+ "zotero.dialog.modelTarget": "模型指定目录:{name}",
1518
+ "zotero.dialog.modelTargetMissing": "模型指定的目录「{name}」已不在 Zotero 目录列表中,请手动选择目录后再同步。",
1519
+ "zotero.dialog.modelNoAuto": "无法读取 Zotero 目录,已取消自动同步;请恢复后手动点「同步」。",
1520
+ "zotero.dialog.modelEmpty": "模型指令里没有可同步的文献",
1521
+ "zotero.dialog.source": "来源 {source}",
1522
+ "zotero.dialog.target": "目标目录",
1523
+ "zotero.dialog.current": "(当前选中)",
1524
+ "zotero.dialog.readonly": "(不可写附件)",
1525
+ "zotero.dialog.loading": "正在读取 Zotero 目录…",
1526
+ "zotero.dialog.loadFail": "无法读取 Zotero 目录:{message}",
1527
+ "zotero.dialog.unknownError": "未知错误",
1528
+ "zotero.dialog.noTarget": "Zotero 里没有可写入的目录,请先在 Zotero 中新建分类",
1529
+ "zotero.dialog.rerunHint": "重新同步会在 Zotero 里新建条目,不会覆盖已有条目。",
1530
+ "zotero.dialog.total": "共 {n} 篇",
1531
+ "zotero.dialog.synced": "已同步 {n}",
1532
+ "zotero.dialog.syncingCount": "同步中 {n}",
1533
+ "zotero.dialog.pending": "待同步 {n}",
1534
+ "zotero.dialog.failed": "失败 {n}",
1535
+ "zotero.dialog.running": "正在同步中…",
1536
+ "zotero.dialog.done": "同步完成:{n} 篇已存入 Zotero",
1537
+ "zotero.dialog.partial": "同步结束:成功 {saved} 篇,失败 {failed} 篇",
1538
+ "zotero.dialog.progress": "已处理 {done}/{total}",
1539
+ "zotero.dialog.sync": "同步",
1540
+ "zotero.dialog.syncing": "同步中…",
1541
+ "zotero.dialog.resync": "重新同步",
1542
+ "zotero.dialog.cancel": "取消",
1543
+ "zotero.dialog.close": "关闭",
1544
+ "zotero.dialog.untitled": "(无标题)",
1545
+ "zotero.dialog.failFallback": "同步失败",
1546
+ "zotero.dialog.status.pending": "待同步",
1547
+ "zotero.dialog.status.syncing": "同步中…",
1548
+ "zotero.dialog.status.synced": "已同步",
1549
+ "zotero.dialog.status.failed": "失败",
1306
1550
  "form.unavailable": "该插件当前未加载,暂时无法配置。",
1307
1551
  "form.readOnly": "本部署的设置为只读。",
1308
1552
  "form.saveFailed": "本部署没有接受这些值,已保留供你修改。",
@@ -1517,6 +1761,8 @@ const dicts = {
1517
1761
  "pdf.downloadDirUnknown": "the dsh-litsearch folder under the workspace",
1518
1762
  "pdf.fulltext": "HTML full text",
1519
1763
  "pdf.zotero": "Save to Zotero",
1764
+ "pdf.zoteroSynced": "Synced to Zotero",
1765
+ "pdf.zoteroRetry": "Zotero sync failed",
1520
1766
  "pdf.error": "Full text failed: {message}",
1521
1767
  "pdf.downloaded": "Saved: {name}",
1522
1768
  "pdf.htmlFallback": "No downloadable PDF found; switched to HTML full text. Open the PDF link in a browser if needed.",
@@ -1556,6 +1802,14 @@ const dicts = {
1556
1802
  "batch.complete": "Download task complete",
1557
1803
  "batch.untitled": "(title unavailable)",
1558
1804
  "batch.close": "Close",
1805
+ "batch.modelTitle": "Model-initiated batch download",
1806
+ "batch.modelDesc": "The model selected {n} papers; downloading them here. You can close this dialog or cancel.",
1807
+ "batch.originModel": "Model request",
1808
+ "batch.source": "Source: {source}",
1809
+ "batch.allSkipped": "All papers in this list were already downloaded and were skipped",
1810
+ "batch.cancel": "Cancel download",
1811
+ "batch.cancelled": "Cancelled; remaining papers were not downloaded",
1812
+ "batch.empty": "No papers to download in the model request",
1559
1813
  "batch.status.pending": "Not downloaded",
1560
1814
  "batch.status.downloading": "Downloading…",
1561
1815
  "batch.status.downloaded": "Downloaded",
@@ -1854,6 +2108,45 @@ const dicts = {
1854
2108
  "zotero.probeOk": "Connected (Zotero {version}); selected collection: {name}",
1855
2109
  "zotero.probeOkNoCollection": "Connected, but no collection is selected in Zotero",
1856
2110
  "zotero.probeFail": "Connection failed: {message}",
2111
+ "zotero.dialog.title": "Save to Zotero",
2112
+ "zotero.dialog.desc": "{n} item(s). Choose a Zotero collection, then click Sync.",
2113
+ "zotero.dialog.modelTitle": "Model-initiated Zotero sync",
2114
+ "zotero.dialog.modelDescAuto": "The model selected {n} papers and a collection; syncing automatically. You can cancel.",
2115
+ "zotero.dialog.modelDescPick": "The model selected {n} papers. Confirm the collection, then click Sync.",
2116
+ "zotero.dialog.originModel": "Model request",
2117
+ "zotero.dialog.modelTarget": "Collection requested by the model: {name}",
2118
+ "zotero.dialog.modelTargetMissing": "The collection \"{name}\" requested by the model is no longer available. Choose one manually before syncing.",
2119
+ "zotero.dialog.modelNoAuto": "Could not load Zotero collections, so auto-sync was cancelled. Click Sync manually once available.",
2120
+ "zotero.dialog.modelEmpty": "No papers to sync in the model request",
2121
+ "zotero.dialog.source": "Source: {source}",
2122
+ "zotero.dialog.target": "Target collection",
2123
+ "zotero.dialog.current": "(selected in Zotero)",
2124
+ "zotero.dialog.readonly": "(attachments not writable)",
2125
+ "zotero.dialog.loading": "Loading Zotero collections…",
2126
+ "zotero.dialog.loadFail": "Could not load Zotero collections: {message}",
2127
+ "zotero.dialog.unknownError": "Unknown error",
2128
+ "zotero.dialog.noTarget": "No writable collection found. Create one in Zotero first.",
2129
+ "zotero.dialog.rerunHint": "Re-syncing creates new items in Zotero; existing items are not overwritten.",
2130
+ "zotero.dialog.total": "Total {n}",
2131
+ "zotero.dialog.synced": "Synced {n}",
2132
+ "zotero.dialog.syncingCount": "Syncing {n}",
2133
+ "zotero.dialog.pending": "Pending {n}",
2134
+ "zotero.dialog.failed": "Failed {n}",
2135
+ "zotero.dialog.running": "Syncing…",
2136
+ "zotero.dialog.done": "Done: {n} item(s) saved to Zotero",
2137
+ "zotero.dialog.partial": "Finished: {saved} saved, {failed} failed",
2138
+ "zotero.dialog.progress": "Processed {done}/{total}",
2139
+ "zotero.dialog.sync": "Sync",
2140
+ "zotero.dialog.syncing": "Syncing…",
2141
+ "zotero.dialog.resync": "Sync again",
2142
+ "zotero.dialog.cancel": "Cancel",
2143
+ "zotero.dialog.close": "Close",
2144
+ "zotero.dialog.untitled": "(untitled)",
2145
+ "zotero.dialog.failFallback": "Sync failed",
2146
+ "zotero.dialog.status.pending": "Pending",
2147
+ "zotero.dialog.status.syncing": "Syncing…",
2148
+ "zotero.dialog.status.synced": "Synced",
2149
+ "zotero.dialog.status.failed": "Failed",
1857
2150
  "form.unavailable": "This plugin is not loaded, so it cannot be configured right now.",
1858
2151
  "form.readOnly": "This deployment stores settings read-only.",
1859
2152
  "form.saveFailed": "This deployment did not accept these values; they were kept for you to fix.",
@@ -2588,7 +2881,7 @@ function SyntaxHint({ t, sources, texts, infos }) {
2588
2881
  const names = affected.map((source) => t(`source.${source}`)).join(" / ");
2589
2882
  const title = (infos ?? []).filter((info) => affected.includes(info.id)).map((info) => `${info.label}:${info.syntax}`).join("\n");
2590
2883
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2591
- style: HINT_STYLE$3,
2884
+ style: HINT_STYLE$4,
2592
2885
  title: title === "" ? void 0 : title,
2593
2886
  children: tpl(t("search.syntaxWarn"), { sources: names })
2594
2887
  });
@@ -2598,7 +2891,7 @@ function sourceSyntaxTitle(infos, id) {
2598
2891
  const info = infos?.find((entry) => entry.id === id);
2599
2892
  return info === void 0 ? void 0 : info.syntax;
2600
2893
  }
2601
- const HINT_STYLE$3 = {
2894
+ const HINT_STYLE$4 = {
2602
2895
  fontSize: 11,
2603
2896
  lineHeight: 1.5,
2604
2897
  color: "#b26a00"
@@ -3265,7 +3558,7 @@ function EntityPanel({ t, taskId, paperId, pmid }) {
3265
3558
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3266
3559
  style: WRAP_STYLE$7,
3267
3560
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3268
- style: HINT_STYLE$2,
3561
+ style: HINT_STYLE$3,
3269
3562
  children: [
3270
3563
  tpl(t("entity.count"), { n: entities.length }),
3271
3564
  " · ",
@@ -3317,7 +3610,7 @@ const WRAP_STYLE$7 = {
3317
3610
  borderRadius: 6,
3318
3611
  background: "rgba(127,127,127,0.07)"
3319
3612
  };
3320
- const HINT_STYLE$2 = {
3613
+ const HINT_STYLE$3 = {
3321
3614
  fontSize: 11,
3322
3615
  opacity: .68
3323
3616
  };
@@ -3493,6 +3786,558 @@ const LOADING_TEXT_STYLE = {
3493
3786
  letterSpacing: "0.02em",
3494
3787
  color: ACCENT
3495
3788
  };
3789
+ // src/client/ZoteroSyncDialog.tsx
3790
+ /** 批量同步的并发数,与批量下载保持一致。 */
3791
+ const SYNC_CONCURRENCY = 5;
3792
+ const PHASE_COLOR$1 = {
3793
+ pending: "rgba(127,127,127,0.9)",
3794
+ syncing: ACCENT,
3795
+ synced: "#2e9e5b",
3796
+ failed: ERROR
3797
+ };
3798
+ function initialRows(items, states) {
3799
+ return items.map((item) => {
3800
+ const state = states[item.paperId];
3801
+ const phase = state?.zoteroStatus === "synced" ? "synced" : state?.zoteroStatus === "failed" ? "failed" : "pending";
3802
+ return {
3803
+ paperId: item.paperId,
3804
+ title: item.title,
3805
+ phase,
3806
+ ...item.source === void 0 || item.source === "" ? {} : { source: item.source },
3807
+ ...item.meta === void 0 || item.meta === "" ? {} : { meta: item.meta },
3808
+ ...state?.zoteroError === void 0 ? {} : { error: state.zoteroError },
3809
+ ...phase === "synced" && state?.zoteroCollection !== void 0 ? { note: state.zoteroCollection } : {}
3810
+ };
3811
+ });
3812
+ }
3813
+ function targetLabel(t, target, currentId) {
3814
+ const indent = " ".repeat(Math.max(0, target.level));
3815
+ const suffix = target.filesEditable === false ? ` ${t("zotero.dialog.readonly")}` : target.id === currentId ? ` ${t("zotero.dialog.current")}` : "";
3816
+ return `${indent}${target.name}${suffix}`;
3817
+ }
3818
+ function ZoteroSyncDialog({ t, open, taskId, items, states, onClose, onDone, meta }) {
3819
+ const [rows, setRows] = (0, react.useState)(() => initialRows(items, states));
3820
+ const [targets, setTargets] = (0, react.useState)([]);
3821
+ const [currentId, setCurrentId] = (0, react.useState)(void 0);
3822
+ const [targetId, setTargetId] = (0, react.useState)("");
3823
+ const [loading, setLoading] = (0, react.useState)(false);
3824
+ /** 目录列表本轮是否已读完(含失败);自动同步必须等它就绪,避免用到上一批目录。 */
3825
+ const [targetsReady, setTargetsReady] = (0, react.useState)(false);
3826
+ const [loadError, setLoadError] = (0, react.useState)(void 0);
3827
+ const [running, setRunning] = (0, react.useState)(false);
3828
+ const [notice, setNotice] = (0, react.useState)(void 0);
3829
+ const [hasRun, setHasRun] = (0, react.useState)(false);
3830
+ /** 取消后停止派发剩余文献;已发出的请求仍会完成。 */
3831
+ const stopRef = (0, react.useRef)(false);
3832
+ /** 自动同步只跑一次;每次打开/换清单时重置。 */
3833
+ const autoStartRef = (0, react.useRef)(false);
3834
+ const key = items.map((item) => item.paperId).join(",");
3835
+ const desiredId = meta?.initialTarget?.id;
3836
+ const desiredName = meta?.initialTarget?.name ?? "";
3837
+ (0, react.useEffect)(() => {
3838
+ if (!open) return;
3839
+ setRows(initialRows(items, states));
3840
+ setHasRun(false);
3841
+ setNotice(void 0);
3842
+ setLoadError(void 0);
3843
+ setTargetsReady(false);
3844
+ setTargets([]);
3845
+ setTargetId("");
3846
+ autoStartRef.current = false;
3847
+ let cancelled = false;
3848
+ setLoading(true);
3849
+ fetchZoteroTargets().then((result) => {
3850
+ if (cancelled) return;
3851
+ if (!result.ok) {
3852
+ setTargets([]);
3853
+ setLoadError(result.error ?? t("zotero.dialog.unknownError"));
3854
+ return;
3855
+ }
3856
+ setTargets(result.targets);
3857
+ setCurrentId(result.current?.id);
3858
+ const matched = desiredId === void 0 ? void 0 : result.targets.find((entry) => entry.id === desiredId);
3859
+ if (desiredId !== void 0 && matched === void 0) setNotice(tpl(t("zotero.dialog.modelTargetMissing"), { name: desiredName }));
3860
+ setTargetId(matched?.id ?? result.current?.id ?? result.targets[0]?.id ?? "");
3861
+ }).catch((error) => {
3862
+ if (cancelled) return;
3863
+ setTargets([]);
3864
+ setLoadError(error instanceof Error ? error.message : String(error));
3865
+ }).finally(() => {
3866
+ if (cancelled) return;
3867
+ setLoading(false);
3868
+ setTargetsReady(true);
3869
+ });
3870
+ return () => {
3871
+ cancelled = true;
3872
+ };
3873
+ }, [
3874
+ open,
3875
+ key,
3876
+ desiredId
3877
+ ]);
3878
+ const target = targets.find((entry) => entry.id === targetId);
3879
+ const total = rows.length;
3880
+ const synced = rows.filter((row) => row.phase === "synced").length;
3881
+ const failed = rows.filter((row) => row.phase === "failed").length;
3882
+ const pending = rows.filter((row) => row.phase === "pending").length;
3883
+ const syncing = rows.filter((row) => row.phase === "syncing").length;
3884
+ const done = total - pending - syncing;
3885
+ const rerunHint = rows.some((row) => row.phase === "synced");
3886
+ const run = async () => {
3887
+ if (running || target === void 0) return;
3888
+ setRunning(true);
3889
+ setNotice(void 0);
3890
+ setHasRun(true);
3891
+ stopRef.current = false;
3892
+ const list = rows.map((row) => ({
3893
+ ...row,
3894
+ phase: "pending",
3895
+ error: void 0,
3896
+ note: void 0
3897
+ }));
3898
+ setRows(list);
3899
+ const failedIds = [];
3900
+ const runOne = async (row) => {
3901
+ setRows((prev) => prev.map((entry) => entry.paperId === row.paperId ? {
3902
+ ...entry,
3903
+ phase: "syncing",
3904
+ error: void 0
3905
+ } : entry));
3906
+ try {
3907
+ const result = await syncZotero(taskId, [row.paperId], {
3908
+ id: target.id,
3909
+ name: target.name,
3910
+ ...target.filesEditable === void 0 ? {} : { filesEditable: target.filesEditable }
3911
+ });
3912
+ const item = result.items[0];
3913
+ const ok = item?.ok ?? result.ok;
3914
+ const note = item?.ok === true ? result.messages[0] : void 0;
3915
+ if (!ok) failedIds.push(row.paperId);
3916
+ setRows((prev) => prev.map((entry) => entry.paperId === row.paperId ? {
3917
+ ...entry,
3918
+ phase: ok ? "synced" : "failed",
3919
+ ...ok ? note === void 0 ? {} : { note } : { error: item?.error ?? result.messages[0] ?? t("zotero.dialog.failFallback") }
3920
+ } : entry));
3921
+ } catch (error) {
3922
+ const message = error instanceof Error ? error.message : String(error);
3923
+ stopRef.current = true;
3924
+ failedIds.push(row.paperId);
3925
+ setRows((prev) => prev.map((entry) => entry.paperId === row.paperId ? {
3926
+ ...entry,
3927
+ phase: "failed",
3928
+ error: message
3929
+ } : entry));
3930
+ setNotice(message);
3931
+ }
3932
+ };
3933
+ let cursor = 0;
3934
+ const worker = async () => {
3935
+ while (!stopRef.current && cursor < list.length) {
3936
+ const row = list[cursor];
3937
+ cursor += 1;
3938
+ if (row !== void 0) await runOne(row);
3939
+ }
3940
+ };
3941
+ await Promise.all(Array.from({ length: Math.min(SYNC_CONCURRENCY, list.length) }, () => worker()));
3942
+ setRunning(false);
3943
+ onDone(failedIds);
3944
+ };
3945
+ (0, react.useEffect)(() => {
3946
+ if (!open || loading || !targetsReady || meta?.autoStart !== true || autoStartRef.current) return;
3947
+ if (running) return;
3948
+ if (desiredId === void 0) {
3949
+ autoStartRef.current = true;
3950
+ return;
3951
+ }
3952
+ if (target === void 0 || target.id !== desiredId) {
3953
+ if (loadError !== void 0 || targets.length === 0) {
3954
+ autoStartRef.current = true;
3955
+ setNotice(t("zotero.dialog.modelNoAuto"));
3956
+ } else if (!targets.some((entry) => entry.id === desiredId)) autoStartRef.current = true;
3957
+ return;
3958
+ }
3959
+ autoStartRef.current = true;
3960
+ run();
3961
+ }, [
3962
+ open,
3963
+ loading,
3964
+ targetsReady,
3965
+ running,
3966
+ target,
3967
+ targets,
3968
+ loadError,
3969
+ desiredId,
3970
+ meta?.autoStart
3971
+ ]);
3972
+ const summary = running ? t("zotero.dialog.running") : done > 0 && failed === 0 && pending === 0 ? tpl(t("zotero.dialog.done"), { n: synced }) : done > 0 ? tpl(t("zotero.dialog.partial"), {
3973
+ saved: synced,
3974
+ failed
3975
+ }) : void 0;
3976
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
3977
+ open,
3978
+ onClose: () => {
3979
+ if (!running) onClose();
3980
+ },
3981
+ title: meta === void 0 ? t("zotero.dialog.title") : t("zotero.dialog.modelTitle"),
3982
+ description: tpl(meta === void 0 ? t("zotero.dialog.desc") : meta.autoStart ? t("zotero.dialog.modelDescAuto") : t("zotero.dialog.modelDescPick"), { n: total }),
3983
+ className: WIDE_MODAL_CLASS$2,
3984
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3985
+ style: FOOTER_STYLE$1,
3986
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3987
+ style: statusStyle,
3988
+ children: tpl(t("zotero.dialog.progress"), {
3989
+ done,
3990
+ total
3991
+ })
3992
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3993
+ style: ACTIONS_STYLE,
3994
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3995
+ variant: "outline",
3996
+ size: "sm",
3997
+ type: "button",
3998
+ onClick: () => {
3999
+ if (running) stopRef.current = true;
4000
+ else onClose();
4001
+ },
4002
+ children: running ? t("zotero.dialog.cancel") : t("zotero.dialog.close")
4003
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
4004
+ variant: "primary",
4005
+ size: "sm",
4006
+ type: "button",
4007
+ onClick: run,
4008
+ disabled: running || loading || target === void 0,
4009
+ children: running ? t("zotero.dialog.syncing") : hasRun || rerunHint ? t("zotero.dialog.resync") : t("zotero.dialog.sync")
4010
+ })]
4011
+ })]
4012
+ }),
4013
+ children: [
4014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS$2 }),
4015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4016
+ style: TARGET_BOX_STYLE,
4017
+ children: [
4018
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
4019
+ style: TARGET_LABEL_STYLE,
4020
+ htmlFor: "litsearch-zotero-target",
4021
+ children: t("zotero.dialog.target")
4022
+ }),
4023
+ loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4024
+ style: MUTED_STYLE,
4025
+ children: t("zotero.dialog.loading")
4026
+ }) : loadError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4027
+ style: ERROR_TEXT_STYLE,
4028
+ title: loadError,
4029
+ children: tpl(t("zotero.dialog.loadFail"), { message: loadError })
4030
+ }) : targets.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4031
+ style: MUTED_STYLE,
4032
+ children: t("zotero.dialog.noTarget")
4033
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
4034
+ id: "litsearch-zotero-target",
4035
+ style: SELECT_STYLE$4,
4036
+ value: targetId,
4037
+ onChange: (event) => setTargetId(event.target.value),
4038
+ disabled: running,
4039
+ children: targets.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4040
+ value: entry.id,
4041
+ children: targetLabel(t, entry, currentId)
4042
+ }, entry.id))
4043
+ }),
4044
+ meta?.initialTarget !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4045
+ style: HINT_STYLE$2,
4046
+ children: tpl(t("zotero.dialog.modelTarget"), { name: meta.initialTarget.name })
4047
+ }),
4048
+ rerunHint && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4049
+ style: HINT_STYLE$2,
4050
+ children: t("zotero.dialog.rerunHint")
4051
+ })
4052
+ ]
4053
+ }),
4054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4055
+ style: OVERVIEW_STYLE$1,
4056
+ role: "status",
4057
+ "aria-live": "polite",
4058
+ children: [
4059
+ meta !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4060
+ style: MODEL_BADGE_STYLE$1,
4061
+ children: [
4062
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4063
+ style: MODEL_TAG_STYLE$1,
4064
+ children: t("zotero.dialog.originModel")
4065
+ }),
4066
+ meta.scopeLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: meta.scopeLabel }),
4067
+ meta.reason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4068
+ style: REASON_STYLE$1,
4069
+ title: meta.reason,
4070
+ children: meta.reason
4071
+ })
4072
+ ]
4073
+ }),
4074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4075
+ style: COUNTS_STYLE$1,
4076
+ children: [
4077
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("zotero.dialog.total"), { n: total }) }),
4078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4079
+ style: { color: PHASE_COLOR$1.synced },
4080
+ children: tpl(t("zotero.dialog.synced"), { n: synced })
4081
+ }),
4082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4083
+ style: { color: PHASE_COLOR$1.syncing },
4084
+ children: tpl(t("zotero.dialog.syncingCount"), { n: syncing })
4085
+ }),
4086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("zotero.dialog.pending"), { n: pending }) }),
4087
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4088
+ style: { color: PHASE_COLOR$1.failed },
4089
+ children: tpl(t("zotero.dialog.failed"), { n: failed })
4090
+ })
4091
+ ]
4092
+ }),
4093
+ summary !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4094
+ style: {
4095
+ ...SUMMARY_STYLE$1,
4096
+ color: failed > 0 ? "#d64545" : "#2e9e5b"
4097
+ },
4098
+ children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), summary]
4099
+ }),
4100
+ notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4101
+ style: ERROR_TEXT_STYLE,
4102
+ title: notice,
4103
+ children: notice
4104
+ })
4105
+ ]
4106
+ }),
4107
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4108
+ style: LIST_STYLE$4,
4109
+ children: rows.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
4110
+ style: ROW_STYLE$5,
4111
+ children: [
4112
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4113
+ style: INDEX_STYLE$1,
4114
+ children: index + 1
4115
+ }),
4116
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4117
+ style: ROW_MAIN_STYLE$2,
4118
+ children: [
4119
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4120
+ style: ROW_TITLE_STYLE$2,
4121
+ title: row.title,
4122
+ children: row.title === "" ? t("zotero.dialog.untitled") : row.title
4123
+ }),
4124
+ row.source !== void 0 && row.source !== "" || row.meta !== void 0 && row.meta !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4125
+ style: ROW_META_STYLE$3,
4126
+ children: [row.source === void 0 || row.source === "" ? "" : tpl(t("zotero.dialog.source"), { source: row.source }), row.meta ?? ""].filter((part) => part !== "").join(" · ")
4127
+ }) : null,
4128
+ row.error !== void 0 && row.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4129
+ style: ROW_ERROR_STYLE$2,
4130
+ title: row.error,
4131
+ children: row.error
4132
+ }),
4133
+ row.error === void 0 && row.note !== void 0 && row.note !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4134
+ style: ROW_NOTE_STYLE,
4135
+ title: row.note,
4136
+ children: row.note
4137
+ })
4138
+ ]
4139
+ }),
4140
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4141
+ style: {
4142
+ ...STATUS_CHIP_STYLE$1,
4143
+ color: PHASE_COLOR$1[row.phase]
4144
+ },
4145
+ children: [row.phase === "syncing" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), t(`zotero.dialog.status.${row.phase}`)]
4146
+ })
4147
+ ]
4148
+ }, row.paperId))
4149
+ })
4150
+ ]
4151
+ });
4152
+ }
4153
+ const WIDE_MODAL_CLASS$2 = "litsearch-zotero-modal";
4154
+ const WIDE_MODAL_CSS$2 = `
4155
+ .${WIDE_MODAL_CLASS$2}{width:min(760px,calc(100vw - 48px))}
4156
+ `;
4157
+ const FOOTER_STYLE$1 = {
4158
+ display: "flex",
4159
+ alignItems: "center",
4160
+ justifyContent: "space-between",
4161
+ gap: 8
4162
+ };
4163
+ const ACTIONS_STYLE = {
4164
+ display: "flex",
4165
+ alignItems: "center",
4166
+ gap: 8
4167
+ };
4168
+ const TARGET_BOX_STYLE = {
4169
+ display: "flex",
4170
+ flexDirection: "column",
4171
+ gap: 6,
4172
+ marginBottom: 10,
4173
+ padding: "8px 10px",
4174
+ borderRadius: 8,
4175
+ border: "1px solid rgba(80,140,255,0.25)",
4176
+ background: "rgba(80,140,255,0.06)"
4177
+ };
4178
+ const TARGET_LABEL_STYLE = {
4179
+ fontSize: 11,
4180
+ fontWeight: 650,
4181
+ opacity: .7
4182
+ };
4183
+ const SELECT_STYLE$4 = {
4184
+ width: "100%",
4185
+ padding: "4px 6px",
4186
+ fontSize: 12.5,
4187
+ borderRadius: 6,
4188
+ border: "1px solid rgba(127,127,127,0.35)",
4189
+ background: "transparent",
4190
+ color: "inherit"
4191
+ };
4192
+ const HINT_STYLE$2 = {
4193
+ fontSize: 11,
4194
+ lineHeight: 1.5,
4195
+ opacity: .7
4196
+ };
4197
+ const OVERVIEW_STYLE$1 = {
4198
+ display: "flex",
4199
+ flexDirection: "column",
4200
+ gap: 6,
4201
+ marginBottom: 8,
4202
+ padding: "8px 10px",
4203
+ borderRadius: 8,
4204
+ border: "1px solid rgba(80,140,255,0.25)",
4205
+ background: "rgba(80,140,255,0.08)"
4206
+ };
4207
+ const COUNTS_STYLE$1 = {
4208
+ display: "flex",
4209
+ flexWrap: "wrap",
4210
+ gap: 10,
4211
+ fontSize: 12,
4212
+ lineHeight: 1.5
4213
+ };
4214
+ const SUMMARY_STYLE$1 = {
4215
+ display: "inline-flex",
4216
+ alignItems: "center",
4217
+ gap: 4,
4218
+ fontSize: 12,
4219
+ fontWeight: 650
4220
+ };
4221
+ const MUTED_STYLE = {
4222
+ fontSize: 12,
4223
+ opacity: .7
4224
+ };
4225
+ const ERROR_TEXT_STYLE = {
4226
+ fontSize: 11.5,
4227
+ lineHeight: 1.5,
4228
+ color: ERROR,
4229
+ overflow: "hidden",
4230
+ display: "-webkit-box",
4231
+ WebkitLineClamp: 2,
4232
+ WebkitBoxOrient: "vertical",
4233
+ wordBreak: "break-word"
4234
+ };
4235
+ const LIST_STYLE$4 = {
4236
+ listStyle: "none",
4237
+ margin: 0,
4238
+ padding: 0,
4239
+ maxHeight: "46vh",
4240
+ overflowY: "auto"
4241
+ };
4242
+ const ROW_STYLE$5 = {
4243
+ display: "flex",
4244
+ alignItems: "flex-start",
4245
+ gap: 8,
4246
+ padding: "7px 0",
4247
+ borderTop: "1px solid rgba(127,127,127,0.18)"
4248
+ };
4249
+ const INDEX_STYLE$1 = {
4250
+ flexShrink: 0,
4251
+ minWidth: 18,
4252
+ fontSize: 11,
4253
+ lineHeight: "18px",
4254
+ opacity: .5,
4255
+ textAlign: "right"
4256
+ };
4257
+ const ROW_MAIN_STYLE$2 = {
4258
+ display: "flex",
4259
+ flexDirection: "column",
4260
+ gap: 2,
4261
+ flex: 1,
4262
+ minWidth: 0
4263
+ };
4264
+ const ROW_TITLE_STYLE$2 = {
4265
+ fontSize: 12.5,
4266
+ lineHeight: 1.45,
4267
+ fontWeight: 600,
4268
+ overflow: "hidden",
4269
+ display: "-webkit-box",
4270
+ WebkitLineClamp: 2,
4271
+ WebkitBoxOrient: "vertical",
4272
+ wordBreak: "break-word"
4273
+ };
4274
+ const ROW_ERROR_STYLE$2 = {
4275
+ fontSize: 11,
4276
+ lineHeight: 1.4,
4277
+ color: ERROR,
4278
+ opacity: .85,
4279
+ overflow: "hidden",
4280
+ display: "-webkit-box",
4281
+ WebkitLineClamp: 2,
4282
+ WebkitBoxOrient: "vertical",
4283
+ wordBreak: "break-word"
4284
+ };
4285
+ const ROW_NOTE_STYLE = {
4286
+ fontSize: 11,
4287
+ lineHeight: 1.4,
4288
+ opacity: .65,
4289
+ overflow: "hidden",
4290
+ display: "-webkit-box",
4291
+ WebkitLineClamp: 1,
4292
+ WebkitBoxOrient: "vertical",
4293
+ wordBreak: "break-word"
4294
+ };
4295
+ const MODEL_BADGE_STYLE$1 = {
4296
+ display: "flex",
4297
+ flexWrap: "wrap",
4298
+ alignItems: "center",
4299
+ gap: 8,
4300
+ fontSize: 12,
4301
+ lineHeight: 1.5,
4302
+ paddingBottom: 4,
4303
+ borderBottom: "1px solid rgba(127,127,127,0.18)"
4304
+ };
4305
+ const MODEL_TAG_STYLE$1 = {
4306
+ flexShrink: 0,
4307
+ padding: "1px 6px",
4308
+ borderRadius: 4,
4309
+ border: `1px solid ${ACCENT}`,
4310
+ color: ACCENT,
4311
+ fontWeight: 650,
4312
+ fontSize: 11
4313
+ };
4314
+ const REASON_STYLE$1 = {
4315
+ opacity: .75,
4316
+ overflow: "hidden",
4317
+ textOverflow: "ellipsis",
4318
+ whiteSpace: "nowrap",
4319
+ maxWidth: "100%"
4320
+ };
4321
+ const ROW_META_STYLE$3 = {
4322
+ fontSize: 11,
4323
+ lineHeight: 1.4,
4324
+ opacity: .65,
4325
+ overflow: "hidden",
4326
+ textOverflow: "ellipsis",
4327
+ whiteSpace: "nowrap"
4328
+ };
4329
+ const STATUS_CHIP_STYLE$1 = {
4330
+ display: "inline-flex",
4331
+ alignItems: "center",
4332
+ gap: 3,
4333
+ flexShrink: 0,
4334
+ padding: "1px 6px",
4335
+ borderRadius: 4,
4336
+ border: "1px solid currentColor",
4337
+ fontSize: 11,
4338
+ lineHeight: 1.5,
4339
+ whiteSpace: "nowrap"
4340
+ };
3496
4341
  // src/client/PaperPane.tsx
3497
4342
  /**
3498
4343
  * 独立论文 tab:`dsh-resource://litsearch-paper/<taskId>/<paperId>`。
@@ -3553,6 +4398,7 @@ function PaperPane({ t, useTabInfo, inputActions, onOpenLocal, onRevealLocal })
3553
4398
  setToastTone(tone);
3554
4399
  };
3555
4400
  const [showHtml, setShowHtml] = (0, react.useState)(false);
4401
+ const [zoteroOpen, setZoteroOpen] = (0, react.useState)(false);
3556
4402
  const [risFallback, setRisFallback] = (0, react.useState)(null);
3557
4403
  const taskId = ref?.taskId ?? "";
3558
4404
  ref?.paperId;
@@ -3644,27 +4490,7 @@ function PaperPane({ t, useTabInfo, inputActions, onOpenLocal, onRevealLocal })
3644
4490
  });
3645
4491
  };
3646
4492
  const zotero = () => {
3647
- setBusy(true);
3648
- syncZotero(taskId, [paper.paperId]).then((result) => {
3649
- const message = result.messages[0] ?? "";
3650
- if (result.failed === 0 && message === "") notify(tpl(t("pdf.zoteroDone"), { n: result.saved }));
3651
- else notify(tpl(t("pdf.zoteroPartial"), {
3652
- saved: result.saved,
3653
- failed: result.failed,
3654
- message
3655
- }));
3656
- if (result.failed > 0) setRisFallback({
3657
- ids: [paper.paperId],
3658
- message: message || t("pdf.zoteroFailed")
3659
- });
3660
- }).catch((err) => {
3661
- const message = messageOf$3(err);
3662
- notify(tpl(t("pdf.zoteroFailed"), { message }));
3663
- setRisFallback({
3664
- ids: [paper.paperId],
3665
- message
3666
- });
3667
- }).finally(() => setBusy(false));
4493
+ setZoteroOpen(true);
3668
4494
  };
3669
4495
  const pdfStatus = state?.pdfStatus ?? (paper.pdfUrl === "" ? "unknown" : "available");
3670
4496
  const canPdf = paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "";
@@ -3872,6 +4698,28 @@ function PaperPane({ t, useTabInfo, inputActions, onOpenLocal, onRevealLocal })
3872
4698
  ]
3873
4699
  })]
3874
4700
  }),
4701
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoteroSyncDialog, {
4702
+ t,
4703
+ open: zoteroOpen,
4704
+ taskId,
4705
+ items: [{
4706
+ paperId: paper.paperId,
4707
+ title: paper.title
4708
+ }],
4709
+ states: state === void 0 ? {} : { [paper.paperId]: state },
4710
+ onClose: () => {
4711
+ setZoteroOpen(false);
4712
+ setNonce((value) => value + 1);
4713
+ },
4714
+ onDone: (failedIds) => {
4715
+ setNonce((value) => value + 1);
4716
+ if (failedIds.length === 0) return;
4717
+ setRisFallback({
4718
+ ids: failedIds,
4719
+ message: t("zotero.dialog.failFallback")
4720
+ });
4721
+ }
4722
+ }),
3875
4723
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3876
4724
  style: ACTION_STYLE,
3877
4725
  children: [
@@ -3903,10 +4751,17 @@ function PaperPane({ t, useTabInfo, inputActions, onOpenLocal, onRevealLocal })
3903
4751
  }),
3904
4752
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3905
4753
  type: "button",
3906
- style: linkButtonStyle,
4754
+ style: state?.zoteroStatus === "synced" ? {
4755
+ ...linkButtonStyle,
4756
+ color: "#2e9e5b"
4757
+ } : state?.zoteroStatus === "failed" ? {
4758
+ ...linkButtonStyle,
4759
+ color: ERROR
4760
+ } : linkButtonStyle,
4761
+ title: state?.zoteroStatus === "synced" ? state.zoteroCollection : state?.zoteroStatus === "failed" ? state.zoteroError : void 0,
3907
4762
  disabled: busy,
3908
4763
  onClick: zotero,
3909
- children: t("pdf.zotero")
4764
+ children: state?.zoteroStatus === "synced" ? t("pdf.zoteroSynced") : state?.zoteroStatus === "failed" ? t("pdf.zoteroRetry") : t("pdf.zotero")
3910
4765
  }),
3911
4766
  paper.pdfUrl !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
3912
4767
  style: linkStyle,
@@ -5192,7 +6047,7 @@ function PaperRow({ paper }) {
5192
6047
  children: paper.title
5193
6048
  }),
5194
6049
  meta !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5195
- style: ROW_META_STYLE$1,
6050
+ style: ROW_META_STYLE$2,
5196
6051
  children: meta
5197
6052
  }),
5198
6053
  paper.doi !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
@@ -5256,7 +6111,7 @@ const ROW_STYLE$4 = {
5256
6111
  padding: "5px 0",
5257
6112
  borderTop: "1px solid rgba(127,127,127,0.14)"
5258
6113
  };
5259
- const ROW_META_STYLE$1 = {
6114
+ const ROW_META_STYLE$2 = {
5260
6115
  fontSize: 11,
5261
6116
  opacity: .7
5262
6117
  };
@@ -5610,7 +6465,7 @@ function phaseOf(paperId, states, downloads) {
5610
6465
  function errorOf(paperId, states, downloads) {
5611
6466
  return downloads[paperId]?.error ?? states[paperId]?.pdfError;
5612
6467
  }
5613
- function DownloadDialog({ t, open, items, states, downloads, onClose }) {
6468
+ function DownloadDialog({ t, open, items, states, downloads, origin = "panel", scopeLabel, reason, cancelled = false, onCancel, onClose }) {
5614
6469
  const phases = items.map((item) => phaseOf(item.paperId, states, downloads));
5615
6470
  const downloaded = phases.filter((phase) => phase === "downloaded").length;
5616
6471
  const downloading = phases.filter((phase) => phase === "downloading").length;
@@ -5621,11 +6476,12 @@ function DownloadDialog({ t, open, items, states, downloads, onClose }) {
5621
6476
  const done = total - pending - downloading;
5622
6477
  const rate = total === 0 ? 0 : Math.round(done / total * 100);
5623
6478
  const complete = total > 0 && pending === 0 && downloading === 0;
6479
+ const allSkipped = complete && downloaded === total;
5624
6480
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
5625
6481
  open,
5626
6482
  onClose,
5627
- title: t("batch.title"),
5628
- description: tpl(t("batch.desc"), { n: total }),
6483
+ title: origin === "model" ? t("batch.modelTitle") : t("batch.title"),
6484
+ description: tpl(origin === "model" ? t("batch.modelDesc") : t("batch.desc"), { n: total }),
5629
6485
  className: WIDE_MODAL_CLASS,
5630
6486
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5631
6487
  style: FOOTER_STYLE,
@@ -5635,12 +6491,21 @@ function DownloadDialog({ t, open, items, states, downloads, onClose }) {
5635
6491
  done,
5636
6492
  total
5637
6493
  })
5638
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
5639
- variant: "outline",
5640
- size: "sm",
5641
- type: "button",
5642
- onClick: onClose,
5643
- children: t("batch.close")
6494
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6495
+ style: FOOTER_ACTIONS_STYLE,
6496
+ children: [onCancel !== void 0 && !cancelled && !complete && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6497
+ variant: "outline",
6498
+ size: "sm",
6499
+ type: "button",
6500
+ onClick: onCancel,
6501
+ children: t("batch.cancel")
6502
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6503
+ variant: "outline",
6504
+ size: "sm",
6505
+ type: "button",
6506
+ onClick: onClose,
6507
+ children: t("batch.close")
6508
+ })]
5644
6509
  })]
5645
6510
  }),
5646
6511
  children: [
@@ -5650,6 +6515,21 @@ function DownloadDialog({ t, open, items, states, downloads, onClose }) {
5650
6515
  role: "status",
5651
6516
  "aria-live": "polite",
5652
6517
  children: [
6518
+ origin === "model" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6519
+ style: MODEL_BADGE_STYLE,
6520
+ children: [
6521
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6522
+ style: MODEL_TAG_STYLE,
6523
+ children: t("batch.originModel")
6524
+ }),
6525
+ scopeLabel !== void 0 && scopeLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: scopeLabel }),
6526
+ reason !== void 0 && reason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6527
+ style: REASON_STYLE,
6528
+ title: reason,
6529
+ children: reason
6530
+ })
6531
+ ]
6532
+ }),
5653
6533
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5654
6534
  style: COUNTS_STYLE,
5655
6535
  children: [
@@ -5682,7 +6562,11 @@ function DownloadDialog({ t, open, items, states, downloads, onClose }) {
5682
6562
  }),
5683
6563
  complete && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5684
6564
  style: COMPLETE_STYLE,
5685
- children: t("batch.complete")
6565
+ children: allSkipped ? t("batch.allSkipped") : t("batch.complete")
6566
+ }),
6567
+ cancelled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6568
+ style: CANCELLED_STYLE,
6569
+ children: t("batch.cancelled")
5686
6570
  })
5687
6571
  ]
5688
6572
  }),
@@ -5700,15 +6584,22 @@ function DownloadDialog({ t, open, items, states, downloads, onClose }) {
5700
6584
  }),
5701
6585
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5702
6586
  style: ROW_MAIN_STYLE$1,
5703
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5704
- style: ROW_TITLE_STYLE$1,
5705
- title: item.title,
5706
- children: item.title === "" ? t("batch.untitled") : item.title
5707
- }), error !== void 0 && error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5708
- style: ROW_ERROR_STYLE$1,
5709
- title: error,
5710
- children: error
5711
- })]
6587
+ children: [
6588
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6589
+ style: ROW_TITLE_STYLE$1,
6590
+ title: item.title,
6591
+ children: item.title === "" ? t("batch.untitled") : item.title
6592
+ }),
6593
+ item.source !== void 0 && item.source !== "" || item.meta !== void 0 && item.meta !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6594
+ style: ROW_META_STYLE$1,
6595
+ children: [item.source === void 0 || item.source === "" ? "" : tpl(t("batch.source"), { source: item.source }), item.meta ?? ""].filter((part) => part !== "").join(" · ")
6596
+ }) : null,
6597
+ error !== void 0 && error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6598
+ style: ROW_ERROR_STYLE$1,
6599
+ title: error,
6600
+ children: error
6601
+ })
6602
+ ]
5712
6603
  }),
5713
6604
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5714
6605
  style: {
@@ -5831,6 +6722,50 @@ const STATUS_CHIP_STYLE = {
5831
6722
  lineHeight: 1.5,
5832
6723
  whiteSpace: "nowrap"
5833
6724
  };
6725
+ const FOOTER_ACTIONS_STYLE = {
6726
+ display: "flex",
6727
+ alignItems: "center",
6728
+ gap: 6
6729
+ };
6730
+ const MODEL_BADGE_STYLE = {
6731
+ display: "flex",
6732
+ flexWrap: "wrap",
6733
+ alignItems: "center",
6734
+ gap: 8,
6735
+ fontSize: 12,
6736
+ lineHeight: 1.5,
6737
+ paddingBottom: 4,
6738
+ borderBottom: "1px solid rgba(127,127,127,0.18)"
6739
+ };
6740
+ const MODEL_TAG_STYLE = {
6741
+ flexShrink: 0,
6742
+ padding: "1px 6px",
6743
+ borderRadius: 4,
6744
+ border: `1px solid ${ACCENT}`,
6745
+ color: ACCENT,
6746
+ fontWeight: 650,
6747
+ fontSize: 11
6748
+ };
6749
+ const REASON_STYLE = {
6750
+ opacity: .75,
6751
+ overflow: "hidden",
6752
+ textOverflow: "ellipsis",
6753
+ whiteSpace: "nowrap",
6754
+ maxWidth: "100%"
6755
+ };
6756
+ const ROW_META_STYLE$1 = {
6757
+ fontSize: 11,
6758
+ lineHeight: 1.4,
6759
+ opacity: .65,
6760
+ overflow: "hidden",
6761
+ textOverflow: "ellipsis",
6762
+ whiteSpace: "nowrap"
6763
+ };
6764
+ const CANCELLED_STYLE = {
6765
+ fontSize: 12,
6766
+ fontWeight: 650,
6767
+ color: ERROR
6768
+ };
5834
6769
  // src/client/FullTextTab.tsx
5835
6770
  /**
5836
6771
  * 「全文」台账:全任务口径的覆盖率 + 可按状态切换的补抓队列。
@@ -8098,6 +9033,9 @@ function PaperCard({ paper, t, taskId, state, groups, busy, downloadDir, downloa
8098
9033
  const failedMessage = download?.state === "failed" ? download.error : pdfStatus === "failed" ? state?.pdfError : void 0;
8099
9034
  const pdfLabel = downloading ? t("pdf.downloading") : downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : pdfStatus === "failed" ? t("pdf.retry") : t("pdf.download");
8100
9035
  const pdfHint = downloading ? t("pdf.downloadingHint") : downloaded ? tpl(t("pdf.downloadedHint"), { path: downloadedPath ?? downloadDir ?? "" }) : pdfMissing ? t("pdf.missingHint") : failedMessage !== void 0 ? tpl(t("pdf.retryHint"), { message: failedMessage }) : tpl(t("pdf.downloadHint"), { path: downloadDir ?? t("pdf.downloadDirUnknown") });
9036
+ const zoteroLabel = state?.zoteroStatus === "synced" ? t("pdf.zoteroSynced") : state?.zoteroStatus === "failed" ? t("pdf.zoteroRetry") : t("pdf.zotero");
9037
+ const zoteroHint = state?.zoteroStatus === "synced" ? state.zoteroCollection : state?.zoteroStatus === "failed" ? state.zoteroError : void 0;
9038
+ const zoteroColor = state?.zoteroStatus === "synced" ? "#2e9e5b" : state?.zoteroStatus === "failed" ? ERROR : void 0;
8101
9039
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
8102
9040
  style: cardStyle,
8103
9041
  children: [
@@ -8326,10 +9264,14 @@ function PaperCard({ paper, t, taskId, state, groups, busy, downloadDir, downloa
8326
9264
  }),
8327
9265
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8328
9266
  type: "button",
8329
- style: linkButtonStyle,
9267
+ style: zoteroColor === void 0 ? linkButtonStyle : {
9268
+ ...linkButtonStyle,
9269
+ color: zoteroColor
9270
+ },
8330
9271
  disabled: busy,
9272
+ title: zoteroHint,
8331
9273
  onClick: () => onZotero(paper),
8332
- children: t("pdf.zotero")
9274
+ children: zoteroLabel
8333
9275
  }),
8334
9276
  paper.pdfUrl !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
8335
9277
  style: linkStyle,
@@ -9232,6 +10174,13 @@ const CHIP_REMOVE_STYLE = {
9232
10174
  * 避免多个 tab 空转。重跑 / 取消都走同一套刷新。
9233
10175
  */
9234
10176
  const POLL_MS = 1500;
10177
+ /**
10178
+ * 结果页每页篇数(与检索条件的「每源条数」无关,只影响面板展示)。
10179
+ *
10180
+ * 与 Host 侧「第 N 页」共用一个常量:模型说「第 2 页」时,宿主就是按这个
10181
+ * 粒度切片的;两处各写一份会静默错位。
10182
+ */
10183
+ const RESULT_PAGE_SIZE = 20;
9235
10184
  const INITIAL = {
9236
10185
  detail: null,
9237
10186
  page: null,
@@ -9244,7 +10193,7 @@ function isActive(status) {
9244
10193
  function errorMessage(error) {
9245
10194
  return error instanceof Error ? error.message : String(error);
9246
10195
  }
9247
- function useTask(id, signal, pageSize = 20, filters = {}) {
10196
+ function useTask(id, signal, pageSize = RESULT_PAGE_SIZE, filters = {}) {
9248
10197
  const [state, setState] = (0, react.useState)(INITIAL);
9249
10198
  const [nonce, setNonce] = (0, react.useState)(0);
9250
10199
  const [busy, setBusy] = (0, react.useState)(false);
@@ -9468,12 +10417,22 @@ function formatDate(value) {
9468
10417
  const date = new Date(value);
9469
10418
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
9470
10419
  }
10420
+ /** `ResultsQuery` 的扁平投影:只保留已生效、可序列化的筛选值。 */
10421
+ function snapshotFilters(filters) {
10422
+ const out = {};
10423
+ for (const [key, value] of Object.entries(filters)) {
10424
+ if (key === "offset" || key === "limit") continue;
10425
+ if (value === void 0 || value === null || value === "" || value === false) continue;
10426
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") out[key] = value;
10427
+ }
10428
+ return out;
10429
+ }
9471
10430
  function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onCloseTask, onOpenLocal, onRevealLocal }) {
9472
10431
  const info = useTabInfo();
9473
10432
  const taskId = parseTaskId(info);
9474
10433
  const [filters, setFilters] = (0, react.useState)({});
9475
10434
  const [tab, setTab] = (0, react.useState)("results");
9476
- const view = useTask(taskId, info.tab.signal, 20, filters);
10435
+ const view = useTask(taskId, info.tab.signal, RESULT_PAGE_SIZE, filters);
9477
10436
  const bodyRef = (0, react.useRef)(null);
9478
10437
  const [toast, setToastState] = (0, react.useState)("");
9479
10438
  const [toastTone, setToastTone] = (0, react.useState)("info");
@@ -9489,8 +10448,20 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9489
10448
  /** 批量下载弹窗:只集中展示,真实状态仍写在 downloads / 任务详情的 states 里。 */
9490
10449
  const [batchItems, setBatchItems] = (0, react.useState)([]);
9491
10450
  const [batchOpen, setBatchOpen] = (0, react.useState)(false);
10451
+ /** Zotero 同步弹窗:单篇与批量共用,先选目标目录再同步,并展示逐篇结果。 */
10452
+ const [zoteroItems, setZoteroItems] = (0, react.useState)([]);
10453
+ const [zoteroOpen, setZoteroOpen] = (0, react.useState)(false);
10454
+ /** 模型发起的同步:范围说明、原话与目标目录;手动操作时为 undefined。 */
10455
+ const [zoteroMeta, setZoteroMeta] = (0, react.useState)(void 0);
9492
10456
  const [batchRunning, setBatchRunning] = (0, react.useState)(false);
9493
10457
  const batchRunningRef = (0, react.useRef)(false);
10458
+ /** 模型发起的下载:弹窗标明来源/范围,并提供独立的「取消下载」。 */
10459
+ const [batchMeta, setBatchMeta] = (0, react.useState)({ origin: "panel" });
10460
+ const [batchCancelled, setBatchCancelled] = (0, react.useState)(false);
10461
+ const batchAbortRef = (0, react.useRef)(null);
10462
+ /** 待消费的模型指令;tab 未挂载时由模块级 store 暂存,focus 拉起后再消费。 */
10463
+ const pendingCommands = usePendingDownloadCommands(taskId);
10464
+ const pendingZoteroCommands = usePendingZoteroCommands(taskId);
9494
10465
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
9495
10466
  /** 结果页里见过的文献:跨页选中的条目做「加入对话」时要能拿到论文详情。 */
9496
10467
  const papersByIdRef = (0, react.useRef)(/* @__PURE__ */ new Map());
@@ -9629,22 +10600,18 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9629
10600
  }
9630
10601
  })();
9631
10602
  };
10603
+ /** 单篇 / 批量 / 模型指令共用一个弹窗:先选 Zotero 目录,再逐篇同步。 */
10604
+ const openZotero = (nextItems, meta) => {
10605
+ if (taskId === null || nextItems.length === 0) return;
10606
+ setZoteroItems(nextItems);
10607
+ setZoteroMeta(meta);
10608
+ setZoteroOpen(true);
10609
+ };
9632
10610
  const zoteroPaper = (paper) => {
9633
- if (taskId === null) return;
9634
- syncZotero(taskId, [paper.paperId]).then((result) => {
9635
- notify(zoteroToast(t, result.saved, result.failed, result.messages));
9636
- if (result.failed > 0) setRisFallback({
9637
- ids: [paper.paperId],
9638
- message: result.messages[0] ?? t("pdf.zoteroFailed")
9639
- });
9640
- }).catch((error) => {
9641
- const message = messageOf(error);
9642
- notify(tpl(t("pdf.zoteroFailed"), { message }));
9643
- setRisFallback({
9644
- ids: [paper.paperId],
9645
- message
9646
- });
9647
- });
10611
+ openZotero([{
10612
+ paperId: paper.paperId,
10613
+ title: paper.title
10614
+ }]);
9648
10615
  };
9649
10616
  const toggleSelect = (paperId) => {
9650
10617
  setSelected((previous) => {
@@ -9691,12 +10658,16 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9691
10658
  * 每篇的结果实时写入 downloads,弹窗只做集中展示。批量未结束时再次触发
9692
10659
  * 只重新打开弹窗,避免两轮循环重复下载同一批文献。
9693
10660
  */
9694
- const startBatchDownload = (entries) => {
10661
+ const startBatchDownload = (entries, meta = { origin: "panel" }) => {
9695
10662
  if (taskId === null || entries.length === 0) return;
9696
10663
  if (batchRunningRef.current) {
9697
10664
  setBatchOpen(true);
9698
10665
  return;
9699
10666
  }
10667
+ const controller = new AbortController();
10668
+ batchAbortRef.current = controller;
10669
+ setBatchCancelled(false);
10670
+ setBatchMeta(meta);
9700
10671
  setBatchItems(entries.map((entry) => ({ ...entry })));
9701
10672
  setBatchOpen(true);
9702
10673
  batchRunningRef.current = true;
@@ -9717,7 +10688,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9717
10688
  [paperId]: { state: "downloading" }
9718
10689
  }));
9719
10690
  try {
9720
- const result = await downloadPaperPdf(taskId, paperId);
10691
+ const result = await downloadPaperPdf(taskId, paperId, controller.signal);
9721
10692
  if (result.status === "downloaded") {
9722
10693
  ok += 1;
9723
10694
  setDownloads((previous) => ({
@@ -9740,6 +10711,14 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9740
10711
  }));
9741
10712
  }
9742
10713
  } catch (error) {
10714
+ if (controller.signal.aborted) {
10715
+ setDownloads((previous) => {
10716
+ const next = { ...previous };
10717
+ delete next[paperId];
10718
+ return next;
10719
+ });
10720
+ return;
10721
+ }
9743
10722
  failedCount += 1;
9744
10723
  setDownloads((previous) => ({
9745
10724
  ...previous,
@@ -9753,6 +10732,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9753
10732
  let cursor = 0;
9754
10733
  const worker = async () => {
9755
10734
  while (cursor < entries.length) {
10735
+ if (controller.signal.aborted) return;
9756
10736
  const entry = entries[cursor];
9757
10737
  cursor += 1;
9758
10738
  if (entry !== void 0) await runOne(entry.paperId);
@@ -9762,10 +10742,11 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9762
10742
  await Promise.all(Array.from({ length: Math.min(BATCH_CONCURRENCY, entries.length) }, () => worker()));
9763
10743
  } finally {
9764
10744
  batchRunningRef.current = false;
10745
+ batchAbortRef.current = null;
9765
10746
  setBatchRunning(false);
9766
10747
  if (changed) view.reload();
9767
10748
  }
9768
- if (changed) notify(tpl(t("bulk.downloadDone"), {
10749
+ if (changed && !controller.signal.aborted) notify(tpl(t("bulk.downloadDone"), {
9769
10750
  ok,
9770
10751
  manual: manualCount,
9771
10752
  failed: failedCount
@@ -9790,22 +10771,109 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9790
10771
  } : item));
9791
10772
  }).catch(() => void 0)));
9792
10773
  };
10774
+ /** 只有显式的「取消下载」才中止;关闭弹窗仍只隐藏,后台继续跑完。 */
10775
+ const cancelBatch = () => {
10776
+ batchAbortRef.current?.abort();
10777
+ setBatchCancelled(true);
10778
+ };
10779
+ (0, react.useEffect)(() => {
10780
+ if (taskId === null || view.detail === null || view.page === null) return void 0;
10781
+ const body = {
10782
+ taskId,
10783
+ pageIndex: view.pageIndex,
10784
+ pageSize: view.pageSize,
10785
+ total: view.page.total,
10786
+ ...filters.sort === void 0 ? {} : { sort: filters.sort },
10787
+ filters: snapshotFilters(filters),
10788
+ pagePaperIds: view.page.papers.map((paper) => paper.paperId),
10789
+ selectedIds: [...selected],
10790
+ rev: view.page.rev
10791
+ };
10792
+ const timer = setTimeout(() => {
10793
+ if (document.hidden) return;
10794
+ postViewSnapshot(body).catch(() => void 0);
10795
+ }, 400);
10796
+ return () => clearTimeout(timer);
10797
+ }, [
10798
+ taskId,
10799
+ view.detail,
10800
+ view.page,
10801
+ view.pageIndex,
10802
+ view.pageSize,
10803
+ filters,
10804
+ selected
10805
+ ]);
10806
+ (0, react.useEffect)(() => {
10807
+ if (taskId === null || batchRunningRef.current || batchRunning) return;
10808
+ const command = pendingCommands[0];
10809
+ if (command === void 0) return;
10810
+ if (command.paperIds.length === 0) {
10811
+ markConsumed(command.commandId);
10812
+ notify(t("batch.empty"));
10813
+ return;
10814
+ }
10815
+ const byId = new Map(command.items.map((item) => [item.paperId, item]));
10816
+ const items = command.paperIds.map((paperId) => {
10817
+ const item = byId.get(paperId);
10818
+ return {
10819
+ paperId,
10820
+ title: item?.title ?? papersByIdRef.current.get(paperId)?.title ?? "",
10821
+ ...item?.source === void 0 ? {} : { source: item.source },
10822
+ ...item?.meta === void 0 ? {} : { meta: item.meta }
10823
+ };
10824
+ });
10825
+ markConsumed(command.commandId);
10826
+ startBatchDownload(items, {
10827
+ origin: "model",
10828
+ scopeLabel: command.scopeLabel,
10829
+ reason: command.reason
10830
+ });
10831
+ }, [
10832
+ taskId,
10833
+ pendingCommands,
10834
+ batchRunning
10835
+ ]);
10836
+ (0, react.useEffect)(() => {
10837
+ if (taskId === null || zoteroOpen) return;
10838
+ const command = pendingZoteroCommands[0];
10839
+ if (command === void 0) return;
10840
+ if (command.paperIds.length === 0) {
10841
+ markZoteroConsumed(command.commandId);
10842
+ notify(t("zotero.dialog.modelEmpty"));
10843
+ return;
10844
+ }
10845
+ const byId = new Map(command.items.map((item) => [item.paperId, item]));
10846
+ const items = command.paperIds.map((paperId) => {
10847
+ const item = byId.get(paperId);
10848
+ return {
10849
+ paperId,
10850
+ title: item?.title ?? papersByIdRef.current.get(paperId)?.title ?? "",
10851
+ ...item?.source === void 0 ? {} : { source: item.source },
10852
+ ...item?.meta === void 0 ? {} : { meta: item.meta }
10853
+ };
10854
+ });
10855
+ markZoteroConsumed(command.commandId);
10856
+ openZotero(items, {
10857
+ origin: "model",
10858
+ scopeLabel: command.scopeLabel,
10859
+ reason: command.reason,
10860
+ ...command.target === void 0 ? {} : { initialTarget: command.target },
10861
+ autoStart: command.autoStart
10862
+ });
10863
+ }, [
10864
+ taskId,
10865
+ pendingZoteroCommands,
10866
+ zoteroOpen
10867
+ ]);
9793
10868
  const zoteroIds = (paperIds) => {
9794
10869
  if (taskId === null || paperIds.length === 0) return;
9795
- syncZotero(taskId, paperIds).then((result) => {
9796
- notify(zoteroToast(t, result.saved, result.failed, result.messages));
9797
- if (result.failed > 0) setRisFallback({
9798
- ids: [...paperIds],
9799
- message: result.messages[0] ?? t("pdf.zoteroFailed")
9800
- });
9801
- }).catch((error) => {
9802
- const message = messageOf(error);
9803
- notify(tpl(t("pdf.zoteroFailed"), { message }));
9804
- setRisFallback({
9805
- ids: [...paperIds],
9806
- message
9807
- });
9808
- });
10870
+ const known = /* @__PURE__ */ new Map();
10871
+ for (const paper of view.page?.papers ?? []) known.set(paper.paperId, paper.title);
10872
+ for (const paper of papersByIdRef.current.values()) if (!known.has(paper.paperId)) known.set(paper.paperId, paper.title);
10873
+ openZotero(paperIds.map((paperId) => ({
10874
+ paperId,
10875
+ title: known.get(paperId) ?? ""
10876
+ })));
9809
10877
  };
9810
10878
  const zoteroMany = () => zoteroIds(ids);
9811
10879
  if (taskId === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -10429,11 +11497,36 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
10429
11497
  items: batchItems,
10430
11498
  states: detail.states,
10431
11499
  downloads,
11500
+ origin: batchMeta.origin,
11501
+ scopeLabel: batchMeta.scopeLabel,
11502
+ reason: batchMeta.reason,
11503
+ cancelled: batchCancelled,
11504
+ onCancel: batchMeta.origin === "model" ? cancelBatch : void 0,
10432
11505
  onClose: () => {
10433
11506
  setBatchOpen(false);
10434
11507
  view.reload();
10435
11508
  }
10436
11509
  }),
11510
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoteroSyncDialog, {
11511
+ t,
11512
+ open: zoteroOpen,
11513
+ taskId,
11514
+ items: zoteroItems,
11515
+ states: detail.states,
11516
+ meta: zoteroMeta,
11517
+ onClose: () => {
11518
+ setZoteroOpen(false);
11519
+ view.reload();
11520
+ },
11521
+ onDone: (failedIds) => {
11522
+ view.reload();
11523
+ if (failedIds.length === 0) return;
11524
+ setRisFallback({
11525
+ ids: failedIds,
11526
+ message: t("zotero.dialog.failFallback")
11527
+ });
11528
+ }
11529
+ }),
10437
11530
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundsDialog, {
10438
11531
  t,
10439
11532
  open: showRounds,
@@ -10891,15 +11984,6 @@ function Pager({ t, index, count, total, onChange }) {
10891
11984
  function messageOf(error) {
10892
11985
  return error instanceof Error ? error.message : String(error);
10893
11986
  }
10894
- function zoteroToast(t, saved, failed, messages) {
10895
- const detail = messages[0] ?? "";
10896
- if (failed === 0 && detail === "") return tpl(t("pdf.zoteroDone"), { n: saved });
10897
- return tpl(t("pdf.zoteroPartial"), {
10898
- saved,
10899
- failed,
10900
- message: detail
10901
- });
10902
- }
10903
11987
  /** 结果页「检索还在进行」的提示条:蓝色高亮块 + 旋转图标。 */
10904
11988
  const ROUND_RESULTS_HINT_STYLE = {
10905
11989
  display: "flex",