Two improvements to browse's preview-markdown plugin so it can replace the standalone mdedit tool: 1. **YAML front-matter editing.** The FM pane above the outline used to render a read-only <dl> of parsed keys — sparse and unusable when the file had no envelope yet. It's now a dedicated <textarea> that's always present. On load, parseFrontMatter() splits the `---\n…\n---` envelope off the body: the body feeds Toast UI Editor, the envelope feeds the textarea. On save, assembleContent() recombines them. Dirty tracking covers both halves via a SHA-256 of the assembled bytes. The shell mirrors mdedit's old layout (FM textarea top, outline below) but the FM pane is now always functional, eliminating the "empty pane over the TOC" problem. 2. **Download as DOCX / HTML / PDF.** When the file handle is HTTP- backed (server mode) and the file is a .md, three buttons appear in the info header next to Save. Clicking one fetches the server's ?convert=<fmt> endpoint and triggers a browser download with a clean filename (foo.md → foo.docx). Auto-saves the buffer first if dirty so the converted bytes reflect what's on screen. Helper at window.zddc.source.downloadConverted (shared/zddc-source.js) so other tools — archive, transmittal — can reuse the same flow later. Friendly error messages map HTTP 503 / 422 / 504 to actionable toasts.
417 lines
17 KiB
JavaScript
417 lines
17 KiB
JavaScript
// shared/zddc-source.js — source abstraction for tools that handle
|
|
// directory trees (classifier, transmittal, browse, archive).
|
|
//
|
|
// Two backends:
|
|
//
|
|
// 1. Local — wraps a real FileSystemDirectoryHandle from the
|
|
// File System Access API. Reads + writes go through the
|
|
// FS Access API directly.
|
|
//
|
|
// 2. HTTP — talks to zddc-server's directory listing JSON
|
|
// (Accept: application/json) for reads and the file API
|
|
// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
|
|
// polyfill of the FS Access API surface area the tools
|
|
// use (kind, name, values(), getFileHandle, getDirectoryHandle,
|
|
// removeEntry, getFile, createWritable, queryPermission /
|
|
// requestPermission) so existing code works unchanged.
|
|
//
|
|
// The polyfill makes auto-load possible: when zddc-server serves
|
|
// a tool at /<dir>/<tool>.html, the tool detects HTTP mode at
|
|
// startup, builds an HttpDirectoryHandle for the tool's containing
|
|
// directory, and hands it to the existing openDirectory(handle)
|
|
// flow without ever showing the file picker.
|
|
//
|
|
// Renames inside a tool today are typically done as
|
|
// "write new + remove old". With HTTP-backed handles this becomes
|
|
// PUT + DELETE — non-atomic. Tools that prefer the atomic server
|
|
// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
|
|
// directly instead of going through the polyfill.
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!window.zddc) window.zddc = {};
|
|
var FA = window.FileSystemDirectoryHandle || null;
|
|
|
|
// -----------------------------------------------------------------
|
|
// HTTP file API helpers
|
|
// -----------------------------------------------------------------
|
|
|
|
function joinUrl(base, name, isDir) {
|
|
if (!base.endsWith('/')) base = base + '/';
|
|
return base + encodeURIComponent(name) + (isDir ? '/' : '');
|
|
}
|
|
|
|
// Server returns directory entries with a trailing "/" on names.
|
|
// Strip it for the FS Access API name surface.
|
|
function stripSlash(name) {
|
|
return name.endsWith('/') ? name.slice(0, -1) : name;
|
|
}
|
|
|
|
async function httpListing(url) {
|
|
var resp = await fetch(url, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var err = new Error('listing ' + url + ': HTTP ' + resp.status);
|
|
err.status = resp.status;
|
|
throw err;
|
|
}
|
|
var data = await resp.json();
|
|
if (!Array.isArray(data)) {
|
|
throw new Error('listing ' + url + ': non-array body');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function httpExists(url) {
|
|
try {
|
|
var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
|
|
return r.ok;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// HttpFileHandle — FileSystemFileHandle polyfill
|
|
// -----------------------------------------------------------------
|
|
|
|
function makeFile(blob, name, modTime) {
|
|
return new File([blob], name, {
|
|
type: blob.type,
|
|
lastModified: modTime ? modTime.getTime() : Date.now()
|
|
});
|
|
}
|
|
|
|
function HttpFileHandle(url, name, size, modTime) {
|
|
this.kind = 'file';
|
|
this.name = name;
|
|
this._url = url;
|
|
this._size = size || 0;
|
|
this._modTime = modTime || null;
|
|
this._etag = null;
|
|
}
|
|
HttpFileHandle.prototype.getFile = async function () {
|
|
var resp = await fetch(this._url, { credentials: 'same-origin' });
|
|
if (!resp.ok) {
|
|
throw new Error('GET ' + this._url + ': ' + resp.status);
|
|
}
|
|
var etag = resp.headers.get('ETag');
|
|
if (etag) this._etag = etag.replace(/"/g, '');
|
|
var lm = resp.headers.get('Last-Modified');
|
|
var modTime = lm ? new Date(lm) : this._modTime;
|
|
var blob = await resp.blob();
|
|
return makeFile(blob, this.name, modTime);
|
|
};
|
|
HttpFileHandle.prototype.createWritable = async function () {
|
|
var chunks = [];
|
|
var handle = this;
|
|
return {
|
|
async write(data) {
|
|
if (data == null) return;
|
|
if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
|
|
chunks.push(data.data);
|
|
return;
|
|
}
|
|
if (typeof data === 'object' && data && 'type' in data) {
|
|
// seek/truncate not supported by HTTP backend
|
|
throw new Error('HttpFileHandle write op not supported: ' + data.type);
|
|
}
|
|
chunks.push(data);
|
|
},
|
|
async close() {
|
|
var blob = new Blob(chunks);
|
|
var resp = await fetch(handle._url, {
|
|
method: 'PUT',
|
|
body: blob,
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var body = '';
|
|
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
|
throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
|
|
}
|
|
var et = resp.headers.get('ETag');
|
|
if (et) handle._etag = et.replace(/"/g, '');
|
|
handle._size = blob.size;
|
|
},
|
|
async abort() { chunks = []; }
|
|
};
|
|
};
|
|
HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
|
|
HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
|
|
HttpFileHandle.prototype.isHttp = true;
|
|
HttpFileHandle.prototype.url = function () { return this._url; };
|
|
|
|
// -----------------------------------------------------------------
|
|
// HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
|
|
// -----------------------------------------------------------------
|
|
|
|
function HttpDirectoryHandle(url, name) {
|
|
this.kind = 'directory';
|
|
if (!url.endsWith('/')) url = url + '/';
|
|
this._url = url;
|
|
this.name = name || guessNameFromUrl(url);
|
|
}
|
|
function guessNameFromUrl(url) {
|
|
var u = url.replace(/\/+$/, '');
|
|
var slash = u.lastIndexOf('/');
|
|
return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
|
|
}
|
|
HttpDirectoryHandle.prototype.values = function () {
|
|
var url = this._url;
|
|
return (async function* () {
|
|
var entries;
|
|
try {
|
|
entries = await httpListing(url);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var e = entries[i];
|
|
var rawName = stripSlash(e.name);
|
|
// Listing entries can carry an explicit URL for virtual
|
|
// links (e.g. the reviewing-aggregator's received/+staged/
|
|
// entries point to canonical archive/+staging paths).
|
|
// Use it when present so navigation follows the listing's
|
|
// own routing rather than computing a synthetic child URL
|
|
// off the parent. Caddy-shape listings don't set url
|
|
// (or set it to a relative form) — joinUrl handles those.
|
|
var childUrl;
|
|
if (e.url && /^https?:\/\/|^\//.test(e.url)) {
|
|
// Absolute or root-relative: use as-is, normalised against origin.
|
|
var u = e.url;
|
|
if (u[0] === '/') {
|
|
u = location.origin + u;
|
|
}
|
|
childUrl = u;
|
|
} else {
|
|
childUrl = joinUrl(url, rawName, e.is_dir);
|
|
}
|
|
if (e.is_dir) {
|
|
yield new HttpDirectoryHandle(childUrl, rawName);
|
|
} else {
|
|
var modTime = e.mod_time ? new Date(e.mod_time) : null;
|
|
yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime);
|
|
}
|
|
}
|
|
})();
|
|
};
|
|
HttpDirectoryHandle.prototype.entries = function () {
|
|
var iter = this.values();
|
|
return (async function* () {
|
|
for (;;) {
|
|
var step = await iter.next();
|
|
if (step.done) return;
|
|
yield [step.value.name, step.value];
|
|
}
|
|
})();
|
|
};
|
|
HttpDirectoryHandle.prototype.keys = function () {
|
|
var iter = this.values();
|
|
return (async function* () {
|
|
for (;;) {
|
|
var step = await iter.next();
|
|
if (step.done) return;
|
|
yield step.value.name;
|
|
}
|
|
})();
|
|
};
|
|
HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) {
|
|
opts = opts || {};
|
|
var url = joinUrl(this._url, name, false);
|
|
var exists = await httpExists(url);
|
|
if (!exists && !opts.create) {
|
|
var err = new Error('NotFoundError: ' + name);
|
|
err.name = 'NotFoundError';
|
|
throw err;
|
|
}
|
|
return new HttpFileHandle(url, name, 0, null);
|
|
};
|
|
HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) {
|
|
opts = opts || {};
|
|
var url = joinUrl(this._url, name, true);
|
|
if (opts.create) {
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-ZDDC-Op': 'mkdir' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok && resp.status !== 200 && resp.status !== 201) {
|
|
throw new Error('mkdir ' + url + ': ' + resp.status);
|
|
}
|
|
}
|
|
return new HttpDirectoryHandle(url, name);
|
|
};
|
|
HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) {
|
|
opts = opts || {};
|
|
// Probe listing to discover whether name is a file or directory.
|
|
var entries;
|
|
try {
|
|
entries = await httpListing(this._url);
|
|
} catch (e) {
|
|
throw new Error('removeEntry probe failed: ' + e.message);
|
|
}
|
|
var match = null;
|
|
for (var i = 0; i < entries.length; i++) {
|
|
if (stripSlash(entries[i].name) === name) {
|
|
match = entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!match) {
|
|
var err = new Error('NotFoundError: ' + name);
|
|
err.name = 'NotFoundError';
|
|
throw err;
|
|
}
|
|
if (match.is_dir && !opts.recursive) {
|
|
// Server doesn't expose a recursive-delete endpoint yet,
|
|
// and FS Access API requires recursive=true to remove a
|
|
// non-empty directory anyway. Reject explicitly so the
|
|
// caller doesn't silently leave a stale tree behind.
|
|
var derr = new Error('Removing directories over HTTP is not supported');
|
|
derr.name = 'InvalidStateError';
|
|
throw derr;
|
|
}
|
|
var url = joinUrl(this._url, name, match.is_dir);
|
|
var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
|
|
if (!resp.ok && resp.status !== 204) {
|
|
throw new Error('DELETE ' + url + ': ' + resp.status);
|
|
}
|
|
};
|
|
HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
|
|
HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
|
|
HttpDirectoryHandle.prototype.isHttp = true;
|
|
HttpDirectoryHandle.prototype.url = function () { return this._url; };
|
|
|
|
// -----------------------------------------------------------------
|
|
// Top-level helpers
|
|
// -----------------------------------------------------------------
|
|
|
|
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
|
// to land on the "directory the tool was opened in".
|
|
function pathToDir(pathname) {
|
|
if (!pathname) return '/';
|
|
if (pathname.endsWith('/')) return pathname;
|
|
var slash = pathname.lastIndexOf('/');
|
|
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
|
}
|
|
|
|
// Probe the server-mode root for the current page. Returns:
|
|
//
|
|
// { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
|
|
// { handle: null, status: 403 } — server reachable but listing forbidden
|
|
// { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
|
|
//
|
|
// Tools that auto-load on startup distinguish 403 (show "no
|
|
// permission to list this directory" message) from 0 (fall back
|
|
// to local-mode welcome screen).
|
|
//
|
|
// Tool init pattern:
|
|
// if (location.protocol !== 'file:') {
|
|
// const r = await zddc.source.detectServerRoot();
|
|
// if (r.handle) await openDirectory(r.handle);
|
|
// else if (r.status === 403) showNoPermissionMessage();
|
|
// else showWelcome();
|
|
// } else { showWelcome(); }
|
|
async function detectServerRoot() {
|
|
if (typeof location === 'undefined') {
|
|
return { handle: null, status: 0 };
|
|
}
|
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
return { handle: null, status: 0 };
|
|
}
|
|
var dirPath = pathToDir(location.pathname);
|
|
var url = location.origin + dirPath;
|
|
try {
|
|
await httpListing(url);
|
|
} catch (e) {
|
|
if (e && e.status === 403) {
|
|
return { handle: null, status: 403 };
|
|
}
|
|
return { handle: null, status: 0 };
|
|
}
|
|
return {
|
|
handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
|
|
status: 200,
|
|
};
|
|
}
|
|
|
|
// Atomic file move. Path arguments are absolute URL paths
|
|
// (starting with /). Honors the file API's POST /op=move
|
|
// contract. Returns the new ETag.
|
|
async function moveFile(srcUrlPath, dstUrlPath, opts) {
|
|
opts = opts || {};
|
|
var headers = {
|
|
'X-ZDDC-Op': 'move',
|
|
'X-ZDDC-Destination': dstUrlPath
|
|
};
|
|
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
|
|
var resp = await fetch(srcUrlPath, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var body = '';
|
|
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
|
throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
|
|
}
|
|
var et = resp.headers.get('ETag');
|
|
return et ? et.replace(/"/g, '') : null;
|
|
}
|
|
|
|
// Detect at construction time whether a directory handle is the
|
|
// HTTP polyfill or a real FS Access API handle. Useful for tools
|
|
// that want to take the optimized path (e.g. atomic moveFile)
|
|
// when in HTTP mode rather than the FS-API copy+remove fallback.
|
|
function isHttpHandle(handle) {
|
|
return !!(handle && handle.isHttp === true);
|
|
}
|
|
|
|
// downloadConverted fetches a server-side MD→{docx,html,pdf}
|
|
// conversion and triggers a browser download with a clean filename.
|
|
// srcUrl points at the .md source on the server. fmt is one of
|
|
// "docx" | "html" | "pdf". The server response status maps to a
|
|
// friendly error message for the caller to surface (toast / status).
|
|
async function downloadConverted(srcUrl, fileName, fmt) {
|
|
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
|
{ credentials: 'same-origin' });
|
|
if (!resp.ok) {
|
|
var msg;
|
|
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
|
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
|
|
else if (resp.status === 504) msg = 'Conversion timed out.';
|
|
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
|
|
// Append server-supplied body text if it adds detail.
|
|
try {
|
|
var detail = await resp.text();
|
|
if (detail && detail.length < 400) msg += ' ' + detail.trim();
|
|
} catch (_) { /* ignore */ }
|
|
throw new Error(msg);
|
|
}
|
|
var blob = await resp.blob();
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
|
}
|
|
|
|
window.zddc.source = {
|
|
HttpDirectoryHandle: HttpDirectoryHandle,
|
|
HttpFileHandle: HttpFileHandle,
|
|
detectServerRoot: detectServerRoot,
|
|
moveFile: moveFile,
|
|
isHttpHandle: isHttpHandle,
|
|
downloadConverted: downloadConverted,
|
|
// Lower-level helpers exposed for tools that want to call the
|
|
// server directly without going through the polyfill.
|
|
httpListing: httpListing,
|
|
joinUrl: joinUrl,
|
|
stripSlash: stripSlash
|
|
};
|
|
})();
|