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.
432 lines
18 KiB
JavaScript
432 lines
18 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
// Source abstraction — local (File System Access API) and HTTP (Caddy JSON browse)
|
|
//
|
|
// Shared utility used by both source implementations
|
|
function getDisplayPath(fullPath) {
|
|
if (fullPath.length <= 100) {
|
|
return fullPath;
|
|
}
|
|
const parts = fullPath.split('/');
|
|
if (parts.length > 3) {
|
|
return parts[0] + '/.../' + parts.slice(-2).join('/');
|
|
}
|
|
return '...' + fullPath.substring(fullPath.length - 80);
|
|
}
|
|
|
|
// createSource(type, options) returns a source object:
|
|
// source.type — 'local' | 'http'
|
|
// source.canWrite — boolean
|
|
// source.scan(rootIdentifier, callbacks) — Promise; walks tree calling:
|
|
// callbacks.onGroupingFolder(folder) folder: { name, path, displayPath, handle? }
|
|
// callbacks.onTransmittalFolder(folder) folder: { name, path, displayPath, handle?, url? }
|
|
// callbacks.onFile(file) file: full file object (parsed + metadata)
|
|
// callbacks.onProgress(message)
|
|
// source.fetchFile(fileRef) — Promise<ArrayBuffer>
|
|
|
|
function createSource(type, options) {
|
|
if (type === 'local') {
|
|
return createLocalSource();
|
|
} else if (type === 'http') {
|
|
return createHttpSource(options.baseUrl);
|
|
}
|
|
throw new Error('Unknown source type: ' + type);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local source — wraps File System Access API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createLocalSource() {
|
|
return {
|
|
type: 'local',
|
|
canWrite: true,
|
|
|
|
scan: function(dirHandle, callbacks) {
|
|
return scanLocalRecursive(dirHandle, dirHandle.name, 0, callbacks);
|
|
},
|
|
|
|
fetchFile: function(fileRef) {
|
|
return fileRef.handle.getFile().then(function(f) {
|
|
return f.arrayBuffer();
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
async function scanLocalRecursive(dirHandle, currentPath, depth, callbacks) {
|
|
if (currentPath.length > 200) {
|
|
console.warn('Path too long, skipping deeper scan: ' + currentPath);
|
|
return;
|
|
}
|
|
if (depth > 10) {
|
|
console.warn('Directory depth limit reached at: ' + currentPath);
|
|
return;
|
|
}
|
|
|
|
callbacks.onProgress('Scanning ' + currentPath + '...');
|
|
|
|
const entries = [];
|
|
try {
|
|
for await (const entry of dirHandle.values()) {
|
|
entries.push(entry);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not read directory ' + currentPath + ':', err);
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.kind === 'directory') {
|
|
const subPath = currentPath + '/' + entry.name;
|
|
try {
|
|
if (window.app.modules.parser.isTransmittalFolder(entry.name)) {
|
|
const folder = {
|
|
name: entry.name,
|
|
path: subPath,
|
|
displayPath: getDisplayPath(subPath),
|
|
handle: entry
|
|
};
|
|
callbacks.onTransmittalFolder(folder);
|
|
await scanLocalTransmittalFolder(entry, subPath, 0, subPath, callbacks);
|
|
} else {
|
|
const folder = {
|
|
name: entry.name,
|
|
path: subPath,
|
|
displayPath: entry.name,
|
|
handle: entry
|
|
};
|
|
callbacks.onGroupingFolder(folder);
|
|
await scanLocalRecursive(entry, subPath, depth + 1, callbacks);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not process directory ' + entry.name + ':', err);
|
|
}
|
|
} else if (entry.kind === 'file') {
|
|
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
|
|
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
|
try {
|
|
const file = await entry.getFile();
|
|
const parsed = zddc.parseFilename(file.name) || {};
|
|
|
|
if (!parsed.trackingNumber) {
|
|
console.warn('File does not match ZDDC naming convention: ' + file.name);
|
|
}
|
|
|
|
const fullPath = currentPath + '/' + file.name;
|
|
const displayPath = fullPath.length > 250
|
|
? '...' + fullPath.substring(fullPath.length - 200)
|
|
: fullPath;
|
|
|
|
callbacks.onFile({
|
|
id: crypto.randomUUID(),
|
|
name: file.name,
|
|
path: fullPath,
|
|
displayPath: displayPath,
|
|
url: null,
|
|
size: file.size,
|
|
modified: file.lastModified,
|
|
handle: entry,
|
|
folderPath: '__outstanding__',
|
|
actualPath: currentPath,
|
|
hasPathError: false,
|
|
...parsed
|
|
});
|
|
} catch (fileErr) {
|
|
const fullPath = currentPath + '/' + entry.name;
|
|
const displayPath = fullPath.length > 250
|
|
? '...' + fullPath.substring(fullPath.length - 200)
|
|
: fullPath;
|
|
const parsed = zddc.parseFilename(entry.name) || {};
|
|
|
|
callbacks.onFile({
|
|
id: crypto.randomUUID(),
|
|
name: entry.name,
|
|
path: fullPath,
|
|
displayPath: displayPath,
|
|
url: null,
|
|
size: null,
|
|
modified: null,
|
|
handle: null,
|
|
folderPath: '__outstanding__',
|
|
actualPath: currentPath,
|
|
hasPathError: true,
|
|
pathErrorMessage: fileErr.message || 'File access error',
|
|
...parsed
|
|
});
|
|
|
|
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function scanLocalTransmittalFolder(dirHandle, folderPath, depth, transmittalPath, callbacks) {
|
|
if (depth > 10) {
|
|
console.warn('Directory depth limit reached in transmittal folder: ' + folderPath);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (folderPath.length > 240) {
|
|
console.warn('Path approaching Windows limit (' + folderPath.length + ' chars): ' + folderPath);
|
|
}
|
|
|
|
for await (const entry of dirHandle.values()) {
|
|
if (entry.kind === 'file') {
|
|
try {
|
|
const file = await entry.getFile();
|
|
const parsed = zddc.parseFilename(file.name) || {};
|
|
|
|
if (!parsed.trackingNumber) {
|
|
console.warn('File does not match ZDDC naming convention: ' + file.name);
|
|
}
|
|
|
|
const fullPath = folderPath + '/' + file.name;
|
|
const displayPath = fullPath.length > 250
|
|
? '...' + fullPath.substring(fullPath.length - 200)
|
|
: fullPath;
|
|
|
|
callbacks.onFile({
|
|
id: crypto.randomUUID(),
|
|
name: file.name,
|
|
path: fullPath,
|
|
displayPath: displayPath,
|
|
url: null,
|
|
size: file.size,
|
|
modified: file.lastModified,
|
|
handle: entry,
|
|
folderPath: transmittalPath,
|
|
actualPath: folderPath,
|
|
hasPathError: false,
|
|
...parsed
|
|
});
|
|
} catch (fileErr) {
|
|
const fullPath = folderPath + '/' + entry.name;
|
|
const displayPath = fullPath.length > 250
|
|
? '...' + fullPath.substring(fullPath.length - 200)
|
|
: fullPath;
|
|
const parsed = zddc.parseFilename(entry.name) || {};
|
|
|
|
callbacks.onFile({
|
|
id: crypto.randomUUID(),
|
|
name: entry.name,
|
|
path: fullPath,
|
|
displayPath: displayPath,
|
|
url: null,
|
|
size: null,
|
|
modified: null,
|
|
handle: null,
|
|
folderPath: transmittalPath,
|
|
actualPath: folderPath,
|
|
hasPathError: true,
|
|
pathErrorMessage: fileErr.message || 'File access error',
|
|
...parsed
|
|
});
|
|
|
|
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
|
|
}
|
|
} else if (entry.kind === 'directory') {
|
|
const subPath = folderPath + '/' + entry.name;
|
|
try {
|
|
await scanLocalTransmittalFolder(entry, subPath, depth + 1, transmittalPath, callbacks);
|
|
} catch (err) {
|
|
console.warn('Could not scan subdirectory ' + entry.name + ' in ' + folderPath + ':', err);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error scanning folder ' + folderPath + ':', err);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP source — uses Caddy JSON browse (Accept: application/json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createHttpSource(baseUrl) {
|
|
// Normalise: ensure baseUrl ends with /
|
|
const root = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
|
|
|
return {
|
|
type: 'http',
|
|
canWrite: false,
|
|
|
|
scan: function(rootUrl, callbacks) {
|
|
const scanRoot = (rootUrl && rootUrl !== root) ? rootUrl : root;
|
|
return scanHttpRecursive(scanRoot, root, 0, null, callbacks);
|
|
},
|
|
|
|
fetchFile: function(fileRef) {
|
|
return fetch(fileRef.url).then(function(r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status + ' fetching ' + fileRef.url);
|
|
return r.arrayBuffer();
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
async function scanHttpRecursive(dirUrl, rootUrl, depth, transmittalPath, callbacks) {
|
|
if (depth > 10) {
|
|
console.warn('HTTP directory depth limit reached at: ' + dirUrl);
|
|
return;
|
|
}
|
|
|
|
let items;
|
|
try {
|
|
// Caddy returns JSON when the Accept header requests it
|
|
const resp = await fetch(dirUrl, {
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error('HTTP ' + resp.status + ' listing ' + dirUrl);
|
|
}
|
|
// Caddy encodes listing.Items directly — a bare JSON array of fileInfo objects
|
|
// fileInfo fields: name (dirs have trailing "/"), size, url, mod_time, mode, is_dir, is_symlink
|
|
items = await resp.json();
|
|
if (!Array.isArray(items)) {
|
|
throw new Error('Unexpected response format (expected JSON array)');
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not fetch directory listing for ' + dirUrl + ':', err);
|
|
return;
|
|
}
|
|
|
|
// Collect subdirectory scan promises so siblings run in parallel
|
|
const subdirPromises = [];
|
|
|
|
for (const item of items) {
|
|
// Caddy appends "/" to directory names; strip it to get the bare name for matching
|
|
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
|
|
|
// Skip hidden files
|
|
if (rawName.startsWith('.')) continue;
|
|
|
|
const isDir = item.is_dir === true;
|
|
// Project filter: at root depth, skip directories not in the allowed set
|
|
if (depth === 0 && isDir && window.app.projectFilter && window.app.projectFilter.size > 0) {
|
|
if (!window.app.projectFilter.has(rawName)) continue;
|
|
}
|
|
|
|
const itemUrl = resolveHttpUrl(dirUrl, rawName, isDir);
|
|
const logicalPath = urlToLogicalPath(itemUrl, rootUrl);
|
|
|
|
if (isDir) {
|
|
if (transmittalPath !== null) {
|
|
// Inside a transmittal folder — recurse into subdirectories
|
|
subdirPromises.push(
|
|
scanHttpRecursive(itemUrl, rootUrl, depth + 1, transmittalPath, callbacks)
|
|
);
|
|
} else if (window.app.modules.parser.isTransmittalFolder(rawName)) {
|
|
const folder = {
|
|
name: rawName,
|
|
path: logicalPath,
|
|
displayPath: getDisplayPath(logicalPath),
|
|
handle: null,
|
|
url: itemUrl
|
|
};
|
|
callbacks.onTransmittalFolder(folder);
|
|
subdirPromises.push(
|
|
scanHttpRecursive(itemUrl, rootUrl, depth + 1, logicalPath, callbacks)
|
|
);
|
|
} else {
|
|
const folder = {
|
|
name: rawName,
|
|
path: logicalPath,
|
|
displayPath: rawName,
|
|
handle: null,
|
|
url: itemUrl
|
|
};
|
|
callbacks.onGroupingFolder(folder);
|
|
subdirPromises.push(
|
|
scanHttpRecursive(itemUrl, rootUrl, depth + 1, null, callbacks)
|
|
);
|
|
}
|
|
} else {
|
|
// It's a file
|
|
if (transmittalPath === null) {
|
|
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
|
|
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
|
const dirLogicalPath = urlToLogicalPath(dirUrl, rootUrl);
|
|
const parsed = zddc.parseFilename(rawName) || {};
|
|
if (!parsed.trackingNumber) {
|
|
console.warn('File does not match ZDDC naming convention: ' + rawName);
|
|
}
|
|
|
|
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
|
|
|
|
callbacks.onFile({
|
|
id: crypto.randomUUID(),
|
|
name: rawName,
|
|
path: logicalPath,
|
|
displayPath: logicalPath,
|
|
url: itemUrl,
|
|
size: item.size || null,
|
|
modified: modified,
|
|
handle: null,
|
|
folderPath: '__outstanding__',
|
|
actualPath: dirLogicalPath,
|
|
hasPathError: false,
|
|
...parsed
|
|
});
|
|
} else {
|
|
// Inside a transmittal folder
|
|
const parsed = zddc.parseFilename(rawName) || {};
|
|
if (!parsed.trackingNumber) {
|
|
console.warn('File does not match ZDDC naming convention: ' + rawName);
|
|
}
|
|
|
|
// mod_time is an ISO 8601 string from Go's time.Time.UTC()
|
|
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
|
|
|
|
callbacks.onFile({
|
|
id: crypto.randomUUID(),
|
|
name: rawName,
|
|
path: logicalPath,
|
|
displayPath: logicalPath,
|
|
url: itemUrl,
|
|
size: item.size || null,
|
|
modified: modified,
|
|
handle: null,
|
|
folderPath: transmittalPath,
|
|
actualPath: logicalPath.substring(0, logicalPath.lastIndexOf('/')),
|
|
hasPathError: false,
|
|
...parsed
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for all sibling subdirectory scans to complete in parallel
|
|
if (subdirPromises.length > 0) {
|
|
await Promise.all(subdirPromises);
|
|
}
|
|
}
|
|
|
|
// Build an absolute URL for an item inside a directory listing URL
|
|
function resolveHttpUrl(dirUrl, name, isDir) {
|
|
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
|
|
const encoded = encodeURIComponent(name);
|
|
return base + encoded + (isDir ? '/' : '');
|
|
}
|
|
|
|
// Convert an absolute item URL to a logical relative path (for display / filtering)
|
|
function urlToLogicalPath(itemUrl, rootUrl) {
|
|
const root = rootUrl.endsWith('/') ? rootUrl : rootUrl + '/';
|
|
let rel = itemUrl;
|
|
if (rel.startsWith(root)) {
|
|
rel = rel.substring(root.length);
|
|
}
|
|
// Decode percent-encoding for display
|
|
try { rel = decodeURIComponent(rel); } catch (e) { /* leave encoded */ }
|
|
// Strip trailing slash for directories
|
|
if (rel.endsWith('/')) rel = rel.slice(0, -1);
|
|
return rel;
|
|
}
|
|
|
|
window.app.modules.source = {
|
|
getDisplayPath,
|
|
createSource
|
|
};
|
|
|
|
})();
|