/** * File system operations using File System Access API */ /** * Open the scratchpad editor */ function openScratchpad() { // Check if scratchpad already exists if (editorInstances.has(SCRATCHPAD_ID)) { // Just show it const instance = editorInstances.get(SCRATCHPAD_ID); document.getElementById('welcome-screen').classList.add('hidden'); document.getElementById('content-container').classList.remove('hidden'); // Hide all other editors, show scratchpad editorInstances.forEach((data, path) => { if (data.fileViewContainer) { data.fileViewContainer.style.display = path === SCRATCHPAD_ID ? 'flex' : 'none'; } }); return; } // Hide welcome screen, show content container document.getElementById('welcome-screen').classList.add('hidden'); document.getElementById('content-container').classList.remove('hidden'); // Initialize editor with the welcome text seeded as the starting content. initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null); // Mark as scratchpad const instance = editorInstances.get(SCRATCHPAD_ID); if (instance) { instance.isScratchpad = true; } // Reflect non-empty starting content on the scratchpad row's download button. updateScratchpadDownloadState(); if (DEBUG) console.log('Opened scratchpad'); } /** * Enable/disable the scratchpad-row download button based on whether the * scratchpad currently holds any content. Idempotent — safe to call from * editor change listeners. */ function updateScratchpadDownloadState() { const btn = document.getElementById('scratchpad-download-btn'); if (!btn) return; const instance = editorInstances.get(SCRATCHPAD_ID); let hasContent = false; if (instance && instance.editor) { try { hasContent = (instance.editor.getMarkdown() || '').trim().length > 0; } catch (_) { /* editor may not be ready yet */ } } btn.disabled = !hasContent; btn.classList.toggle('is-disabled', !hasContent); } /** * Trigger a browser download of the current scratchpad markdown. * No-op if the scratchpad has no content. */ function downloadScratchpad() { const instance = editorInstances.get(SCRATCHPAD_ID); if (!instance || !instance.editor) return; let content = ''; try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; } // Pull front matter from the textarea if any if (instance.frontMatterTextarea) { const fmText = instance.frontMatterTextarea.value.trim(); if (fmText) content = `---\n${fmText}\n---\n${content}`; } if (!content.trim()) return; // Suggest a filename derived from the first H1 if present let suggested = 'scratchpad.md'; const h1 = content.match(/^#\s+(.+)$/m); if (h1) { const slug = h1[1].trim().toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .substring(0, 60); if (slug) suggested = `${slug}.md`; } saveFileAs(content, suggested); } /** * Save file using Save As dialog (for scratchpads or new saves) * @param {string} content - Content to save * @param {string} suggestedName - Suggested filename * @returns {Promise} File handle if saved, null otherwise */ async function saveFileAs(content, suggestedName = 'untitled.md') { if (hasFileSystemAccess) { try { const fileHandle = await window.showSaveFilePicker({ suggestedName: suggestedName, types: [{ description: 'Markdown files', accept: { 'text/markdown': ['.md', '.markdown'] } }] }); const writable = await fileHandle.createWritable(); await writable.write(content); await writable.close(); if (DEBUG) console.log(`File saved as: ${fileHandle.name}`); return fileHandle; } catch (error) { if (error.name === 'AbortError') { if (DEBUG) console.log('Save cancelled by user'); return null; } throw error; } } else { // Fallback: download as blob const blob = new Blob([content], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = suggestedName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); if (DEBUG) console.log(`File downloaded as: ${suggestedName}`); return null; } } /** * Open directory picker and handle selection */ async function openDirectory() { try { if (!('showDirectoryPicker' in window)) { throw new Error('The File System API is not supported in this browser.'); } directoryHandle = await window.showDirectoryPicker(); if (DEBUG) console.log('Directory selected:', directoryHandle.name); // Local picker wins over any active server-source mode. serverSourceMode = false; updateDirectoryStatus(directoryHandle.name); await readDirectory(directoryHandle); } catch (error) { if (error.name === 'AbortError') { if (DEBUG) console.log('User cancelled the directory selection'); } else { console.error('Error selecting directory:', error); alert(`Error: ${error.message}`); } } } /** * Update UI to show selected directory * @param {string} directoryName - Name of the selected directory */ function updateDirectoryStatus(directoryName) { // Standardized header pattern (across all ZDDC tools): the button // keeps the label "Add Local Directory"; de-emphasize it once a // directory is loaded (the user can still click to pick another) // by applying the shared btn--subtle variant. The directory name // is shown in the file-nav pane, not on the button. const selectDirectoryBtn = document.getElementById('addDirectoryBtn'); if (selectDirectoryBtn) { selectDirectoryBtn.classList.remove('btn-primary'); selectDirectoryBtn.classList.add('btn--subtle'); selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`; } const refreshBtn = document.getElementById('refreshHeaderBtn'); if (refreshBtn) { refreshBtn.classList.remove('hidden'); } // Show new file button when directory is selected const newFileRootBtn = document.getElementById('new-file-root'); if (newFileRootBtn) { newFileRootBtn.classList.remove('hidden'); } } /** * Read directory contents and build tree structure * @param {FileSystemDirectoryHandle} dirHandle - Directory handle * @param {Object} parentNode - Parent node in tree (for recursion) * @returns {Object} Statistics about the directory */ async function readDirectory(dirHandle, parentNode = null) { if (parentNode === null) { fileTree = { name: dirHandle.name, type: 'directory', handle: dirHandle, entries: {} }; const fileTreeElement = document.getElementById('file-tree'); if (fileTreeElement) { fileTreeElement.innerHTML = ''; } parentNode = fileTree; } try { let stats = { folderCount: 0, fileCount: 0 }; for await (const entry of dirHandle.values()) { if (entry.kind === 'file' && !entry.name.startsWith('_')) { parentNode.entries[entry.name] = { name: entry.name, type: 'file', handle: entry }; stats.fileCount++; } else if (entry.kind === 'directory' && !entry.name.startsWith('_')) { const dirNode = { name: entry.name, type: 'directory', handle: entry, entries: {} }; parentNode.entries[entry.name] = dirNode; const subStats = await readDirectory(entry, dirNode); stats.folderCount += subStats.folderCount + 1; stats.fileCount += subStats.fileCount; } } if (parentNode === fileTree) { renderFileTree(); updateStatusCounts(stats.folderCount, stats.fileCount); } return stats; } catch (error) { console.error('Error reading directory:', error); return { folderCount: 0, fileCount: 0 }; } } /** * Save a file by its path * @param {string} filePath - Path of the file to save * @returns {Promise} Whether save was successful */ async function saveFile(filePath) { if (!filePath && currentFileHandle) { filePath = currentFileHandle.name; } else if (!filePath) { alert('No file is currently open'); return false; } try { const editorInstance = editorInstances.get(filePath); if (!editorInstance) { throw new Error('No editor instance found for this file'); } if (!editorInstance.isDirty) { if (DEBUG) console.log(`File ${filePath} is not dirty, skipping save`); return true; } const fileHandle = editorInstance.fileHandle; if (!fileHandle) { throw new Error('No file handle available for this file'); } // Check for external modifications const file = await fileHandle.getFile(); const currentLastModified = file.lastModified; const storedLastModified = editorInstance.lastModified; if (storedLastModified && currentLastModified !== storedLastModified) { const confirmSave = confirm( 'Warning: This file has been modified outside of the application since you opened it. ' + 'Saving will overwrite those changes. Do you want to continue?' ); if (!confirmSave) { if (DEBUG) console.log('Save aborted by user due to external file modifications'); return false; } } // Get markdown content from editor const markdownContent = editorInstance.editor.getMarkdown(); // Get front matter from textarea let frontMatterData = {}; if (editorInstance.frontMatterTextarea) { const frontMatterText = editorInstance.frontMatterTextarea.value.trim(); if (frontMatterText) { try { const yamlContent = `---\n${frontMatterText}\n---\n`; const parsed = parseFrontMatter(yamlContent); frontMatterData = parsed.data; } catch (error) { console.error('Error parsing front matter:', error); throw new Error(`Invalid YAML front matter: ${error.message}`); } } } // Apply before save hooks frontMatterData = await applyBeforeSaveHooks(frontMatterData, markdownContent, fileHandle); // Combine front matter with markdown const finalContent = frontMatterData && Object.keys(frontMatterData).length > 0 ? stringifyFrontMatter(markdownContent, frontMatterData) : markdownContent; // Server-mode files are read-only: fall back to a Save-As download. if (typeof fileHandle.createWritable !== 'function') { const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md'); await saveFileAs(finalContent, downloadName); editorInstance.isDirty = false; updateFileDirtyStatus(filePath, false); updateUnsavedCount(); if (editorInstance.saveButton) editorInstance.saveButton.disabled = true; return true; } // Write to file const writable = await fileHandle.createWritable(); await writable.write(finalContent); await writable.close(); // Update state const updatedFile = await fileHandle.getFile(); editorInstance.lastModified = updatedFile.lastModified; editorInstance.isDirty = false; updateFileDirtyStatus(filePath, false); updateUnsavedCount(); if (editorInstance.saveButton) { editorInstance.saveButton.disabled = true; } if (DEBUG) console.log(`File ${filePath} saved successfully!`); await applyAfterSaveHooks(frontMatterData, markdownContent, fileHandle); return true; } catch (error) { console.error(`Error saving file ${filePath}:`, error); alert(`Error saving file: ${error.message}`); return false; } } /** * Save all files with unsaved changes * @returns {Promise<{saved: number, failed: number}>} */ async function saveAllFiles() { let saved = 0; let failed = 0; const dirtyFiles = []; editorInstances.forEach((instance, filePath) => { if (instance.isDirty) { dirtyFiles.push(filePath); } }); if (dirtyFiles.length === 0) { if (DEBUG) console.log('No files with unsaved changes'); return { saved, failed }; } for (const filePath of dirtyFiles) { try { const success = await saveFile(filePath); if (success) { saved++; } else { failed++; } } catch (error) { console.error(`Error saving file ${filePath}:`, error); failed++; } } if (failed === 0) { if (DEBUG) console.log(`All ${saved} files saved successfully`); } else { if (DEBUG) console.log(`Saved ${saved} files, ${failed} files failed to save`); } return { saved, failed }; } /** * Reload file from disk * @param {string} filePath - Path of file to reload * @returns {Promise} Whether reload was successful */ async function reloadFileFromDisk(filePath) { try { const editorInstance = editorInstances.get(filePath); if (!editorInstance) { throw new Error('No editor instance found for this file'); } if (editorInstance.isDirty) { const confirmReload = confirm( 'This file has unsaved changes. Reloading will discard all changes. ' + 'Do you want to continue?' ); if (!confirmReload) { if (DEBUG) console.log('Reload cancelled by user'); return false; } } const fileHandle = editorInstance.fileHandle; if (!fileHandle) { throw new Error('No file handle available for this file'); } const file = await fileHandle.getFile(); const fileContent = await file.text(); editorInstance.lastModified = file.lastModified; if (filePath.endsWith('.md') || filePath.endsWith('.markdown')) { const parsed = parseFrontMatter(fileContent); if (editorInstance.frontMatterTextarea) { const frontMatterYaml = stringifyFrontMatterToTextarea(parsed.data); editorInstance.frontMatterTextarea.value = frontMatterYaml; } let currentScrollTop = 0; try { currentScrollTop = editorInstance.editor.getScrollTop(); } catch (error) { if (DEBUG) console.debug('Could not get scroll position:', error); } editorInstance.editor.setMarkdown(parsed.content); setTimeout(() => { try { editorInstance.editor.setScrollTop(currentScrollTop); } catch (error) { if (DEBUG) console.debug('Could not restore scroll position:', error); } }, 100); if (editorInstance.tocContainer && window.updateToc) { try { window.updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth); } catch (error) { console.error('Error updating TOC during reload:', error); } } } else { editorInstance.editor.setMarkdown(fileContent); } editorInstance.isDirty = false; updateFileDirtyStatus(filePath, false); updateUnsavedCount(); if (editorInstance.saveButton) { editorInstance.saveButton.disabled = true; } if (DEBUG) console.log(`File ${filePath} reloaded successfully from disk!`); return true; } catch (error) { console.error(`Error reloading file ${filePath}:`, error); alert(`Error reloading file: ${error.message}`); return false; } } /** * Before save hook - apply modifications before saving */ async function applyBeforeSaveHooks(frontMatter, markdownContent, fileHandle) { frontMatter.lastModified = new Date().toISOString(); if (!frontMatter.title) { const firstHeading = markdownContent.match(/^#\s+(.+)$/m); if (firstHeading) { frontMatter.title = firstHeading[1]; } } const customTags = (markdownContent.match(/<(deliverable|meeting|report|trkno)>/g) || []).length; if (customTags > 0) { frontMatter.customTagCount = customTags; } return frontMatter; } /** * After save hook - perform actions after saving */ async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) { const tags = ['deliverable', 'meeting', 'report', 'trkno']; const preservedTags = tags.filter(tag => markdownContent.includes(`<${tag}>`)); if (preservedTags.length > 0) { if (DEBUG) console.log(`Preserved custom tags: ${preservedTags.join(', ')}`); } } /** * Refresh directory from disk without losing unsaved work */ async function refreshDirectory() { if (serverSourceMode) { await loadServerDirectory(); return; } if (!directoryHandle) { if (DEBUG) console.log('No directory selected, cannot refresh'); return; } // Get active file path from DOM before refresh const activeFileEl = document.querySelector('.file-item.active-file'); const activeFilePath = activeFileEl ? activeFileEl.dataset.path : null; // Get dirty files from editorInstances const dirtyFiles = new Set(); editorInstances.forEach((instance, filePath) => { if (instance.isDirty) { dirtyFiles.add(filePath); } }); // Re-read directory (calls renderFileTree at the end) await readDirectory(directoryHandle); // Restore active file state if (activeFilePath) { const activeElement = document.querySelector(`.file-item[data-path="${activeFilePath}"]`); if (activeElement) { activeElement.classList.add('active-file'); } } // Restore dirty indicators dirtyFiles.forEach(filePath => { updateFileDirtyStatus(filePath, true); }); } /** * Build a synthetic, read-only "file handle" backed by a URL. * Implements `getFile()` so the rest of the app (which only needs to read) * works without changes. Lacks `createWritable()` — saveFile detects this * and routes to a Save-As download. */ function createServerFileHandle(name, url) { let cached = null; return { kind: 'file', name, _serverUrl: url, _readOnly: true, async getFile() { if (cached) return cached; const resp = await fetch(url, { cache: 'no-cache' }); if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`); const lastMod = resp.headers.get('Last-Modified'); const lastModified = lastMod ? Date.parse(lastMod) : Date.now(); const blob = await resp.blob(); cached = new File([blob], name, { type: blob.type, lastModified }); return cached; }, }; } /** * Build a synthetic directory handle (read-only) backed by a server URL. * Returned for nested entries so existing code paths that probe for `.handle` * still work; not currently used for traversal. */ function createServerDirectoryHandle(name, url) { return { kind: 'directory', name, _serverUrl: url, _readOnly: true, }; } /** * Recursively fetch the JSON directory listing for `dirUrl` and populate * `parentNode.entries` with synthetic handles. Returns folder/file counts. * Uses the same Caddy/zddc-server JSON shape archive's source.js consumes. */ async function readServerDirectory(dirUrl, parentNode, depth) { if (depth > 10) return { folderCount: 0, fileCount: 0 }; let items; try { const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); items = await resp.json(); if (!Array.isArray(items)) throw new Error('Expected JSON array'); } catch (err) { if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err); return { folderCount: 0, fileCount: 0 }; } const stats = { folderCount: 0, fileCount: 0 }; const subdirPromises = []; for (const item of items) { const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name; if (rawName.startsWith('.') || rawName.startsWith('_')) continue; const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/'; const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : ''); if (item.is_dir) { const dirNode = { name: rawName, type: 'directory', handle: createServerDirectoryHandle(rawName, childUrl), entries: {}, }; parentNode.entries[rawName] = dirNode; stats.folderCount++; subdirPromises.push( readServerDirectory(childUrl, dirNode, depth + 1).then((s) => { stats.folderCount += s.folderCount; stats.fileCount += s.fileCount; }) ); } else { parentNode.entries[rawName] = { name: rawName, type: 'file', handle: createServerFileHandle(rawName, childUrl), }; stats.fileCount++; } } await Promise.all(subdirPromises); return stats; } /** * Detect HTTP context, fetch the directory the page lives under, and render * the resulting subtree in the file pane. Idempotent — safe to re-call. */ async function loadServerDirectory() { if (!(location.protocol === 'http:' || location.protocol === 'https:')) return; let href = window.location.href.split('?')[0].split('#')[0]; const lastSlash = href.lastIndexOf('/'); const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/'; // Only enter server-source mode if the host actually serves JSON directory // listings (zddc-server / Caddy). On a plain static host the probe fails // and we must leave "Add Local Directory" visible so the user can still // load local files. try { const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); if (!resp.ok) return; const items = await resp.json(); if (!Array.isArray(items)) return; } catch (_) { return; } serverSourceMode = true; const rootName = (() => { const path = baseUrl.replace(/\/$/, ''); const seg = path.substring(path.lastIndexOf('/') + 1); return seg || baseUrl; })(); fileTree = { name: rootName, type: 'directory', handle: createServerDirectoryHandle(rootName, baseUrl), entries: {}, }; // Surface refresh, hide write-only controls. "Add Local Directory" // stays visible (de-emphasized via btn--subtle) so the user can // switch to a local folder at any time. const refreshBtn = document.getElementById('refreshHeaderBtn'); if (refreshBtn) refreshBtn.classList.remove('hidden'); const newFileRootBtn = document.getElementById('new-file-root'); if (newFileRootBtn) newFileRootBtn.classList.add('hidden'); const addDirBtn = document.getElementById('addDirectoryBtn'); if (addDirBtn) { addDirBtn.classList.remove('btn-primary'); addDirBtn.classList.add('btn--subtle'); } const stats = await readServerDirectory(baseUrl, fileTree, 0); renderFileTree(); updateStatusCounts(stats.folderCount, stats.fileCount); } /** * Start monitoring files for external changes */ function startFileChangeMonitoring() { setInterval(async () => { for (const [filePath, editorInstance] of editorInstances) { try { const fileHandle = editorInstance.fileHandle; if (!fileHandle) continue; if (fileHandle._readOnly) continue; const file = await fileHandle.getFile(); const currentLastModified = file.lastModified; const storedLastModified = editorInstance.lastModified; if (storedLastModified && currentLastModified !== storedLastModified) { if (DEBUG) console.log(`File ${filePath} changed externally`); const action = confirm( `File "${filePath}" has been modified by another application.\n\n` + 'Click OK to reload from disk (discards unsaved changes)\n' + 'Click Cancel to keep current version' ); if (action) { await reloadFileFromDisk(filePath); } else { editorInstance.lastModified = currentLastModified; } } } catch (error) { if (DEBUG) console.debug(`Error checking file ${filePath}:`, error.message); } } }, 3000); }