@zucker-framework/ext-kit 1.0.2 → 1.0.5

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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  (() => {
3
- // src/popup.ts
3
+ // src/popup.js
4
4
  function initPopup(config) {
5
5
  const byId = (id) => document.getElementById(id);
6
6
  const el = {
@@ -36,35 +36,94 @@
36
36
  licenseCode: byId("licenseCode"),
37
37
  activateBtn: byId("activateBtn"),
38
38
  deactivateBtn: byId("deactivateBtn"),
39
+ recheckLicense: byId("recheckLicense"),
40
+ licenseHint: byId("licenseHint"),
39
41
  status: byId("status")
40
42
  };
41
43
  let pollTimer = null;
44
+ let disposed = false;
45
+ window.addEventListener("pagehide", () => {
46
+ disposed = true;
47
+ pollGeneration++;
48
+ clearTimeout(pollTimer);
49
+ if (scanJob) {
50
+ const job = scanJob;
51
+ scanJob = null;
52
+ void chrome.scripting.executeScript({
53
+ target: { tabId: job.tabId },
54
+ func: collectSameOriginLinks,
55
+ args: [{ phase: "cancel", id: job.id }]
56
+ }).catch(() => {
57
+ });
58
+ }
59
+ }, { once: true });
42
60
  let queue = [];
43
61
  let queueLocked = false;
62
+ let operationBusy = false;
63
+ let editingIndex = -1;
64
+ let scanJob = null;
65
+ let pollGeneration = 0;
66
+ const settingsKey = config.settingsKey || config.queueKey.replace(/_url_queue$/, "_settings");
67
+ const settingFields = () => document.querySelectorAll(
68
+ ".settings select, .settings input[data-setting]"
69
+ );
70
+ let savedSettings = {};
44
71
  const prefix = config.messagePrefix;
45
- void init();
72
+ void init().catch((error) => setStatus(errText(error), true));
46
73
  async function init() {
47
74
  el.urls.addEventListener("input", updateUrlCount);
48
75
  el.addUrls.addEventListener("click", addInputUrls);
49
76
  el.scanSite.addEventListener("click", scanCurrentSite);
50
77
  el.selectAll.addEventListener("click", () => setAllSelected(true));
51
78
  el.selectNone.addEventListener("click", () => setAllSelected(false));
52
- el.moreQueueActions.addEventListener("click", () => el.queueMenu.classList.toggle("hidden"));
79
+ el.moreQueueActions.addEventListener("click", (event) => {
80
+ event.stopPropagation();
81
+ setQueueMenu(el.queueMenu.classList.contains("hidden"));
82
+ });
83
+ document.addEventListener("click", (event) => {
84
+ if (!el.queueMenu.classList.contains("hidden") && !el.queueMenu.contains(event.target))
85
+ setQueueMenu(false);
86
+ });
87
+ document.addEventListener("keydown", (event) => {
88
+ if (event.key === "Escape" && !el.queueMenu.classList.contains("hidden")) {
89
+ setQueueMenu(false);
90
+ el.moreQueueActions.focus();
91
+ }
92
+ });
53
93
  el.removeUnselected.addEventListener("click", removeUnselected);
54
94
  el.clearQueue.addEventListener("click", clearQueue);
55
95
  el.captureCurrent.addEventListener("click", quickCapture);
56
96
  el.start.addEventListener("click", startBatch);
57
97
  el.cancel.addEventListener("click", cancelBatch);
58
98
  el.clearBatch.addEventListener("click", clearBatchResults);
59
- el.skipVerify.addEventListener("click", () => send({ type: `${prefix}_SKIP_VERIFY` }));
60
- el.haveCode.addEventListener("click", () => el.licensePanel.classList.toggle("hidden"));
99
+ el.skipVerify.addEventListener(
100
+ "click",
101
+ () => send({ type: `${prefix}_SKIP_VERIFY` })
102
+ );
103
+ el.haveCode.addEventListener(
104
+ "click",
105
+ () => el.licensePanel.classList.toggle("hidden")
106
+ );
61
107
  el.activateBtn.addEventListener("click", activateLicense);
62
108
  el.deactivateBtn.addEventListener("click", deactivateLicense);
63
- await loadQueue();
109
+ el.recheckLicense?.addEventListener("click", recheckLicense);
110
+ el.start.disabled = true;
111
+ el.captureCurrent.disabled = true;
112
+ await Promise.all([loadQueue(), loadSettings()]);
113
+ config.onSettingsLoaded?.();
114
+ settingFields().forEach((field) => {
115
+ field.addEventListener("change", saveSettings);
116
+ if (field.type === "number")
117
+ field.addEventListener("input", saveSettings);
118
+ });
64
119
  updateUrlCount();
65
120
  renderQueue();
66
121
  await refreshState();
67
122
  }
123
+ function setQueueMenu(open) {
124
+ el.queueMenu.classList.toggle("hidden", !open);
125
+ el.moreQueueActions.setAttribute("aria-expanded", String(open));
126
+ }
68
127
  async function activateLicense() {
69
128
  const code = el.licenseCode.value.trim();
70
129
  if (!code) {
@@ -85,11 +144,29 @@
85
144
  setStatus("Pro activated \u2014 enjoy unlimited pages!");
86
145
  }
87
146
  async function deactivateLicense() {
147
+ el.deactivateBtn.disabled = true;
88
148
  const response = await send({ type: `${prefix}_CLEAR_LICENSE` });
149
+ el.deactivateBtn.disabled = false;
89
150
  if (response.ok) {
90
151
  renderQuota(response.quota);
91
- setStatus("License removed from this device");
92
- }
152
+ setStatus("Device released. Its license slot is now available.");
153
+ } else setStatus(response.error || "Could not release this device", true);
154
+ }
155
+ async function recheckLicense() {
156
+ el.recheckLicense.disabled = true;
157
+ setStatus("Checking your Pro license\u2026");
158
+ const response = await send({ type: `${prefix}_REFRESH_LICENSE` });
159
+ el.recheckLicense.disabled = false;
160
+ if (!response.ok)
161
+ return setStatus(
162
+ response.error || "Could not check your license",
163
+ true
164
+ );
165
+ renderQuota(response.quota);
166
+ setStatus(
167
+ response.quota.pro ? "Pro confirmed \u2014 unlimited exports." : response.quota.licenseMessage || "Enter your original activation code to restore Pro in this extension.",
168
+ !response.quota.pro
169
+ );
93
170
  }
94
171
  function parseUrls() {
95
172
  return normalizeInputUrls(el.urls.value.split(/\s+/));
@@ -106,7 +183,6 @@
106
183
  if (!/^https?:$/.test(url.protocol)) {
107
184
  continue;
108
185
  }
109
- url.hash = "";
110
186
  const normalized = url.toString();
111
187
  if (!seen.has(normalized)) {
112
188
  seen.add(normalized);
@@ -147,30 +223,60 @@
147
223
  return added;
148
224
  }
149
225
  async function scanCurrentSite() {
150
- setStatus("Scanning current tab\u2026");
226
+ if (scanJob) {
227
+ const stopped = scanJob;
228
+ scanJob = null;
229
+ await chrome.scripting.executeScript({
230
+ target: { tabId: stopped.tabId },
231
+ func: collectSameOriginLinks,
232
+ args: [{ phase: "cancel", id: stopped.id }]
233
+ }).catch(() => {
234
+ });
235
+ el.scanSite.textContent = "\u2316 Scan site";
236
+ setStatus("Scan stopped; imported links are kept");
237
+ return;
238
+ }
239
+ if (queueLocked || operationBusy) return;
240
+ let job;
151
241
  try {
152
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
153
- if (!tab?.id) {
154
- throw new Error("No active tab");
155
- }
156
- const [result] = await chrome.scripting.executeScript({
242
+ const [tab] = await chrome.tabs.query({
243
+ active: true,
244
+ currentWindow: true
245
+ });
246
+ if (!tab?.id || !/^https?:\/\//i.test(tab.url || ""))
247
+ throw new Error("Open an http(s) website first");
248
+ job = { tabId: tab.id, id: crypto.randomUUID() };
249
+ scanJob = job;
250
+ el.scanSite.textContent = "Stop scan";
251
+ const [links] = await chrome.scripting.executeScript({
157
252
  target: { tabId: tab.id },
158
- func: collectSameOriginLinks
253
+ func: collectSameOriginLinks,
254
+ args: [{ phase: "links" }]
159
255
  });
160
- const scan = result?.result || {
161
- urls: [],
162
- sitemapCount: 0
163
- };
164
- const found = scan.urls || [];
165
- if (!found.length) {
166
- setStatus("No same-site links found on this page", true);
256
+ if (scanJob !== job) return;
257
+ const added = addUrlsToQueue(links?.result?.urls || []);
258
+ setStatus(`Added ${added} page links; checking sitemap\u2026`);
259
+ const [maps] = await chrome.scripting.executeScript({
260
+ target: { tabId: tab.id },
261
+ func: collectSameOriginLinks,
262
+ args: [{ phase: "sitemap", id: job.id }]
263
+ });
264
+ if (scanJob !== job) return;
265
+ if (queueLocked || operationBusy) {
266
+ setStatus("Scan stopped when the task started; page links are kept");
167
267
  return;
168
268
  }
169
- const added = addUrlsToQueue(found);
170
- const sitemapNote = scan.sitemapCount ? `, including ${scan.sitemapCount} from sitemap` : "";
171
- setStatus(`Found ${found.length} same-site URLs${sitemapNote}; added ${added} new`);
269
+ const extra = addUrlsToQueue(maps?.result?.urls || []);
270
+ setStatus(
271
+ `Added ${added + extra} URLs${maps?.result?.incomplete ? "; some sitemaps could not be read" : ""}`
272
+ );
172
273
  } catch (error) {
173
274
  setStatus(errText(error), true);
275
+ } finally {
276
+ if (scanJob === job) {
277
+ scanJob = null;
278
+ el.scanSite.textContent = "\u2316 Scan site";
279
+ }
174
280
  }
175
281
  }
176
282
  async function startBatch() {
@@ -179,14 +285,21 @@
179
285
  setStatus("Select at least one URL from the queue", true);
180
286
  return;
181
287
  }
182
- el.start.disabled = true;
288
+ operationBusy = true;
289
+ renderQueue();
183
290
  setStatus("Starting\u2026");
184
- const response = await send({
185
- type: `${prefix}_START`,
186
- urls,
187
- settings: config.collectSettings()
188
- });
189
- el.start.disabled = false;
291
+ let response;
292
+ try {
293
+ response = await send({
294
+ type: `${prefix}_START`,
295
+ urls,
296
+ settings: await config.collectSettings()
297
+ });
298
+ } catch (error) {
299
+ response = { ok: false, error: errText(error) };
300
+ }
301
+ operationBusy = false;
302
+ renderQueue();
190
303
  if (!response.ok) {
191
304
  setStatus(response.error || "Failed to start", true);
192
305
  return;
@@ -199,20 +312,30 @@
199
312
  );
200
313
  }
201
314
  async function quickCapture() {
202
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
315
+ const [tab] = await chrome.tabs.query({
316
+ active: true,
317
+ currentWindow: true
318
+ });
203
319
  if (!tab?.id || !/^https?:\/\//i.test(tab.url || "")) {
204
320
  setStatus("Open an http(s) website first", true);
205
321
  return;
206
322
  }
207
- el.captureCurrent.disabled = true;
208
- el.start.disabled = true;
323
+ operationBusy = true;
324
+ renderQueue();
325
+ el.cancel.classList.remove("hidden");
209
326
  setStatus(config.texts.quickIng);
210
- const response = await send({
211
- type: `${prefix}_${config.quickType}`,
212
- tabId: tab.id,
213
- settings: config.collectQuickSettings()
214
- });
215
- el.captureCurrent.disabled = false;
327
+ let response;
328
+ try {
329
+ response = await send({
330
+ type: `${prefix}_${config.quickType}`,
331
+ tabId: tab.id,
332
+ settings: await config.collectQuickSettings()
333
+ });
334
+ } catch (error) {
335
+ response = { ok: false, error: errText(error) };
336
+ }
337
+ operationBusy = false;
338
+ await refreshState();
216
339
  renderQueue();
217
340
  if (!response.ok || !response.result) {
218
341
  setStatus(response.error || config.texts.quickFailed, true);
@@ -235,6 +358,7 @@
235
358
  }
236
359
  renderBatch(response.batch);
237
360
  setStatus("Batch results cleared");
361
+ renderQuota(response.quota);
238
362
  }
239
363
  async function refreshState() {
240
364
  const response = await send({ type: `${prefix}_STATE` });
@@ -243,54 +367,61 @@
243
367
  }
244
368
  renderQuota(response.quota);
245
369
  renderBatch(response.batch);
246
- if (response.batch?.running) {
370
+ if (response.batch?.running || response.batch?.busy || response.quota?.licenseRefreshing) {
247
371
  schedulePoll();
248
372
  }
249
373
  }
250
374
  function schedulePoll() {
251
- if (pollTimer) {
252
- clearInterval(pollTimer);
253
- }
254
- pollTimer = setInterval(async () => {
375
+ if (disposed) return;
376
+ clearTimeout(pollTimer);
377
+ const generation = ++pollGeneration;
378
+ async function poll() {
255
379
  const response = await send({ type: `${prefix}_STATE` });
256
- if (!response.ok) {
257
- return;
258
- }
259
- renderQuota(response.quota);
260
- renderBatch(response.batch);
261
- if (!response.batch?.running && pollTimer) {
262
- clearInterval(pollTimer);
380
+ if (disposed || generation !== pollGeneration) return;
381
+ if (response.ok) {
382
+ renderQuota(response.quota);
383
+ renderBatch(response.batch);
263
384
  }
264
- }, 800);
385
+ if (!response.ok || response.batch?.running || response.batch?.busy || response.quota?.licenseRefreshing)
386
+ pollTimer = setTimeout(poll, 800);
387
+ }
388
+ pollTimer = setTimeout(poll, 800);
265
389
  }
266
390
  function renderQuota(quota) {
267
391
  if (!quota) {
268
392
  return;
269
393
  }
394
+ if (el.licenseHint)
395
+ el.licenseHint.textContent = quota.licenseMessage || (quota.pro ? "Your Pro license includes unlimited exports." : "Already purchased? Enter your original activation code to restore Pro in this extension and browser.");
396
+ el.recheckLicense?.classList.toggle("hidden", !quota.hasLicense);
397
+ el.proLink.classList.toggle("hidden", quota.pro || quota.hasLicense);
398
+ el.deactivateBtn.classList.toggle("hidden", !quota.hasLicense);
399
+ el.activateBtn.classList.toggle("hidden", quota.pro);
270
400
  if (quota.pro) {
271
401
  const until = quota.expiresAt ? ` \xB7 ${String(quota.expiresAt).slice(0, 10)}` : "";
272
402
  el.quotaText.textContent = `unlimited${until}`;
273
- el.proLink.classList.add("hidden");
274
- el.deactivateBtn.classList.remove("hidden");
275
- el.activateBtn.classList.add("hidden");
403
+ } else if (quota.hasLicense) {
404
+ el.quotaText.textContent = quota.licenseStatus === "checking" ? "Checking Pro\u2026" : "Pro needs verification";
276
405
  } else {
277
406
  el.quotaText.textContent = `${quota.remaining}/${quota.limit} today`;
278
- el.proLink.classList.remove("hidden");
279
- el.deactivateBtn.classList.add("hidden");
280
- el.activateBtn.classList.remove("hidden");
281
407
  }
282
408
  }
283
409
  function renderBatch(batch) {
284
410
  if (!batch) {
285
411
  return;
286
412
  }
287
- el.start.classList.toggle("hidden", batch.running);
288
- el.captureCurrent.classList.toggle("hidden", batch.running);
289
- el.cancel.classList.toggle("hidden", !batch.running);
290
- queueLocked = batch.running;
291
- renderQueue();
413
+ const busy = batch.running || batch.busy;
414
+ const stalled = Boolean(batch.stalled);
415
+ el.start.classList.toggle("hidden", busy);
416
+ el.captureCurrent.classList.toggle("hidden", busy);
417
+ el.cancel.classList.toggle("hidden", !busy);
418
+ queueLocked = busy && !stalled;
419
+ renderQueue(true);
292
420
  el.progressWrap.classList.toggle("hidden", !batch.total);
293
- el.clearBatch.classList.toggle("hidden", batch.running || !batch.total);
421
+ el.clearBatch.classList.toggle(
422
+ "hidden",
423
+ busy && !stalled || !batch.total
424
+ );
294
425
  if (batch.total) {
295
426
  el.progressText.textContent = `${batch.done} / ${batch.total}`;
296
427
  el.progressCurrent.textContent = batch.current || "";
@@ -307,7 +438,14 @@
307
438
  rowEl.className = "resultItem";
308
439
  const state = document.createElement("span");
309
440
  state.className = `state ${item.status}`;
310
- state.textContent = { pending: "\xB7", running: "\u2026", success: "\u2713", failed: "\u2717" }[item.status] || "\xB7";
441
+ state.textContent = {
442
+ pending: "\xB7",
443
+ running: "\u2026",
444
+ downloading: "\u2193",
445
+ cancelled: "\u2013",
446
+ success: "\u2713",
447
+ failed: "\u2717"
448
+ }[item.status] || "\xB7";
311
449
  const url = document.createElement("span");
312
450
  url.className = "url";
313
451
  url.textContent = item.url.replace(/^https?:\/\//, "");
@@ -326,7 +464,7 @@
326
464
  }
327
465
  el.resultList.append(rowEl);
328
466
  }
329
- if (batch.folder && batch.done > 0) {
467
+ if (batch.folder && batch.success > 0) {
330
468
  el.folderNote.classList.remove("hidden");
331
469
  el.folderNote.textContent = `Saved to Downloads/${batch.folder}/`;
332
470
  } else {
@@ -334,25 +472,52 @@
334
472
  el.folderNote.textContent = "";
335
473
  }
336
474
  }
475
+ async function loadSettings() {
476
+ const data = await chrome.storage.local.get(settingsKey);
477
+ const saved = data[settingsKey];
478
+ if (!saved || typeof saved !== "object") return;
479
+ settingFields().forEach((field) => {
480
+ const value = String(saved[field.id]);
481
+ if (field.tagName === "SELECT" && [...field.options].some((option) => option.value === value))
482
+ field.value = value;
483
+ if (field.type === "number" && value.trim() && Number.isFinite(Number(value))) {
484
+ const fallback = field.value;
485
+ field.value = value;
486
+ if (!field.checkValidity()) field.value = fallback;
487
+ }
488
+ savedSettings[field.id] = field.value;
489
+ });
490
+ }
491
+ function saveSettings() {
492
+ settingFields().forEach((field) => {
493
+ if (field.type !== "number" || field.value.trim() && Number.isFinite(field.valueAsNumber) && field.checkValidity())
494
+ savedSettings[field.id] = field.value;
495
+ });
496
+ void chrome.storage.local.set({ [settingsKey]: { ...savedSettings } }).catch((error) => setStatus(errText(error), true));
497
+ }
337
498
  async function loadQueue() {
338
499
  const data = await chrome.storage.local.get(config.queueKey);
339
500
  const saved = Array.isArray(data[config.queueKey]) ? data[config.queueKey] : [];
340
501
  queue = saved.filter((item) => item && typeof item.url === "string").slice(0, 500).map((item) => ({ url: item.url, selected: item.selected !== false }));
341
502
  }
342
503
  function saveQueue() {
343
- void chrome.storage.local.set({ [config.queueKey]: queue });
504
+ void chrome.storage.local.set({ [config.queueKey]: queue }).catch((error) => setStatus(errText(error), true));
344
505
  }
345
- function renderQueue() {
506
+ function renderQueue(preserveEditor = false) {
507
+ if (queueLocked || operationBusy) editingIndex = -1;
508
+ if (editingIndex >= queue.length) editingIndex = -1;
346
509
  const selected = queue.filter((item) => item.selected).length;
347
510
  el.queueSummary.textContent = `${selected}/${queue.length} selected`;
348
511
  el.start.textContent = selected ? config.texts.startSome(selected) : config.texts.startNone;
349
- el.start.disabled = selected === 0;
350
- el.selectAll.disabled = queueLocked || !queue.length;
351
- el.selectNone.disabled = queueLocked || !queue.length;
352
- el.moreQueueActions.disabled = queueLocked || !queue.length;
353
- el.addUrls.disabled = queueLocked;
354
- el.scanSite.disabled = queueLocked;
355
- el.captureCurrent.disabled = queueLocked;
512
+ el.start.disabled = operationBusy || queueLocked || selected === 0;
513
+ el.selectAll.disabled = queueLocked || operationBusy || !queue.length;
514
+ el.selectNone.disabled = queueLocked || operationBusy || !queue.length;
515
+ el.moreQueueActions.disabled = queueLocked || operationBusy || !queue.length;
516
+ el.addUrls.disabled = queueLocked || operationBusy;
517
+ el.scanSite.disabled = queueLocked || operationBusy;
518
+ el.captureCurrent.disabled = queueLocked || operationBusy;
519
+ if (preserveEditor && editingIndex >= 0 && el.queueList.querySelector(".queueEdit"))
520
+ return;
356
521
  el.queueList.replaceChildren();
357
522
  if (!queue.length) {
358
523
  const empty = document.createElement("p");
@@ -367,28 +532,23 @@
367
532
  const checkbox = document.createElement("input");
368
533
  checkbox.type = "checkbox";
369
534
  checkbox.checked = item.selected;
370
- checkbox.disabled = queueLocked;
535
+ checkbox.disabled = queueLocked || operationBusy;
371
536
  checkbox.setAttribute("aria-label", `Include ${item.url}`);
372
537
  checkbox.addEventListener("change", () => {
373
538
  item.selected = checkbox.checked;
374
539
  saveQueue();
375
540
  renderQueue();
376
541
  });
377
- const url = document.createElement("button");
378
- url.type = "button";
379
- url.className = "queueUrl";
380
- url.textContent = item.url.replace(/^https?:\/\//, "");
381
- url.title = item.url;
382
- url.disabled = queueLocked;
383
- url.addEventListener("click", () => editQueueItem(index));
542
+ const url = index === editingIndex ? buildQueueEditor(item, index) : buildQueueLabel(item, index);
384
543
  const remove = document.createElement("button");
385
544
  remove.type = "button";
386
545
  remove.className = "queueRemove";
387
546
  remove.textContent = "\xD7";
388
547
  remove.title = "Remove URL";
389
- remove.disabled = queueLocked;
548
+ remove.disabled = queueLocked || operationBusy;
390
549
  remove.addEventListener("click", () => {
391
550
  queue.splice(index, 1);
551
+ editingIndex = -1;
392
552
  saveQueue();
393
553
  renderQueue();
394
554
  });
@@ -396,27 +556,80 @@
396
556
  el.queueList.append(row);
397
557
  });
398
558
  }
399
- function editQueueItem(index) {
400
- const current = queue[index];
401
- const value = prompt("Edit URL", current.url);
402
- if (value === null) {
403
- return;
404
- }
405
- const [normalized] = normalizeInputUrls([value]);
406
- if (!normalized) {
407
- setStatus("Enter a valid http(s) URL", true);
408
- return;
409
- }
410
- if (queue.some((item, itemIndex) => itemIndex !== index && item.url === normalized)) {
411
- setStatus("That URL is already in the queue", true);
412
- return;
413
- }
414
- current.url = normalized;
415
- saveQueue();
416
- renderQueue();
559
+ function buildQueueLabel(item, index) {
560
+ const url = document.createElement("button");
561
+ url.type = "button";
562
+ url.className = "queueUrl";
563
+ url.textContent = item.url.replace(/^https?:\/\//, "");
564
+ url.title = `${item.url}
565
+ Click to edit`;
566
+ url.disabled = queueLocked || operationBusy;
567
+ url.addEventListener("click", () => {
568
+ if (queueLocked || operationBusy) return;
569
+ editingIndex = index;
570
+ renderQueue();
571
+ });
572
+ return url;
573
+ }
574
+ function buildQueueEditor(item, index) {
575
+ const input = document.createElement("input");
576
+ input.type = "text";
577
+ input.className = "queueEdit";
578
+ input.value = item.url;
579
+ input.spellcheck = false;
580
+ input.setAttribute("aria-label", `Edit ${item.url}`);
581
+ const leaveEditor = () => {
582
+ editingIndex = -1;
583
+ const label = buildQueueLabel(item, index);
584
+ input.parentElement?.querySelector('input[type="checkbox"]')?.setAttribute("aria-label", `Include ${item.url}`);
585
+ input.replaceWith(label);
586
+ return label;
587
+ };
588
+ const finish = (fromBlur) => {
589
+ if (editingIndex !== index) return;
590
+ const [normalized] = normalizeInputUrls([input.value]);
591
+ const duplicate = normalized && queue.some(
592
+ (other, otherIndex) => otherIndex !== index && other.url === normalized
593
+ );
594
+ if (!normalized || duplicate) {
595
+ const message = duplicate ? "That URL is already in the queue" : "Enter a valid http(s) URL";
596
+ if (fromBlur) {
597
+ setStatus(`${message} \u2014 edit discarded`, true);
598
+ leaveEditor();
599
+ } else {
600
+ setStatus(message, true);
601
+ input.focus();
602
+ input.select();
603
+ }
604
+ return;
605
+ }
606
+ if (normalized !== item.url) {
607
+ item.url = normalized;
608
+ saveQueue();
609
+ setStatus("URL updated");
610
+ }
611
+ const label = leaveEditor();
612
+ if (!fromBlur) label.focus();
613
+ };
614
+ input.addEventListener("keydown", (event) => {
615
+ if (event.key === "Enter") {
616
+ event.preventDefault();
617
+ finish(false);
618
+ } else if (event.key === "Escape") {
619
+ event.preventDefault();
620
+ event.stopPropagation();
621
+ leaveEditor().focus();
622
+ }
623
+ });
624
+ input.addEventListener("blur", () => finish(true));
625
+ queueMicrotask(() => {
626
+ input.focus();
627
+ input.select();
628
+ });
629
+ return input;
417
630
  }
418
631
  function setAllSelected(selected) {
419
- if (queueLocked) {
632
+ if (queueLocked || operationBusy) {
420
633
  return;
421
634
  }
422
635
  queue.forEach((item) => {
@@ -426,14 +639,18 @@
426
639
  renderQueue();
427
640
  }
428
641
  function removeUnselected() {
642
+ if (queueLocked || operationBusy) return;
429
643
  queue = queue.filter((item) => item.selected);
430
- el.queueMenu.classList.add("hidden");
644
+ editingIndex = -1;
645
+ setQueueMenu(false);
431
646
  saveQueue();
432
647
  renderQueue();
433
648
  }
434
649
  function clearQueue() {
650
+ if (queueLocked || operationBusy) return;
435
651
  queue = [];
436
- el.queueMenu.classList.add("hidden");
652
+ editingIndex = -1;
653
+ setQueueMenu(false);
437
654
  saveQueue();
438
655
  renderQueue();
439
656
  }
@@ -448,6 +665,7 @@
448
665
  });
449
666
  }
450
667
  function setStatus(text, isError = false) {
668
+ if (disposed) return;
451
669
  el.status.textContent = text;
452
670
  el.status.classList.toggle("error", isError);
453
671
  }
@@ -455,69 +673,135 @@
455
673
  return error instanceof Error ? error.message : String(error);
456
674
  }
457
675
  }
458
- async function collectSameOriginLinks() {
459
- const seen = /* @__PURE__ */ new Set();
460
- const out = [];
461
- const origin = location.origin;
462
- let sitemapCount = 0;
676
+ async function collectSameOriginLinks(options = {}) {
677
+ const origin = location.origin, seen = /* @__PURE__ */ new Set(), urls = [];
463
678
  const add = (value) => {
464
679
  try {
465
680
  const url = new URL(value || "", location.href);
466
- url.hash = "";
467
- if (url.origin === origin && /^https?:$/.test(url.protocol) && !seen.has(url.toString()) && out.length < 500) {
468
- seen.add(url.toString());
469
- out.push(url.toString());
681
+ if (url.origin === origin && /^https?:$/.test(url.protocol) && !seen.has(url.href) && urls.length < 500) {
682
+ seen.add(url.href);
683
+ urls.push(url.href);
470
684
  return true;
471
685
  }
472
686
  } catch {
473
687
  }
474
688
  return false;
475
689
  };
476
- add(location.href);
477
- for (const a of document.querySelectorAll("a[href]")) {
478
- add(a.getAttribute("href"));
479
- if (out.length >= 500) {
480
- break;
481
- }
482
- }
483
- const sitemapQueue = [new URL("/sitemap.xml", origin).toString()];
484
- const visitedSitemaps = /* @__PURE__ */ new Set();
485
- while (sitemapQueue.length && visitedSitemaps.size < 8 && out.length < 500) {
486
- const sitemapUrl = sitemapQueue.shift();
487
- if (visitedSitemaps.has(sitemapUrl)) {
488
- continue;
690
+ if (options.phase === "cancel") {
691
+ const active = globalThis.__zuckerExtScan;
692
+ if (active?.id === options.id) {
693
+ active.cancelled = true;
694
+ active.controller?.abort();
489
695
  }
490
- visitedSitemaps.add(sitemapUrl);
696
+ return { urls: [], cancelled: true };
697
+ }
698
+ if (options.phase !== "sitemap") {
699
+ add(location.href);
700
+ for (const a of document.querySelectorAll("a[href]"))
701
+ add(a.getAttribute("href"));
702
+ }
703
+ if (options.phase === "links") return { urls, sitemapCount: 0 };
704
+ const scan = { id: options.id, cancelled: false, controller: null };
705
+ globalThis.__zuckerExtScan?.controller?.abort();
706
+ globalThis.__zuckerExtScan = scan;
707
+ const deadline = Date.now() + 6e3;
708
+ let incomplete = false, sitemapCount = 0;
709
+ const queue = [new URL("/sitemap.xml", origin).href], visited = /* @__PURE__ */ new Set();
710
+ const sameOrigin = (value) => {
491
711
  try {
492
- const response = await fetch(sitemapUrl, { credentials: "same-origin" });
493
- if (!response.ok) {
494
- continue;
495
- }
496
- const xml = new DOMParser().parseFromString(await response.text(), "application/xml");
497
- for (const loc of xml.querySelectorAll("loc")) {
498
- const value = loc.textContent?.trim();
499
- if (!value) {
500
- continue;
501
- }
502
- if (/\.xml(?:\.gz)?(?:$|\?)/i.test(value)) {
503
- try {
504
- const nested = new URL(value, sitemapUrl);
505
- if (nested.origin === origin && !visitedSitemaps.has(nested.toString())) {
506
- sitemapQueue.push(nested.toString());
507
- }
508
- } catch {
509
- }
510
- } else if (add(value)) {
511
- sitemapCount += 1;
712
+ const url = new URL(value, origin);
713
+ return url.origin === origin && /^https?:$/.test(url.protocol) ? url.href : null;
714
+ } catch {
715
+ return null;
716
+ }
717
+ };
718
+ async function read(url) {
719
+ if (scan.cancelled || Date.now() >= deadline) {
720
+ incomplete = true;
721
+ return null;
722
+ }
723
+ const controller = new AbortController();
724
+ scan.controller = controller;
725
+ const timer = setTimeout(
726
+ () => controller.abort(),
727
+ Math.min(1500, deadline - Date.now())
728
+ );
729
+ try {
730
+ const response = await fetch(url, {
731
+ credentials: "same-origin",
732
+ signal: controller.signal
733
+ });
734
+ if (!response.ok) return null;
735
+ if (Number(response.headers.get("content-length")) > 2 * 1024 * 1024)
736
+ throw new Error("Sitemap too large");
737
+ let stream = response.body;
738
+ const reader = stream.getReader();
739
+ const first = await reader.read();
740
+ const gzip = first.value?.[0] === 31 && first.value?.[1] === 139;
741
+ stream = new ReadableStream({
742
+ start(ctrl) {
743
+ if (first.value) ctrl.enqueue(first.value);
744
+ if (first.done) ctrl.close();
745
+ },
746
+ async pull(ctrl) {
747
+ const value = await reader.read();
748
+ value.done ? ctrl.close() : ctrl.enqueue(value.value);
749
+ },
750
+ cancel(reason) {
751
+ return reader.cancel(reason);
512
752
  }
513
- if (out.length >= 500) {
514
- break;
753
+ });
754
+ if (gzip) stream = stream.pipeThrough(new DecompressionStream("gzip"));
755
+ const decoded = stream.getReader(), chunks = [];
756
+ let size = 0;
757
+ try {
758
+ while (true) {
759
+ const next = await decoded.read();
760
+ if (next.done) break;
761
+ size += next.value.length;
762
+ if (size > 2 * 1024 * 1024) throw new Error("Sitemap too large");
763
+ chunks.push(next.value);
515
764
  }
765
+ } finally {
766
+ await decoded.cancel().catch(() => {
767
+ });
516
768
  }
769
+ return await new Blob(chunks).text();
517
770
  } catch {
771
+ incomplete = true;
772
+ return null;
773
+ } finally {
774
+ clearTimeout(timer);
775
+ }
776
+ }
777
+ const robots = await read(new URL("/robots.txt", origin).href);
778
+ for (const match of (robots || "").matchAll(/^sitemap:\s*(\S+)/gim)) {
779
+ const url = sameOrigin(match[1]);
780
+ if (url) queue.push(url);
781
+ }
782
+ while (queue.length && visited.size < 8 && urls.length < 500 && !scan.cancelled && Date.now() < deadline) {
783
+ const url = queue.shift();
784
+ if (visited.has(url)) continue;
785
+ visited.add(url);
786
+ const content = await read(url);
787
+ if (!content) continue;
788
+ const xml = new DOMParser().parseFromString(content, "application/xml");
789
+ if (xml.querySelector("parsererror")) {
790
+ incomplete = true;
791
+ continue;
792
+ }
793
+ const index = xml.documentElement.localName === "sitemapindex";
794
+ for (const loc of xml.querySelectorAll("loc")) {
795
+ const value = sameOrigin(loc.textContent?.trim());
796
+ if (!value) continue;
797
+ if (index) {
798
+ if (!visited.has(value)) queue.push(value);
799
+ } else if (add(value)) sitemapCount++;
518
800
  }
519
801
  }
520
- return { urls: out, sitemapCount };
802
+ if (queue.length) incomplete = true;
803
+ if (globalThis.__zuckerExtScan === scan) delete globalThis.__zuckerExtScan;
804
+ return { urls, sitemapCount, incomplete, cancelled: scan.cancelled };
521
805
  }
522
806
 
523
807
  // src/popup.entry.ts