think
16px
820px

DOCX → editable document (feasibility & plan)

Goal. Upload a .docx and turn it into an in-app editable document — the same
shape as the existing "Write document" flow: DOCX → new text/html document → edit in the rich-text editor → Finalize to PDF makes it a real record.

Verdict: very feasible, ~a day of work. Every hard part already exists in the
stack; the only missing piece is one DOCX→HTML conversion endpoint and a "convert"
action wiring it to the editor.

Why it slots in cleanly

The Write-document flow already does everything downstream of the conversion:

Piece Status
Rich-text editor Live — TipTap/ProseMirror (TextEditorView.tsx) with StarterKit + tables, and @tiptap/extension-image is already a dependency
Editable storage format Live — documents whose current version is text/html, saved through the same AddVersion as uploads
Editable → record Live — Finalize renders the HTML to PDF via gotenberg (letterhead supported) and publishes as a version/new doc
DOCX parsing in the stack Partially — the extract sidecar already uses python-docx for text extraction (formatting lost)

Conversion options considered

  1. mammoth (Python) in the extract sidecar — RECOMMENDED. Purpose-built
    DOCX→semantic HTML: headings, lists, tables, bold/italic, links, images
    (data URIs). Its output vocabulary maps almost 1:1 onto what TipTap accepts, so the
    round-trip is stable. One pip dependency (mammoth==1.12.0), no system packages,
    and the sidecar is already our document-parsing home.
  2. LibreOffice --convert-to html. Gotenberg's API only outputs PDF, so this needs a
    new LO container or shell-outs; the HTML it emits is presentational soup (inline
    styles, spans) that TipTap would mangle on first save. Rejected.
  3. mammoth.js client-side in the browser. Zero backend work, but conversion then
    exists only in the web app (mobile/API miss out) and skips server-side
    sanitization. Rejected as primary; fine as a fallback idea.

Proposed pipeline

  1. Sidecar: POST /docx/html (multipart file → {html}) using mammoth.
    ~30 lines next to the existing extractors. ⚠️ Remember: the extract sidecar must be
    rebuilt separately from update.sh.
  2. Backend: POST /documents/{docID}/make-editable (ReadWrite access, PDF-style
    gate on the mime being docx): reads the version bytes → sidecar → sanitize the
    HTML server-side
    (allowlist; TipTap re-parsing is a de-facto filter but must not
    be the only one since text/html versions are also rendered by gotenberg for
    previews) → create a new document titled " (editable)" with a<br /> <code>text/html</code> first version, same folder/classification — exactly what "Write<br /> document" produces. (New document, not a new version, so the original DOCX record<br /> stays untouched — mirrors the Finalize "new document" option.)</li> <li><strong>FE</strong>: a "Convert to editable document" action on the detail view for docx<br /> versions (and later an upload-modal checkbox); on success navigate straight into<br /> <code>/documents/d/{id}/edit</code>.</li> </ol> <h2>Fidelity expectations (to set with users)</h2> <p>mammoth deliberately keeps <em>meaning</em> and drops <em>layout</em>: fonts, colors, columns,<br /> headers/footers, text boxes and tracked changes do not survive; footnotes partially.<br /> Images arrive as base64 data URIs (watch document size; we may strip or cap them in<br /> v1). This is excellent for letter/memo/report-grade documents — which is what the<br /> Write flow targets — and NOT a pixel-faithful contract editor. The banner on the<br /> converted doc should say so.</p> <h2>Open decisions for v1</h2> <ul> <li>Images: keep (data URIs), cap size, or strip with a notice. Suggest cap ~2 MB total.</li> <li>Where the action lives first: detail-view action (suggested) vs upload-time option.</li> <li>Whether <code>.doc</code>/<code>.odt</code> join later via LibreOffice→docx pre-step (defer).</li> </ul> </div> </div> </div> </div> <!-- Fullscreen overlay --> <div class="fullscreen-overlay" id="fullscreen-overlay"> <button class="fullscreen-close" id="fullscreen-close"> <i data-lucide="x"></i> </button> <div class="fullscreen-content" id="fullscreen-content"></div> </div> <!-- Toast --> <div class="toast" id="toast"></div> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <script> (function() { 'use strict'; const prefs = { theme: localStorage.getItem('think-theme') || 'light', font: localStorage.getItem('think-font') || 'system', fontSize: parseInt(localStorage.getItem('think-font-size') || '16'), justify: localStorage.getItem('think-justify') === 'true', containerWidth: parseInt(localStorage.getItem('think-container-width') || '820'), sidebarVisible: localStorage.getItem('think-sidebar') !== 'false', }; const tableColWidths = {}; const html = document.documentElement; const body = document.body; const container = document.getElementById('main-container'); const sidebar = document.getElementById('doc-sidebar'); const sidebarBackdrop = document.getElementById('sidebar-backdrop'); // ============================================================== // Theme // ============================================================== function setTheme(theme) { prefs.theme = theme; html.setAttribute('data-theme', theme); const icon = document.querySelector('#theme-toggle i'); if (icon) icon.setAttribute('data-lucide', theme === 'dark' ? 'moon' : 'sun'); lucide.createIcons({ elements: [icon] }); localStorage.setItem('think-theme', theme); } document.getElementById('theme-toggle').addEventListener('click', () => { setTheme(prefs.theme === 'dark' ? 'light' : 'dark'); }); // ============================================================== // Sidebar toggle // ============================================================== function setSidebar(visible) { prefs.sidebarVisible = visible; sidebar.classList.toggle('hidden', !visible); sidebarBackdrop.classList.toggle('hidden', !visible); const btn = document.getElementById('sidebar-toggle'); btn.classList.toggle('active', visible); // Adjust main area margin const mainArea = document.getElementById('main-area'); if (visible) { mainArea.style.marginLeft = ''; } else { mainArea.style.marginLeft = '0'; } localStorage.setItem('think-sidebar', visible); } document.getElementById('sidebar-toggle').addEventListener('click', () => { setSidebar(!sidebar.classList.contains('hidden') && getComputedStyle(sidebar).transform === 'none'); // Simpler: use prefs setSidebar(!prefs.sidebarVisible); }); sidebarBackdrop.addEventListener('click', () => setSidebar(false)); // Keyboard shortcut document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault(); setSidebar(!prefs.sidebarVisible); } }); // ============================================================== // Font family // ============================================================== const fontMap = { system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', inter: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', merriweather: '"Merriweather", Georgia, "Times New Roman", serif', atkinson: '"Atkinson Hyperlegible", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', jetbrains: '"JetBrains Mono", "SF Mono", "Fira Code", Menlo, Consolas, monospace', }; const fontSelect = document.getElementById('font-selector'); fontSelect.value = prefs.font; function setFont(name) { prefs.font = name; body.style.setProperty('--font-body', fontMap[name] || fontMap.system); body.style.setProperty('--font-mono', name === 'jetbrains' ? fontMap.jetbrains : '"JetBrains Mono", "SF Mono", "Fira Code", Menlo, Consolas, monospace'); localStorage.setItem('think-font', name); fontSelect.value = name; } fontSelect.addEventListener('change', (e) => setFont(e.target.value)); // ============================================================== // Font size // ============================================================== const fontSizeLabel = document.getElementById('font-size-label'); function setFontSize(size) { size = Math.max(12, Math.min(24, size)); prefs.fontSize = size; html.style.setProperty('--font-size-root', size + 'px'); fontSizeLabel.textContent = size + 'px'; localStorage.setItem('think-font-size', size); document.getElementById('font-size-down').disabled = size <= 12; document.getElementById('font-size-up').disabled = size >= 24; } document.getElementById('font-size-up').addEventListener('click', () => setFontSize(prefs.fontSize + 1)); document.getElementById('font-size-down').addEventListener('click', () => setFontSize(prefs.fontSize - 1)); // ============================================================== // Justify // ============================================================== const justifyBtn = document.getElementById('justify-toggle'); function setJustify(on) { prefs.justify = on; body.classList.toggle('justify-text', on); justifyBtn.classList.toggle('active', on); localStorage.setItem('think-justify', on); } justifyBtn.addEventListener('click', () => setJustify(!prefs.justify)); // ============================================================== // Download // ============================================================== document.getElementById('download-md').addEventListener('click', () => { const path = window.location.pathname; if (path === '/' || path === '') { toast('No file to download on index page'); return; } const filename = path.split('/').pop(); const blob = new Blob([document.querySelector('.container').innerText], {type: 'text/markdown'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); toast('Downloaded ' + filename); }); // ============================================================== // Container width — slider + presets + drag // ============================================================== const widthSlider = document.getElementById('width-slider'); const widthValue = document.getElementById('width-value'); const widthButtons = { narrow: document.getElementById('width-narrow'), medium: document.getElementById('width-medium'), wide: document.getElementById('width-wide'), }; const presetWidths = { narrow: 640, medium: 820, wide: 1100 }; function setContainerWidth(width, fromSlider) { width = Math.max(400, Math.min(1400, width)); prefs.containerWidth = width; container.style.setProperty('--container-width', width + 'px'); container.classList.remove('narrow', 'medium', 'wide'); if (!fromSlider) { widthSlider.value = width; } widthValue.textContent = width + 'px'; localStorage.setItem('think-container-width', width); // Update preset active state Object.entries(presetWidths).forEach(([k, v]) => { widthButtons[k].classList.toggle('active', width === v); }); } widthSlider.addEventListener('input', () => setContainerWidth(parseInt(widthSlider.value), true)); widthButtons.narrow.addEventListener('click', () => setContainerWidth(640)); widthButtons.medium.addEventListener('click', () => setContainerWidth(820)); widthButtons.wide.addEventListener('click', () => setContainerWidth(1100)); // Drag resize const resizeHandle = document.getElementById('container-resize-handle'); let isResizing = false, startX, startWidth; resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; startX = e.clientX; startWidth = container.getBoundingClientRect().width; resizeHandle.classList.add('active'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; Object.values(widthButtons).forEach(b => b.classList.remove('active')); e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const newWidth = Math.max(400, Math.min(window.innerWidth - 320, startWidth + (e.clientX - startX))); container.style.setProperty('--container-width', newWidth + 'px'); container.classList.remove('narrow', 'medium', 'wide'); widthSlider.value = newWidth; widthValue.textContent = newWidth + 'px'; }); document.addEventListener('mouseup', () => { if (!isResizing) return; isResizing = false; resizeHandle.classList.remove('active'); document.body.style.cursor = ''; document.body.style.userSelect = ''; prefs.containerWidth = parseInt(container.style.getPropertyValue('--container-width')); localStorage.setItem('think-container-width', prefs.containerWidth); }); // ============================================================== // Table column resize // ============================================================== function setupTableResize() { document.querySelectorAll('table').forEach((table, tableIdx) => { const headers = table.querySelectorAll('th'); if (headers.length === 0) return; headers.forEach((th, colIdx) => { if (th.querySelector('.col-resize')) return; const handle = document.createElement('div'); handle.className = 'col-resize'; th.appendChild(handle); let colResizing = false, colStartX, colStartWidth; handle.addEventListener('mousedown', (e) => { colResizing = true; colStartX = e.clientX; colStartWidth = th.getBoundingClientRect().width; handle.classList.add('active'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', (e) => { if (!colResizing) return; const newWidth = Math.max(40, colStartWidth + (e.clientX - colStartX)); if (!tableColWidths[tableIdx]) tableColWidths[tableIdx] = {}; tableColWidths[tableIdx][colIdx] = newWidth; th.style.width = newWidth + 'px'; th.style.minWidth = newWidth + 'px'; }); document.addEventListener('mouseup', () => { if (!colResizing) return; colResizing = false; handle.classList.remove('active'); document.body.style.cursor = ''; document.body.style.userSelect = ''; }); }); }); } // ============================================================== // Collapsible headings (H1, H2) // ============================================================== function setupCollapsibleHeadings() { const headings = container.querySelectorAll('h1, h2'); headings.forEach((h) => { if (h.dataset.collapsibleSetup) return; h.dataset.collapsibleSetup = '1'; // Create toggle wrapper const toggle = document.createElement('span'); toggle.className = 'collapse-toggle'; toggle.innerHTML = '<i data-lucide="chevron-down"></i>'; // Move heading text into a span, insert toggle before const text = document.createElement('span'); text.innerHTML = h.innerHTML; h.innerHTML = ''; h.appendChild(toggle); h.appendChild(text); h.style.cursor = 'pointer'; // Wrap following content until next heading of same/higher level const level = parseInt(h.tagName[1]); const section = document.createElement('div'); section.className = 'collapsible-section'; let sibling = h.nextElementSibling; while (sibling) { const tag = sibling.tagName; if (tag && tag.match(/^H[1-6]$/)) { const sibLevel = parseInt(tag[1]); if (sibLevel <= level) break; } const next = sibling.nextElementSibling; section.appendChild(sibling); sibling = next; } if (section.children.length > 0) { h.after(section); section.style.maxHeight = section.scrollHeight + 'px'; } h.addEventListener('click', () => { const isCollapsed = section.classList.toggle('collapsed'); toggle.classList.toggle('collapsed', isCollapsed); if (isCollapsed) { section.style.maxHeight = '0'; } else { section.style.maxHeight = section.scrollHeight + 'px'; } }); }); lucide.createIcons({ attrs: { stroke: 'currentColor', width: '18', height: '18' } }); } // ============================================================== // Sidebar ToC builder + scrollspy // ============================================================== function buildSidebarToc() { const toc = document.getElementById('sidebar-toc'); const headings = container.querySelectorAll('h1, h2'); toc.innerHTML = ''; if (headings.length === 0) { toc.innerHTML = '<li style="padding:0.5rem 1rem;color:var(--muted);font-size:0.8rem;">No headings</li>'; return; } headings.forEach((h, idx) => { const id = h.id || 'heading-' + idx; if (!h.id) h.id = id; const li = document.createElement('li'); const a = document.createElement('a'); a.href = '#' + id; a.textContent = h.textContent.replace(/^\s+|\s+$/g, '').substring(0, 80); a.className = h.tagName === 'H2' ? 'h2' : ''; a.addEventListener('click', (e) => { e.preventDefault(); document.getElementById(id).scrollIntoView({ behavior: 'smooth', block: 'start' }); // On mobile, close sidebar if (window.innerWidth <= 900) setSidebar(false); }); li.appendChild(a); toc.appendChild(li); }); updateActiveTocLink(); } function updateActiveTocLink() { const links = document.querySelectorAll('#sidebar-toc a'); let currentId = ''; document.querySelectorAll('#main-container h1[id], #main-container h2[id]').forEach((h) => { const rect = h.getBoundingClientRect(); if (rect.top <= 120) currentId = h.id; }); links.forEach(a => { a.classList.toggle('active', a.getAttribute('href') === '#' + currentId); // If no heading is above the fold, highlight the first if (!currentId && links.length > 0) { links[0].classList.add('active'); } }); } window.addEventListener('scroll', updateActiveTocLink, { passive: true }); // ============================================================== // Fullscreen diagrams // ============================================================== const overlay = document.getElementById('fullscreen-overlay'); const overlayContent = document.getElementById('fullscreen-content'); const overlayClose = document.getElementById('fullscreen-close'); function setupFullscreenDiagrams() { document.querySelectorAll('.mermaid').forEach((el) => { if (el.dataset.fsSetup) return; el.dataset.fsSetup = '1'; el.dataset.mermaidSrc = el.dataset.mermaidSrc || el.textContent || ''; // Add hint icon const hint = document.createElement('span'); hint.className = 'fs-hint'; hint.innerHTML = '<i data-lucide="maximize-2" style="width:14px;height:14px;"></i>'; el.appendChild(hint); lucide.createIcons({ elements: [el.querySelector('.fs-hint i')] }); el.addEventListener('click', () => { const src = el.dataset.mermaidSrc; if (src && src.trim()) { overlayContent.innerHTML = '<div class="mermaid">' + src + '</div>'; try { mermaid.run({ nodes: [overlayContent.querySelector('.mermaid')] }); } catch(_e) {} } else { overlayContent.innerHTML = '<div class="mermaid">' + el.innerHTML + '</div>'; } overlay.classList.add('visible'); document.body.style.overflow = 'hidden'; }); }); } function closeFullscreen() { overlay.classList.remove('visible'); document.body.style.overflow = ''; } overlayClose.addEventListener('click', closeFullscreen); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeFullscreen(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && overlay.classList.contains('visible')) closeFullscreen(); }); // ============================================================== // Toast // ============================================================== let toastTimer; function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2000); } // ============================================================== // Init // ============================================================== setTheme(prefs.theme); setFont(prefs.font); setFontSize(prefs.fontSize); setJustify(prefs.justify); setContainerWidth(prefs.containerWidth); setSidebar(prefs.sidebarVisible); // Width slider init widthSlider.value = prefs.containerWidth; widthValue.textContent = prefs.containerWidth + 'px'; // Initialize Lucide icons lucide.createIcons({ attrs: { stroke: 'currentColor', 'stroke-width': '1.8', width: '16', height: '16' } }); // Initialize mermaid mermaid.initialize({ startOnLoad: true, theme: 'default', securityLevel: 'loose' }); // Post-render setup setTimeout(() => { setupTableResize(); setupCollapsibleHeadings(); setupFullscreenDiagrams(); buildSidebarToc(); updateActiveTocLink(); // Mutation observer for dynamic content const observer = new MutationObserver(() => { setupTableResize(); setupCollapsibleHeadings(); setupFullscreenDiagrams(); buildSidebarToc(); }); observer.observe(container, { childList: true, subtree: true }); }, 250); })(); </script> </body> </html>