PageSourceSearch

https://goblin.tools/js/todo-exports.js?v=YSZmNJHa_JKrjPGK-Wt9q3NM-9NwIgjQgTvY54iofeo

js goblin.tools collected 2026-10-01 11:37:07 UTC 29,552 bytes, 762 lines download raw bytes

1// Scope all export / save / copy operations to the active tab so users don't
2// accidentally dump items from other tabs. If no active tab is set (shouldn't
3// happen post-migration, but defensive), fall back to the full list.
4function getActiveTabTodos() {
5    if (typeof TodoStore === 'undefined') return [];
6    const activeTabId = TodoStore.getActiveTabId ? TodoStore.getActiveTabId() : null;
7    const all = (TodoStore.getTodos && TodoStore.getTodos()) || [];
8    if (!activeTabId) return all;
9    return all.filter(function (t) { return t.tabId === activeTabId; });
10}
11
12// ---- Deadline formatters for various export targets ----
13
14// YYYY-MM-DD for Todoist / Asana / Marvin / Notion / Markdown. Deadlines are
15// stored as datetime-local strings (e.g. "2026-04-20T14:30"); slice out the
16// date portion directly if present, otherwise best-effort Date parse.
17function formatDeadlineDate(deadline) {
18    if (!deadline) return '';
19    if (typeof deadline === 'string' && /^\d{4}-\d{2}-\d{2}/.test(deadline)) {
20        return deadline.slice(0, 10);
21    }
22    try {
23        const d = new Date(deadline);
24        if (isNaN(d.getTime())) return '';
25        const pad = n => (n < 10 ? '0' + n : n);
26        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
27    } catch (e) { return ''; }
28}
29
30// ISO 8601 with offset for TickTick's "Due Date" column (its backup/restore
31// format stores full timestamps). Returns '' if the deadline is missing or
32// unparseable.
33function formatDeadlineIso(deadline) {
34    if (!deadline) return '';
35    try {
36        const d = new Date(deadline);
37        if (isNaN(d.getTime())) return '';
38        return d.toISOString();
39    } catch (e) { return ''; }
40}
41
42// Human-ish string for plain-text exports ("2026-04-20 14:30" when the
43// deadline has a time component, otherwise just the date).
44function formatDeadlineHuman(deadline) {
45    if (!deadline) return '';
46    if (typeof deadline === 'string') {
47        // datetime-local format "YYYY-MM-DDTHH:MM" → "YYYY-MM-DD HH:MM"
48        const m = /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/.exec(deadline);
49        if (m) return m[2] ? (m[1] + ' ' + m[2]) : m[1];
50    }
51    return formatDeadlineDate(deadline);
52}
53
54// iCal DUE value (VTODO only). Floating/local format — calendar clients will
55// display it in the viewer's local timezone, matching how the user entered
56// the deadline in Magic Todo's datetime-local picker.
57function formatDeadlineIcalDue(deadline) {
58    if (!deadline) return '';
59    try {
60        const d = new Date(deadline);
61        if (isNaN(d.getTime())) return '';
62        return toIcalFloatingDate(d);
63    } catch (e) { return ''; }
64}
65
66// Save the current tab's todos to a .goblin file. We strip tabId from the
67// serialized payload so the file is portable: on import we'll assign the
68// current device's active tabId to every item.
69function saveActiveTabToFile() {
70    const minimal = getActiveTabTodos().map(function (t) {
71        const obj = {
72            id: t.id,
73            parentId: t.parentId,
74            text: t.text
75        };
76        if (t.isCompleted === true) obj.isCompleted = true;
77        if (t.category) obj.category = t.category;
78        if (typeof t.estimate === 'number') obj.estimate = t.estimate;
79        if (t.timestamp) obj.timestamp = t.timestamp;
80        // Preserve deadlines and timer progress so a .goblin round-trip
81        // (export → reimport) is lossless — Task.fromObject already knows
82        // how to read both back.
83        if (t.deadline) obj.deadline = t.deadline;
84        if (typeof t.elapsed === 'number' && t.elapsed > 0) obj.elapsed = t.elapsed;
85        return obj;
86    });
87    downloadAsFile(JSON.stringify(minimal), 'magic_todo.goblin');
88}
89
90// Imported items are assigned to whichever tab is currently active. Any
91// tabId embedded in the file is overwritten so items can't leak into a tab
92// that doesn't exist on this device.
93function loadFileIntoActiveTab(todoArray) {
94    if (!Array.isArray(todoArray)) return;
95    const activeTabId = (typeof TodoStore !== 'undefined' && TodoStore.getActiveTabId)
96        ? TodoStore.getActiveTabId() : null;
97    if (activeTabId) {
98        todoArray.forEach(function (t) { if (t) t.tabId = activeTabId; });
99    }
100    saveTodos(todoArray);
101}
102
103function parseFlatTree(nodes) {
104    let result = '';
105
106    // Build an intermediate representation of the tree where nodes are grouped by their parent
107    let tree = {};
108    for (let node of nodes) {
109        if (node.parentId !== null) {
110            if (!tree[node.parentId]) {
111                tree[node.parentId] = [];
112            }
113            tree[node.parentId].push(node);
114        }
115    }
116
117    // Recursive function to add nodes to the result in DFS order
118    function dfs(node) {
119        const due = node.deadline ? ' (due ' + formatDeadlineHuman(node.deadline) + ')' : '';
120        result += node.text + due + '\n';
121        if (tree[node.id]) {
122            for (let child of tree[node.id]) {
123                dfs(child);
124            }
125        }
126    }
127
128    // Find the root node(s) and start the DFS
129    for (let node of nodes) {
130        if (node.parentId === null) {
131            dfs(node);
132        }
133    }
134
135    return result;
136}
137
138
139
140function convertToTodoist() {
141    const allTodos = getActiveTabTodos();
142    const projects = {};
143
144    allTodos.forEach(todo => {
145        if (todo.parentId == null) {
146            projects[todo.id] = { name: todo.text, items: [], deadline: todo.deadline };
147        }
148    });
149
150    function addSubtasksToProject(project, parentId, indent) {
151        allTodos
152            .filter(todo => todo.parentId == parentId)
153            .forEach(subtask => {
154                project.items.push({ content: subtask.text, indent: indent, deadline: subtask.deadline });
155                addSubtasksToProject(project, subtask.id, indent + 1);
156            });
157    }
158
159    Object.keys(projects).forEach(projectId => {
160        const project = projects[projectId];
161        addSubtasksToProject(project, projectId, 2);
162    });
163
164    const csvHeader = [
165        'TYPE',
166        'CONTENT',
167        'DESCRIPTION',
168        'PRIORITY',
169        'INDENT',
170        'AUTHOR',
171        'RESPONSIBLE',
172        'DATE',
173        'DATE_LANG',
174        'TIMEZONE',
175        'DURATION',
176        'DURATION_UNIT',
177        'DEADLINE',
178        'DEADLINE_LANG'
179    ];
180
181    // CSV-escape a cell value: quote if it contains comma/quote/newline, and
182    // defuse spreadsheet formula-injection by prefixing with a single quote
183    // when the first char is one Excel/Sheets would interpret as a formula.
184    function csvCell(value) {
185        var s = value == null ? "" : String(value);
186        if (/^[=+\-@\t\r]/.test(s)) {
187            s = "'" + s;
188        }
189        if (/[",\r\n]/.test(s)) {
190            s = '"' + s.replace(/"/g, '""') + '"';
191        }
192        return s;
193    }
194
195    // Build a task row matching the header column count. TYPE, CONTENT,
196    // and INDENT always carry data; DEADLINE / DEADLINE_LANG are populated
197    // when the todo has one set. Todoist's deadline picker accepts a plain
198    // YYYY-MM-DD paired with an ISO language code ("en" here) in the
199    // DEADLINE_LANG column.
200    function taskRow(content, indent, deadline) {
201        const row = new Array(csvHeader.length).fill('');
202        row[0] = 'task';
203        row[1] = csvCell(content);
204        row[4] = String(indent);
205        const due = formatDeadlineDate(deadline);
206        if (due) {
207            row[12] = due;   // DEADLINE
208            row[13] = 'en';  // DEADLINE_LANG
209        }
210        return row.join(',');
211    }
212
213    let csvContent = csvHeader.join(',') + "\r\n";
214
215    Object.values(projects).forEach(project => {
216        csvContent += taskRow(project.name, 1, project.deadline) + "\r\n";
217        project.items.forEach(item => {
218            csvContent += taskRow(item.content, item.indent, item.deadline) + "\r\n";
219        });
220    });
221
222    return csvContent;
223}
224
225const _icalPad = n => (n < 10 ? '0' + n : n);
226
227// UTC timestamp for DTSTAMP / COMPLETED (trailing Z per RFC 5545 §3.3.5).
228function toIcalDate(date) {
229    return `${date.getUTCFullYear()}${_icalPad(date.getUTCMonth() + 1)}${_icalPad(date.getUTCDate())}T${_icalPad(date.getUTCHours())}${_icalPad(date.getUTCMinutes())}${_icalPad(date.getUTCSeconds())}Z`;
230}
231
232// Floating (local wall-clock) datetime per RFC 5545 §3.3.5 — no Z, no TZID.
233// Calendar clients interpret in the viewer's local timezone.
234function toIcalFloatingDate(date) {
235    return `${date.getFullYear()}${_icalPad(date.getMonth() + 1)}${_icalPad(date.getDate())}T${_icalPad(date.getHours())}${_icalPad(date.getMinutes())}${_icalPad(date.getSeconds())}`;
236}
237
238// RFC 5545 §3.3.11: escape \ ; , and newlines in TEXT property values.
239function escapeIcalText(text) {
240    if (text == null) return '';
241    return String(text)
242        .replace(/\\/g, '\\\\')
243        .replace(/;/g, '\\;')
244        .replace(/,/g, '\\,')
245        .replace(/\r\n|\r|\n/g, '\\n');
246}
247
248// RFC 5545 §3.1: fold content lines longer than 75 octets. Counts UTF-8
249// bytes (not JS chars) so multi-byte characters aren't split mid-sequence.
250function foldIcalLine(line) {
251    const enc = new TextEncoder();
252    const bytes = enc.encode(line);
253    if (bytes.length <= 75) return line;
254    const dec = new TextDecoder();
255    let out = '';
256    let i = 0;
257    let first = true;
258    while (i < bytes.length) {
259        const chunkLen = first ? 75 : 74;
260        let end = Math.min(i + chunkLen, bytes.length);
261        while (end < bytes.length && (bytes[end] & 0xC0) === 0x80) end--;
262        out += (first ? '' : '\r\n ') + dec.decode(bytes.slice(i, end));
263        i = end;
264        first = false;
265    }
266    return out;
267}
268
269function convertToICal() {
270    const now = toIcalDate(new Date());
271    const lines = [
272        'BEGIN:VCALENDAR',
273        'VERSION:2.0',
274        'PRODID:-//goblin.tools//Magic ToDo//EN'
275    ];
276
277    getActiveTabTodos().forEach(todo => {
278        lines.push('BEGIN:VTODO');
279        lines.push(`UID:${todo.id}@goblin.tools`);
280        lines.push(`DTSTAMP:${now}`);
281        lines.push(`SUMMARY:${escapeIcalText(todo.text)}`);
282        // DUE is the VTODO-specific deadline property per RFC 5545 §3.8.2.3.
283        // Emitted as a floating (no TZID, no Z) datetime so clients render it
284        // in the viewer's local timezone — matching how Magic Todo's
285        // datetime-local picker captured it.
286        const due = formatDeadlineIcalDue(todo.deadline);
287        if (due) {
288            lines.push(`DUE:${due}`);
289        }
290        if (todo.parentId) {
291            lines.push(`RELATED-TO;RELTYPE=PARENT:${todo.parentId}@goblin.tools`);
292        }
293        if (todo.isCompleted) {
294            lines.push('STATUS:COMPLETED');
295            const completedAt = todo.timestamp ? toIcalDate(new Date(todo.timestamp)) : now;
296            lines.push(`COMPLETED:${completedAt}`);
297        } else {
298            lines.push('STATUS:NEEDS-ACTION');
299        }
300        lines.push('END:VTODO');
301    });
302
303    lines.push('END:VCALENDAR');
304
305    return lines.map(foldIcalLine).join('\r\n') + '\r\n';
306}
307
308// Render the tab as calendar events. Two modes per top-level task:
309//   1. Has a deadline AND subtasks: schedule the SUBTASKS (recursively)
310//      so the last leaf ends at the deadline, each earlier leaf ending
311//      where the next one begins. No event is emitted for the parent
312//      itself — it's a container whose work is covered by its subtasks.
313//   2. Otherwise: emit a single event for the top-level task. With a
314//      deadline the event ends at the deadline (start = deadline −
315//      duration); without one it falls into a cascading 9-am-tomorrow
316//      schedule. Subtasks are flattened into DESCRIPTION as before.
317// Each leaf's duration is max(15 min, ceil(estimate ÷ 60s)).
318function convertToICalEvents() {
319    const allTodos = getActiveTabTodos();
320
321    const childrenByParent = {};
322    const todoById = {};
323    allTodos.forEach(t => {
324        todoById[t.id] = t;
325        if (t.parentId != null) {
326            (childrenByParent[t.parentId] = childrenByParent[t.parentId] || []).push(t);
327        }
328    });
329
330    function durationMinutesFor(task) {
331        const m = task.estimate ? Math.ceil(task.estimate / 60) : 0;
332        return Math.max(15, m);
333    }
334
335    function renderSubtaskTree(parentId, depth) {
336        const kids = childrenByParent[parentId] || [];
337        const out = [];
338        kids.forEach(k => {
339            const marker = k.isCompleted ? '[x]' : '[ ]';
340            out.push('  '.repeat(depth) + marker + ' ' + k.text);
341            const sub = renderSubtaskTree(k.id, depth + 1);
342            if (sub) out.push(sub);
343        });
344        return out.join('\n');
345    }
346
347    // Walk up the parent chain for ancestry context in the event
348    // DESCRIPTION ("Part of: grandparent > parent").
349    function ancestorChain(task) {
350        const chain = [];
351        let cur = task.parentId ? todoById[task.parentId] : null;
352        while (cur) {
353            chain.unshift(cur);
354            cur = cur.parentId ? todoById[cur.parentId] : null;
355        }
356        return chain;
357    }
358
359    // Reverse-schedule a task so its subtree ends at `anchorEnd`. If the
360    // task has subtasks, it's a container: its children get scheduled
361    // instead, each ending at the NEXT child's start (last child ends at
362    // `anchorEnd`). Children's children recurse with the same rule, so a
363    // deeply nested task-with-deadline lays out bottom-up automatically.
364    // Leaves produce one scheduling entry. Returns { events, earliestStart }
365    // where earliestStart is the first start-time of the entire subtree
366    // (used by the caller as the NEXT child's anchor-end when it unwinds).
367    function scheduleReverse(task, anchorEnd) {
368        const kids = childrenByParent[task.id] || [];
369        if (kids.length === 0) {
370            const durMin = durationMinutesFor(task);
371            const end = new Date(anchorEnd.getTime());
372            const start = new Date(end.getTime() - durMin * 60 * 1000);
373            return { events: [{ task, start, end }], earliestStart: start };
374        }
375        const all = [];
376        let currentAnchor = anchorEnd;
377        for (let i = kids.length - 1; i >= 0; i--) {
378            const sub = scheduleReverse(kids[i], currentAnchor);
379            for (let j = sub.events.length - 1; j >= 0; j--) {
380                all.unshift(sub.events[j]);
381            }
382            currentAnchor = sub.earliestStart;
383        }
384        return { events: all, earliestStart: currentAnchor };
385    }
386
387    const cursor = new Date();
388    cursor.setDate(cursor.getDate() + 1);
389    cursor.setHours(9, 0, 0, 0);
390
391    const now = toIcalDate(new Date());
392    const lines = [
393        'BEGIN:VCALENDAR',
394        'VERSION:2.0',
395        'PRODID:-//goblin.tools//Magic ToDo//EN'
396    ];
397
398    function emitEvent(task, start, end, extraDescription) {
399        const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000));
400        const ancestors = ancestorChain(task);
401        const descriptionParts = [];
402        if (ancestors.length > 0) {
403            descriptionParts.push('Part of: ' + ancestors.map(a => a.text).join(' > '));
404        }
405        if (extraDescription) descriptionParts.push(extraDescription);
406        const description = descriptionParts.join('\n\n');
407
408        lines.push('BEGIN:VEVENT');
409        lines.push(`UID:${task.id}[email protected]`);
410        lines.push(`DTSTAMP:${now}`);
411        lines.push(`DTSTART:${toIcalFloatingDate(start)}`);
412        lines.push(`DURATION:PT${durationMinutes}M`);
413        lines.push(`SUMMARY:${escapeIcalText(task.text)}`);
414        if (description) {
415            lines.push(`DESCRIPTION:${escapeIcalText(description)}`);
416        }
417        lines.push('END:VEVENT');
418    }
419
420    allTodos.filter(t => t.parentId == null).forEach(todo => {
421        const kids = childrenByParent[todo.id] || [];
422
423        // Mode 1: deadline + subtasks → reverse-schedule the whole subtree
424        // so the last leaf ends at the deadline. No event for the parent
425        // itself.
426        if (todo.deadline && kids.length > 0) {
427            const deadlineDate = new Date(todo.deadline);
428            if (!isNaN(deadlineDate.getTime())) {
429                const result = scheduleReverse(todo, deadlineDate);
430                result.events.forEach(ev => emitEvent(ev.task, ev.start, ev.end));
431                return;
432            }
433        }
434
435        // Mode 2: single event for this top-level task. With a deadline it
436        // ends at the deadline; without, it slots into the cascading
437        // tomorrow-9-am schedule. Subtasks (if any) get flattened into the
438        // DESCRIPTION as an indented checklist.
439        const durationMinutes = durationMinutesFor(todo);
440        const subtasksText = renderSubtaskTree(todo.id, 0);
441
442        let dtStart, dtEnd;
443        if (todo.deadline) {
444            const dueDate = new Date(todo.deadline);
445            if (!isNaN(dueDate.getTime())) {
446                dtEnd = dueDate;
447                dtStart = new Date(dueDate.getTime() - durationMinutes * 60 * 1000);
448            }
449        }
450        if (!dtStart) {
451            dtStart = new Date(cursor.getTime());
452            dtEnd = new Date(cursor.getTime() + durationMinutes * 60 * 1000);
453            cursor.setMinutes(cursor.getMinutes() + durationMinutes);
454        }
455
456        emitEvent(todo, dtStart, dtEnd, subtasksText);
457    });
458
459    lines.push('END:VCALENDAR');
460    return lines.map(foldIcalLine).join('\r\n') + '\r\n';
461}
462
463function convertToMarkdown() {
464    const allTodos = getActiveTabTodos();
465    const childrenByParent = {};
466    allTodos.forEach(t => {
467        if (t.parentId != null) {
468            (childrenByParent[t.parentId] = childrenByParent[t.parentId] || []).push(t);
469        }
470    });
471
472    function formatItem(t, depth) {
473        const marker = t.isCompleted ? '[x]' : '[ ]';
474        const estimate = t.estimate ? ` _(${Math.ceil(t.estimate / 60)} min)_` : '';
475        const deadline = t.deadline ? ` **[due: ${formatDeadlineHuman(t.deadline)}]**` : '';
476        return '  '.repeat(depth) + '- ' + marker + ' ' + t.text + estimate + deadline;
477    }
478
479    const lines = [];
480    function walk(item, depth) {
481        lines.push(formatItem(item, depth));
482        (childrenByParent[item.id] || []).forEach(c => walk(c, depth + 1));
483    }
484
485    allTodos.filter(t => t.parentId == null).forEach(r => walk(r, 0));
486    return lines.join('\n') + '\n';
487}
488
489// Notion CSV import creates a flat database — no subtask hierarchy on import.
490// We flatten subtasks into a "Subtasks" rich-text column as an indented list so
491// the structure is preserved as readable text even though Notion can't nest it.
492function convertToNotion() {
493    const allTodos = getActiveTabTodos();
494    const childrenByParent = {};
495    allTodos.forEach(t => {
496        if (t.parentId != null) {
497            (childrenByParent[t.parentId] = childrenByParent[t.parentId] || []).push(t);
498        }
499    });
500
501    function csvCell(value) {
502        var s = value == null ? '' : String(value);
503        if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
504        if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
505        return s;
506    }
507
508    function subtasksAsText(parentId, depth) {
509        const kids = childrenByParent[parentId] || [];
510        const out = [];
511        kids.forEach(k => {
512            const marker = k.isCompleted ? '[x]' : '[ ]';
513            out.push('  '.repeat(depth) + marker + ' ' + k.text);
514            const sub = subtasksAsText(k.id, depth + 1);
515            if (sub) out.push(sub);
516        });
517        return out.join('\n');
518    }
519
520    // Notion CSV imports auto-detect column types — `Deadline` with a plain
521    // ISO date maps to a Date property. Not using datetime here so the
522    // column comes through as just a date (matching the precision Notion
523    // typically wants for task due dates).
524    const header = ['Name', 'Status', 'Subtasks', 'Estimate', 'Deadline'];
525    const rows = [header.join(',')];
526
527    allTodos.filter(t => t.parentId == null).forEach(t => {
528        const status = t.isCompleted ? 'Done' : 'Todo';
529        const subtasks = subtasksAsText(t.id, 0);
530        const estimate = t.estimate ? Math.ceil(t.estimate / 60) + ' min' : '';
531        const deadline = formatDeadlineDate(t.deadline);
532        rows.push([
533            csvCell(t.text),
534            csvCell(status),
535            csvCell(subtasks),
536            csvCell(estimate),
537            csvCell(deadline)
538        ].join(','));
539    });
540
541    return rows.join('\r\n') + '\r\n';
542}
543
544// Amazing Marvin's documented generic CSV. Subtasks use the Parent column
545// (by parent title) rather than the inline Subtasks column, since Parent
546// preserves nested hierarchy while Subtasks flattens to a checklist.
547// Priority scale: 0 none, 1 yellow, 2 orange, 3 red. Duration in minutes.
548function convertToMarvin() {
549    const allTodos = getActiveTabTodos();
550
551    function csvCell(value) {
552        var s = value == null ? '' : String(value);
553        if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
554        if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
555        return s;
556    }
557
558    const header = ['Type', 'Title', 'Parent', 'Schedule Date', 'Due Date',
559        'End Date', 'Start Date', 'Labels', 'Note', 'Subtasks', 'Duration', 'Priority'];
560    const rows = [header.join(',')];
561
562    function emit(parentId, parentTitle) {
563        allTodos.filter(t => t.parentId == parentId).forEach(t => {
564            const durationMinutes = t.estimate ? Math.ceil(t.estimate / 60) : '';
565            const note = t.isCompleted ? 'Completed in goblin.tools' : '';
566            // Marvin's header has both Due Date and End Date in the YYYY-MM-DD
567            // format — we populate Due Date (index 4) since that's the field
568            // the app surfaces as "by when". Schedule Date / Start Date are
569            // left empty so the user can place the task on a day themselves.
570            const dueDate = formatDeadlineDate(t.deadline);
571            rows.push([
572                'task',
573                csvCell(t.text),
574                csvCell(parentTitle || ''),
575                '',
576                csvCell(dueDate),
577                '',
578                '',
579                '',
580                csvCell(note),
581                '',
582                csvCell(String(durationMinutes)),
583                '0'
584            ].join(','));
585            emit(t.id, t.text);
586        });
587    }
588
589    emit(null, null);
590    return rows.join('\r\n') + '\r\n';
591}
592
593// TickTick has no user-facing generic CSV import — this targets its own
594// backup/restore format (Settings → Data Backup → Restore). Columns are
595// reverse-engineered from export samples; the format is not officially
596// documented for external use and may change. Flagged as experimental in
597// the modal copy.
598function convertToTickTick() {
599    const allTodos = getActiveTabTodos();
600
601    function csvCell(value) {
602        var s = value == null ? '' : String(value);
603        if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
604        if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
605        return s;
606    }
607
608    function isoOrEmpty(epochMs) {
609        if (!epochMs) return '';
610        try { return new Date(epochMs).toISOString(); } catch (e) { return ''; }
611    }
612
613    const header = ['Folder Name', 'List Name', 'Title', 'Tags', 'Content',
614        'Is Check list', 'Start Date', 'Due Date', 'Reminder', 'Repeat',
615        'Priority', 'Status', 'Created Time', 'Completed Time', 'Order',
616        'Timezone', 'Is All Day', 'Is Floating', 'Column Name', 'Column Order',
617        'View Mode', 'taskId', 'parentId'];
618    const rows = [header.join(',')];
619
620    const listName = 'Magic ToDo';
621
622    allTodos.forEach(t => {
623        const status = t.isCompleted ? '1' : '0';
624        const created = isoOrEmpty(t.timestamp);
625        const completed = t.isCompleted ? isoOrEmpty(t.timestamp) : '';
626        // TickTick's "Due Date" column (header index 7) takes a full ISO
627        // 8601 timestamp. "Is All Day" stays Y for consistency with tasks
628        // that don't have a specific time component in the deadline.
629        const dueDate = formatDeadlineIso(t.deadline);
630        rows.push([
631            '',
632            csvCell(listName),
633            csvCell(t.text),
634            '', '',
635            'N',
636            '',
637            csvCell(dueDate),
638            '', '',
639            '0',
640            status,
641            csvCell(created),
642            csvCell(completed),
643            '', '',
644            'Y', '', '', '', '',
645            csvCell(t.id),
646            csvCell(t.parentId || '')
647        ].join(','));
648    });
649
650    return rows.join('\r\n') + '\r\n';
651}
652
653// Asana matches subtasks to parents by the exact parent NAME in the
654// "Subtask of" column, and requires parent rows to appear above child rows.
655// Identical task names will collide — we flag this in the modal copy.
656function convertToAsana() {
657    const allTodos = getActiveTabTodos();
658
659    function csvCell(value) {
660        var s = value == null ? '' : String(value);
661        if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
662        if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
663        return s;
664    }
665
666    const header = [
667        'Name', 'Description', 'Section', 'Assignee', 'Collaborators',
668        'Subtask of', 'Start Date', 'Due Date', 'Type', 'Dependents'
669    ];
670    const rows = [header.join(',')];
671
672    function emit(parentId, parentName) {
673        allTodos.filter(t => t.parentId == parentId).forEach(t => {
674            const notes = [];
675            if (t.estimate) notes.push(`Estimated: ${Math.ceil(t.estimate / 60)} min`);
676            if (t.isCompleted) notes.push('Completed in goblin.tools');
677            // Asana's "Due Date" column accepts YYYY-MM-DD.
678            const dueDate = formatDeadlineDate(t.deadline);
679            rows.push([
680                csvCell(t.text),
681                csvCell(notes.join(' — ')),
682                '', '', '',
683                csvCell(parentName || ''),
684                '',
685                csvCell(dueDate),
686                '', ''
687            ].join(','));
688            emit(t.id, t.text);
689        });
690    }
691
692    emit(null, null);
693    return rows.join('\r\n') + '\r\n';
694}
695
696const EXPORT_HANDLERS = {
697    todoist: () => downloadAsFile(convertToTodoist(), 'magic_todo_todoist.csv'),
698    ical: () => downloadAsFile(convertToICal(), 'magic_todo_ical.ics'),
699    icalEvents: () => downloadAsFile(convertToICalEvents(), 'magic_todo_calendar.ics'),
700    markdown: () => downloadAsFile(convertToMarkdown(), 'magic_todo.md'),
701    asana: () => downloadAsFile(convertToAsana(), 'magic_todo_asana.csv'),
702    notion: () => downloadAsFile(convertToNotion(), 'magic_todo_notion.csv'),
703    marvin: () => downloadAsFile(convertToMarvin(), 'magic_todo_marvin.csv'),
704    ticktick: () => downloadAsFile(convertToTickTick(), 'magic_todo_ticktick.csv')
705};
706
707function openExportModal(type) {
708    const modal = document.getElementById('exportModal');
709    if (!modal) return;
710    modal.querySelectorAll('.export-modal-variant').forEach(v => {
711        v.style.display = v.dataset.exportType === type ? 'block' : 'none';
712    });
713    modal.style.display = 'flex';
714    const closeBtn = modal.querySelector('.export-modal-close');
715    if (closeBtn) closeBtn.focus();
716}
717
718function closeExportModal() {
719    const modal = document.getElementById('exportModal');
720    if (modal) modal.style.display = 'none';
721}
722
723function closeExportModalFromOverlay(event) {
724    if (event.target && event.target.id === 'exportModal') closeExportModal();
725}
726
727function performExport(type) {
728    const handler = EXPORT_HANDLERS[type];
729    if (handler) handler();
730    closeExportModal();
731}
732
733document.addEventListener('keydown', function (e) {
734    if (e.key !== 'Escape') return;
735    const modal = document.getElementById('exportModal');
736    if (modal && modal.style.display !== 'none') closeExportModal();
737});
738
739function copyToClipboard() {
740    var text = parseFlatTree(getActiveTabTodos());
741    navigator.clipboard.writeText(text).then(function () {
742        toast("Copied ToDo list to clipboard.");
743    }, function (e) {
744        try {
745
746            const textArea = document.createElement('textarea');
747            textArea.style.position = "absolute";
748            // to prevent scrolling on IE/Safari, place the element beside us... (not a perfect solution)
749            textArea.style.top = "" + ((document.documentElement && document.documentElement.scrollTop) || document.body.scrollTop) + "px";
750            textArea.style.left = "-100%";
751            textArea.textContent = text
752            document.body.appendChild(textArea);
753            textArea.select();
754            textArea.setSelectionRange(0, 99999); // For mobile devices
755            document.execCommand("copy");
756            toast("Copied ToDo list to clipboard.");
757            textArea.remove()
758        } catch (err) {
759            error('Could not copy list to clipboard: ', err);
760        }
761    });
vendor: 1 bytes, line 762
762}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.