ZDDC/archive/js/source.js
ZDDC f8a3da2ea1 feat(archive,landing): local-mode ?projects= filter + ?v= propagation
Two small additions to the project-filter / channel-selector flow that
already worked end-to-end for HTTP-mode but were missing in the local
File-System-Access path and across landing→archive navigation:

* archive: scanLocalRecursive now applies window.app.projectFilter at
  depth 0, mirroring the HTTP source's existing filter at source.js:316.
  Loading archive.html?projects=A,B in local mode (file://) now virtually
  merges A and B into one combined view, same as HTTP mode does today.

* landing: openArchive() reads ?v= from its own URL and passes it through
  to the archive.html link it generates. This keeps the user on the same
  channel (alpha/beta/stable/<version>) when they cross from the project
  picker to the archive — without it, alpha-channel users would silently
  drop back to whatever the deployment-default channel is at the
  archive.html boundary.

Test exercises the local-mode filter via the existing mock-fs-api
fixture: three top-level projects, projectFilter set to {A, B}, scan
produces only A's and B's files. (The url-state.restore() URL parsing
path is well-trodden in the HTTP case — the test sets projectFilter
directly to isolate the new source.js change from a pre-existing init()
fragility in the mock environment.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:24:07 -05:00

439 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') {
// Project filter: at root depth, skip directories not in the
// allowed set so ?projects=A,B virtually merges A and B into a
// single combined view. Mirrors the HTTP-source filter at the
// depth === 0 site below.
if (depth === 0 && window.app.projectFilter && window.app.projectFilter.size > 0) {
if (!window.app.projectFilter.has(entry.name)) continue;
}
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
};
})();