|
/** |
|
* workflowy_export.js (v2 — adds PDFs, videos, and other uploaded files) |
|
* ========================================================================= |
|
* Paste into DevTools console on any WorkFlowy page. |
|
* |
|
* USAGE: |
|
* 1. Navigate to the node you want to export (or root) |
|
* 2. Expand all nodes: Ctrl+Shift+9 (so virtualised nodes render) |
|
* 3. Open DevTools console: Cmd+Option+J (Mac) or Ctrl+Shift+J (Win) |
|
* 4. Paste this script and press Enter |
|
* 5. Wait for "WorkFlowy export complete!" in the console |
|
* |
|
* OUTPUT FILES (~/Downloads): |
|
* <slug>.md — full markdown |
|
* attachment-001-<name>.ext — image 1 |
|
* attachment-002-<name>.ext — image 2 |
|
* ... |
|
* |
|
* WHAT'S NEW vs v1: |
|
* • Downloads PDFs and other uploaded files (invoices, manuals, etc.) |
|
* via WorkFlowy's /file-proxy/signed-original/ endpoint |
|
* • Downloads S3-hosted videos |
|
* • Falls back to WF internal tree to catch any PDF/video nodes that |
|
* were not expanded / rendered in the DOM |
|
*/ |
|
|
|
(async function workflowyExport() { |
|
console.log('WorkFlowy Export v2: starting...'); |
|
|
|
// ── HELPERS ─────────────────────────────────────────────────────────────── |
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } |
|
|
|
// ── 1. GET USER ID (needed for PDF signed-original URLs) ───────────────── |
|
const userId = (window.WF && WF.User && WF.User.id) ? WF.User.id : null; |
|
if (!userId) { |
|
console.warn('⚠ Could not read WF.User.id — PDF downloads will be skipped.'); |
|
} else { |
|
console.log('User ID:', userId); |
|
} |
|
|
|
// ── 2. SCROLL TO LOAD ALL VIRTUALISED NODES ─────────────────────────────── |
|
async function scrollToLoadAll() { |
|
const step = 2000; |
|
let prev = -1; |
|
for (let pass = 0; pass < 5; pass++) { |
|
window.scrollTo(0, 0); |
|
await sleep(200); |
|
let pos = 0; |
|
while (pos <= document.documentElement.scrollHeight) { |
|
window.scrollTo(0, pos); |
|
await sleep(60); |
|
pos += step; |
|
} |
|
window.scrollTo(0, document.documentElement.scrollHeight); |
|
await sleep(400); |
|
const count = document.querySelectorAll('.project:not(.root)').length; |
|
console.log(' Pass ' + (pass + 1) + ': ' + count + ' nodes, height: ' + document.documentElement.scrollHeight); |
|
if (count === prev) break; |
|
prev = count; |
|
} |
|
return document.querySelectorAll('.project:not(.root)').length; |
|
} |
|
|
|
// ── 3. EXTRACT CONTENT ──────────────────────────────────────────────────── |
|
function getDepth(el) { |
|
let depth = 0, parent = el.parentElement; |
|
while (parent) { |
|
if (parent.className && typeof parent.className === 'string' |
|
&& parent.className.includes('project') && !parent.className.includes('root')) depth++; |
|
parent = parent.parentElement; |
|
} |
|
return depth; |
|
} |
|
|
|
function htmlToMarkdown(html) { |
|
if (!html) return ''; |
|
let t = html; |
|
t = t.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, |
|
(_, href, txt) => '[' + txt.replace(/<[^>]+>/g, '').trim() + '](' + href + ')'); |
|
t = t.replace(/<span[^>]*contentTag[^>]*>[\s\S]*?<span class="contentTagText">([^<]*)<\/span>[\s\S]*?<\/span>/g, '#$1'); |
|
t = t.replace(/<[^>]+>/g, ''); |
|
t = t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' '); |
|
t = t.replace(/[\u200b\ufeff]/g, '').trim(); |
|
return t; |
|
} |
|
|
|
/** |
|
* Classify a project element into one of: |
|
* 'image' — .item--with-image with a file-proxy <img> |
|
* 'video' — .file-preview with a <video src> |
|
* 'file' — .file-preview with a Download button (PDF, doc, etc.) |
|
* 'text' — plain outline node |
|
*/ |
|
function classifyProject(p) { |
|
const fp = p.querySelector(':scope > .file-preview'); |
|
if (!fp) return 'text'; |
|
|
|
// Video node? |
|
const video = fp.querySelector('video[src]'); |
|
if (video) return 'video'; |
|
|
|
// PDF / other uploaded file — has a Download button in the direct .file-preview |
|
const btn = fp.querySelector('button'); |
|
if (btn) return 'file'; |
|
|
|
// Image node — img with a real (non-blob) src |
|
const img = fp.querySelector('img[src]'); |
|
if (img && !img.src.startsWith('blob:')) return 'image'; |
|
|
|
return 'text'; |
|
} |
|
|
|
function extractNodes() { |
|
return Array.from(document.querySelectorAll('.project:not(.root)')).map((p, idx) => { |
|
const depth = getDepth(p); |
|
const nameEl = p.querySelector(':scope > .name'); |
|
const contentEl = nameEl ? nameEl.querySelector('.content') : null; |
|
const noteEl = p.querySelector(':scope > .notes .content') |
|
|| (nameEl ? nameEl.querySelector('.note') : null); |
|
const text = htmlToMarkdown(contentEl ? contentEl.innerHTML : ''); |
|
const note = htmlToMarkdown(noteEl ? noteEl.innerHTML : ''); |
|
const type = classifyProject(p); |
|
const projectId = p.getAttribute('projectid') || ''; |
|
|
|
const images = []; |
|
const videos = []; |
|
const files = []; |
|
|
|
if (type === 'image') { |
|
const fp = p.querySelector(':scope > .file-preview'); |
|
fp && fp.querySelectorAll('img[src]').forEach(img => { |
|
if (!img.src.startsWith('blob:') && |
|
(img.src.includes('file-proxy') || |
|
(img.src.includes('workflowy') && !img.src.includes('data:')))) { |
|
images.push({ src: img.src, alt: img.alt || '' }); |
|
} |
|
}); |
|
} else if (type === 'video') { |
|
const fp = p.querySelector(':scope > .file-preview'); |
|
fp && fp.querySelectorAll('video[src]').forEach(v => { |
|
if (v.src) videos.push({ src: v.src, name: text || ('video-' + projectId) }); |
|
}); |
|
} else if (type === 'file') { |
|
// PDF / other uploaded file |
|
if (userId && projectId) { |
|
const url = 'https://workflowy.com/file-proxy/signed-original/' + userId + '/' + projectId + '/?attempt=1'; |
|
files.push({ src: url, name: text || ('file-' + projectId) }); |
|
} |
|
} |
|
|
|
return { depth, type, text, note, images, videos, files, projectId }; |
|
}); |
|
} |
|
|
|
// ── 4. FALLBACK: catch PDFs from WF internal tree (collapsed nodes) ─────── |
|
// |
|
// If the user did NOT expand all nodes, some file nodes won't be rendered. |
|
// We scan WF.currentItem() sub-tree for leaf items whose names end in |
|
// common file extensions, then add them to the file list if not already seen. |
|
// |
|
function collectUnrenderedFiles(seenProjectIds) { |
|
const extraFiles = []; |
|
if (!window.WF || !WF.currentItem || !userId) return extraFiles; |
|
|
|
const FILE_EXTS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip']; |
|
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm']; |
|
|
|
function traverse(item) { |
|
const id = item.getId(); |
|
if (!seenProjectIds.has(id)) { |
|
const name = (item.getName() || '').trim(); |
|
const lname = name.toLowerCase(); |
|
const isFile = FILE_EXTS.some(e => lname.endsWith(e)); |
|
const isVideo = VIDEO_EXTS.some(e => lname.endsWith(e)); |
|
const children = item.getChildren() || []; |
|
|
|
if ((isFile || isVideo) && children.length === 0) { |
|
// Leaf node with a file-like name — assume it's an attachment |
|
const url = 'https://workflowy.com/file-proxy/signed-original/' + userId + '/' + id + '/?attempt=1'; |
|
extraFiles.push({ src: url, name: name, id }); |
|
console.log(' [WF tree] Found unrendered file:', name); |
|
} |
|
} |
|
(item.getChildren() || []).forEach(traverse); |
|
} |
|
|
|
traverse(WF.currentItem()); |
|
return extraFiles; |
|
} |
|
|
|
// ── 5. BUILD MARKDOWN ───────────────────────────────────────────────────── |
|
function buildMarkdown(nodes, title, extraFiles) { |
|
const lines = ['# ' + title, '']; |
|
const attachmentList = []; // { src, filename, alt } |
|
let counter = 1; |
|
|
|
function nextFilename(rawName, fallback) { |
|
const safe = (rawName || fallback || ('file-' + counter)) |
|
.replace(/[^a-zA-Z0-9._\- ]/g, '_').replace(/_+/g, '_').trim(); |
|
return 'attachment-' + String(counter).padStart(3, '0') + '-' + safe; |
|
} |
|
|
|
nodes.forEach(node => { |
|
// ── Images ── |
|
if (node.type === 'image') { |
|
node.images.forEach(img => { |
|
const safeAlt = img.alt.replace(/[^a-zA-Z0-9._-]/g, '_') || ('image-' + counter); |
|
const filename = nextFilename(safeAlt, 'image'); |
|
attachmentList.push({ src: img.src, filename, alt: img.alt, kind: 'image' }); |
|
const indent = node.depth >= 2 ? ' '.repeat(node.depth - 2) + '- ' : '- '; |
|
lines.push(indent + ''); |
|
counter++; |
|
}); |
|
return; |
|
} |
|
|
|
// ── Videos ── |
|
if (node.type === 'video') { |
|
node.videos.forEach(v => { |
|
const filename = nextFilename(v.name, 'video'); |
|
attachmentList.push({ src: v.src, filename, alt: v.name, kind: 'video' }); |
|
const indent = node.depth >= 2 ? ' '.repeat(node.depth - 2) + '- ' : '- '; |
|
lines.push(indent + '[VIDEO: ' + (v.name || filename) + '](' + filename + ')'); |
|
counter++; |
|
}); |
|
return; |
|
} |
|
|
|
// ── PDF / other uploaded files ── |
|
if (node.type === 'file') { |
|
node.files.forEach(f => { |
|
const filename = nextFilename(f.name, 'file'); |
|
attachmentList.push({ src: f.src, filename, alt: f.name, kind: 'file' }); |
|
const indent = node.depth >= 2 ? ' '.repeat(node.depth - 2) + '- ' : '- '; |
|
lines.push(indent + '[📎 ' + (f.name || filename) + '](' + filename + ')'); |
|
counter++; |
|
}); |
|
return; |
|
} |
|
|
|
// ── Text node ── |
|
if (!node.text) return; |
|
if (node.depth === 0) { |
|
lines.push('', '## ' + node.text); |
|
} else if (node.depth === 1) { |
|
lines.push('', '### ' + node.text); |
|
} else { |
|
lines.push(' '.repeat(node.depth - 2) + '- ' + node.text); |
|
} |
|
if (node.note) { |
|
const noteIndent = node.depth >= 2 ? ' '.repeat(node.depth - 2) + ' ' : ' '; |
|
node.note.split('\n').forEach(l => { if (l.trim()) lines.push(noteIndent + '> ' + l.trim()); }); |
|
} |
|
}); |
|
|
|
// Append any files found via WF tree traversal (not in DOM) |
|
if (extraFiles.length > 0) { |
|
lines.push('', '---', '## Unrendered file attachments (collapsed nodes)', ''); |
|
extraFiles.forEach(f => { |
|
const filename = nextFilename(f.name, 'file'); |
|
attachmentList.push({ src: f.src, filename, alt: f.name, kind: 'file' }); |
|
lines.push('- [📎 ' + f.name + '](' + filename + ')'); |
|
counter++; |
|
}); |
|
} |
|
|
|
return { markdown: lines.join('\n'), attachmentList }; |
|
} |
|
|
|
// ── 6. DOWNLOAD HELPERS ─────────────────────────────────────────────────── |
|
function triggerDownload(blob, filename) { |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = url; a.download = filename; |
|
document.body.appendChild(a); a.click(); document.body.removeChild(a); |
|
setTimeout(() => URL.revokeObjectURL(url), 8000); |
|
} |
|
|
|
async function downloadAttachments(list) { |
|
let ok = 0, fail = 0; |
|
for (const item of list) { |
|
try { |
|
// Step 1: fetch the signed-original endpoint |
|
const r = await fetch(item.src, { credentials: 'include' }); |
|
if (!r.ok) throw new Error('HTTP ' + r.status); |
|
|
|
let fileUrl = item.src; |
|
let blob; |
|
|
|
if (item.kind === 'file') { |
|
// The signed-original endpoint returns JSON: {"url": "https://s3.amazonaws.com/..."} |
|
// Parse it and fetch the real file from the S3 URL inside. |
|
const contentType = r.headers.get('content-type') || ''; |
|
if (contentType.includes('application/json') || contentType.includes('text/')) { |
|
const json = await r.json(); |
|
if (!json.url) throw new Error('No url field in JSON response'); |
|
fileUrl = json.url; |
|
const r2 = await fetch(fileUrl); |
|
if (!r2.ok) throw new Error('S3 HTTP ' + r2.status); |
|
blob = await r2.blob(); |
|
} else { |
|
// Unexpected: already a binary response — use it directly |
|
blob = await r.blob(); |
|
} |
|
} else { |
|
blob = await r.blob(); |
|
} |
|
|
|
triggerDownload(blob, item.filename); |
|
ok++; |
|
if (ok % 10 === 0) console.log(' Attachments: ' + ok + '/' + list.length + ' downloaded...'); |
|
await sleep(300); |
|
} catch (e) { |
|
fail++; |
|
console.warn(' FAILED [' + item.kind + ']:', item.filename, '|', e.message); |
|
} |
|
} |
|
return { ok, fail }; |
|
} |
|
// ── 7. DERIVE TITLE / SLUG ──────────────────────────────────────────────── |
|
function getTitle() { |
|
if (window.WF && WF.currentItem) { |
|
const cur = WF.currentItem(); |
|
if (cur && cur.getName) { |
|
const name = cur.getName().trim().replace(/<[^>]+>/g, '').trim(); |
|
if (name) return name; |
|
} |
|
} |
|
const heading = document.querySelector('.pageTitle, [class*="pageTitle"], .breadcrumb [class*="name"]'); |
|
if (heading) return heading.textContent.trim(); |
|
const t = document.title || ''; |
|
return t.replace(/\s*-\s*WorkFlowy.*/, '').trim() || 'workflowy-export'; |
|
} |
|
|
|
function slugify(s) { |
|
return s.replace(/[^a-zA-Z0-9_#-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').toLowerCase(); |
|
} |
|
|
|
// ── MAIN ────────────────────────────────────────────────────────────────── |
|
const nodeCount = await scrollToLoadAll(); |
|
console.log('Total nodes loaded:', nodeCount); |
|
|
|
const nodes = extractNodes(); |
|
const seenIds = new Set(nodes.map(n => n.projectId).filter(Boolean)); |
|
|
|
const imgCount = nodes.reduce((s, n) => s + n.images.length, 0); |
|
const videoCount = nodes.reduce((s, n) => s + n.videos.length, 0); |
|
const fileCount = nodes.reduce((s, n) => s + n.files.length, 0); |
|
console.log('Extracted:', nodes.length, 'nodes,', imgCount, 'images,', videoCount, 'videos,', fileCount, 'files (PDFs etc.)'); |
|
|
|
// Fallback: collect PDFs from collapsed/unrendered nodes via WF internal tree |
|
const extraFiles = collectUnrenderedFiles(seenIds); |
|
if (extraFiles.length) { |
|
console.log('Found', extraFiles.length, 'additional file(s) in collapsed nodes via WF tree'); |
|
} |
|
|
|
const title = getTitle(); |
|
const slug = slugify(title); |
|
const { markdown, attachmentList } = buildMarkdown(nodes, title, extraFiles); |
|
|
|
const imageAttachments = attachmentList.filter(a => a.kind === 'image'); |
|
const videoAttachments = attachmentList.filter(a => a.kind === 'video'); |
|
const fileAttachments = attachmentList.filter(a => a.kind === 'file'); |
|
|
|
console.log('Built markdown:', markdown.length, 'chars'); |
|
console.log('Attachments to download:', imageAttachments.length, 'images,', videoAttachments.length, 'videos,', fileAttachments.length, 'files'); |
|
|
|
// Download markdown |
|
triggerDownload(new Blob([markdown], { type: 'text/markdown' }), slug + '.md'); |
|
console.log('Downloaded:', slug + '.md'); |
|
await sleep(1000); |
|
|
|
// Download all attachments |
|
console.log('Downloading', attachmentList.length, 'attachments...'); |
|
const { ok, fail } = await downloadAttachments(attachmentList); |
|
|
|
console.log('====================================='); |
|
console.log('WorkFlowy export v2 complete!'); |
|
console.log(' File :', slug + '.md'); |
|
console.log(' Images :', imageAttachments.length); |
|
console.log(' Videos :', videoAttachments.length); |
|
console.log(' Files/PDFs:', fileAttachments.length); |
|
console.log(' Downloaded:', ok, '✓ |', fail, '✗ failed'); |
|
console.log(' Location : ~/Downloads/'); |
|
console.log('====================================='); |
|
})(); |