Files
UmbraTrans/web/assets/api.js
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

1019 lines
31 KiB
JavaScript

// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
/*
* UmbraTrans browser API client, DOM helpers, and modal utility.
*
* Rules enforced by this file, deliberately rather than by convention:
*
* - Every request is same-origin and sends `credentials: "same-origin"`.
* - Every non-GET /api request carries an `X-CSRF-Token` header whose value is
* read fresh from the readable `umbratrans_csrf` cookie on each call.
* `POST /api/auth/login` is the only exempt route. Nothing here can turn
* CSRF enforcement off.
* - No value that came from the API is ever turned into markup. `el()` builds
* nodes and assigns text with `textContent`; the HTML-parsing sinks (raw
* markup assignment, adjacent-HTML insertion, and document writing) are not
* used anywhere in this file or in the page scripts.
* - A one-time password is never logged, never written to storage, never put
* in a URL, and never passed to a toast. Only the provisioning dialog
* receives it, from the single response that contains it.
*
* This file runs on all three pages and assumes almost nothing about the DOM,
* so every lookup is guarded.
*/
(function (global) {
'use strict';
var CSRF_COOKIE = 'umbratrans_csrf';
var CSRF_HEADER = 'X-CSRF-Token';
var UNSAFE_METHODS = { POST: true, PUT: true, PATCH: true, DELETE: true };
var CSRF_EXEMPT = { '/api/auth/login': true };
var PASSWORD_MIN_LENGTH = 14;
/**
* Token returned in a response body. The server sends the same value in the
* cookie, so this is only a fallback for the pathological case where the
* browser withheld the cookie. It is never persisted.
*/
var fallbackCsrfToken = '';
var sessionLostHandler = null;
var passwordChangeHandler = null;
/* ---------------------------------------------------------------- errors */
/**
* A failed API call. Carries the server's stable error code, its sanitized
* message, and the optional `fields` / `requirements` lists. It never carries
* a submitted value: the backend strips those before responding.
*/
function ApiError(status, code, message, fields, requirements) {
this.name = 'ApiError';
this.status = status || 0;
this.code = code || 'request_failed';
this.message = message || 'The request could not be completed.';
this.fields = Array.isArray(fields) ? fields.slice() : [];
this.requirements = Array.isArray(requirements) ? requirements.slice() : [];
}
ApiError.prototype = Object.create(Error.prototype);
ApiError.prototype.constructor = ApiError;
function describeError(error) {
if (!error) {
return 'The request could not be completed.';
}
if (error instanceof ApiError) {
return error.message;
}
return 'The request could not be completed.';
}
/* ---------------------------------------------------------------- cookie */
function readCookie(name) {
var jar = typeof document === 'undefined' ? '' : document.cookie;
if (!jar) {
return '';
}
var parts = jar.split(';');
for (var i = 0; i < parts.length; i += 1) {
var part = parts[i];
var eq = part.indexOf('=');
if (eq < 0) {
continue;
}
if (part.slice(0, eq).trim() === name) {
var raw = part.slice(eq + 1).trim();
try {
return decodeURIComponent(raw);
} catch (error) {
return raw;
}
}
}
return '';
}
function csrfToken() {
return readCookie(CSRF_COOKIE) || fallbackCsrfToken || '';
}
/*
* Make sure a CSRF token exists, fetching one if it does not.
*
* The server issues the cookie on any safe API request that arrives without
* one, so a page only needs one harmless GET before its first state-changing
* call. Pages that arrive from elsewhere do not get that for free: the
* confirmation link opens `verify-email.html` directly, and that page had never
* made a safe request, so its POST was refused here in the browser, before any
* network call. Nothing reached the server, so nothing appeared in its log and
* the page simply never confirmed anything.
*
* The alternative was exempting `/api/auth/verify-email` from CSRF, the way
* login and first-run setup are. Those are exempt because nothing about them
* relies on a session. This one is not, and keeping the exemption list short is
* worth more than saving one round trip.
*/
function ensureCsrfToken() {
if (csrfToken()) {
return Promise.resolve(csrfToken());
}
return request('/api/health', { method: 'GET' }).then(
function () {
var token = csrfToken();
if (!token) {
throw new ApiError(
0,
'csrf_token_missing',
'This page could not obtain a security token. Reload and try again.'
);
}
return token;
},
function (error) {
throw new ApiError(
0,
'csrf_token_missing',
'This page could not reach the service to obtain a security token. Check your connection and reload.'
);
}
);
}
/* ------------------------------------------------------------- requests */
function buildQuery(params) {
if (!params) {
return '';
}
var search = new URLSearchParams();
Object.keys(params).forEach(function (key) {
var value = params[key];
if (value === null || value === undefined || value === '') {
return;
}
search.set(key, String(value));
});
var text = search.toString();
return text ? '?' + text : '';
}
function noteSessionFailure(error) {
if (error.status === 401 && error.code === 'authentication_required') {
if (typeof sessionLostHandler === 'function') {
sessionLostHandler(error);
}
return;
}
if (error.status === 403 && error.code === 'password_change_required') {
if (typeof passwordChangeHandler === 'function') {
passwordChangeHandler(error);
}
}
}
/**
* Perform one API call.
*
* @param {string} path Same-origin path beginning with `/api`.
* @param {{method?: string, body?: unknown, params?: object, signal?: AbortSignal}} [options]
* @returns {Promise<object|null>} Parsed JSON body, or null when empty.
*/
function request(path, options) {
var opts = options || {};
var method = String(opts.method || 'GET').toUpperCase();
var init = {
method: method,
credentials: 'same-origin',
cache: 'no-store',
headers: { Accept: 'application/json' }
};
if (opts.signal) {
init.signal = opts.signal;
}
if (UNSAFE_METHODS[method] && !CSRF_EXEMPT[path]) {
var token = csrfToken();
if (!token) {
return Promise.reject(
new ApiError(
0,
'csrf_token_missing',
'This page has not received its security token yet. Reload and try again.'
)
);
}
init.headers[CSRF_HEADER] = token;
}
if (opts.body !== undefined && opts.body !== null) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.body);
}
return global.fetch(path + buildQuery(opts.params), init).then(
function (response) {
var body = null;
var contentType = response.headers.get('content-type') || '';
if (contentType.indexOf('application/json') !== -1) {
return response.json().then(
function (parsed) {
body = parsed;
return { response: response, body: body };
},
function (readError) {
// A body that cannot be read is either an abort or a truncated
// response. Let an abort propagate so the caller recognises it as
// a superseded request rather than as an empty result.
if (readError && readError.name === 'AbortError') {
throw readError;
}
return { response: response, body: null };
}
);
}
return { response: response, body: null };
}
).then(function (result) {
var response = result.response;
var body = result.body;
if (body && typeof body.csrf_token === 'string' && body.csrf_token) {
fallbackCsrfToken = body.csrf_token;
}
if (response.ok) {
return body;
}
var error = new ApiError(
response.status,
'request_failed',
'The request could not be completed.'
);
if (body && body.error && typeof body.error === 'object') {
error.code = typeof body.error.code === 'string' ? body.error.code : error.code;
if (typeof body.error.message === 'string' && body.error.message) {
error.message = body.error.message;
}
error.fields = Array.isArray(body.error.fields) ? body.error.fields : [];
error.requirements = Array.isArray(body.error.requirements) ? body.error.requirements : [];
}
noteSessionFailure(error);
throw error;
}, function (networkError) {
if (networkError && networkError.name === 'AbortError') {
throw networkError;
}
var error = new ApiError(
0,
'network_error',
'The server could not be reached. Check your connection and retry.'
);
throw error;
});
}
/* ------------------------------------------------------------------ auth */
/**
* Unauthenticated probe. Also the CSRF bootstrap: any GET /api response
* issues the readable double-submit cookie when the browser does not have
* one yet, so calling this on page load guarantees the header is available
* for the first state-changing request.
*/
function health() {
return request('/api/health');
}
function login(email, password, signal) {
return request('/api/auth/login', {
method: 'POST',
body: { email: email, password: password },
signal: signal
});
}
function verifyEmail(token, signal) {
return request('/api/auth/verify-email', {
method: 'POST',
body: { token: token },
signal: signal
});
}
function passwordResetAvailable(token, signal) {
// buildQuery already returns a leading "?", so this must not add another.
// It did, and the request went out as "/api/auth/reset-password??token=...",
// which the server refused as a 422 with an empty parameter name. The page
// then read that as "this link is no longer valid" and told the person their
// link had expired when it had not.
return request('/api/auth/reset-password' + buildQuery({ token: token }), { method: 'GET', signal: signal });
}
function resetPassword(token, newPassword, signal) {
return request('/api/auth/reset-password', {
method: 'POST',
body: { token: token, new_password: newPassword },
signal: signal
});
}
function changeOwnPassword(currentPassword, newPassword, signal) {
return request('/api/auth/change-password', {
method: 'POST',
body: { current_password: currentPassword, new_password: newPassword },
signal: signal
});
}
function requestPasswordReset(userId, signal) {
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/password-reset', {
method: 'POST',
signal: signal
});
}
function resendVerification(email, signal) {
return request('/api/auth/resend-verification', {
method: 'POST',
body: { email: email },
signal: signal
});
}
function changeFirstPassword(currentPassword, newPassword, signal) {
return request('/api/auth/change-first-password', {
method: 'POST',
body: { current_password: currentPassword, new_password: newPassword },
signal: signal
});
}
function me(signal) {
return request('/api/auth/me', { signal: signal });
}
function logout(signal) {
return request('/api/auth/logout', { method: 'POST', signal: signal });
}
function logoutEverywhere(signal) {
return request('/api/auth/logout-everywhere', { method: 'POST', signal: signal });
}
/* ----------------------------------------------------------------- admin */
function getSummary(signal) {
return request('/api/uadmin/summary', { signal: signal });
}
function listAccounts(filters, signal) {
return request('/api/uadmin/accounts', { params: filters, signal: signal });
}
/**
* Provision an account. The response may carry `temporary_password`; that
* value is returned to the caller untouched and is never logged, stored, or
* interpolated into any other string.
*/
function createAccount(payload, signal) {
return request('/api/uadmin/accounts', { method: 'POST', body: payload, signal: signal });
}
function setAccountStatus(userId, status, reason, signal) {
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/status', {
method: 'PATCH',
body: { status: status, reason: reason === '' ? null : reason },
signal: signal
});
}
function revokeAccountSessions(userId, signal) {
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/revoke-sessions', {
method: 'POST',
signal: signal
});
}
function listOrganizations(filters, signal) {
return request('/api/uadmin/organizations', { params: filters, signal: signal });
}
function createOrganization(payload, signal) {
return request('/api/uadmin/organizations', { method: 'POST', body: payload, signal: signal });
}
function updateOrganization(organizationId, payload, signal) {
return request('/api/uadmin/organizations/' + encodeURIComponent(organizationId), {
method: 'PATCH',
body: payload,
signal: signal
});
}
function setOrganizationStatus(organizationId, status, reason, signal) {
return request(
'/api/uadmin/organizations/' + encodeURIComponent(organizationId) + '/status',
{ method: 'PATCH', body: { status: status, reason: reason === '' ? null : reason }, signal: signal }
);
}
function listAudit(filters, signal) {
return request('/api/uadmin/audit', { params: filters, signal: signal });
}
/* --------------------------------------------------------------- projects */
/**
* The signed-in caller's organization and its projects, in one response.
*
* Deliberately not `listProjects` plus `me` plus `getOrganization`. The server
* already knows which organization this session belongs to, and asking the
* browser to pass it back is how a stale or mistimed filter shows one tenant's
* work to another. There is no organization parameter here to get wrong.
*/
function workspace(signal) {
return request('/api/workspace', { signal: signal });
}
function listProjects(filters, signal) {
return request('/api/uadmin/projects', { params: filters, signal: signal });
}
function projectStatus(projectId, signal) {
return request('/api/workspace/' + encodeURIComponent(projectId) + '/status', { signal: signal });
}
function projectDetail(projectId, signal) {
return request('/api/workspace/' + encodeURIComponent(projectId), { signal: signal });
}
function createProject(payload, signal) {
return request('/api/uadmin/projects', { method: 'POST', body: payload, signal: signal });
}
function setProjectStatus(projectId, status, reason, signal) {
return request('/api/uadmin/projects/' + encodeURIComponent(projectId) + '/status', {
method: 'PATCH',
body: { status: status, reason: reason === '' ? null : reason },
signal: signal
});
}
/**
* Assign an account to a project. A PUT because the request states a desired
* state: assigning someone who is already assigned succeeds and reports
* `changed: false`, so a retry after a dropped response is safe.
*/
function assignToProject(projectId, userId, signal) {
return request(
'/api/uadmin/projects/' + encodeURIComponent(projectId) + '/members/' + encodeURIComponent(userId),
{ method: 'PUT', signal: signal }
);
}
/**
* Remove an assignment. The project stays visible to the whole organization;
* only the work stops being anybody's.
*/
function unassignFromProject(projectId, userId, signal) {
return request(
'/api/uadmin/projects/' + encodeURIComponent(projectId) + '/members/' + encodeURIComponent(userId),
{ method: 'DELETE', signal: signal }
);
}
/* ------------------------------------------------- language-project workflow */
/**
* Queue a translation job for one language project.
*/
function queueTranslation(projectId, languageTag, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/translation/' + encodeURIComponent(languageTag) + '/queue',
{ method: 'POST', signal: signal }
);
}
/**
* Queue an audio generation job for one language project.
* Requires the language's translation to be complete first.
*/
function queueAudio(projectId, languageTag, voiceConfig, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/audio/' + encodeURIComponent(languageTag) + '/queue',
{ method: 'POST', body: { voice_config: voiceConfig || {} }, signal: signal }
);
}
/**
* Read format metadata (EPUB/PDF) for one language project.
*/
function getFormatMetadata(projectId, languageTag, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/format/' + encodeURIComponent(languageTag),
{ signal: signal }
);
}
/**
* Save format metadata (cover, ISBN, blurb, search terms, etc.) for one
* language project.
*/
function setFormatMetadata(projectId, languageTag, metadata, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/format/' + encodeURIComponent(languageTag),
{ method: 'POST', body: metadata, signal: signal }
);
}
/**
* Return utterances for one chapter of one language, for speaker-alignment
* review.
*/
function getUtteranceReview(projectId, languageTag, chapterId, signal) {
return request(
'/api/workspace/' +
encodeURIComponent(projectId) +
'/review/' +
encodeURIComponent(languageTag) +
'/chapters/' +
encodeURIComponent(chapterId),
{ signal: signal }
);
}
/**
* Read front-matter items for one language project.
*/
function getFrontMatter(projectId, languageTag, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/front-matter/' + encodeURIComponent(languageTag),
{ signal: signal }
);
}
/**
* Save front-matter items for one language project.
*/
function saveFrontMatter(projectId, languageTag, items, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/front-matter/' + encodeURIComponent(languageTag),
{ method: 'POST', body: { language_tag: languageTag, items: items }, signal: signal }
);
}
/**
* Return chapter metadata for one language project.
*/
function getChapters(projectId, languageTag, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/chapters/' + encodeURIComponent(languageTag),
{ signal: signal }
);
}
/**
* Return full paragraph content for one chapter (front matter editing).
*/
function getChapterContent(projectId, languageTag, chapterId, signal) {
return request(
'/api/workspace/' + encodeURIComponent(projectId) + '/chapter-content/' +
encodeURIComponent(languageTag) + '/' + encodeURIComponent(chapterId),
{ signal: signal }
);
}
/* --------------------------------------------------------- DOM utilities */
function appendChild(node, child) {
if (child === null || child === undefined || child === false) {
return;
}
if (Array.isArray(child)) {
child.forEach(function (item) {
appendChild(node, item);
});
return;
}
if (typeof child === 'string' || typeof child === 'number') {
node.appendChild(document.createTextNode(String(child)));
return;
}
if (child && typeof child === 'object' && child.nodeType) {
node.appendChild(child);
}
}
/**
* Build one element.
*
* `class` and `text` are applied as properties; `dataset` and `on` take
* objects; a `true` value sets a bare attribute; anything else becomes a
* string attribute. Text always goes through `textContent`.
*/
function el(tag, props, children) {
var node = document.createElement(tag);
if (props) {
Object.keys(props).forEach(function (key) {
var value = props[key];
if (value === null || value === undefined || value === false) {
return;
}
if (key === 'class') {
node.className = value;
} else if (key === 'text') {
node.textContent = String(value);
} else if (key === 'dataset') {
Object.keys(value).forEach(function (dataKey) {
node.dataset[dataKey] = String(value[dataKey]);
});
} else if (key === 'on') {
Object.keys(value).forEach(function (type) {
node.addEventListener(type, value[type]);
});
} else if (value === true) {
node.setAttribute(key, '');
} else {
node.setAttribute(key, String(value));
}
});
}
appendChild(node, children);
return node;
}
function clearNode(node) {
if (!node) {
return;
}
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
function setText(node, value) {
if (!node) {
return;
}
node.textContent = value === null || value === undefined ? '' : String(value);
}
function query(selector, root) {
return (root || document).querySelector(selector);
}
function queryAll(selector, root) {
return Array.prototype.slice.call((root || document).querySelectorAll(selector));
}
function show(node, visible) {
if (!node) {
return;
}
if (visible) {
node.removeAttribute('hidden');
} else {
node.setAttribute('hidden', '');
}
}
function setBusy(node, busy) {
if (!node) {
return;
}
if (busy) {
node.setAttribute('aria-busy', 'true');
} else {
node.removeAttribute('aria-busy');
}
}
function disable(node, disabled) {
if (!node) {
return;
}
node.disabled = Boolean(disabled);
}
/* ------------------------------------------------------------ formatting */
var dateTimeFormat = null;
function formatDateTime(value) {
if (!value) {
return '—';
}
var parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return '—';
}
if (!dateTimeFormat) {
try {
dateTimeFormat = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
});
} catch (error) {
dateTimeFormat = null;
}
}
if (!dateTimeFormat) {
return parsed.toISOString();
}
return dateTimeFormat.format(parsed);
}
var LABELS = {
platform_admin: 'Platform administrator',
organization_admin: 'Organization administrator',
member: 'Member',
active: 'Active',
suspended: 'Suspended',
deactivated: 'Deactivated',
archived: 'Archived',
local_password: 'Local password',
oidc: 'OIDC',
temporary_password: 'Temporary password',
success: 'Success',
failure: 'Failure',
denied: 'Denied',
revoked: 'Revoked',
expired: 'Expired'
};
function label(value) {
if (value === null || value === undefined || value === '') {
return '—';
}
var text = String(value);
if (LABELS[text]) {
return LABELS[text];
}
return text.replace(/[_.]+/g, ' ').replace(/^./, function (first) {
return first.toUpperCase();
});
}
function shortId(value) {
if (!value) {
return '—';
}
return String(value);
}
function initials(displayName, username) {
var source = (displayName || username || '').trim();
if (!source) {
return '?';
}
var pieces = source.split(/\s+/).filter(function (piece) {
return piece.length > 0;
});
if (pieces.length === 0) {
return '?';
}
if (pieces.length === 1) {
return pieces[0].slice(0, 2).toUpperCase();
}
return (pieces[0].charAt(0) + pieces[pieces.length - 1].charAt(0)).toUpperCase();
}
var PASSWORD_RULE_TEXT = {
not_empty: 'must not be empty',
min_length: 'must be at least ' + PASSWORD_MIN_LENGTH + ' characters',
max_length: 'is too long',
no_surrounding_whitespace: 'must not begin or end with whitespace',
must_not_contain_username: 'must not contain your username',
must_differ_from_current: 'must differ from your current password',
unmet: 'does not meet the required policy'
};
/**
* Render a password-policy failure as rule names only. The candidate value is
* never involved, and the server never sends it.
*/
function describeRequirements(error) {
if (!error || !error.requirements || error.requirements.length === 0) {
return describeError(error);
}
return error.requirements
.map(function (rule) {
return PASSWORD_RULE_TEXT[rule] || label(rule);
})
.join('; ');
}
/* ----------------------------------------------------------------- toast */
var toastTimer = null;
function showToast(message, tone) {
var toast = document.getElementById('toast');
if (!toast) {
return;
}
clearNode(toast);
toast.appendChild(document.createTextNode(String(message)));
toast.classList.add('toast--visible');
if (tone) {
toast.classList.add('toast--' + tone);
}
if (toastTimer) {
global.clearTimeout(toastTimer);
}
toastTimer = global.setTimeout(function () {
toast.classList.remove('toast--visible');
clearNode(toast);
}, 5000);
}
/* ----------------------------------------------------------------- modal */
var FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusableWithin(root) {
return queryAll(FOCUSABLE, root).filter(function (node) {
if (node.hidden || node.closest('[hidden]')) {
return false;
}
return node.getClientRects().length > 0;
});
}
/**
* Wrap a `[hidden]` backdrop element. The dialog traps Tab, closes on Escape
* and on a backdrop click, and returns focus to whatever opened it.
*/
function createModal(backdrop, options) {
var settings = options || {};
var dialog = backdrop ? backdrop.querySelector('[role="dialog"]') : null;
var lastFocused = null;
var open = false;
function onKeydown(event) {
if (!open) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key !== 'Tab') {
return;
}
var focusable = focusableWithin(dialog || backdrop);
if (focusable.length === 0) {
event.preventDefault();
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
var active = document.activeElement;
if (event.shiftKey) {
if (active === first || !focusable.indexOf(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last) {
event.preventDefault();
first.focus();
}
}
function onBackdropClick(event) {
if (event.target === backdrop) {
close();
}
}
function openModal() {
if (!backdrop || open) {
return;
}
lastFocused = document.activeElement;
backdrop.removeAttribute('hidden');
document.documentElement.classList.add('modal-open');
open = true;
var target = settings.initialFocus ? query(settings.initialFocus, dialog || backdrop) : null;
var focusable = focusableWithin(dialog || backdrop);
if (!target && focusable.length > 0) {
target = focusable[0];
}
if (target && typeof target.focus === 'function') {
target.focus();
}
}
function close() {
if (!backdrop || !open) {
return;
}
open = false;
backdrop.setAttribute('hidden', '');
document.documentElement.classList.remove('modal-open');
if (typeof settings.onClose === 'function') {
settings.onClose();
}
if (lastFocused && typeof lastFocused.focus === 'function' && document.contains(lastFocused)) {
lastFocused.focus();
}
lastFocused = null;
}
if (backdrop) {
backdrop.addEventListener('keydown', onKeydown);
backdrop.addEventListener('mousedown', onBackdropClick);
}
return {
backdrop: backdrop,
dialog: dialog,
isOpen: function () {
return open;
},
open: openModal,
close: close
};
}
/* ---------------------------------------------------------------- export */
global.UmbraApi = {
ApiError: ApiError,
PASSWORD_MIN_LENGTH: PASSWORD_MIN_LENGTH,
health: health,
login: login,
verifyEmail: verifyEmail,
passwordResetAvailable: passwordResetAvailable,
resetPassword: resetPassword,
changeOwnPassword: changeOwnPassword,
requestPasswordReset: requestPasswordReset,
resendVerification: resendVerification,
changeFirstPassword: changeFirstPassword,
me: me,
logout: logout,
logoutEverywhere: logoutEverywhere,
getSummary: getSummary,
listAccounts: listAccounts,
createAccount: createAccount,
setAccountStatus: setAccountStatus,
revokeAccountSessions: revokeAccountSessions,
listOrganizations: listOrganizations,
createOrganization: createOrganization,
updateOrganization: updateOrganization,
setOrganizationStatus: setOrganizationStatus,
listAudit: listAudit,
workspace: workspace,
listProjects: listProjects,
createProject: createProject,
setProjectStatus: setProjectStatus,
projectStatus: projectStatus,
projectDetail: projectDetail,
assignToProject: assignToProject,
unassignFromProject: unassignFromProject,
queueTranslation: queueTranslation,
queueAudio: queueAudio,
getFormatMetadata: getFormatMetadata,
setFormatMetadata: setFormatMetadata,
getUtteranceReview: getUtteranceReview,
getFrontMatter: getFrontMatter,
saveFrontMatter: saveFrontMatter,
getChapters: getChapters,
getChapterContent: getChapterContent,
request: request,
el: el,
clear: clearNode,
setText: setText,
query: query,
ensureCsrfToken: ensureCsrfToken,
queryAll: queryAll,
show: show,
setBusy: setBusy,
disable: disable,
formatDateTime: formatDateTime,
label: label,
shortId: shortId,
initials: initials,
describeError: describeError,
describeRequirements: describeRequirements,
showToast: showToast,
createModal: createModal,
readCookie: readCookie,
setSessionLostHandler: function (handler) {
sessionLostHandler = handler;
},
setPasswordChangeHandler: function (handler) {
passwordChangeHandler = handler;
},
forgetLocalState: function () {
fallbackCsrfToken = '';
}
};
})(window);