- Option fields show the server defaults that Generate sends: the
controller bumps an options revision on open and on a model change,
and the panel re-renders only then (I1).
- A tag conflict shows the current set at once ("Saved now: ...") and
puts it ahead of the draft in the manual field, so the next Save
sends what is shown (I2).
- The opening sentence is the concept's on-screen copy; "Remove
anything you do not want to add." leads the chips only while chips
exist; the comparison's opened mapping is gone (I3).
- ApiClient gains an additive keepalive option; the capture release
and the Save PUT use it, and pagehide closes the dialog (I4).
- Closing during a save keeps the capture until the PUT settles, then
releases it and still reports the outcome (toast, page refresh) (I5).
- A refresh asked during a page load is pending and runs once it
settles; re-reads use pages up to 200 files; a failed re-read
toasts; Load more waits for a refresh (I6, M2, M3).
- The selection bar drops checked files a refreshed result no longer
lists (I7).
- D116 records Retry; after a failed Generate it takes Generate's own
path, and after a changed storage, file or expired capture it
observes again keeping the draft (I9, M4, M5).
- A save made from Tags marks a filtered Images visit stale (M6).
- images.mjs drops under 800 lines: the tagging wiring moves to
images-tagging.mjs and the favorites star to images-favorites.mjs
(unchanged), and reset() reuses dropSelection() (M1).
- Restore the activity-scan.mjs coverage floors; drop the duplicate
paired captures test (I8, M9).
133 lines
6.2 KiB
JavaScript
133 lines
6.2 KiB
JavaScript
/**
|
|
* Small JSON API client for the admin and gallery pages.
|
|
*
|
|
* Every method resolves with the parsed body of a successful response and
|
|
* throws ApiError(status, message) for a non-2xx status, a `success: false`
|
|
* body or a body that is not JSON, so callers have one failure path.
|
|
*
|
|
* latestOnly() guards list requests against out-of-order responses: each new
|
|
* call aborts the previous in-flight request and a superseded call resolves
|
|
* to STALE instead of its (outdated) result.
|
|
*
|
|
* Loadable in the browser (window.ApiClient) and in Node for tests.
|
|
*/
|
|
(function (root) {
|
|
"use strict";
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, message, code = null, recovery = null, current = null) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = typeof code === "string" && /^[a-z0-9-]{1,64}$/.test(code) ? code : null;
|
|
this.recovery = null;
|
|
if (recovery && typeof recovery === "object" && !Array.isArray(recovery)) {
|
|
const keys = Object.keys(recovery);
|
|
if (keys.length === 1 && keys[0] === "action" &&
|
|
["refresh-history", "reload-outputs", "retry-read"].includes(recovery.action)) {
|
|
this.recovery = { action: recovery.action };
|
|
} else if (keys.length === 2 && keys.includes("action") && keys.includes("limit") &&
|
|
recovery.action === "smaller-limit" && Number.isInteger(recovery.limit) &&
|
|
recovery.limit >= 1 && recovery.limit <= 100) {
|
|
this.recovery = { action: "smaller-limit", limit: recovery.limit };
|
|
}
|
|
}
|
|
// A tag-conflict answer carries the image's current tag set; only a
|
|
// well-formed one is kept, copied, so the dialog can show it.
|
|
this.current = null;
|
|
if (current && Array.isArray(current.tags) && current.tags.length <= 100 &&
|
|
current.tags.every(tag => typeof tag === "string") &&
|
|
Number.isSafeInteger(current.tag_revision) && current.tag_revision >= 0) {
|
|
this.current = { tags: current.tags.slice(), tag_revision: current.tag_revision };
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Result of a latestOnly() call that was superseded by a newer one. */
|
|
const STALE = Symbol("stale response");
|
|
|
|
/** Keep Prompt Manager requests under the prefix that serves its page. */
|
|
function resolveUrl(url, pathname = root.location && root.location.pathname) {
|
|
if (typeof url !== "string" || !url.startsWith("/prompt_manager/")) return url;
|
|
const match = (pathname || "").match(/^(.*)\/prompt_manager(?:\/|$)/);
|
|
return match && match[1] ? match[1] + url : url;
|
|
}
|
|
|
|
/** Like fetch(), but keep Prompt Manager URLs under the page's proxy mount. */
|
|
function fetchUrl(url, init) {
|
|
return globalThis.fetch(resolveUrl(url), init);
|
|
}
|
|
|
|
/**
|
|
* @param {typeof fetch} [fetchImpl] - defaults to the global fetch (bound to globalThis)
|
|
*/
|
|
function createApiClient(fetchImpl) {
|
|
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
|
|
|
|
async function request(method, url, { body, signal, cache, keepalive } = {}) {
|
|
const init = { method, headers: {} };
|
|
if (cache !== undefined) init.cache = cache;
|
|
// keepalive lets a write or a release finish while its page unloads (pagehide).
|
|
if (keepalive) init.keepalive = true;
|
|
if (body !== undefined) {
|
|
init.headers["Content-Type"] = "application/json";
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
if (signal) init.signal = signal;
|
|
|
|
const response = await doFetch(resolveUrl(url), init);
|
|
let data = null;
|
|
let parsed = true;
|
|
try {
|
|
data = await response.json();
|
|
} catch (_) {
|
|
parsed = false;
|
|
}
|
|
|
|
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery, data);
|
|
}
|
|
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
|
|
if (data && typeof data === "object" && data.success === false) {
|
|
throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery, data);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
return {
|
|
// cache is a fetch RequestCache mode such as "no-store"; omitted keeps the default.
|
|
get: (url, { signal, cache } = {}) => request("GET", url, { signal, cache }),
|
|
post: (url, body, { signal, keepalive } = {}) => request("POST", url, { body, signal, keepalive }),
|
|
put: (url, body, { signal, keepalive } = {}) => request("PUT", url, { body, signal, keepalive }),
|
|
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
|
|
// A DELETE body is optional; the favorites store names its file in one.
|
|
del: (url, { signal, body, keepalive } = {}) => request("DELETE", url, { body, signal, keepalive }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @returns {(run: (signal: AbortSignal) => Promise<*>) => Promise<*>} wrapper that
|
|
* aborts the previous call's signal and resolves superseded calls to STALE
|
|
*/
|
|
function latestOnly() {
|
|
let current = null;
|
|
return async function (run) {
|
|
if (current) current.abort();
|
|
const controller = new AbortController();
|
|
current = controller;
|
|
try {
|
|
const result = await run(controller.signal);
|
|
return controller === current ? result : STALE;
|
|
} catch (error) {
|
|
if (controller !== current) return STALE;
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
const api = { createApiClient, latestOnly, resolveUrl, fetchUrl, ApiError, STALE };
|
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
else root.ApiClient = api;
|
|
})(typeof window !== "undefined" ? window : globalThis);
|