Skip to content

Instantly share code, notes, and snippets.

@kdavh
Last active April 5, 2026 01:19
Show Gist options
  • Select an option

  • Save kdavh/d3a2f21f1db21171b8da98e33b6b4a60 to your computer and use it in GitHub Desktop.

Select an option

Save kdavh/d3a2f21f1db21171b8da98e33b6b4a60 to your computer and use it in GitHub Desktop.
WorkFlowy export script — produces clean markdown with embedded attachments (images, PDFs, videos)

WorkFlowy Export Script

WorkFlowy's built-in export is frustrating. You can export your data, but images, PDFs, videos, and other attachments come out completely disconnected from your notes — a disorganized mess that's painful to make sense of.

This script fixes that. Paste it into your browser's DevTools console on any WorkFlowy page, and it exports your outline as clean markdown with all attachments properly linked and downloaded alongside it.

Note: This will get out of date quickly because it's based off of Workflowy's current frontend page structure. It is not maintained.

What it exports

  • Text nodes as nested markdown (headings + bullet lists preserving hierarchy)
  • Images downloaded and linked inline
  • PDFs and documents downloaded via WorkFlowy's file-proxy endpoint
  • Videos downloaded from S3
  • Collapsed/unrendered nodes caught via WorkFlowy's internal tree API as a fallback

Usage

  1. Navigate to the WorkFlowy node you want to export (or stay at root for everything)
  2. Expand all nodes: Ctrl+Shift+9
  3. Open DevTools console: Cmd+Option+J (Mac) or Ctrl+Shift+J (Windows)
  4. Paste the script and press Enter
  5. Wait for "WorkFlowy export complete!" in the console

Output lands in ~/Downloads/: one .md file plus numbered attachment files.

/**
* 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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/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 + '![](' + filename + ')');
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('=====================================');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment