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.