SimpleDocReview / sdr-api-core.js
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9
Raw History Blame Contribute Delete
60.8 kB
/**
* sdr-api-core.js - SimpleDocReview's HTTP API, ported to run without a server.
*
* A Hugging Face Static Space has no compute, so `app.py` cannot run. This file
* is a port of every route upstream's `ReviewHandler` serves and of the logic
* behind them: the rubric analyzer, the DOCX text extractor, status
* recalculation, the notification queue, and the record migrations.
*
* It contains no DOM and no storage engine. Everything environment-specific
* arrives through `createApi()`:
*
* store persistence (app.py's data/reviews.json + uploads/ pair)
* now clock (app.py utc_now)
* uuidHex identity (app.py uuid.uuid4().hex)
* inflateRaw DEFLATE (app.py zipfile)
*
* That separation is what makes it testable: `sdr-api-shim.js` supplies
* IndexedDB and the browser clock, while `tests/parity/` supplies an in-memory
* store and a fixed clock so these responses can be diffed against the real
* Python server's.
*
* Comments name the app.py function each part mirrors. When upstream changes
* that file, change this one - never the other way round.
*/
(function (root, factory) {
"use strict";
var api = factory();
if (typeof module === "object" && module !== null && module.exports) {
module.exports = api;
}
if (root) {
root.SdrApiCore = api;
}
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
// --------------------------------------------------------------- constants
// app.py: MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS, CONTACT_FIELDS, *_LABELS.
var MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
var ALLOWED_EXTENSIONS = [".txt", ".md", ".docx"];
var CONTACT_FIELDS = ["email", "slack", "teams"];
var STATUS_LABELS = {
uploaded: "Uploaded",
in_review: "In review",
changes_requested: "Changes requested",
approved: "Approved"
};
var ASSIGNMENT_LABELS = {
pending: "Pending",
commented: "Commented",
changes_requested: "Changes requested",
approved: "Approved"
};
var SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
var DOWNLOAD_TYPES = {
".txt": "text/plain",
".md": "text/markdown",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
};
// Python's `\w` is Unicode-aware where JavaScript's is ASCII-only, so the
// port uses Unicode property escapes. `\b[\w'-]+\b` can only begin and end on
// a word character - an apostrophe or hyphen at either edge fails the word
// boundary - which is what the outer/inner split below spells out.
var WORD_PATTERN = /[\p{L}\p{N}_](?:[\p{L}\p{N}_'-]*[\p{L}\p{N}_])?/gu;
// ------------------------------------------------------------------ errors
/**
* An error carrying the status app.py would have replied with.
*
* app.py maps `ValueError` to 400 and calls `send_error_json(..., NOT_FOUND)`
* for missing records, so the status has to travel with the message for the
* responses to stay identical.
*/
function apiError(message, status) {
var error = new Error(message);
error.apiStatus = status || 400;
return error;
}
function badZip(message) {
// app.py catches zipfile.BadZipFile in do_POST and replies with this text.
return apiError(message || "The DOCX file appears to be invalid.", 400);
}
// ----------------------------------------------------------------- helpers
function two(value) {
return value < 10 ? "0" + value : String(value);
}
/** app.py utc_now(): an ISO-8601 UTC stamp truncated to whole seconds. */
function defaultNow() {
var when = new Date();
return (
when.getUTCFullYear() +
"-" + two(when.getUTCMonth() + 1) +
"-" + two(when.getUTCDate()) +
"T" + two(when.getUTCHours()) +
":" + two(when.getUTCMinutes()) +
":" + two(when.getUTCSeconds()) +
"+00:00"
);
}
/** app.py uuid.uuid4().hex: 32 lowercase hex characters, no dashes. */
function defaultUuidHex() {
var bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
var hex = "";
for (var index = 0; index < bytes.length; index += 1) {
hex += (bytes[index] < 16 ? "0" : "") + bytes[index].toString(16);
}
return hex;
}
/** DEFLATE with no zlib wrapper - what a ZIP member stores. */
function defaultInflateRaw(bytes) {
if (typeof DecompressionStream === "undefined") {
return Promise.reject(
badZip("This browser cannot read DOCX files: DecompressionStream is unavailable.")
);
}
var stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Response(stream).arrayBuffer().then(function (buffer) {
return new Uint8Array(buffer);
});
}
/** Python `str(value or "")`: falsy values collapse to the empty string. */
function pyStr(value) {
if (value === undefined || value === null || value === false || value === 0 || value === "") {
return "";
}
if (Array.isArray(value) && value.length === 0) {
return "";
}
return String(value);
}
/** app.py clean_text(). */
function cleanText(value, fallback, maxLength) {
var limit = maxLength === undefined ? 240 : maxLength;
var text = pyStr(value).trim();
return (text || (fallback === undefined ? "" : fallback)).slice(0, limit);
}
/** Python re.escape(), for the characters these patterns actually contain. */
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\\-]/g, "\\$&");
}
function emptyContact() {
return { email: "", slack: "", teams: "" };
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOwn(target, key) {
return Object.prototype.hasOwnProperty.call(target, key);
}
/** app.py normalize_contact(). */
function normalizeContact(payload) {
var source = isPlainObject(payload) ? payload : {};
var contact = {};
CONTACT_FIELDS.forEach(function (field) {
contact[field] = cleanText(source[field], "", 240);
});
return contact;
}
/** app.py owner_from_payload(). */
function ownerFromPayload(payload) {
var source = isPlainObject(payload) ? payload : {};
var owner = { name: cleanText(source.name || source.owner_name, "Owner", 120) };
var contact = normalizeContact(source);
CONTACT_FIELDS.forEach(function (field) {
owner[field] = contact[field];
});
return owner;
}
/**
* Stable descending sort on one string key.
*
* Python's `sorted(..., reverse=True)` keeps equal elements in their original
* order rather than reversing them. These timestamps have one-second
* resolution, so ties are routine and a plain `.reverse()` would reorder
* same-second activity that the Python server leaves alone.
*/
function sortByDesc(items, key) {
return items.slice().sort(function (left, right) {
var a = left[key] || "";
var b = right[key] || "";
if (a === b) return 0;
return a < b ? 1 : -1;
});
}
function sortByAsc(items, read) {
return items.slice().sort(function (left, right) {
var a = read(left);
var b = read(right);
if (a === b) return 0;
return a < b ? -1 : 1;
});
}
// ----------------------------------------------------- filenames and bytes
/** app.py sanitize_filename(). */
function sanitizeFilename(filename) {
var name = pyStr(filename) || "document";
var segments = name.split(/[\\/]/);
name = segments[segments.length - 1].trim();
var dot = name.lastIndexOf(".");
var stem = name;
var suffix = "";
// pathlib keeps a leading dot in the stem ('.env' -> stem '.env') and reads
// a trailing dot as no suffix at all ('notes.' -> stem 'notes.').
if (dot > 0 && dot < name.length - 1) {
stem = name.slice(0, dot);
suffix = name.slice(dot).toLowerCase();
}
if (!stem) stem = "document";
var cleaned = stem.replace(/[^A-Za-z0-9._ -]+/g, "-").replace(/^[ .\-_]+|[ .\-_]+$/g, "");
return (cleaned || "document") + suffix;
}
/** pathlib's Path(name).suffix.lower(). */
function extensionOf(filename) {
var name = pyStr(filename);
var segments = name.split(/[\\/]/);
name = segments[segments.length - 1];
var dot = name.lastIndexOf(".");
if (dot <= 0 || dot === name.length - 1) return "";
return name.slice(dot).toLowerCase();
}
function tryDecode(bytes, encoding) {
try {
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
} catch (error) {
return null;
}
}
/**
* app.py decode_text_bytes(): utf-8-sig, then utf-16, then cp1252, then utf-8
* with replacement characters.
*
* TextDecoder("utf-8") strips a leading BOM, which is what utf-8-sig does.
* Python's utf-16 codec consumes a BOM when present and assumes
* little-endian when it is absent.
*
* One knowing divergence: WHATWG windows-1252 maps the five bytes Python's
* cp1252 rejects (0x81, 0x8D, 0x8F, 0x90, 0x9D) rather than raising, so a
* file Python would fall through to replacement-character utf-8 decodes here
* as windows-1252 instead.
*/
function decodeTextBytes(bytes) {
var utf8 = tryDecode(bytes, "utf-8");
if (utf8 !== null) return utf8;
if (bytes.length % 2 === 0) {
var bigEndian = bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff;
var utf16 = tryDecode(bytes, bigEndian ? "utf-16be" : "utf-16le");
if (utf16 !== null) return utf16;
}
var cp1252 = tryDecode(bytes, "windows-1252");
if (cp1252 !== null) return cp1252;
return new TextDecoder("utf-8").decode(bytes);
}
// --------------------------------------------------------------- ZIP/DOCX
function readUint16(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8);
}
function readUint32(bytes, offset) {
return (
(bytes[offset] |
(bytes[offset + 1] << 8) |
(bytes[offset + 2] << 16) |
(bytes[offset + 3] << 24)) >>> 0
);
}
function findEndOfCentralDirectory(bytes) {
// The record is 22 bytes plus a comment of at most 65535, so it lies within
// the last 65557 bytes. Scan backwards for its signature.
var earliest = Math.max(0, bytes.length - (22 + 65535));
for (var offset = bytes.length - 22; offset >= earliest; offset -= 1) {
if (readUint32(bytes, offset) === 0x06054b50) return offset;
}
return -1;
}
/**
* Locate one member of a ZIP archive and return its stored bytes.
*
* Just enough of the format to read a DOCX. Sizes come from the central
* directory because a local header may defer them to a data descriptor, and
* Word writes members either stored or deflated. ZIP64 is rejected rather
* than guessed at; Word does not produce it for a 5 MB document.
*/
function findZipMember(bytes, wanted) {
var eocd = findEndOfCentralDirectory(bytes);
if (eocd < 0) throw badZip();
var entryCount = readUint16(bytes, eocd + 10);
var directoryOffset = readUint32(bytes, eocd + 16);
if (entryCount === 0xffff || directoryOffset === 0xffffffff) {
throw badZip("This DOCX file uses ZIP64, which this static build cannot read.");
}
var cursor = directoryOffset;
var decoder = new TextDecoder("utf-8");
for (var index = 0; index < entryCount; index += 1) {
if (cursor + 46 > bytes.length || readUint32(bytes, cursor) !== 0x02014b50) {
throw badZip();
}
var method = readUint16(bytes, cursor + 10);
var compressedSize = readUint32(bytes, cursor + 20);
var nameLength = readUint16(bytes, cursor + 28);
var extraLength = readUint16(bytes, cursor + 30);
var commentLength = readUint16(bytes, cursor + 32);
var localOffset = readUint32(bytes, cursor + 42);
var name = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength));
if (name === wanted) {
if (localOffset + 30 > bytes.length || readUint32(bytes, localOffset) !== 0x04034b50) {
throw badZip();
}
var localNameLength = readUint16(bytes, localOffset + 26);
var localExtraLength = readUint16(bytes, localOffset + 28);
var dataStart = localOffset + 30 + localNameLength + localExtraLength;
if (dataStart + compressedSize > bytes.length) throw badZip();
return {
method: method,
data: bytes.subarray(dataStart, dataStart + compressedSize)
};
}
cursor += 46 + nameLength + extraLength + commentLength;
}
return null;
}
/** Python html.unescape(), narrowed to what WordprocessingML emits. */
function unescapeXml(value) {
return String(value).replace(
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|(amp|lt|gt|quot|apos|nbsp));/g,
function (match, decimal, hex, named) {
if (decimal) return String.fromCodePoint(parseInt(decimal, 10));
if (hex) return String.fromCodePoint(parseInt(hex, 16));
return {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " "
}[named];
}
);
}
/** app.py extract_docx_text(). */
function extractDocxText(bytes, inflateRaw) {
var member = findZipMember(bytes, "word/document.xml");
if (!member) {
// app.py turns zipfile's KeyError into this ValueError.
throw apiError("The DOCX file does not contain document text.", 400);
}
var raw;
if (member.method === 0) {
raw = Promise.resolve(member.data);
} else if (member.method === 8) {
raw = inflateRaw(member.data);
} else {
raw = Promise.reject(badZip("This DOCX file uses an unsupported compression method."));
}
return raw.then(function (xmlBytes) {
var xmlText = new TextDecoder("utf-8").decode(xmlBytes);
var paragraphPattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g;
var lines = [];
var paragraph;
while ((paragraph = paragraphPattern.exec(xmlText)) !== null) {
var runPattern = /<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g;
var runs = [];
var run;
while ((run = runPattern.exec(paragraph[1])) !== null) {
runs.push(unescapeXml(run[1]));
}
if (runs.length) lines.push(runs.join(""));
}
return lines
.map(function (line) { return line.trim(); })
.filter(function (line) { return line.length > 0; })
.join("\n");
});
}
/** app.py extract_text(). */
function extractText(filename, bytes, inflateRaw) {
var extension = extensionOf(filename);
if (extension === ".txt" || extension === ".md") {
return Promise.resolve(decodeTextBytes(bytes).trim());
}
if (extension === ".docx") {
return Promise.resolve()
.then(function () {
return extractDocxText(bytes, inflateRaw);
})
.then(function (text) {
return text.trim();
});
}
return Promise.reject(apiError("Unsupported document type.", 400));
}
// --------------------------------------------------------------- analyzer
function countWords(text) {
var matches = text.match(WORD_PATTERN);
return matches ? matches.length : 0;
}
/** app.py snippet_for(). */
function snippetFor(text, pattern) {
var search = new RegExp(pattern.source, pattern.flags.replace("g", ""));
var match = search.exec(text);
if (!match) return "";
var start = Math.max(0, match.index - 70);
var end = Math.min(text.length, match.index + match[0].length + 70);
return text.slice(start, end).replace(/\s+/g, " ").trim();
}
/** app.py has_section(). */
function hasSection(text, terms) {
for (var index = 0; index < terms.length; index += 1) {
var term = escapeRegExp(terms[index]);
if (new RegExp("^\\s*(?:#+\\s*)?" + term + "\\b", "im").test(text)) return true;
if (new RegExp("\\b" + term + "\\s*:", "i").test(text)) return true;
}
return false;
}
var SECTION_CHECKS = [
["purpose", ["purpose", "objective"], "Purpose or objective"],
["scope", ["scope", "applicability"], "Scope"],
["owner", ["owner", "document owner", "responsible party"], "Owner"],
["date", ["date", "effective date", "review date"], "Date or review date"],
["risk", ["risk", "risks", "risk assessment"], "Risk section"],
["approval", ["approval", "approved by", "signature", "sign-off"], "Approval or signature"]
];
var PLACEHOLDER_CHECKS = [
["todo", /\bTODO\b|\bTBD\b|\bTBA\b/i],
["insert", /\[(?:insert|add|fill in)[^\]]*\]|<\s*(?:insert|add|fill in)[^>]*>/i],
["blank", /_{4,}|\{\{[^}]+\}\}/]
];
var VAGUE_LANGUAGE = [
"best effort",
"as needed",
"reasonable",
"as appropriate",
"when possible",
"from time to time"
];
var LONG_SENTENCE_PATTERN = /[^.!?]{260,}[.!?]/;
/** app.py analyze_document(). */
function analyzeDocument(text, now) {
var findings = [];
var normalized = pyStr(text).trim();
var wordCount = countWords(normalized);
SECTION_CHECKS.forEach(function (check) {
var key = check[0];
var label = check[2];
if (!hasSection(normalized, check[1])) {
findings.push({
id: "missing-" + key,
severity: key === "approval" ? "high" : "medium",
title: "Missing " + label.toLowerCase(),
detail: "Add a clear " + label.toLowerCase() + " section or label before approval.",
snippet: ""
});
}
});
PLACEHOLDER_CHECKS.forEach(function (check) {
var pattern = check[1];
if (pattern.test(normalized)) {
findings.push({
id: "placeholder-" + check[0],
severity: "high",
title: "Unresolved placeholder",
detail: "Replace template placeholders before the document can be considered final.",
snippet: snippetFor(normalized, pattern)
});
}
});
VAGUE_LANGUAGE.forEach(function (phrase) {
var pattern = new RegExp("\\b" + escapeRegExp(phrase) + "\\b", "i");
if (pattern.test(normalized)) {
findings.push({
id: "vague-" + phrase.split(" ").join("-"),
severity: "medium",
title: 'Vague language: "' + phrase + '"',
detail: "Make the requirement measurable, accountable, or time-bound.",
snippet: snippetFor(normalized, pattern)
});
}
});
if (wordCount < 80) {
findings.push({
id: "short-document",
severity: "low",
title: "Very short document",
detail: "Confirm the document has enough context for reviewers and approvers.",
snippet: ""
});
}
if (LONG_SENTENCE_PATTERN.test(normalized)) {
findings.push({
id: "long-sentence",
severity: "low",
title: "Long sentence",
detail: "Break long sentences into shorter reviewable statements.",
snippet: snippetFor(normalized, LONG_SENTENCE_PATTERN)
});
}
// app.py sorts on (severity rank, title). Compare code units rather than
// locale: Python compares strings by code point, and these titles contain
// quote marks that a locale-aware comparison orders differently.
findings.sort(function (left, right) {
if (SEVERITY_ORDER[left.severity] !== SEVERITY_ORDER[right.severity]) {
return SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity];
}
if (left.title === right.title) return 0;
return left.title < right.title ? -1 : 1;
});
return {
reviewed_at: now(),
word_count: wordCount,
findings: findings,
counts: {
high: findings.filter(function (item) { return item.severity === "high"; }).length,
medium: findings.filter(function (item) { return item.severity === "medium"; }).length,
low: findings.filter(function (item) { return item.severity === "low"; }).length
}
};
}
// ------------------------------------------------------------------ the API
/**
* Build an API instance over one storage backend.
*
* @param {object} options
* @param {object} options.store load/save the record store and uploads
* @param {function} [options.now] app.py utc_now
* @param {function} [options.uuidHex] app.py uuid.uuid4().hex
* @param {function} [options.inflateRaw] raw DEFLATE, for DOCX
*/
function createApi(options) {
var settings = options || {};
var store = settings.store;
var now = settings.now || defaultNow;
var uuidHex = settings.uuidHex || defaultUuidHex;
var inflateRaw = settings.inflateRaw || defaultInflateRaw;
if (!store) throw new Error("createApi requires a store.");
// ------------------------------------------------------------ migrations
// Ported from app.py's migrate_* family. The default objects are built
// eagerly, exactly as Python builds its `defaults` dict, so each call
// consumes one id from `uuidHex` whether or not the id is used. The parity
// tests depend on that: they run both implementations off the same
// deterministic id sequence.
function migrateReviewer(reviewer) {
var changed = false;
var timestamp = now();
var defaults = {
id: uuidHex(),
name: "Reviewer",
created_at: timestamp,
updated_at: timestamp
};
Object.keys(defaults).forEach(function (key) {
if (!reviewer[key]) {
reviewer[key] = defaults[key];
changed = true;
}
});
CONTACT_FIELDS.forEach(function (field) {
if (!hasOwn(reviewer, field)) {
reviewer[field] = "";
changed = true;
}
});
return changed;
}
function migrateNotification(notification) {
var changed = false;
var defaults = {
id: uuidHex(),
recipient_type: "owner",
recipient_name: "Owner",
recipient_contact: emptyContact(),
document_id: "",
document_title: "",
event: "notice",
message: "",
created_at: now(),
read_at: null
};
Object.keys(defaults).forEach(function (key) {
if (!hasOwn(notification, key)) {
notification[key] = defaults[key];
changed = true;
}
});
if (!isPlainObject(notification.recipient_contact)) {
notification.recipient_contact = emptyContact();
changed = true;
} else {
CONTACT_FIELDS.forEach(function (field) {
if (!hasOwn(notification.recipient_contact, field)) {
notification.recipient_contact[field] = "";
changed = true;
}
});
}
return changed;
}
function migrateAssignment(assignment) {
var changed = false;
var timestamp = now();
var defaults = {
id: uuidHex(),
reviewer_id: "",
reviewer_name: "Reviewer",
reviewer_contact: emptyContact(),
status: "pending",
status_label: "Pending",
assigned_at: timestamp,
updated_at: timestamp,
approved_at: null,
approval_name: "",
approval_initials: "",
comments: []
};
Object.keys(defaults).forEach(function (key) {
if (!hasOwn(assignment, key)) {
assignment[key] = defaults[key];
changed = true;
}
});
var label = hasOwn(ASSIGNMENT_LABELS, assignment.status)
? ASSIGNMENT_LABELS[assignment.status]
: assignment.status;
if (assignment.status_label !== label) {
assignment.status_label = label;
changed = true;
}
if (!isPlainObject(assignment.reviewer_contact)) {
assignment.reviewer_contact = emptyContact();
changed = true;
} else {
CONTACT_FIELDS.forEach(function (field) {
if (!hasOwn(assignment.reviewer_contact, field)) {
assignment.reviewer_contact[field] = "";
changed = true;
}
});
}
if (!Array.isArray(assignment.comments)) {
assignment.comments = [];
changed = true;
}
return changed;
}
/** app.py build_legacy_activity(). */
function buildLegacyActivity(record) {
var owner = isPlainObject(record.owner) ? record.owner : {};
var activity = [
{
id: uuidHex(),
type: "uploaded",
actor: owner.name || "Owner",
message: "Uploaded " + (record.filename || "document") + ".",
created_at: record.created_at || now()
}
];
if (record.reviewed_at) {
activity.push({
id: uuidHex(),
type: "review_run",
actor: "System",
message: "Ran the local rubric review.",
created_at: record.reviewed_at
});
}
(Array.isArray(record.comments) ? record.comments : []).forEach(function (comment) {
activity.push({
id: uuidHex(),
type: "comment_added",
actor: comment.author || "Reviewer",
message: "Commented: " + pyStr(comment.body).slice(0, 120),
created_at: comment.created_at || now()
});
});
if (record.approved_at) {
activity.push({
id: uuidHex(),
type: "approved",
actor: "Reviewer",
message: "Approved the document.",
created_at: record.approved_at
});
}
return sortByAsc(activity, function (item) { return item.created_at; });
}
function migrateDocument(record) {
var changed = false;
var timestamp = now();
var defaults = {
id: uuidHex(),
filename: "document",
extension: ".txt",
status: "uploaded",
created_at: timestamp,
updated_at: timestamp,
reviewed_at: null,
approved_at: null,
word_count: 0,
findings: [],
comments: []
};
Object.keys(defaults).forEach(function (key) {
if (!hasOwn(record, key)) {
record[key] = defaults[key];
changed = true;
}
});
if (!isPlainObject(record.owner)) {
record.owner = ownerFromPayload({});
changed = true;
} else {
var owner = ownerFromPayload(record.owner);
if (JSON.stringify(owner) !== JSON.stringify(record.owner)) {
record.owner = owner;
changed = true;
}
}
if (!Array.isArray(record.assignments)) {
record.assignments = [];
changed = true;
}
record.assignments.forEach(function (assignment) {
changed = migrateAssignment(assignment) || changed;
});
if (!Array.isArray(record.activity) || record.activity.length === 0) {
record.activity = buildLegacyActivity(record);
changed = true;
}
return changed;
}
function migrateDb(data) {
var changed = false;
if (!Array.isArray(data.documents)) {
data.documents = [];
changed = true;
}
if (!Array.isArray(data.reviewers)) {
data.reviewers = [];
changed = true;
}
if (!Array.isArray(data.notifications)) {
data.notifications = [];
changed = true;
}
data.reviewers.forEach(function (reviewer) {
changed = migrateReviewer(reviewer) || changed;
});
data.notifications.forEach(function (notification) {
changed = migrateNotification(notification) || changed;
});
data.documents.forEach(function (record) {
changed = migrateDocument(record) || changed;
});
return changed;
}
/** app.py load_db(): read, migrate, and write back when migration ran. */
function loadDb() {
return Promise.resolve(store.load()).then(function (loaded) {
var data = isPlainObject(loaded)
? loaded
: { documents: [], reviewers: [], notifications: [] };
if (migrateDb(data)) {
return Promise.resolve(store.save(data)).then(function () {
return data;
});
}
return data;
});
}
function saveDb(data) {
return Promise.resolve(store.save(data));
}
// --------------------------------------------------------- record shapes
/** app.py add_activity(). */
function addActivity(record, eventType, actor, message, timestamp) {
if (!Array.isArray(record.activity)) record.activity = [];
record.activity.push({
id: uuidHex(),
type: eventType,
actor: cleanText(actor, "System", 120),
message: cleanText(message, "", 500),
created_at: timestamp || now()
});
}
/** app.py assignment_counts(). */
function assignmentCounts(record) {
var counts = { pending: 0, commented: 0, changes_requested: 0, approved: 0 };
(record.assignments || []).forEach(function (assignment) {
var status = assignment.status || "pending";
counts[status] = (hasOwn(counts, status) ? counts[status] : 0) + 1;
});
return counts;
}
/** app.py recalculate_document_status(). */
function recalculateDocumentStatus(record) {
var assignments = record.assignments || [];
if (!assignments.length) return;
var statuses = assignments.map(function (assignment) {
return assignment.status || "pending";
});
if (statuses.indexOf("changes_requested") !== -1) {
record.status = "changes_requested";
record.approved_at = null;
return;
}
var allApproved = statuses.every(function (status) { return status === "approved"; });
if (allApproved) {
record.status = "approved";
record.approved_at = record.approved_at || now();
return;
}
record.status = "in_review";
record.approved_at = null;
}
/** app.py public_assignment(). */
function publicAssignment(assignment) {
return {
id: assignment.id,
reviewer_id: assignment.reviewer_id,
reviewer_name: assignment.reviewer_name,
reviewer_contact: assignment.reviewer_contact || emptyContact(),
status: assignment.status,
status_label: hasOwn(ASSIGNMENT_LABELS, assignment.status)
? ASSIGNMENT_LABELS[assignment.status]
: assignment.status,
assigned_at: assignment.assigned_at,
updated_at: assignment.updated_at,
approved_at: assignment.approved_at === undefined ? null : assignment.approved_at,
approval_name: assignment.approval_name || "",
approval_initials: assignment.approval_initials || "",
comments: assignment.comments || []
};
}
/** app.py public_document(). */
function publicDocument(record, includeText) {
var view = {
id: record.id,
filename: record.filename,
extension: record.extension,
status: record.status,
status_label: hasOwn(STATUS_LABELS, record.status)
? STATUS_LABELS[record.status]
: record.status,
created_at: record.created_at,
updated_at: record.updated_at,
reviewed_at: record.reviewed_at === undefined ? null : record.reviewed_at,
approved_at: record.approved_at === undefined ? null : record.approved_at,
word_count: record.word_count === undefined ? 0 : record.word_count,
findings: record.findings || [],
comments: record.comments || [],
owner: isPlainObject(record.owner) ? record.owner : ownerFromPayload({}),
assignments: (record.assignments || []).map(publicAssignment),
assignment_counts: assignmentCounts(record),
activity: sortByDesc(record.activity || [], "created_at")
};
if (includeText) {
view.text = record.text || "";
}
return view;
}
/** app.py public_reviewer(). */
function publicReviewer(reviewer) {
return {
id: reviewer.id,
name: reviewer.name,
email: reviewer.email || "",
slack: reviewer.slack || "",
teams: reviewer.teams || "",
created_at: reviewer.created_at,
updated_at: reviewer.updated_at
};
}
/** app.py public_notification(). */
function publicNotification(notification) {
return {
id: notification.id,
recipient_type: notification.recipient_type,
recipient_name: notification.recipient_name,
recipient_contact: notification.recipient_contact || emptyContact(),
document_id: notification.document_id,
document_title: notification.document_title,
event: notification.event,
message: notification.message,
created_at: notification.created_at,
read_at: notification.read_at === undefined ? null : notification.read_at
};
}
function findDocument(data, documentId) {
return data.documents.filter(function (record) {
return record.id === documentId;
})[0] || null;
}
function findReviewer(data, reviewerId) {
return data.reviewers.filter(function (reviewer) {
return reviewer.id === reviewerId;
})[0] || null;
}
function findAssignment(record, reviewerId) {
return (record.assignments || []).filter(function (assignment) {
return assignment.reviewer_id === reviewerId;
})[0] || null;
}
/** app.py reviewer_payload(). */
function reviewerPayload(payload, existing) {
var reviewer = {};
if (existing) {
Object.keys(existing).forEach(function (key) {
reviewer[key] = existing[key];
});
}
reviewer.name = cleanText(payload.name, "", 120);
if (!reviewer.name) throw apiError("Reviewer name is required.", 400);
var contact = normalizeContact(payload);
CONTACT_FIELDS.forEach(function (field) {
reviewer[field] = contact[field];
});
return reviewer;
}
/** app.py build_assignment(). */
function buildAssignment(reviewer) {
var timestamp = now();
var assignment = {
id: uuidHex(),
reviewer_id: reviewer.id,
reviewer_name: reviewer.name,
reviewer_contact: normalizeContact(reviewer),
status: "pending",
status_label: "Pending",
assigned_at: timestamp,
updated_at: timestamp,
approved_at: null,
approval_name: "",
approval_initials: "",
comments: []
};
migrateAssignment(assignment);
return assignment;
}
/** app.py create_notification(). */
function createNotification(data, record, recipientType, recipientName, recipientContact, event, message) {
var notification = {
id: uuidHex(),
recipient_type: recipientType,
recipient_name: cleanText(recipientName, "Recipient", 120),
recipient_contact: normalizeContact(recipientContact),
document_id: record.id,
document_title: record.filename,
event: event,
message: cleanText(message, "", 1000),
created_at: now(),
read_at: null
};
if (!Array.isArray(data.notifications)) data.notifications = [];
data.notifications.push(notification);
addActivity(
record,
"notification",
"System",
"Queued notification for " + notification.recipient_name + ": " + notification.message,
notification.created_at
);
return notification;
}
// ------------------------------------------------------------- responses
function jsonResponse(payload, status) {
return {
status: status || 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(payload)
};
}
function errorResponse(message, status) {
return jsonResponse({ error: message }, status || 400);
}
function notFound(message) {
return errorResponse(message || "Not found.", 404);
}
function parseJsonBody(body) {
// app.py read_json_body(): an empty body is {}, malformed JSON is a 400.
if (body === null || body === undefined || body === "") return {};
if (isPlainObject(body)) return body;
try {
var parsed = JSON.parse(body);
return isPlainObject(parsed) ? parsed : {};
} catch (error) {
throw apiError("Request body must be valid JSON.", 400);
}
}
// -------------------------------------------------------------- handlers
function handleDocumentsIndex() {
return loadDb().then(function (data) {
var documents = data.documents.map(function (record) {
return publicDocument(record, false);
});
return jsonResponse({ documents: sortByDesc(documents, "updated_at") });
});
}
function handleReviewersIndex() {
return loadDb().then(function (data) {
var reviewers = data.reviewers.map(publicReviewer);
return jsonResponse({
reviewers: sortByAsc(reviewers, function (reviewer) {
return String(reviewer.name).toLowerCase();
})
});
});
}
function handleNotificationsIndex() {
return loadDb().then(function (data) {
var notifications = sortByDesc(data.notifications.map(publicNotification), "created_at");
var unread = notifications.filter(function (item) { return !item.read_at; }).length;
return jsonResponse({ notifications: notifications, unread_count: unread });
});
}
function handleDocumentDetail(documentId) {
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) return notFound("Document not found.");
return jsonResponse(publicDocument(record, true));
});
}
function handleDownload(documentId) {
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) return notFound("Document not found.");
return Promise.resolve(store.getUpload(record.stored_name)).then(function (bytes) {
if (!bytes) return notFound("Original upload is missing.");
var extension = extensionOf(record.filename);
return {
status: 200,
headers: {
"Content-Type": DOWNLOAD_TYPES[extension] || "application/octet-stream",
"Content-Disposition": 'attachment; filename="' + record.filename + '"'
},
bytes: bytes,
filename: record.filename
};
});
});
}
/** app.py handle_upload(). */
function handleUpload(upload) {
if (!upload || !upload.bytes) throw apiError("Upload cannot be empty.", 400);
// app.py measures Content-Length, so its 5 MB ceiling covers the whole
// multipart request - the file plus a few hundred bytes of part headers.
// There is no request here, so the file itself is what gets measured,
// which makes this marginally more permissive for a file within a few
// hundred bytes of the limit.
if (upload.bytes.length > MAX_UPLOAD_BYTES) {
throw apiError("Upload is too large. Maximum size is 5 MB.", 400);
}
var fields = upload.fields || {};
var filename = sanitizeFilename(upload.filename);
var extension = extensionOf(filename);
if (ALLOWED_EXTENSIONS.indexOf(extension) === -1) {
return Promise.resolve(
errorResponse("Unsupported file type. Upload TXT, MD, or DOCX.", 415)
);
}
if (upload.bytes.length === 0) throw apiError("Uploaded file is empty.", 400);
return extractText(filename, upload.bytes, inflateRaw).then(function (text) {
if (!text) throw apiError("No readable text was found in the document.", 400);
var documentId = uuidHex();
var storedName = documentId + extension;
return Promise.resolve(store.putUpload(storedName, upload.bytes)).then(function () {
var timestamp = now();
var owner = ownerFromPayload({
name: fields.owner_name || fields.owner,
email: fields.owner_email,
slack: fields.owner_slack,
teams: fields.owner_teams
});
var record = {
id: documentId,
filename: filename,
stored_name: storedName,
extension: extension,
status: "uploaded",
created_at: timestamp,
updated_at: timestamp,
reviewed_at: null,
approved_at: null,
word_count: countWords(text),
text: text,
findings: [],
comments: [],
owner: owner,
assignments: [],
activity: []
};
addActivity(record, "uploaded", owner.name, "Uploaded " + filename + ".", timestamp);
return loadDb().then(function (data) {
data.documents.push(record);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true), 201);
});
});
});
});
}
/** app.py handle_review(). */
function handleReview(documentId) {
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) return notFound("Document not found.");
var result = analyzeDocument(record.text || "", now);
record.findings = result.findings;
record.reviewed_at = result.reviewed_at;
record.word_count = result.word_count;
if (record.status !== "approved") record.status = "in_review";
record.updated_at = now();
addActivity(record, "review_run", "System", "Ran the local rubric review.", result.reviewed_at);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/** app.py handle_comment(). */
function handleComment(documentId, body) {
var payload = parseJsonBody(body);
var text = cleanText(payload.body, "", 2000);
var author = cleanText(payload.author, "Reviewer", 80);
if (!text) throw apiError("Comment body is required.", 400);
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) return notFound("Document not found.");
var timestamp = now();
if (!Array.isArray(record.comments)) record.comments = [];
record.comments.push({
id: uuidHex(),
author: author,
body: text,
created_at: timestamp
});
record.updated_at = timestamp;
addActivity(record, "comment_added", author, "Commented: " + text.slice(0, 120), timestamp);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/** app.py handle_status(). */
function handleStatus(documentId, status) {
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) return notFound("Document not found.");
var owner = isPlainObject(record.owner) ? record.owner : {};
record.status = status;
record.updated_at = now();
if (status === "approved") {
record.approved_at = now();
addActivity(record, "approved", owner.name || "Owner", "Owner approved the document.");
} else {
record.approved_at = null;
addActivity(record, "changes_requested", owner.name || "Owner", "Owner requested changes.");
}
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/** app.py handle_create_reviewer(). */
function handleCreateReviewer(body) {
var payload = parseJsonBody(body);
var timestamp = now();
var reviewer = reviewerPayload(payload, null);
reviewer.id = uuidHex();
reviewer.created_at = timestamp;
reviewer.updated_at = timestamp;
return loadDb().then(function (data) {
data.reviewers.push(reviewer);
return saveDb(data).then(function () {
return jsonResponse(publicReviewer(reviewer), 201);
});
});
}
/** app.py handle_update_reviewer(). */
function handleUpdateReviewer(reviewerId, body) {
return loadDb().then(function (data) {
var reviewer = findReviewer(data, reviewerId);
if (!reviewer) return notFound("Reviewer not found.");
var updated = reviewerPayload(parseJsonBody(body), reviewer);
Object.keys(updated).forEach(function (key) {
reviewer[key] = updated[key];
});
reviewer.updated_at = now();
return saveDb(data).then(function () {
return jsonResponse(publicReviewer(reviewer));
});
});
}
/** app.py handle_delete_reviewer(). */
function handleDeleteReviewer(reviewerId) {
return loadDb().then(function (data) {
var before = data.reviewers.length;
data.reviewers = data.reviewers.filter(function (reviewer) {
return reviewer.id !== reviewerId;
});
if (data.reviewers.length === before) return notFound("Reviewer not found.");
return saveDb(data).then(function () {
return jsonResponse({ ok: true });
});
});
}
/** app.py handle_assign_reviewers(). */
function handleAssignReviewers(documentId, body) {
var payload = parseJsonBody(body);
var requested = payload.reviewer_ids === undefined ? [] : payload.reviewer_ids;
if (!Array.isArray(requested)) throw apiError("reviewer_ids must be a list.", 400);
var reviewerIds = [];
requested.forEach(function (item) {
var id = pyStr(item);
if (!id.trim()) return;
if (reviewerIds.indexOf(id) === -1) reviewerIds.push(id);
});
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) return notFound("Document not found.");
var reviewersById = {};
data.reviewers.forEach(function (reviewer) {
reviewersById[reviewer.id] = reviewer;
});
var missing = reviewerIds.filter(function (id) {
return !hasOwn(reviewersById, id);
});
if (missing.length) {
throw apiError("One or more selected reviewers no longer exists.", 400);
}
var existingById = {};
(record.assignments || []).forEach(function (assignment) {
existingById[assignment.reviewer_id] = assignment;
});
var assignments = [];
var created = [];
reviewerIds.forEach(function (id) {
if (hasOwn(existingById, id)) {
assignments.push(existingById[id]);
} else {
var assignment = buildAssignment(reviewersById[id]);
assignments.push(assignment);
created.push(assignment);
}
});
record.assignments = assignments;
var timestamp = now();
record.updated_at = timestamp;
if (reviewerIds.length) {
record.status = "in_review";
var names = assignments.map(function (assignment) {
return assignment.reviewer_name;
}).join(", ");
addActivity(record, "reviewers_assigned", record.owner.name, "Assigned reviewers: " + names + ".", timestamp);
} else {
record.status = record.reviewed_at ? "in_review" : "uploaded";
record.approved_at = null;
addActivity(record, "reviewers_assigned", record.owner.name, "Cleared reviewer assignments.", timestamp);
}
created.forEach(function (assignment) {
var message =
"Please review " + record.filename + " in SimpleDocReview. " +
"Open the local app, select the document, and respond as " + assignment.reviewer_name + ".";
createNotification(
data,
record,
"reviewer",
assignment.reviewer_name,
assignment.reviewer_contact,
"review_assigned",
message
);
});
recalculateDocumentStatus(record);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/**
* app.py require_assignment().
*
* Note the status quirk this preserves: a missing document reaches the
* client as a 400 here, not the 404 that GET /api/documents/<id> returns,
* because upstream raises ValueError rather than replying directly.
*/
function requireAssignment(documentId, payload) {
var reviewerId = cleanText(payload.reviewer_id, "", 80);
if (!reviewerId) throw apiError("Reviewer is required.", 400);
return loadDb().then(function (data) {
var record = findDocument(data, documentId);
if (!record) throw apiError("Document not found.", 400);
var assignment = findAssignment(record, reviewerId);
if (!assignment) throw apiError("Reviewer is not assigned to this document.", 400);
return { data: data, record: record, assignment: assignment };
});
}
/** app.py handle_reviewer_comment(). */
function handleReviewerComment(documentId, body) {
var payload = parseJsonBody(body);
return requireAssignment(documentId, payload).then(function (context) {
var data = context.data;
var record = context.record;
var assignment = context.assignment;
var text = cleanText(payload.body, "", 2000);
var signature = cleanText(payload.signature, assignment.reviewer_name, 120);
if (!text) throw apiError("Comment body is required.", 400);
var timestamp = now();
var comment = {
id: uuidHex(),
author: signature,
reviewer_id: assignment.reviewer_id,
body: text,
created_at: timestamp
};
if (!Array.isArray(assignment.comments)) assignment.comments = [];
assignment.comments.push(comment);
if (assignment.status !== "approved" && assignment.status !== "changes_requested") {
assignment.status = "commented";
}
assignment.updated_at = timestamp;
migrateAssignment(assignment);
if (!Array.isArray(record.comments)) record.comments = [];
record.comments.push(comment);
record.updated_at = timestamp;
addActivity(record, "reviewer_comment", signature, "Commented: " + text.slice(0, 120), timestamp);
createNotification(
data,
record,
"owner",
record.owner.name,
record.owner,
"reviewer_comment",
signature + " commented on " + record.filename + ": " + text.slice(0, 180)
);
recalculateDocumentStatus(record);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/** app.py handle_reviewer_approve(). */
function handleReviewerApprove(documentId, body) {
var payload = parseJsonBody(body);
return requireAssignment(documentId, payload).then(function (context) {
var data = context.data;
var record = context.record;
var assignment = context.assignment;
var signature = cleanText(payload.signature, "", 120);
if (!signature) throw apiError("Approval name or initials are required.", 400);
var wasFinal = record.status === "approved";
var timestamp = now();
assignment.status = "approved";
assignment.approval_name = signature;
assignment.approval_initials = cleanText(payload.initials || signature, "", 24);
assignment.approved_at = timestamp;
assignment.updated_at = timestamp;
migrateAssignment(assignment);
record.updated_at = timestamp;
addActivity(record, "reviewer_approved", signature, "Approved as " + signature + ".", timestamp);
recalculateDocumentStatus(record);
if (record.status === "approved" && !wasFinal) {
addActivity(
record,
"approved",
"System",
"All assigned reviewers approved the document.",
record.approved_at
);
}
createNotification(
data,
record,
"owner",
record.owner.name,
record.owner,
"reviewer_approved",
signature + " approved " + record.filename + "."
);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/** app.py handle_reviewer_request_changes(). */
function handleReviewerRequestChanges(documentId, body) {
var payload = parseJsonBody(body);
return requireAssignment(documentId, payload).then(function (context) {
var data = context.data;
var record = context.record;
var assignment = context.assignment;
var text = cleanText(payload.body, "", 2000);
var signature = cleanText(payload.signature, assignment.reviewer_name, 120);
if (!text) throw apiError("A change request note is required.", 400);
var timestamp = now();
assignment.status = "changes_requested";
assignment.approved_at = null;
assignment.approval_name = "";
assignment.approval_initials = "";
assignment.updated_at = timestamp;
var comment = {
id: uuidHex(),
author: signature,
reviewer_id: assignment.reviewer_id,
body: text,
created_at: timestamp
};
if (!Array.isArray(assignment.comments)) assignment.comments = [];
assignment.comments.push(comment);
migrateAssignment(assignment);
if (!Array.isArray(record.comments)) record.comments = [];
record.comments.push(comment);
record.updated_at = timestamp;
recalculateDocumentStatus(record);
addActivity(
record,
"reviewer_changes_requested",
signature,
"Requested changes: " + text.slice(0, 120),
timestamp
);
createNotification(
data,
record,
"owner",
record.owner.name,
record.owner,
"reviewer_changes_requested",
signature + " requested changes on " + record.filename + ": " + text.slice(0, 180)
);
return saveDb(data).then(function () {
return jsonResponse(publicDocument(record, true));
});
});
}
/** app.py handle_notification_read(). */
function handleNotificationRead(notificationId) {
return loadDb().then(function (data) {
var notification = data.notifications.filter(function (item) {
return item.id === notificationId;
})[0];
if (!notification) return notFound("Notification not found.");
notification.read_at = notification.read_at || now();
return saveDb(data).then(function () {
return jsonResponse(publicNotification(notification));
});
});
}
// ----------------------------------------------------------------- router
// Mirrors app.py's do_GET / do_POST / do_PUT / do_DELETE dispatch, in the
// same order, so an unmatched path produces the same 404 body.
function pathParts(pathname) {
return String(pathname)
.split("?")[0]
.split("#")[0]
.split("/")
.filter(function (part) { return part.length > 0; })
.map(function (part) { return decodeURIComponent(part); });
}
function route(method, pathname, body) {
var parts = pathParts(pathname);
var verb = String(method || "GET").toUpperCase();
if (verb === "GET") {
if (parts.length === 2 && parts[0] === "api") {
if (parts[1] === "documents") return handleDocumentsIndex();
if (parts[1] === "reviewers") return handleReviewersIndex();
if (parts[1] === "notifications") return handleNotificationsIndex();
}
if (parts.length === 3 && parts[0] === "api" && parts[1] === "documents") {
return handleDocumentDetail(parts[2]);
}
if (parts.length === 4 && parts[0] === "api" && parts[1] === "documents" && parts[3] === "download") {
return handleDownload(parts[2]);
}
return Promise.resolve(notFound());
}
if (verb === "POST") {
if (parts.length === 2 && parts[0] === "api" && parts[1] === "upload") {
return handleUpload(body);
}
if (parts.length === 2 && parts[0] === "api" && parts[1] === "reviewers") {
return handleCreateReviewer(body);
}
if (parts.length === 4 && parts[0] === "api" && parts[1] === "notifications" && parts[3] === "read") {
return handleNotificationRead(parts[2]);
}
if (parts.length === 4 && parts[0] === "api" && parts[1] === "documents") {
var documentId = parts[2];
var action = parts[3];
if (action === "review") return handleReview(documentId);
if (action === "comments") return handleComment(documentId, body);
if (action === "approve") return handleStatus(documentId, "approved");
if (action === "request-changes") return handleStatus(documentId, "changes_requested");
if (action === "assign-reviewers") return handleAssignReviewers(documentId, body);
if (action === "reviewer-comment") return handleReviewerComment(documentId, body);
if (action === "reviewer-approve") return handleReviewerApprove(documentId, body);
if (action === "reviewer-request-changes") return handleReviewerRequestChanges(documentId, body);
}
return Promise.resolve(notFound());
}
if (verb === "PUT") {
if (parts.length === 3 && parts[0] === "api" && parts[1] === "reviewers") {
return handleUpdateReviewer(parts[2], body);
}
return Promise.resolve(notFound());
}
if (verb === "DELETE") {
if (parts.length === 3 && parts[0] === "api" && parts[1] === "reviewers") {
return handleDeleteReviewer(parts[2]);
}
return Promise.resolve(notFound());
}
return Promise.resolve(notFound());
}
// One request at a time. app.py serializes on the file it rewrites; here
// every handler is a read-modify-write against the same store, and the app
// fires three GETs in parallel on load, so interleaving them would let one
// response overwrite another's changes.
var queue = Promise.resolve();
function handle(method, pathname, body) {
var result = queue.then(function () {
return Promise.resolve()
.then(function () {
return route(method, pathname, body);
})
.catch(function (error) {
if (error && error.apiStatus) {
return errorResponse(error.message, error.apiStatus);
}
throw error;
});
});
// Keep the chain alive even when a request rejects, and never let a
// rejection here surface as an unhandled promise.
queue = result.then(function () { return undefined; }, function () { return undefined; });
return result;
}
return {
handle: handle,
isApiPath: function (pathname) {
var parts = pathParts(pathname);
return parts[0] === "api";
},
// Exposed for the parity tests and for debugging in the browser console.
internals: {
analyzeDocument: function (text) { return analyzeDocument(text, now); },
extractText: function (filename, bytes) { return extractText(filename, bytes, inflateRaw); },
sanitizeFilename: sanitizeFilename,
decodeTextBytes: decodeTextBytes,
cleanText: cleanText,
countWords: countWords,
publicDocument: publicDocument,
loadDb: loadDb
}
};
}
return {
createApi: createApi,
MAX_UPLOAD_BYTES: MAX_UPLOAD_BYTES,
ALLOWED_EXTENSIONS: ALLOWED_EXTENSIONS,
STATUS_LABELS: STATUS_LABELS,
ASSIGNMENT_LABELS: ASSIGNMENT_LABELS,
defaultNow: defaultNow,
defaultUuidHex: defaultUuidHex,
defaultInflateRaw: defaultInflateRaw,
sanitizeFilename: sanitizeFilename,
decodeTextBytes: decodeTextBytes,
cleanText: cleanText
};
});