Auto-sync on load; export tasks via API/JSON.parse/IndexedDB with completion/due dates and improved listId mapping.
Size
33.8 KB
Version
1.8.0
Created
Mar 17, 2026
Updated
4 days ago
1// ==UserScript==
2// @name TasksBoard Auto Sync & Robust Exporter (ListId mapping via IDB keys)
3// @description Auto-sync on load; export tasks via API/JSON.parse/IndexedDB with completion/due dates and improved listId mapping.
4// @version 1.8.0
5// @match https://*.tasksboard.com/*
6// @icon https://tasksboard.com/favicon.png
7// @grant GM.getValue
8// @grant GM.setValue
9// @run-at document-start
10// ==/UserScript==
11
12(function () {
13 "use strict";
14
15 if (window.__TB_EXPORTER_V180__) return;
16 window.__TB_EXPORTER_V180__ = true;
17
18 const CONFIG = {
19 AUTO_SYNC_ON_LOAD: true,
20 SYNC_DELAY_MS: 3000,
21
22 AUTO_EXPORT_ENABLED: true,
23 EXPORT_INTERVAL_MINUTES: 60,
24 EXPORT_FORMAT: "json", // "json" | "csv"
25 EXPORT_INITIAL_DELAY_MS: 5000,
26
27 TOOLBAR_SELECTOR: ".fixed.z-\\[50\\].flex.items-center.justify-between",
28
29 MIN_NON_DOM_TASKS_TO_TRUST: 10,
30
31 IDB_SCAN_ENABLED: true,
32 IDB_MAX_RECORDS_PER_STORE: 8000,
33 IDB_MAX_TOTAL_RECORDS: 30000,
34 IDB_SCAN_TIMEOUT_MS: 20000,
35
36 JSON_PARSE_INSPECT_ENABLED: true,
37 JSON_PARSE_MAX_LEN: 6_000_000,
38
39 FILTER_URL_ONLY: false,
40 FILTER_SHORT_STUBS: false,
41 SHORT_STUB_MAX_LEN: 3,
42 DEDUP_EXACT_TEXT: false,
43
44 INCLUDE_DEBUG_FIELDS: true,
45 LOG_INGEST: false,
46 NETWORK_LOG_LIMIT: 60,
47 };
48
49 const LOG_PREFIX = "[TB Exporter]";
50 const log = (...args) => console.log(LOG_PREFIX, ...args);
51 const warn = (...args) => console.warn(LOG_PREFIX, ...args);
52 const err = (...args) => console.error(LOG_PREFIX, ...args);
53
54 const SOURCE_RANK = { dom: 1, idb: 2, api: 3, jsonparse: 3 };
55
56 function nowIso() {
57 return new Date().toISOString();
58 }
59
60 function normalizeText(s) {
61 return String(s ?? "").replace(/\s+/g, " ").trim();
62 }
63
64 function isUrlOnly(text) {
65 const t = normalizeText(text);
66 return t ? /^https?:\/\/\S+$/i.test(t) : false;
67 }
68
69 function isShortStub(text) {
70 const t = normalizeText(text);
71 return t.length > 0 && t.length <= CONFIG.SHORT_STUB_MAX_LEN;
72 }
73
74 async function gmGet(key, fallback) {
75 try {
76 if (typeof GM !== "undefined" && GM.getValue) return await GM.getValue(key, fallback);
77 } catch {}
78 try {
79 const raw = localStorage.getItem(key);
80 return raw == null ? fallback : JSON.parse(raw);
81 } catch {
82 return fallback;
83 }
84 }
85
86 async function gmSet(key, value) {
87 try {
88 if (typeof GM !== "undefined" && GM.setValue) return await GM.setValue(key, value);
89 } catch {}
90 try {
91 localStorage.setItem(key, JSON.stringify(value));
92 } catch {}
93 }
94
95 function sleep(ms) {
96 return new Promise((r) => setTimeout(r, ms));
97 }
98
99 function safeJsonParse(txt) {
100 try {
101 return JSON.parse(txt);
102 } catch {
103 return null;
104 }
105 }
106
107 // -----------------------------
108 // Capture store
109 // -----------------------------
110 const store = {
111 tasksById: new Map(),
112 listsById: new Map(),
113
114 taskToListId: new Map(), // taskId -> listId
115 taskSourceById: new Map(), // taskId -> source
116 taskContextsById: new Map(), // taskId -> Set<string> (for late mapping)
117
118 lastIngestAt: null,
119 networkUrls: [],
120
121 idb: {
122 scanned: false,
123 dbsTried: 0,
124 storesTried: 0,
125 recordsScanned: 0,
126 tasksFoundDuringScan: 0,
127 listsFoundDuringScan: 0,
128 durationMs: 0,
129 error: null,
130 },
131 };
132
133 function pushNetworkUrl(url) {
134 const u = String(url || "");
135 if (!u) return;
136 store.networkUrls.push(u);
137 if (store.networkUrls.length > CONFIG.NETWORK_LOG_LIMIT) store.networkUrls.shift();
138 }
139
140 function tryExtractListIdFromString(s) {
141 const str = String(s || "");
142 if (!str) return null;
143
144 // Google Tasks: .../lists/{listId}/tasks/{taskId}
145 let m = str.match(/\/lists\/([^/]+)\/tasks/i);
146 if (m) return m[1];
147
148 // Alternate: .../tasklists/{listId}/tasks
149 m = str.match(/\/tasklists\/([^/]+)\/tasks/i);
150 if (m) return m[1];
151
152 // Query params
153 m = str.match(/[?&](?:listId|taskListId|tasklistId|tasklist|taskList)=([^&#]+)/i);
154 if (m) return decodeURIComponent(m[1]);
155
156 // Common key formats: "listId:xyz", "xyz|taskId", etc.
157 m = str.match(/\b(listId|taskListId|tasklistId)[:=]\s*([A-Za-z0-9_\-]{4,})/i);
158 if (m) return m[2];
159
160 return null;
161 }
162
163 function keyToStrings(key) {
164 const out = [];
165 const push = (v) => {
166 if (v == null) return;
167 if (typeof v === "string") out.push(v);
168 else if (typeof v === "number" || typeof v === "boolean") out.push(String(v));
169 };
170
171 if (Array.isArray(key)) {
172 for (const k of key) {
173 if (typeof k === "string") out.push(k);
174 else if (Array.isArray(k) || (k && typeof k === "object")) out.push(JSON.stringify(k));
175 else push(k);
176 }
177 return out;
178 }
179
180 if (key && typeof key === "object") {
181 try {
182 out.push(JSON.stringify(key));
183 } catch {}
184 for (const [k, v] of Object.entries(key)) {
185 push(k);
186 push(v);
187 }
188 return out;
189 }
190
191 push(key);
192 return out;
193 }
194
195 function extractListIdFromIdbMeta({ dbName, storeName, key, primaryKey }) {
196 const candidates = [
197 String(dbName || ""),
198 String(storeName || ""),
199 ...keyToStrings(key),
200 ...keyToStrings(primaryKey),
201 ];
202
203 for (const c of candidates) {
204 const id = tryExtractListIdFromString(c);
205 if (id) return id;
206 }
207
208 // If we already know list IDs, try substring match on meta strings
209 const known = [...store.listsById.keys()];
210 if (known.length) {
211 const blob = candidates.join(" | ");
212 for (const listId of known) {
213 if (listId && blob.includes(listId)) return listId;
214 }
215 }
216
217 return null;
218 }
219
220 function looksLikeGoogleTask(obj) {
221 if (!obj || typeof obj !== "object") return false;
222 const hasId = typeof obj.id === "string" && obj.id.length >= 6;
223 const hasTitle = typeof obj.title === "string" && obj.title.length >= 1;
224 const hasSignals = "status" in obj || "due" in obj || "completed" in obj || "selfLink" in obj;
225 return hasId && hasTitle && hasSignals;
226 }
227
228 function looksLikeTaskList(obj) {
229 if (!obj || typeof obj !== "object") return false;
230 const hasId = typeof obj.id === "string" && obj.id.length >= 4;
231 const hasTitle = typeof obj.title === "string" && obj.title.length >= 1;
232 const kind = String(obj.kind || "");
233 return hasId && hasTitle && (kind.includes("taskList") || kind.includes("tasks#") || /tasklist/i.test(kind));
234 }
235
236 function deriveListIdFromTask(task) {
237 if (!task || typeof task !== "object") return null;
238
239 for (const k of ["listId", "taskListId", "tasklistId", "taskList", "tasklist"]) {
240 const v = task[k];
241 if (typeof v === "string" && v.length >= 4) return v;
242 }
243
244 for (const k of ["selfLink", "url", "link", "href"]) {
245 const v = task[k];
246 const id = tryExtractListIdFromString(v);
247 if (id) return id;
248 }
249
250 if (Array.isArray(task.links)) {
251 for (const link of task.links) {
252 const id = tryExtractListIdFromString(link?.link || link?.url || link?.href || "");
253 if (id) return id;
254 }
255 }
256
257 return null;
258 }
259
260 function addTaskContext(taskId, contextHint) {
261 if (!taskId || !contextHint) return;
262 const set = store.taskContextsById.get(taskId) || new Set();
263 set.add(String(contextHint).slice(0, 2000));
264 store.taskContextsById.set(taskId, set);
265 }
266
267 function upsertTask(task, { listId = null, source = "api", sourceUrl = "", contextHint = "" } = {}) {
268 const id = normalizeText(task?.id);
269 if (!id) return;
270
271 const prev = store.tasksById.get(id) || {};
272 const merged = { ...prev, ...task };
273 store.tasksById.set(id, merged);
274
275 addTaskContext(id, contextHint || sourceUrl);
276
277 const inferredFromTask = deriveListIdFromTask(merged);
278 const finalListId = listId || inferredFromTask || store.taskToListId.get(id) || null;
279 if (finalListId) store.taskToListId.set(id, finalListId);
280
281 const prevSource = store.taskSourceById.get(id);
282 const prevRank = prevSource ? SOURCE_RANK[prevSource] || 0 : 0;
283 const nextRank = SOURCE_RANK[source] || 0;
284 if (!prevSource || nextRank >= prevRank) store.taskSourceById.set(id, source);
285
286 store.lastIngestAt = Date.now();
287
288 if (CONFIG.LOG_INGEST) {
289 log("upsertTask", { id, title: merged.title, status: merged.status, due: merged.due, listId: finalListId, source, sourceUrl });
290 }
291 }
292
293 function upsertList(list, { source = "api", contextHint = "" } = {}) {
294 const id = normalizeText(list?.id);
295 const title = normalizeText(list?.title);
296 if (!id || !title) return;
297 store.listsById.set(id, { id, title, source });
298 store.lastIngestAt = Date.now();
299 if (contextHint) {
300 // Useful for debugging list capture origin
301 // (not stored per-list to keep small)
302 }
303 }
304
305 function inferListContextFromObject(obj, currentListId) {
306 if (!obj || typeof obj !== "object") return currentListId || null;
307
308 if (looksLikeTaskList(obj)) return obj.id;
309
310 for (const k of ["listId", "taskListId", "tasklistId", "taskList", "tasklist"]) {
311 const v = obj[k];
312 if (typeof v === "string" && v.length >= 4) return v;
313 }
314
315 for (const k of ["selfLink", "url", "href"]) {
316 const v = obj[k];
317 const id = tryExtractListIdFromString(v);
318 if (id) return id;
319 }
320
321 return currentListId || null;
322 }
323
324 function deepIngestJson(value, { listId, sourceUrl, source, contextHint } = {}) {
325 const seen = new WeakSet();
326
327 function walk(v, depth, currentListId) {
328 if (depth > 12) return;
329 if (!v || typeof v !== "object") return;
330 if (seen.has(v)) return;
331 seen.add(v);
332
333 const nextListId = inferListContextFromObject(v, currentListId);
334
335 if (looksLikeTaskList(v)) {
336 upsertList(v, { source, contextHint });
337 } else if (looksLikeGoogleTask(v)) {
338 upsertTask(v, { listId: nextListId, source, sourceUrl, contextHint });
339 }
340
341 const arrKeys = ["items", "tasks", "taskLists", "lists"];
342 for (const k of arrKeys) {
343 if (Array.isArray(v[k])) {
344 for (const item of v[k]) {
345 if (looksLikeTaskList(item)) upsertList(item, { source, contextHint });
346 else if (looksLikeGoogleTask(item)) upsertTask(item, { listId: nextListId, source, sourceUrl, contextHint });
347 walk(item, depth + 1, nextListId);
348 }
349 }
350 }
351
352 if (Array.isArray(v)) {
353 for (const item of v) walk(item, depth + 1, nextListId);
354 return;
355 }
356
357 for (const key of Object.keys(v)) {
358 walk(v[key], depth + 1, nextListId);
359 }
360 }
361
362 walk(value, 0, listId || null);
363 }
364
365 function ingestJsonFromUrl(url, json, source) {
366 const inferredListId = tryExtractListIdFromString(url);
367 deepIngestJson(json, {
368 listId: inferredListId,
369 sourceUrl: url,
370 source,
371 contextHint: `url:${String(url).slice(0, 800)}`,
372 });
373 }
374
375 function finalizeListMapping() {
376 const knownListIds = [...store.listsById.keys()];
377 if (!knownListIds.length) return;
378
379 for (const [taskId, taskObj] of store.tasksById.entries()) {
380 if (store.taskToListId.get(taskId)) continue;
381
382 const fromTask = deriveListIdFromTask(taskObj);
383 if (fromTask && store.listsById.has(fromTask)) {
384 store.taskToListId.set(taskId, fromTask);
385 continue;
386 }
387
388 const contexts = store.taskContextsById.get(taskId);
389 if (!contexts || contexts.size === 0) continue;
390
391 const blob = [...contexts].join(" | ");
392 for (const listId of knownListIds) {
393 if (blob.includes(listId)) {
394 store.taskToListId.set(taskId, listId);
395 break;
396 }
397 }
398 }
399 }
400
401 // -----------------------------
402 // Network / JSON hooks
403 // -----------------------------
404 function hookFetch() {
405 const origFetch = window.fetch;
406 if (typeof origFetch !== "function") return;
407
408 window.fetch = async function (...args) {
409 const res = await origFetch.apply(this, args);
410
411 try {
412 const url = typeof args[0] === "string" ? args[0] : args[0]?.url || "";
413 pushNetworkUrl(url);
414
415 const ct = res.headers?.get?.("content-type") || "";
416 const isJson = ct.includes("application/json") || ct.includes("+json");
417
418 if (isJson) {
419 const cloned = res.clone();
420 cloned
421 .text()
422 .then((txt) => {
423 if (!txt || txt.length > CONFIG.JSON_PARSE_MAX_LEN) return;
424 const json = safeJsonParse(txt);
425 if (json) ingestJsonFromUrl(url, json, "api");
426 })
427 .catch(() => {});
428 }
429 } catch {}
430
431 return res;
432 };
433 }
434
435 function hookXHR() {
436 const origOpen = XMLHttpRequest.prototype.open;
437 const origSend = XMLHttpRequest.prototype.send;
438
439 XMLHttpRequest.prototype.open = function (method, url, ...rest) {
440 this.__tb_url__ = url;
441 return origOpen.call(this, method, url, ...rest);
442 };
443
444 XMLHttpRequest.prototype.send = function (...args) {
445 this.addEventListener("load", function () {
446 try {
447 const url = this.__tb_url__ || "";
448 pushNetworkUrl(url);
449
450 const ct = this.getResponseHeader("content-type") || "";
451 const isJson = ct.includes("application/json") || ct.includes("+json");
452 if (!isJson) return;
453
454 const txt = this.responseText;
455 if (!txt || txt.length > CONFIG.JSON_PARSE_MAX_LEN) return;
456
457 const json = safeJsonParse(txt);
458 if (json) ingestJsonFromUrl(url, json, "api");
459 } catch {}
460 });
461
462 return origSend.apply(this, args);
463 };
464 }
465
466 function hookResponseJson() {
467 if (!window.Response || !Response.prototype) return;
468 const orig = Response.prototype.json;
469 if (typeof orig !== "function") return;
470
471 Response.prototype.json = async function (...args) {
472 const url = this.url || "";
473 pushNetworkUrl(url);
474
475 const data = await orig.apply(this, args);
476 try {
477 ingestJsonFromUrl(url, data, "api");
478 } catch {}
479 return data;
480 };
481 }
482
483 function shouldInspectJsonString(str) {
484 if (!CONFIG.JSON_PARSE_INSPECT_ENABLED) return false;
485 if (typeof str !== "string") return false;
486 if (str.length < 400 || str.length > CONFIG.JSON_PARSE_MAX_LEN) return false;
487 if (!str.includes('"title"')) return false;
488
489 return (
490 str.includes('"tasks#') ||
491 str.includes('"taskLists"') ||
492 str.includes('"needsAction"') ||
493 str.includes('"completed"') ||
494 str.includes('"due"') ||
495 str.includes('"status"') ||
496 str.includes('"selfLink"')
497 );
498 }
499
500 function hookJsonParse() {
501 if (!CONFIG.JSON_PARSE_INSPECT_ENABLED) return;
502 const orig = JSON.parse;
503 if (typeof orig !== "function") return;
504
505 JSON.parse = function (text, reviver) {
506 const result = orig.call(this, text, reviver);
507 try {
508 if (shouldInspectJsonString(text)) {
509 deepIngestJson(result, { listId: null, sourceUrl: "JSON.parse", source: "jsonparse", contextHint: "jsonparse" });
510 }
511 } catch {}
512 return result;
513 };
514 }
515
516 hookFetch();
517 hookXHR();
518 hookResponseJson();
519 hookJsonParse();
520
521 // -----------------------------
522 // IndexedDB scan (key-aware)
523 // -----------------------------
524 async function listIndexedDbNames() {
525 if (!("indexedDB" in window)) return [];
526 if (typeof indexedDB.databases === "function") {
527 try {
528 const dbs = await indexedDB.databases();
529 return dbs.map((d) => d.name).filter(Boolean);
530 } catch {
531 return [];
532 }
533 }
534 return [];
535 }
536
537 function openDb(name) {
538 return new Promise((resolve) => {
539 const req = indexedDB.open(name);
540 req.onsuccess = () => resolve(req.result);
541 req.onerror = () => resolve(null);
542 });
543 }
544
545 function scanStoreWithCursor(db, dbName, storeName, limits, timeoutAt) {
546 return new Promise((resolve) => {
547 let done = false;
548
549 try {
550 const tx = db.transaction(storeName, "readonly");
551 const objStore = tx.objectStore(storeName);
552 const req = objStore.openCursor();
553
554 req.onsuccess = (ev) => {
555 if (done) return;
556 if (Date.now() > timeoutAt) {
557 done = true;
558 resolve();
559 return;
560 }
561
562 const cursor = ev.target.result;
563 if (!cursor) {
564 done = true;
565 resolve();
566 return;
567 }
568
569 limits.scanned += 1;
570 store.idb.recordsScanned = limits.scanned;
571
572 const meta = {
573 dbName,
574 storeName,
575 key: cursor.key,
576 primaryKey: cursor.primaryKey,
577 };
578
579 const listIdFromMeta = extractListIdFromIdbMeta(meta);
580 const contextHint = `idb:${dbName}/${storeName} key=${JSON.stringify(cursor.key).slice(0, 800)}`;
581
582 const beforeTasks = store.tasksById.size;
583 const beforeLists = store.listsById.size;
584
585 deepIngestJson(cursor.value, {
586 listId: listIdFromMeta,
587 sourceUrl: `indexedDB:${dbName}/${storeName}`,
588 source: "idb",
589 contextHint,
590 });
591
592 // Late mapping gets better once lists appear
593 if (store.listsById.size > beforeLists || store.tasksById.size > beforeTasks) {
594 const newTasks = store.tasksById.size - beforeTasks;
595 const newLists = store.listsById.size - beforeLists;
596 if (newTasks > 0) store.idb.tasksFoundDuringScan += newTasks;
597 if (newLists > 0) store.idb.listsFoundDuringScan += newLists;
598 }
599
600 if (limits.scanned >= limits.maxTotal) {
601 done = true;
602 resolve();
603 return;
604 }
605
606 cursor.continue();
607 };
608
609 req.onerror = () => resolve();
610 tx.onerror = () => resolve();
611 } catch {
612 resolve();
613 }
614 });
615 }
616
617 async function scanIndexedDbForTasks() {
618 if (!CONFIG.IDB_SCAN_ENABLED) return;
619 if (store.idb.scanned) return;
620
621 const start = Date.now();
622 store.idb.scanned = true;
623
624 const limits = {
625 maxTotal: CONFIG.IDB_MAX_TOTAL_RECORDS,
626 scanned: 0,
627 };
628
629 try {
630 const names = await listIndexedDbNames();
631 store.idb.dbsTried = names.length;
632
633 const timeoutAt = Date.now() + CONFIG.IDB_SCAN_TIMEOUT_MS;
634
635 for (const name of names) {
636 if (Date.now() > timeoutAt) break;
637
638 const db = await openDb(name);
639 if (!db) continue;
640
641 try {
642 const storeNames = Array.from(db.objectStoreNames || []);
643 for (const sName of storeNames) {
644 if (Date.now() > timeoutAt) break;
645
646 store.idb.storesTried += 1;
647 await scanStoreWithCursor(db, name, sName, limits, timeoutAt);
648
649 // Improve mapping progressively
650 finalizeListMapping();
651
652 if (limits.scanned >= limits.maxTotal) break;
653 }
654 } finally {
655 try {
656 db.close();
657 } catch {}
658 }
659
660 if (limits.scanned >= limits.maxTotal) break;
661 }
662 } catch (e) {
663 store.idb.error = String(e?.message || e);
664 } finally {
665 store.idb.durationMs = Date.now() - start;
666 }
667 }
668
669 // -----------------------------
670 // DOM fallback (only if non-dom missing)
671 // -----------------------------
672 function findFirst(root, selectors) {
673 for (const sel of selectors) {
674 try {
675 const el = root.querySelector(sel);
676 if (el) return el;
677 } catch {}
678 }
679 return null;
680 }
681
682 function getListNameFromUiFallback() {
683 const el = findFirst(document, ["[data-testid='board-title']", "h1", "h2", "[role='heading']"]);
684 const t = normalizeText(el?.textContent);
685 return t || "My Tasks";
686 }
687
688 function getTaskElementsFromDom() {
689 const root = document.querySelector("#root") || document.body;
690 return Array.from(root.querySelectorAll("[data-rbd-draggable-id]"));
691 }
692
693 function extractPrimaryTextFromDom(taskEl) {
694 const el = findFirst(taskEl, [
695 '[data-testid="task-text"]',
696 '[data-testid*="task" i][data-testid*="text" i]',
697 "[data-task-text]",
698 ".text-sm.leading-\\[22px\\]",
699 '[class*="text-sm"][class*="leading"]',
700 '[contenteditable="true"]',
701 '[role="textbox"]',
702 ]);
703
704 if (el) {
705 const t = normalizeText(el.textContent || el.value);
706 if (t) return { text: t, textEl: el };
707 }
708
709 const raw = normalizeText(taskEl.innerText || taskEl.textContent || "");
710 const firstLine = raw.split("\n").map(normalizeText).find(Boolean) || "";
711 return { text: firstLine, textEl: null };
712 }
713
714 function inferCompletedFromDom(taskEl, textEl) {
715 const cb = taskEl.querySelector('input[type="checkbox"]');
716 if (cb) return { value: !!cb.checked, reason: "dom:input_checkbox" };
717
718 if (textEl) {
719 const dec = window.getComputedStyle(textEl).textDecorationLine || "";
720 if (dec.includes("line-through")) return { value: true, reason: "dom:title_line_through" };
721 }
722
723 return { value: false, reason: "dom:default_false" };
724 }
725
726 function extractDueDateFromDom(taskEl) {
727 const timeEl = taskEl.querySelector("time[datetime], time");
728 if (timeEl) return normalizeText(timeEl.getAttribute("datetime") || timeEl.textContent) || null;
729
730 const dueEl = findFirst(taskEl, [
731 '[data-testid*="due" i]',
732 '[aria-label*="due" i]',
733 '[title*="due" i]',
734 '[class*="due" i]',
735 '[class*="date" i]',
736 ]);
737
738 return normalizeText(dueEl?.textContent || dueEl?.getAttribute("title") || "") || null;
739 }
740
741 // -----------------------------
742 // Build export dataset
743 // -----------------------------
744 function taskCompletedFromApi(taskObj) {
745 const status = normalizeText(taskObj?.status).toLowerCase();
746 if (status === "completed") return { value: true, reason: "api:status_completed" };
747 if (status === "needsaction") return { value: false, reason: "api:status_needsAction" };
748
749 if (taskObj?.completed) return { value: true, reason: "api:completed_field" };
750 return { value: null, reason: "api:unknown" };
751 }
752
753 function dueDateFromApi(taskObj) {
754 const due = normalizeText(taskObj?.due);
755 return due || null;
756 }
757
758 function listNameFor(listId) {
759 if (!listId) return getListNameFromUiFallback();
760 const list = store.listsById.get(listId);
761 return list?.title || getListNameFromUiFallback();
762 }
763
764 function applyHygieneFilters(tasks) {
765 let out = tasks;
766
767 if (CONFIG.FILTER_URL_ONLY) out = out.filter((t) => !isUrlOnly(t.text));
768 if (CONFIG.FILTER_SHORT_STUBS) out = out.filter((t) => !isShortStub(t.text));
769
770 if (CONFIG.DEDUP_EXACT_TEXT) {
771 const seen = new Set();
772 out = out.filter((t) => {
773 const k = normalizeText(t.text);
774 if (seen.has(k)) return false;
775 seen.add(k);
776 return true;
777 });
778 }
779
780 return out;
781 }
782
783 function computeStats(tasks) {
784 let t = 0,
785 f = 0,
786 u = 0,
787 due = 0,
788 linkOnly = 0,
789 shortStub = 0;
790
791 const listCounts = new Map();
792 const sourceCounts = new Map();
793
794 for (const x of tasks) {
795 if (x.completed === true) t++;
796 else if (x.completed === false) f++;
797 else u++;
798
799 if (x.dueDate) due++;
800 if (isUrlOnly(x.text)) linkOnly++;
801 if (isShortStub(x.text)) shortStub++;
802
803 const ln = x.list || "My Tasks";
804 listCounts.set(ln, (listCounts.get(ln) || 0) + 1);
805
806 const src = x.source || "unknown";
807 sourceCounts.set(src, (sourceCounts.get(src) || 0) + 1);
808 }
809
810 return {
811 total: tasks.length,
812 completedTrue: t,
813 completedFalse: f,
814 completedUnknown: u,
815 dueDateCount: due,
816 linkOnlyCount: linkOnly,
817 shortStubCount: shortStub,
818 lists: Object.fromEntries([...listCounts.entries()].sort((a, b) => b[1] - a[1])),
819 sources: Object.fromEntries([...sourceCounts.entries()].sort((a, b) => b[1] - a[1])),
820 };
821 }
822
823 function buildTasksFromNonDomStore() {
824 finalizeListMapping();
825
826 const tasks = [];
827 for (const [id, obj] of store.tasksById.entries()) {
828 const title = normalizeText(obj?.title);
829 if (!title) continue;
830
831 const listId = store.taskToListId.get(id) || null;
832 const completion = taskCompletedFromApi(obj);
833 const dueDate = dueDateFromApi(obj);
834
835 const source = store.taskSourceById.get(id) || "api";
836
837 const task = {
838 id,
839 text: title,
840 completed: completion.value,
841 dueDate,
842 listId,
843 list: listNameFor(listId),
844 extractedAt: nowIso(),
845 };
846
847 if (CONFIG.INCLUDE_DEBUG_FIELDS) {
848 task.source = source;
849 task.completedReason = completion.reason;
850 }
851
852 tasks.push(task);
853 }
854 return tasks;
855 }
856
857 function buildTasksFromDomFallback() {
858 const listName = getListNameFromUiFallback();
859 const els = getTaskElementsFromDom();
860
861 const tasks = [];
862 const seen = new Set();
863
864 for (let i = 0; i < els.length; i++) {
865 const el = els[i];
866 const id = el.getAttribute("data-rbd-draggable-id") || `idx_${i}`;
867
868 const { text, textEl } = extractPrimaryTextFromDom(el);
869 if (!text) continue;
870
871 const key = `${id}|${text}`;
872 if (seen.has(key)) continue;
873 seen.add(key);
874
875 const completion = inferCompletedFromDom(el, textEl);
876 const dueDate = extractDueDateFromDom(el);
877
878 const task = {
879 id,
880 text,
881 completed: completion.value,
882 dueDate,
883 listId: null,
884 list: listName,
885 extractedAt: nowIso(),
886 };
887
888 if (CONFIG.INCLUDE_DEBUG_FIELDS) {
889 task.source = "dom";
890 task.completedReason = completion.reason;
891 }
892
893 tasks.push(task);
894 }
895
896 return tasks;
897 }
898
899 function buildListsSummary(tasks) {
900 const by = new Map();
901 for (const t of tasks) {
902 const lid = t.listId || null;
903 const name = t.list || "My Tasks";
904 const k = `${lid ?? "null"}|${name}`;
905 by.set(k, { listId: lid, list: name, count: (by.get(k)?.count || 0) + 1 });
906 }
907 return [...by.values()].sort((a, b) => b.count - a.count);
908 }
909
910 async function buildExportDataset() {
911 if (CONFIG.IDB_SCAN_ENABLED && store.tasksById.size === 0) {
912 await scanIndexedDbForTasks();
913 } else {
914 finalizeListMapping();
915 }
916
917 const nonDomTasks = applyHygieneFilters(buildTasksFromNonDomStore());
918 const trustNonDom = nonDomTasks.length >= CONFIG.MIN_NON_DOM_TASKS_TO_TRUST;
919
920 const tasks = trustNonDom ? nonDomTasks : applyHygieneFilters(buildTasksFromDomFallback());
921 const mode = trustNonDom ? "non-dom" : "dom";
922
923 const stats = computeStats(tasks);
924 const listsSummary = buildListsSummary(tasks);
925 const knownLists = [...store.listsById.values()].sort((a, b) => a.title.localeCompare(b.title));
926
927 const mappedToListCount = [...store.taskToListId.values()].filter(Boolean).length;
928
929 return {
930 exportedAt: nowIso(),
931 url: location.href,
932 exporterVersion: "1.8.0",
933 mode,
934 apiStore: {
935 tasksInStore: store.tasksById.size,
936 listsInStore: store.listsById.size,
937 mappedToListCount,
938 lastIngestAt: store.lastIngestAt ? new Date(store.lastIngestAt).toISOString() : null,
939 },
940 idbScan: { ...store.idb },
941 networkRecentUrls: store.networkUrls.slice(-CONFIG.NETWORK_LOG_LIMIT),
942 stats,
943 listsSummary,
944 knownLists,
945 tasks,
946 };
947 }
948
949 function tasksToCSV(tasks) {
950 const headers = ["id", "text", "completed", "dueDate", "listId", "list", "extractedAt", "source", "completedReason"];
951 const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
952 const rows = tasks.map((t) => [
953 esc(t.id),
954 esc(t.text),
955 esc(t.completed === true ? "true" : t.completed === false ? "false" : ""),
956 esc(t.dueDate || ""),
957 esc(t.listId || ""),
958 esc(t.list || ""),
959 esc(t.extractedAt || ""),
960 esc(t.source || ""),
961 esc(t.completedReason || ""),
962 ]);
963 return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
964 }
965
966 function downloadData(data, filename, mimeType) {
967 const blob = new Blob([data], { type: mimeType });
968 const url = URL.createObjectURL(blob);
969 const a = document.createElement("a");
970 a.href = url;
971 a.download = filename;
972 document.body.appendChild(a);
973 a.click();
974 a.remove();
975 URL.revokeObjectURL(url);
976 }
977
978 async function exportNow() {
979 const payload = await buildExportDataset();
980 const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
981
982 if (CONFIG.EXPORT_FORMAT === "csv") {
983 downloadData(tasksToCSV(payload.tasks), `tasksboard-export-${ts}.csv`, "text/csv");
984 } else {
985 downloadData(JSON.stringify(payload, null, 2), `tasksboard-export-${ts}.json`, "application/json");
986 }
987
988 await gmSet("lastExportTime", Date.now());
989 log("Export complete", {
990 mode: payload.mode,
991 apiStore: payload.apiStore,
992 topLists: payload.listsSummary.slice(0, 5),
993 });
994 }
995
996 // -----------------------------
997 // Auto export scheduling
998 // -----------------------------
999 async function setupAutoExport() {
1000 if (!CONFIG.AUTO_EXPORT_ENABLED) return;
1001
1002 const intervalMs = CONFIG.EXPORT_INTERVAL_MINUTES * 60 * 1000;
1003 const last = await gmGet("lastExportTime", 0);
1004 const elapsed = Date.now() - last;
1005
1006 const firstInMs = last === 0 ? CONFIG.EXPORT_INITIAL_DELAY_MS : Math.max(5000, intervalMs - elapsed);
1007
1008 setTimeout(() => exportNow(), firstInMs);
1009 setInterval(() => exportNow(), intervalMs);
1010
1011 log("Auto-export scheduled", { everyMin: CONFIG.EXPORT_INTERVAL_MINUTES, nextInSec: Math.round(firstInMs / 1000) });
1012 }
1013
1014 // -----------------------------
1015 // Sync + UI buttons
1016 // -----------------------------
1017 function safeQuerySelector(root, selector) {
1018 try {
1019 return root.querySelector(selector);
1020 } catch {
1021 return null;
1022 }
1023 }
1024
1025 function findToolbar() {
1026 return document.querySelector(CONFIG.TOOLBAR_SELECTOR) || document.querySelector("header") || null;
1027 }
1028
1029 function findSyncButton() {
1030 const toolbar = findToolbar();
1031 const scope = toolbar || document;
1032
1033 const selectors = [
1034 'button[aria-label*="sync" i]',
1035 'button[title*="sync" i]',
1036 'button.MuiIconButton-root[aria-label*="sync" i]',
1037 'button.MuiIconButton-root[title*="sync" i]',
1038 'button:has(svg[data-testid*="CloudSync" i])',
1039 'button:has(svg[data-testid*="CloudDone" i])',
1040 'button:has(svg[data-testid*="Cloud" i])',
1041 ];
1042
1043 for (const sel of selectors) {
1044 const btn = safeQuerySelector(scope, sel);
1045 if (btn) return btn;
1046 }
1047
1048 if (toolbar) {
1049 const btns = Array.from(toolbar.querySelectorAll("button"));
1050 return btns.find((b) => (b.innerHTML || "").includes("CloudSync") || (b.innerHTML || "").includes("CloudDone")) || null;
1051 }
1052
1053 return null;
1054 }
1055
1056 async function triggerSyncWithRetries() {
1057 await sleep(CONFIG.SYNC_DELAY_MS);
1058 for (let i = 0; i < 8; i++) {
1059 const btn = findSyncButton();
1060 if (btn) {
1061 btn.click();
1062 log("Clicked sync button");
1063 return;
1064 }
1065 await sleep(800);
1066 }
1067 warn("Sync button not found after retries");
1068 }
1069
1070 function ensureButtons() {
1071 const toolbar = findToolbar();
1072 if (!toolbar) return false;
1073
1074 if (!toolbar.querySelector('[data-tb-export-btn="1"]')) {
1075 const exportBtn = document.createElement("button");
1076 exportBtn.type = "button";
1077 exportBtn.setAttribute("data-tb-export-btn", "1");
1078 exportBtn.textContent = "📥 Export";
1079 exportBtn.style.cssText =
1080 "background:#10b981;color:white;border:none;border-radius:6px;padding:6px 12px;margin-left:10px;cursor:pointer;font-size:14px;font-weight:600;";
1081 exportBtn.addEventListener("mouseenter", () => (exportBtn.style.background = "#059669"));
1082 exportBtn.addEventListener("mouseleave", () => (exportBtn.style.background = "#10b981"));
1083 exportBtn.addEventListener("click", async () => {
1084 exportBtn.textContent = "⏳ Exporting...";
1085 try {
1086 await exportNow();
1087 exportBtn.textContent = "✓ Exported!";
1088 } catch (e) {
1089 err("Export failed", e);
1090 exportBtn.textContent = "⚠ Failed";
1091 } finally {
1092 setTimeout(() => (exportBtn.textContent = "📥 Export"), 1600);
1093 }
1094 });
1095 toolbar.appendChild(exportBtn);
1096 }
1097
1098 if (!toolbar.querySelector('[data-tb-capture-btn="1"]')) {
1099 const capBtn = document.createElement("button");
1100 capBtn.type = "button";
1101 capBtn.setAttribute("data-tb-capture-btn", "1");
1102 capBtn.textContent = "🔎 Capture";
1103 capBtn.style.cssText =
1104 "background:#2563eb;color:white;border:none;border-radius:6px;padding:6px 12px;margin-left:8px;cursor:pointer;font-size:14px;font-weight:600;";
1105 capBtn.addEventListener("mouseenter", () => (capBtn.style.background = "#1d4ed8"));
1106 capBtn.addEventListener("mouseleave", () => (capBtn.style.background = "#2563eb"));
1107 capBtn.addEventListener("click", async () => {
1108 capBtn.textContent = "⏳ Scanning...";
1109 try {
1110 await scanIndexedDbForTasks();
1111 finalizeListMapping();
1112 capBtn.textContent = `✓ Captured (${store.tasksById.size})`;
1113 log("Capture summary", {
1114 tasksInStore: store.tasksById.size,
1115 listsInStore: store.listsById.size,
1116 mappedToList: store.taskToListId.size,
1117 idb: store.idb,
1118 });
1119 } catch (e) {
1120 err("Capture failed", e);
1121 capBtn.textContent = "⚠ Capture failed";
1122 } finally {
1123 setTimeout(() => (capBtn.textContent = "🔎 Capture"), 2200);
1124 }
1125 });
1126 toolbar.appendChild(capBtn);
1127 }
1128
1129 return true;
1130 }
1131
1132 function keepButtonsAlive() {
1133 const obs = new MutationObserver(() => ensureButtons());
1134 obs.observe(document.documentElement, { childList: true, subtree: true });
1135 }
1136
1137 // -----------------------------
1138 // Init
1139 // -----------------------------
1140 async function init() {
1141 while (!document.body) await sleep(50);
1142
1143 keepButtonsAlive();
1144 ensureButtons();
1145
1146 if (CONFIG.AUTO_SYNC_ON_LOAD) {
1147 window.addEventListener("load", () => {
1148 triggerSyncWithRetries();
1149 });
1150 }
1151
1152 setupAutoExport();
1153
1154 window.__TB_EXPORTER__ = {
1155 exportNow,
1156 buildExportDataset,
1157 store,
1158 config: CONFIG,
1159 finalizeListMapping,
1160 };
1161
1162 log("Initialized v1.8.0 (IDB key-aware list mapping)");
1163 }
1164
1165 init();
1166})();
1167