TasksBoard Auto Sync & Robust Exporter (ListId mapping via IDB keys)

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