ZDDC — Zero Day Document Control. A file-naming convention plus five single-file HTML tools (archive, transmittal, classifier, mdedit, landing) and an optional Go HTTP server (zddc-server) with ACL and a virtual archive index. Self-contained, offline-capable, dependency-free. See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the build/release/architecture detail, bootstrap/README.md for the two-level deployment install pattern, and zddc/README.md for the HTTP server.
400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
/**
|
|
* File management operations (create, rename, delete)
|
|
* Plain functions, no module wrapper
|
|
*/
|
|
|
|
/**
|
|
* Resolve a node in fileTree by filePath
|
|
* @param {string} filePath - Path like 'subdir/file.md' or ''
|
|
* @returns {Object|null} The node object or null if not found
|
|
*/
|
|
function resolveNode(filePath) {
|
|
if (!filePath) return fileTree;
|
|
const parts = filePath.split('/');
|
|
let node = fileTree;
|
|
for (const part of parts) {
|
|
if (!node.entries || !node.entries[part]) return null;
|
|
node = node.entries[part];
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Resolve the parent directory handle for a given file path
|
|
* @param {string} filePath - Full path like 'subdir/file.md'
|
|
* @returns {FileSystemDirectoryHandle|null} Parent directory handle or null
|
|
*/
|
|
function resolveParentDirHandle(filePath) {
|
|
const parts = filePath.split('/');
|
|
if (parts.length === 1) return directoryHandle;
|
|
let node = fileTree;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
node = node.entries[parts[i]];
|
|
if (!node) return null;
|
|
}
|
|
return node.handle;
|
|
}
|
|
|
|
/**
|
|
* Create a new file
|
|
* @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c'
|
|
*/
|
|
async function createNewFile(parentDirPath) {
|
|
// Resolve parent directory handle first (no user activation needed for reads)
|
|
let parentHandle;
|
|
if (parentDirPath === '') {
|
|
parentHandle = directoryHandle;
|
|
} else {
|
|
const node = resolveNode(parentDirPath);
|
|
if (!node || !node.handle) {
|
|
alert('Could not locate parent directory.');
|
|
return;
|
|
}
|
|
parentHandle = node.handle;
|
|
}
|
|
|
|
// Show in-page modal and wait for user to confirm or cancel.
|
|
// Returns the filename string, or null if cancelled.
|
|
const name = await new Promise((resolve) => {
|
|
const modal = document.getElementById('new-file-modal');
|
|
const input = document.getElementById('new-file-input');
|
|
const confirmBtn = document.getElementById('new-file-confirm');
|
|
const cancelBtn = document.getElementById('new-file-cancel');
|
|
|
|
input.value = 'untitled.md';
|
|
modal.classList.remove('hidden');
|
|
input.focus();
|
|
input.select();
|
|
|
|
function cleanup() {
|
|
modal.classList.add('hidden');
|
|
confirmBtn.removeEventListener('click', onConfirm);
|
|
cancelBtn.removeEventListener('click', onCancel);
|
|
input.removeEventListener('keydown', onKey);
|
|
}
|
|
|
|
function onConfirm() {
|
|
const val = input.value.trim();
|
|
cleanup();
|
|
resolve(val || null);
|
|
}
|
|
|
|
function onCancel() {
|
|
cleanup();
|
|
resolve(null);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === 'Enter') onConfirm();
|
|
if (e.key === 'Escape') onCancel();
|
|
}
|
|
|
|
confirmBtn.addEventListener('click', onConfirm);
|
|
cancelBtn.addEventListener('click', onCancel);
|
|
input.addEventListener('keydown', onKey);
|
|
});
|
|
|
|
if (!name) {
|
|
if (DEBUG) console.log('New file creation cancelled');
|
|
return;
|
|
}
|
|
|
|
// Validate name
|
|
if (name.includes('/') || name.includes('\\')) {
|
|
alert('Invalid filename: cannot contain / or \\.');
|
|
return;
|
|
}
|
|
|
|
// Check if file already exists
|
|
try {
|
|
await parentHandle.getFileHandle(name);
|
|
const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?');
|
|
if (!overwrite) return;
|
|
} catch (e) {
|
|
if (e.name !== 'NotFoundError') throw e;
|
|
}
|
|
|
|
// Create the file — this must happen after the modal's button click
|
|
// which is the user activation token.
|
|
try {
|
|
const newHandle = await parentHandle.getFileHandle(name, { create: true });
|
|
|
|
const writable = await newHandle.createWritable();
|
|
await writable.write('');
|
|
await writable.close();
|
|
|
|
if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`);
|
|
|
|
await refreshDirectory();
|
|
|
|
const newFilePath = parentDirPath ? parentDirPath + '/' + name : name;
|
|
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
|
|
if (element) {
|
|
handleFileClick(newHandle, newFilePath, element);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating new file:', error);
|
|
alert('Error creating file: ' + error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename a file or directory
|
|
* @param {string} filePath - Full path like 'subdir/file.md'
|
|
* @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome)
|
|
*/
|
|
async function renameEntry(filePath, isDirectory) {
|
|
const currentName = filePath.split('/').pop();
|
|
const newName = window.prompt('Rename to:', currentName);
|
|
|
|
if (newName === null || newName === currentName) {
|
|
if (DEBUG) console.log('Rename cancelled or unchanged');
|
|
return;
|
|
}
|
|
|
|
// Validate name
|
|
if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') {
|
|
alert('Invalid filename: cannot contain / or \\ and must not be empty.');
|
|
return;
|
|
}
|
|
|
|
// Resolve parent directory handle
|
|
const parentHandle = resolveParentDirHandle(filePath);
|
|
if (!parentHandle) {
|
|
alert('Could not locate parent directory.');
|
|
return;
|
|
}
|
|
|
|
// For files: rename via File System Access API
|
|
if (!isDirectory) {
|
|
try {
|
|
// Check if new name already exists (file or directory)
|
|
try {
|
|
const existing = await parentHandle.getFileHandle(newName);
|
|
// A file with that name exists
|
|
const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?');
|
|
if (!overwrite) return;
|
|
} catch (fileErr) {
|
|
if (fileErr.name === 'TypeMismatchError') {
|
|
// A directory with that name exists
|
|
window.alert('A folder named "' + newName + '" already exists. Choose a different name.');
|
|
return;
|
|
}
|
|
if (fileErr.name !== 'NotFoundError') throw fileErr;
|
|
// NotFoundError = safe to create
|
|
}
|
|
|
|
const oldHandle = resolveNode(filePath);
|
|
if (!oldHandle || !oldHandle.handle) {
|
|
alert('Could not find file to rename.');
|
|
return;
|
|
}
|
|
|
|
const file = await oldHandle.handle.getFile();
|
|
const content = await file.text();
|
|
|
|
const newHandle = await parentHandle.getFileHandle(newName, { create: true });
|
|
const writable = await newHandle.createWritable();
|
|
await writable.write(content);
|
|
await writable.close();
|
|
const newFile = await newHandle.getFile();
|
|
|
|
await parentHandle.removeEntry(currentName);
|
|
|
|
// Update editor instances
|
|
if (editorInstances.has(filePath)) {
|
|
const instance = editorInstances.get(filePath);
|
|
const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName;
|
|
|
|
// Remove old instance
|
|
const data = editorInstances.get(filePath);
|
|
if (data.fileViewContainer) {
|
|
data.fileViewContainer.classList.add('hidden');
|
|
}
|
|
editorInstances.delete(filePath);
|
|
|
|
// Re-add with new path
|
|
editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified });
|
|
|
|
// Update active state
|
|
if (instance.fileViewContainer) {
|
|
instance.fileViewContainer.classList.remove('hidden');
|
|
instance.fileViewContainer.dataset.path = newFilePath;
|
|
}
|
|
|
|
// Update fileTree entries
|
|
const parts = filePath.split('/');
|
|
const fileName = parts.pop();
|
|
const dirPath = parts.join('/');
|
|
let targetEntries = fileTree.entries;
|
|
if (dirPath) {
|
|
const dirParts = dirPath.split('/');
|
|
let current = fileTree;
|
|
for (const part of dirParts) {
|
|
current = current.entries[part];
|
|
}
|
|
targetEntries = current.entries;
|
|
}
|
|
if (targetEntries && targetEntries[currentName]) {
|
|
delete targetEntries[currentName];
|
|
targetEntries[newName] = {
|
|
name: newName,
|
|
type: 'file',
|
|
handle: newHandle
|
|
};
|
|
}
|
|
|
|
renderFileTree();
|
|
restoreActiveFile(newFilePath);
|
|
} else {
|
|
renderFileTree();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error renaming file:', error);
|
|
alert('Error renaming file: ' + error.message);
|
|
}
|
|
} else {
|
|
// For directories: not supported by browser API
|
|
alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a file or directory
|
|
* @param {string} filePath - Full path like 'subdir/file.md' or 'subdir'
|
|
* @param {boolean} isDirectory - true if deleting a directory
|
|
*/
|
|
async function deleteEntry(filePath, isDirectory) {
|
|
const name = filePath.split('/').pop();
|
|
|
|
const message = isDirectory
|
|
? 'Delete folder "' + name + '" and all its contents?'
|
|
: 'Delete "' + name + '"?';
|
|
|
|
const ok = window.confirm(message);
|
|
if (!ok) {
|
|
if (DEBUG) console.log('Delete cancelled by user');
|
|
return;
|
|
}
|
|
|
|
// Resolve parent directory handle
|
|
const parentHandle = resolveParentDirHandle(filePath);
|
|
if (!parentHandle) {
|
|
alert('Could not locate parent directory.');
|
|
return;
|
|
}
|
|
|
|
let deleted = false;
|
|
try {
|
|
await parentHandle.removeEntry(name, { recursive: isDirectory });
|
|
deleted = true;
|
|
} catch (error) {
|
|
if (error.name === 'NotFoundError') {
|
|
// Already gone — treat as success for cleanup purposes
|
|
deleted = true;
|
|
} else {
|
|
console.error('Error deleting entry:', error);
|
|
alert('Error deleting entry: ' + error.message);
|
|
}
|
|
}
|
|
|
|
if (deleted) {
|
|
// Close editor if open
|
|
if (!isDirectory && editorInstances.has(filePath)) {
|
|
closeEditorInstance(filePath);
|
|
} else if (isDirectory) {
|
|
// Close any editors under this directory
|
|
const dirsToClose = [];
|
|
editorInstances.forEach(function(instance, key) {
|
|
if (key === filePath || key.startsWith(filePath + '/')) {
|
|
dirsToClose.push(key);
|
|
}
|
|
});
|
|
dirsToClose.forEach(function(key) {
|
|
closeEditorInstance(key);
|
|
});
|
|
}
|
|
|
|
// Remove from fileTree entries
|
|
const parts = filePath.split('/');
|
|
const entryName = parts.pop();
|
|
const dirPath = parts.join('/');
|
|
let targetEntries = fileTree.entries;
|
|
if (dirPath) {
|
|
const dirParts = dirPath.split('/');
|
|
let current = fileTree;
|
|
for (const part of dirParts) {
|
|
current = current.entries[part];
|
|
}
|
|
targetEntries = current.entries;
|
|
}
|
|
if (targetEntries && targetEntries[entryName]) {
|
|
delete targetEntries[entryName];
|
|
}
|
|
|
|
renderFileTree();
|
|
updateStatusCountsFromTree();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close an editor instance and show welcome screen if no files open
|
|
* @param {string} filePath - Path of file to close
|
|
*/
|
|
function closeEditorInstance(filePath) {
|
|
const instance = editorInstances.get(filePath);
|
|
if (!instance) return;
|
|
|
|
if (instance.fileViewContainer) {
|
|
instance.fileViewContainer.classList.add('hidden');
|
|
}
|
|
editorInstances.delete(filePath);
|
|
|
|
// Check if any visible file-view-container children remain
|
|
const contentContainer = document.getElementById('content-container');
|
|
if (contentContainer) {
|
|
const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container'))
|
|
.filter(function(el) { return !el.classList.contains('hidden'); });
|
|
if (visibleChildren.length === 0) {
|
|
document.getElementById('welcome-screen').classList.remove('hidden');
|
|
contentContainer.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore active file state after rename
|
|
* @param {string} newFilePath - New path of the file
|
|
*/
|
|
function restoreActiveFile(newFilePath) {
|
|
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
|
|
if (element) {
|
|
element.classList.add('active-file');
|
|
element.style.backgroundColor = '';
|
|
element.style.color = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update status counts from fileTree
|
|
*/
|
|
function updateStatusCountsFromTree() {
|
|
let folderCount = 0;
|
|
let fileCount = 0;
|
|
|
|
function countEntries(entries) {
|
|
if (!entries) return;
|
|
for (const [name, item] of Object.entries(entries)) {
|
|
if (item.type === 'directory') {
|
|
folderCount++;
|
|
countEntries(item.entries);
|
|
} else if (item.type === 'file') {
|
|
fileCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
countEntries(fileTree.entries);
|
|
updateStatusCounts(folderCount, fileCount);
|
|
}
|