@zucker-framework/ext-kit 1.0.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.
@@ -0,0 +1,525 @@
1
+ "use strict";
2
+ (() => {
3
+ // src/popup.ts
4
+ function initPopup(config) {
5
+ const byId = (id) => document.getElementById(id);
6
+ const el = {
7
+ urls: byId("urls"),
8
+ urlCount: byId("urlCount"),
9
+ addUrls: byId("addUrls"),
10
+ scanSite: byId("scanSite"),
11
+ queueSummary: byId("queueSummary"),
12
+ queueList: byId("queueList"),
13
+ selectAll: byId("selectAll"),
14
+ selectNone: byId("selectNone"),
15
+ moreQueueActions: byId("moreQueueActions"),
16
+ queueMenu: byId("queueMenu"),
17
+ removeUnselected: byId("removeUnselected"),
18
+ clearQueue: byId("clearQueue"),
19
+ captureCurrent: byId(config.quickButtonId || "captureCurrent"),
20
+ start: byId("start"),
21
+ cancel: byId("cancel"),
22
+ progressWrap: byId("progressWrap"),
23
+ progressText: byId("progressText"),
24
+ progressCurrent: byId("progressCurrent"),
25
+ clearBatch: byId("clearBatch"),
26
+ progressBar: byId("progressBar"),
27
+ resultList: byId("resultList"),
28
+ verifyBanner: byId("verifyBanner"),
29
+ verifyText: byId("verifyText"),
30
+ skipVerify: byId("skipVerify"),
31
+ folderNote: byId("folderNote"),
32
+ quotaText: byId("quotaText"),
33
+ proLink: byId("proLink"),
34
+ haveCode: byId("haveCode"),
35
+ licensePanel: byId("licensePanel"),
36
+ licenseCode: byId("licenseCode"),
37
+ activateBtn: byId("activateBtn"),
38
+ deactivateBtn: byId("deactivateBtn"),
39
+ status: byId("status")
40
+ };
41
+ let pollTimer = null;
42
+ let queue = [];
43
+ let queueLocked = false;
44
+ const prefix = config.messagePrefix;
45
+ void init();
46
+ async function init() {
47
+ el.urls.addEventListener("input", updateUrlCount);
48
+ el.addUrls.addEventListener("click", addInputUrls);
49
+ el.scanSite.addEventListener("click", scanCurrentSite);
50
+ el.selectAll.addEventListener("click", () => setAllSelected(true));
51
+ el.selectNone.addEventListener("click", () => setAllSelected(false));
52
+ el.moreQueueActions.addEventListener("click", () => el.queueMenu.classList.toggle("hidden"));
53
+ el.removeUnselected.addEventListener("click", removeUnselected);
54
+ el.clearQueue.addEventListener("click", clearQueue);
55
+ el.captureCurrent.addEventListener("click", quickCapture);
56
+ el.start.addEventListener("click", startBatch);
57
+ el.cancel.addEventListener("click", cancelBatch);
58
+ 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"));
61
+ el.activateBtn.addEventListener("click", activateLicense);
62
+ el.deactivateBtn.addEventListener("click", deactivateLicense);
63
+ await loadQueue();
64
+ updateUrlCount();
65
+ renderQueue();
66
+ await refreshState();
67
+ }
68
+ async function activateLicense() {
69
+ const code = el.licenseCode.value.trim();
70
+ if (!code) {
71
+ setStatus("Paste your activation code first", true);
72
+ return;
73
+ }
74
+ el.activateBtn.disabled = true;
75
+ setStatus("Activating\u2026");
76
+ const response = await send({ type: `${prefix}_ACTIVATE`, code });
77
+ el.activateBtn.disabled = false;
78
+ if (!response.ok) {
79
+ setStatus(response.error || "Activation failed", true);
80
+ return;
81
+ }
82
+ el.licenseCode.value = "";
83
+ el.licensePanel.classList.add("hidden");
84
+ renderQuota(response.quota);
85
+ setStatus("Pro activated \u2014 enjoy unlimited pages!");
86
+ }
87
+ async function deactivateLicense() {
88
+ const response = await send({ type: `${prefix}_CLEAR_LICENSE` });
89
+ if (response.ok) {
90
+ renderQuota(response.quota);
91
+ setStatus("License removed from this device");
92
+ }
93
+ }
94
+ function parseUrls() {
95
+ return normalizeInputUrls(el.urls.value.split(/\s+/));
96
+ }
97
+ function updateUrlCount() {
98
+ el.urlCount.textContent = String(parseUrls().length);
99
+ }
100
+ function normalizeInputUrls(values) {
101
+ const out = [];
102
+ const seen = /* @__PURE__ */ new Set();
103
+ for (const value of values) {
104
+ try {
105
+ const url = new URL(String(value || "").trim());
106
+ if (!/^https?:$/.test(url.protocol)) {
107
+ continue;
108
+ }
109
+ url.hash = "";
110
+ const normalized = url.toString();
111
+ if (!seen.has(normalized)) {
112
+ seen.add(normalized);
113
+ out.push(normalized);
114
+ }
115
+ } catch {
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ function addInputUrls() {
121
+ const urls = parseUrls();
122
+ if (!urls.length) {
123
+ setStatus("Paste at least one valid http(s) URL", true);
124
+ return;
125
+ }
126
+ const added = addUrlsToQueue(urls);
127
+ el.urls.value = "";
128
+ updateUrlCount();
129
+ setStatus(
130
+ added ? `Added ${added} URL${added === 1 ? "" : "s"} to the queue` : "Those URLs are already in the queue"
131
+ );
132
+ }
133
+ function addUrlsToQueue(urls) {
134
+ const existing = new Set(queue.map((item) => item.url));
135
+ let added = 0;
136
+ for (const url of normalizeInputUrls(urls)) {
137
+ if (!existing.has(url) && queue.length < 500) {
138
+ queue.push({ url, selected: true });
139
+ existing.add(url);
140
+ added += 1;
141
+ }
142
+ }
143
+ if (added) {
144
+ saveQueue();
145
+ renderQueue();
146
+ }
147
+ return added;
148
+ }
149
+ async function scanCurrentSite() {
150
+ setStatus("Scanning current tab\u2026");
151
+ 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({
157
+ target: { tabId: tab.id },
158
+ func: collectSameOriginLinks
159
+ });
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);
167
+ return;
168
+ }
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`);
172
+ } catch (error) {
173
+ setStatus(errText(error), true);
174
+ }
175
+ }
176
+ async function startBatch() {
177
+ const urls = queue.filter((item) => item.selected).map((item) => item.url);
178
+ if (!urls.length) {
179
+ setStatus("Select at least one URL from the queue", true);
180
+ return;
181
+ }
182
+ el.start.disabled = true;
183
+ setStatus("Starting\u2026");
184
+ const response = await send({
185
+ type: `${prefix}_START`,
186
+ urls,
187
+ settings: config.collectSettings()
188
+ });
189
+ el.start.disabled = false;
190
+ if (!response.ok) {
191
+ setStatus(response.error || "Failed to start", true);
192
+ return;
193
+ }
194
+ renderBatch(response.batch);
195
+ renderQuota(response.quota);
196
+ schedulePoll();
197
+ setStatus(
198
+ response.batch?.freeLimited ? "Started (list truncated to today's free quota)" : "Batch started"
199
+ );
200
+ }
201
+ async function quickCapture() {
202
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
203
+ if (!tab?.id || !/^https?:\/\//i.test(tab.url || "")) {
204
+ setStatus("Open an http(s) website first", true);
205
+ return;
206
+ }
207
+ el.captureCurrent.disabled = true;
208
+ el.start.disabled = true;
209
+ 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;
216
+ renderQueue();
217
+ if (!response.ok || !response.result) {
218
+ setStatus(response.error || config.texts.quickFailed, true);
219
+ return;
220
+ }
221
+ renderQuota(response.quota);
222
+ setStatus(config.quickResult(response.result));
223
+ }
224
+ async function cancelBatch() {
225
+ const response = await send({ type: `${prefix}_CANCEL` });
226
+ if (response.ok) {
227
+ renderBatch(response.batch);
228
+ }
229
+ }
230
+ async function clearBatchResults() {
231
+ const response = await send({ type: `${prefix}_CLEAR_BATCH` });
232
+ if (!response.ok) {
233
+ setStatus(response.error || "Could not clear results", true);
234
+ return;
235
+ }
236
+ renderBatch(response.batch);
237
+ setStatus("Batch results cleared");
238
+ }
239
+ async function refreshState() {
240
+ const response = await send({ type: `${prefix}_STATE` });
241
+ if (!response.ok) {
242
+ return;
243
+ }
244
+ renderQuota(response.quota);
245
+ renderBatch(response.batch);
246
+ if (response.batch?.running) {
247
+ schedulePoll();
248
+ }
249
+ }
250
+ function schedulePoll() {
251
+ if (pollTimer) {
252
+ clearInterval(pollTimer);
253
+ }
254
+ pollTimer = setInterval(async () => {
255
+ 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);
263
+ }
264
+ }, 800);
265
+ }
266
+ function renderQuota(quota) {
267
+ if (!quota) {
268
+ return;
269
+ }
270
+ if (quota.pro) {
271
+ const until = quota.expiresAt ? ` \xB7 ${String(quota.expiresAt).slice(0, 10)}` : "";
272
+ el.quotaText.textContent = `unlimited${until}`;
273
+ el.proLink.classList.add("hidden");
274
+ el.deactivateBtn.classList.remove("hidden");
275
+ el.activateBtn.classList.add("hidden");
276
+ } else {
277
+ 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
+ }
282
+ }
283
+ function renderBatch(batch) {
284
+ if (!batch) {
285
+ return;
286
+ }
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();
292
+ el.progressWrap.classList.toggle("hidden", !batch.total);
293
+ el.clearBatch.classList.toggle("hidden", batch.running || !batch.total);
294
+ if (batch.total) {
295
+ el.progressText.textContent = `${batch.done} / ${batch.total}`;
296
+ el.progressCurrent.textContent = batch.current || "";
297
+ el.progressBar.style.width = `${batch.total ? Math.round(batch.done / batch.total * 100) : 0}%`;
298
+ }
299
+ const verify = batch.needVerify;
300
+ el.verifyBanner.classList.toggle("hidden", !verify);
301
+ if (verify) {
302
+ el.verifyText.textContent = `${verify.challenge} \xB7 ${verify.url.replace(/^https?:\/\//, "")}`;
303
+ }
304
+ el.resultList.replaceChildren();
305
+ for (const item of batch.items) {
306
+ const rowEl = document.createElement("div");
307
+ rowEl.className = "resultItem";
308
+ const state = document.createElement("span");
309
+ state.className = `state ${item.status}`;
310
+ state.textContent = { pending: "\xB7", running: "\u2026", success: "\u2713", failed: "\u2717" }[item.status] || "\xB7";
311
+ const url = document.createElement("span");
312
+ url.className = "url";
313
+ url.textContent = item.url.replace(/^https?:\/\//, "");
314
+ rowEl.append(state, url);
315
+ if (item.error) {
316
+ const err = document.createElement("span");
317
+ err.className = "err";
318
+ err.textContent = item.error.slice(0, 60);
319
+ rowEl.append(err);
320
+ }
321
+ if (item.warning) {
322
+ const warning = document.createElement("span");
323
+ warning.className = "warning";
324
+ warning.textContent = item.warning.slice(0, 100);
325
+ rowEl.append(warning);
326
+ }
327
+ el.resultList.append(rowEl);
328
+ }
329
+ if (batch.folder && batch.done > 0) {
330
+ el.folderNote.classList.remove("hidden");
331
+ el.folderNote.textContent = `Saved to Downloads/${batch.folder}/`;
332
+ } else {
333
+ el.folderNote.classList.add("hidden");
334
+ el.folderNote.textContent = "";
335
+ }
336
+ }
337
+ async function loadQueue() {
338
+ const data = await chrome.storage.local.get(config.queueKey);
339
+ const saved = Array.isArray(data[config.queueKey]) ? data[config.queueKey] : [];
340
+ queue = saved.filter((item) => item && typeof item.url === "string").slice(0, 500).map((item) => ({ url: item.url, selected: item.selected !== false }));
341
+ }
342
+ function saveQueue() {
343
+ void chrome.storage.local.set({ [config.queueKey]: queue });
344
+ }
345
+ function renderQueue() {
346
+ const selected = queue.filter((item) => item.selected).length;
347
+ el.queueSummary.textContent = `${selected}/${queue.length} selected`;
348
+ 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;
356
+ el.queueList.replaceChildren();
357
+ if (!queue.length) {
358
+ const empty = document.createElement("p");
359
+ empty.className = "queueEmpty";
360
+ empty.textContent = "Add URLs or scan the current site to begin.";
361
+ el.queueList.append(empty);
362
+ return;
363
+ }
364
+ queue.forEach((item, index) => {
365
+ const row = document.createElement("div");
366
+ row.className = `queueItem${item.selected ? "" : " unselected"}`;
367
+ const checkbox = document.createElement("input");
368
+ checkbox.type = "checkbox";
369
+ checkbox.checked = item.selected;
370
+ checkbox.disabled = queueLocked;
371
+ checkbox.setAttribute("aria-label", `Include ${item.url}`);
372
+ checkbox.addEventListener("change", () => {
373
+ item.selected = checkbox.checked;
374
+ saveQueue();
375
+ renderQueue();
376
+ });
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));
384
+ const remove = document.createElement("button");
385
+ remove.type = "button";
386
+ remove.className = "queueRemove";
387
+ remove.textContent = "\xD7";
388
+ remove.title = "Remove URL";
389
+ remove.disabled = queueLocked;
390
+ remove.addEventListener("click", () => {
391
+ queue.splice(index, 1);
392
+ saveQueue();
393
+ renderQueue();
394
+ });
395
+ row.append(checkbox, url, remove);
396
+ el.queueList.append(row);
397
+ });
398
+ }
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();
417
+ }
418
+ function setAllSelected(selected) {
419
+ if (queueLocked) {
420
+ return;
421
+ }
422
+ queue.forEach((item) => {
423
+ item.selected = selected;
424
+ });
425
+ saveQueue();
426
+ renderQueue();
427
+ }
428
+ function removeUnselected() {
429
+ queue = queue.filter((item) => item.selected);
430
+ el.queueMenu.classList.add("hidden");
431
+ saveQueue();
432
+ renderQueue();
433
+ }
434
+ function clearQueue() {
435
+ queue = [];
436
+ el.queueMenu.classList.add("hidden");
437
+ saveQueue();
438
+ renderQueue();
439
+ }
440
+ function send(message) {
441
+ return new Promise((resolve) => {
442
+ chrome.runtime.sendMessage(message, (response) => {
443
+ const error = chrome.runtime.lastError;
444
+ resolve(
445
+ error ? { ok: false, error: error.message } : response || { ok: false, error: "No response" }
446
+ );
447
+ });
448
+ });
449
+ }
450
+ function setStatus(text, isError = false) {
451
+ el.status.textContent = text;
452
+ el.status.classList.toggle("error", isError);
453
+ }
454
+ function errText(error) {
455
+ return error instanceof Error ? error.message : String(error);
456
+ }
457
+ }
458
+ async function collectSameOriginLinks() {
459
+ const seen = /* @__PURE__ */ new Set();
460
+ const out = [];
461
+ const origin = location.origin;
462
+ let sitemapCount = 0;
463
+ const add = (value) => {
464
+ try {
465
+ 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());
470
+ return true;
471
+ }
472
+ } catch {
473
+ }
474
+ return false;
475
+ };
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;
489
+ }
490
+ visitedSitemaps.add(sitemapUrl);
491
+ 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;
512
+ }
513
+ if (out.length >= 500) {
514
+ break;
515
+ }
516
+ }
517
+ } catch {
518
+ }
519
+ }
520
+ return { urls: out, sitemapCount };
521
+ }
522
+
523
+ // src/popup.entry.ts
524
+ globalThis.ZuckerExtPopup = { init: initPopup };
525
+ })();