BUGFIX: stresstest u select-vals

This commit is contained in:
Simon Martens
2026-01-23 16:18:05 +01:00
parent d8ed1aebe6
commit 0bd614712f
22 changed files with 1141 additions and 306 deletions

View File

@@ -1,4 +1,4 @@
{
"debug": true,
"debug": false,
"allow_test_login": true
}

View File

@@ -402,13 +402,11 @@ var USER_ROLES = []string{
}
var AGENT_RELATIONS = []string{
"Schöpfer",
"Autor:in",
"Herausgeber:in",
"Verlag",
"Druck",
"Vertrieb",
"Stecher:in",
"Zeichner:in",
"Komponist:in",
"Künstler:in",

View File

@@ -471,13 +471,11 @@
"system": false,
"type": "select",
"values": [
"Schöpfer",
"Autor:in",
"Herausgeber:in",
"Verlag",
"Druck",
"Vertrieb",
"Stecher:in",
"Zeichner:in",
"Komponist:in",
"Künstler:in",
@@ -615,13 +613,11 @@
"system": false,
"type": "select",
"values": [
"Schöpfer",
"Autor:in",
"Herausgeber:in",
"Verlag",
"Druck",
"Vertrieb",
"Stecher:in",
"Zeichner:in",
"Komponist:in",
"Künstler:in",

View File

@@ -60,7 +60,7 @@ const (
<p>Die Mannigfaltigkeit des periodischen Taschenbuches zeigt so viele Facetten, da&szlig; es schwer h&auml;lt, eine best&auml;ndige Gattung auszumachen. Beschreiben lassen sich wiederkehrende Einzelmerkmale, die in unterschiedlicher H&auml;ufung, kaum aber in ihrer Gesamtheit beim jeweiligen Exemplar vorkommen. Unsicher ist schon die Verwendung der Ausdr&uuml;cke ALMANACH und TASCHENBUCH oder auch KALENDER; sie &uuml;berschneiden sich gro&szlig;enteils, ohne sich ganz zu decken. Es k&ouml;nnte sich empfehlen, im Taschenbuch den Oberbegriff zu sehen, wenn nicht heutzutage unter dem Taschenbuch eine ganz andere Produktform des Buches bezeichnet w&auml;re.</p>
<p>Das Wort ALMANACH (arabischen Ursprungs) ist eine Bezeichnung f&uuml;r Kalender, und mit dem Kalender hat das hier dargestellte Taschenbuch die angelegte J&auml;hrlichkeit gemein, auch wenn so manche Erscheinung &uuml;ber den ersten Jahrgang nicht hinauskommt. Oftmals, aber keineswegs immer und immer seltener werdend, ist ein Kalender dem Textteil vorgebunden. Regional erhobene Kalender-Stempelsteuern konnten hier prohibitiv wirken. Einige besonders erfolgreiche Almanache erfuhren noch Jahre nach dem Erstdruck Folgeauflagen, in denen dann der &uuml;berfl&uuml;ssig gewordene Kalender, nicht jedoch die urspr&uuml;ngliche Jahresdatierung, entfallen konnte. &ndash; Seiner Entwicklungsgeschichte nach ist das Taschenbuch durchaus vom Kalender herzuleiten, aber es emanzipiert sich gleichsam von diesem und l&auml;&szlig;t seine Herkunft vergessen. Was bleibt ist die Taschenhandlichkeit des Formates: Sedez oder Duodez, seltener Octav (aber auch hierzu in der Sp&auml;tzeit die seltene Ausnahme des Gro&szlig;octav). Und es scheint, da&szlig; die Almanache, Kalendern gleich, meist keinen Ruheplatz in den B&uuml;cherschr&auml;nken gefunden haben, sondern zur Hand genommen und vernutzt wurden; die bis heute erhalten gebliebenen Exemplare sind nicht selten ramponiert, zum Schaden f&uuml;r den zierlich gestalteten Einband.</p>
<p>Welche Art von Texten f&uuml;llte die Almanache und Taschenb&uuml;cher? Anfangs war es Lyrik, sehr bald aber, als die Mode grassierte: quodlibet, alles was beliebt; unterhalten sollte es, in Spa&szlig; oder Ernst. Nur selten mischt Belehrendes sich ein, im Unterschied zum gr&ouml;&szlig;er formatierten aber sehr viel schmaleren Land- oder Volkskalender. Sieht man in das Register der vorz&uuml;glichen <em>Geschichte der deutschen Taschenb&uuml;cher und Almanache aus der klassisch-romantischen Zeit</em> von LANCKORONSKA und R&Uuml;MANN, so findet man schon in den Titeln die Hinweise auf jede nur denkbare Art von Adressaten und zugeh&ouml;rigen Inhalten: Wanderer, Reiter, Bienenfreunde, K&uuml;nstler, Scheidek&uuml;nstler und Apotheker, Liebende, Tollh&auml;usler, Ketzer, &Auml;rzte und Nicht&auml;rzte, Charadenfreunde, Kaufleute, Lottospieler u.v.a.m.. Vor allem aber wird die Weiblichkeit angesprochen, seien es Frauenzimmer oder Damen, Dienstm&auml;dchen, das Sch&ouml;ne Geschlecht, Kammerjungfern, Grabennymphen, Edle Weiber und M&auml;dchen. Selbst wenn es der Titel nicht verr&auml;t, ist &ouml;fter an die Leserin gedacht als an den Herrn, sie hatte wohl mehr gesellige Mu&szlig;e, und sie war der gemeinte Empf&auml;nger des h&uuml;bschen kleinen Geschenks. Denn zum Schenken war er bestimmt und dazu f&uuml;gte sich der Erscheinungstermin zur Michaelismesse, rechtzeitig zu Weihnachten und Neujahr.</p>
<p>Schwerpunkt der bibliographischen Erfassung und inhaltlichen Erschlie&szlig;ung sind zun&auml;chst die literarischen Almanache &ndash; ungeachtet ihres Niveaus. Sie sind Versammlungsort nicht nur der Gro&szlig;en, sondern vorz&uuml;glich derjenigen Dichter und Prosaisten, deren Schriften heute &ndash; zu Recht oder zu Unrecht&ndash; vergessen sind, die aber aus manchen Gr&uuml;nden gelegentlich doch in den Blick des Interesses r&uuml;cken. Das Verzeichnis soll sie, die bislang nur unter Schwierigkeiten aufzufinden waren, zug&auml;nglich machen. Besonders wichtig, weil eine Wahrnehmungsl&uuml;cke f&uuml;llend, erschien uns daneben die Registrierung der Zeichner und Stecher, deren Graphiken wir als Vollbild wiedergeben wollen. Da&szlig; gerade in diesem Bereich die vorliegenden Exemplare oft unvollst&auml;ndig sind, f&uuml;hrt gelegentlich zu Fehlstellen in unserer Darstellung (die aber auf Dauer geschlossen werden); es unterstreicht zugleich die Notwendigkeit des gesetzten Ziels. Indes werden nicht nur die Vorlagen M&auml;ngel aufweisen, auch in der Bearbeitung werden unvermeidbar Fehler entstehen. Wir bitten aufmerksame Benutzer, uns hier&uuml;ber zu informieren und dadurch zur Besserung zu verhelfen.</p>
<p>Schwerpunkt der bibliographischen Erfassung und inhaltlichen Erschlie&szlig;ung sind zun&auml;chst die literarischen Almanache &ndash; ungeachtet ihres Niveaus. Sie sind Versammlungsort nicht nur der Gro&szlig;en, sondern vorz&uuml;glich derjenigen Dichter und Prosaisten, deren Schriften heute &ndash; zu Recht oder zu Unrecht&ndash; vergessen sind, die aber aus manchen Gr&uuml;nden gelegentlich doch in den Blick des Interesses r&uuml;cken. Das Verzeichnis soll sie, die bislang nur unter Schwierigkeiten aufzufinden waren, zug&auml;nglich machen. Besonders wichtig, weil eine Wahrnehmungsl&uuml;cke f&uuml;llend, erschien uns daneben die Registrierung der Zeichner und Kupferstecher, deren Graphiken wir als Vollbild wiedergeben wollen. Da&szlig; gerade in diesem Bereich die vorliegenden Exemplare oft unvollst&auml;ndig sind, f&uuml;hrt gelegentlich zu Fehlstellen in unserer Darstellung (die aber auf Dauer geschlossen werden); es unterstreicht zugleich die Notwendigkeit des gesetzten Ziels. Indes werden nicht nur die Vorlagen M&auml;ngel aufweisen, auch in der Bearbeitung werden unvermeidbar Fehler entstehen. Wir bitten aufmerksame Benutzer, uns hier&uuml;ber zu informieren und dadurch zur Besserung zu verhelfen.</p>
<p>Auf l&auml;ngere Sicht sollen alle periodisch angelegten Almanache und Taschenb&uuml;cher des 18. und 19. Jahrhunderts aufgenommen werden, um das gesamte Spektrum dieser Publikationsart sichtbar zu machen. Im nicht-literarischen Bereich werden wir uns jedoch zumeist beschr&auml;nken auf die bibliographische Registrierung und eine kurze Beschreibung der Einzelb&auml;nde und wir werden hierbei auf die ausf&uuml;hrliche Inhalts&uuml;bersicht verzichten und uns mit der Wiedergabe eines Inhaltsverzeichnisses begn&uuml;gen.</p>
<p>Grunds&auml;tzlich ist Voraussetzung unserer bibliographischen Erfassung die Autopsie des Einzelemplares. Dies sch&uuml;tzt indes nicht immer vor Verwirrung: Variante Doppeldrucke (etwa bei unbezeichnetet Folgeauflagen oder nach Zensureingriffen), fehlende Bl&auml;tter und andere Fehlerquellen sind nicht in jedem Fall wahrnehmbar. Auf alles auff&auml;llig Sonderliche wird anmerkend hingewiesen. Um uns m&ouml;glicher Vollst&auml;ndigkeit anzun&auml;hern, behalten wir uns vor, im Einzelfall auch ohne Autopsie nach bibliographischen Vorgaben aufzunehmen; wir werden dies jedoch immer unter Nennung der Quelle ausdr&uuml;cklich anmerken.</p>
<p>Adrian Braunbehrens</p>`

View File

@@ -41,7 +41,7 @@ func RecordsFromRelationInhalteAkteure(
switch relation.Relation {
case "1":
record.SetType("Schöpfer")
record.SetType("Autor:in")
cat := content.MusenalmType()
ber := agent.Profession()
probt := 0
@@ -100,7 +100,7 @@ func RecordsFromRelationInhalteAkteure(
break
}
record.SetType("Schöpfer")
record.SetType("Autor:in")
case "2":
record.SetType("Autor:in")
case "3":

View File

@@ -14,7 +14,7 @@ TODO:
BUGS:
- Schriftgröße Edit-Screens
- doppelte Einträge Reihen-Liste, Endpoint /reihen (siehe Abendstunden)
- Neuer ort anlegen führ manachmal auf DB-ID
- Neuer Ort anlegen führ manachmal auf DB-ID
Features:
- Extra-DB für FTS5: ist eigentlich nichtTeil der Haupt-DB, sondern nur Suchindex

1
test/clicker/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

51
test/clicker/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Clicker
Parallel random-click runner for http://localhost:8090/ using Playwright.
## Setup
From repo root:
```
cd test/clicker
npm install
npx playwright install chromium
```
## Run
Default: 20 workers, 200 steps, 230s delay, start at `/reihen`, staggered start, random typing enabled (35 chars), random POST submit.
```
cd test/clicker
npm run click
```
Custom:
```
node clicker.mjs --base http://localhost:8090/reihen --workers 20 --steps 300 --minDelay 2000 --maxDelay 30000 --timeout 10000 --startStagger 500 --typeChance 0.25 --enterChance 0.35 --postChance 0.1 --minTypeLen 3 --maxTypeLen 5
```
Notes:
- Filters out logout/delete/login links, external links, mailto/js links.
- Each worker uses a separate browser context.
- HTTP clicker logs error URLs and counts non-2xx responses as errors.
## HTTP clicker
```
cd test/clicker
node http_clicker.mjs --base http://localhost:8090/ --workers 300 --steps 500 --minDelay 50 --maxDelay 500 --timeout 5000 --minConcurrent 2 --maxConcurrent 6 --warmupMs 15000 --warmupConcurrent 1 --fetchImages 1 --imageChance 0.25
```
Flags:
- `--fetchImages 0|1` (default 1): also request `<img src>` URLs discovered in HTML.
- `--imageChance 0..1` (default 0.25): chance a request is an image instead of a page.
- `--maxImagePool` (default 500): max cached image URLs.
- `--fetchAssets 0|1` (default 1): also request `<script src>` and `<link rel="stylesheet">` URLs.
- `--assetChance 0..1` (default 0.2): chance a request is a JS/CSS asset instead of a page.
- `--maxAssetPool` (default 500): max cached asset URLs.
- `--warmupMs` (default 15000): warmup duration before full concurrency.
- `--warmupConcurrent` (default 1): per-worker concurrency during warmup.
- `--progressEvery` (default 0): log per-worker step progress every N steps.

187
test/clicker/clicker.mjs Normal file
View File

@@ -0,0 +1,187 @@
import { chromium } from "playwright";
const args = process.argv.slice(2);
const getArg = (name, fallback) => {
const idx = args.indexOf(`--${name}`);
if (idx === -1 || idx + 1 >= args.length) return fallback;
return args[idx + 1];
};
const baseUrl = getArg("base", "http://localhost:8090/reihen");
const workers = Number.parseInt(getArg("workers", "20"), 10);
const steps = Number.parseInt(getArg("steps", "200"), 10);
const minDelay = Number.parseInt(getArg("minDelay", "2000"), 10);
const maxDelay = Number.parseInt(getArg("maxDelay", "30000"), 10);
const timeout = Number.parseInt(getArg("timeout", "10000"), 10);
const startStagger = Number.parseInt(getArg("startStagger", "500"), 10);
const typeChance = Number.parseFloat(getArg("typeChance", "0.8"));
const enterChance = Number.parseFloat(getArg("enterChance", "0.35"));
const postChance = Number.parseFloat(getArg("postChance", "0.15"));
const minTypeLen = Number.parseInt(getArg("minTypeLen", "3"), 10);
const maxTypeLen = Number.parseInt(getArg("maxTypeLen", "5"), 10);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const randomText = (minLen, maxLen) => {
const chars = "abcdefghijklmnopqrstuvwxyzäöüß ";
const len = rand(minLen, maxLen);
let out = "";
for (let i = 0; i < len; i += 1) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out.trim() || "test";
};
const pickAndClick = async (page) => {
return page.evaluate(() => {
const origin = window.location.origin;
const links = Array.from(document.querySelectorAll("a[href]"));
const candidates = links.filter((a) => {
const raw = a.getAttribute("href") || "";
if (!raw || raw.startsWith("#")) return false;
if (raw.startsWith("javascript:")) return false;
if (raw.startsWith("mailto:")) return false;
if (a.getAttribute("target") === "_blank") return false;
if (a.getAttribute("aria-disabled") === "true") return false;
const url = new URL(raw, window.location.href);
const path = `${url.pathname}${url.search}`.toLowerCase();
if (path.includes("logout") || path.includes("delete") || path.includes("/login")) return false;
if (url.origin !== origin) return false;
return true;
});
if (!candidates.length) return null;
const link = candidates[Math.floor(Math.random() * candidates.length)];
const href = link.href;
const clickerId = `clicker-${Math.random().toString(36).slice(2)}`;
link.setAttribute("data-clicker-id", clickerId);
return { href, clickerId };
});
};
const pickAndFocusInput = async (page) => {
return page.evaluate(() => {
const candidates = Array.from(document.querySelectorAll("input, textarea")).filter((el) => {
if (!(el instanceof HTMLElement)) return false;
if (el.closest("[aria-hidden='true']")) return false;
if (el.closest("[data-htmx-busy='true']")) return false;
if (el.hasAttribute("disabled") || el.hasAttribute("readonly")) return false;
if (el instanceof HTMLInputElement) {
const type = (el.type || "text").toLowerCase();
if (["hidden", "password", "checkbox", "radio", "file", "submit", "button"].includes(type)) {
return false;
}
}
if (el.offsetParent === null) return false;
return true;
});
if (!candidates.length) return null;
const el = candidates[Math.floor(Math.random() * candidates.length)];
el.focus();
return {
tag: el.tagName.toLowerCase(),
id: el.id || "",
name: el.getAttribute("name") || "",
placeholder: el.getAttribute("placeholder") || "",
};
});
};
const submitRandomPostForm = async (page) => {
return page.evaluate(() => {
const forms = Array.from(document.querySelectorAll("form")).filter((form) => {
if (!(form instanceof HTMLFormElement)) return false;
const method = (form.getAttribute("method") || "get").toLowerCase();
if (method !== "post") return false;
const action = (form.getAttribute("action") || "").toLowerCase();
if (action.includes("/login") || action.includes("logout") || action.includes("delete")) return false;
if (form.hasAttribute("disabled")) return false;
if (form.closest("[aria-hidden='true']")) return false;
if (form.closest("[data-htmx-busy='true']")) return false;
return true;
});
if (!forms.length) return null;
const form = forms[Math.floor(Math.random() * forms.length)];
const submit = form.querySelector("button[type='submit'], input[type='submit']");
if (submit instanceof HTMLElement) {
submit.click();
} else {
form.requestSubmit();
}
return form.getAttribute("action") || window.location.href;
});
};
const runWorker = async (id, browser, delayMs) => {
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(timeout);
const label = `worker-${id}`;
try {
if (delayMs > 0) {
await sleep(delayMs);
}
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
for (let i = 0; i < steps; i += 1) {
const clickInfo = await pickAndClick(page);
if (clickInfo) {
const selector = `[data-clicker-id="${clickInfo.clickerId}"]`;
await page.click(selector).catch(() => {});
console.log(`${label}: click ${clickInfo.href}`);
} else {
console.log(`${label}: no candidates`);
}
await page.waitForLoadState("domcontentloaded").catch(() => {});
await page.waitForTimeout(200);
const currentUrl = page.url();
if (currentUrl) {
console.log(`${label}: at ${currentUrl}`);
}
if (Math.random() < typeChance) {
const focused = await pickAndFocusInput(page);
if (focused) {
const text = randomText(minTypeLen, maxTypeLen);
await page.keyboard.type(text, { delay: rand(10, 80) });
console.log(
`${label}: type \"${text}\" into ${focused.tag}${focused.id ? `#${focused.id}` : ""}${focused.name ? `[name=\"${focused.name}\"]` : ""}`,
);
if (focused.tag !== "textarea" && Math.random() < enterChance) {
await page.keyboard.press("Enter");
console.log(`${label}: press Enter`);
}
} else {
console.log(`${label}: no inputs to type`);
}
} else {
console.log(`${label}: skip typing`);
}
if (Math.random() < postChance) {
const action = await submitRandomPostForm(page);
if (action) {
console.log(`${label}: submit POST ${action}`);
} else {
console.log(`${label}: no POST forms`);
}
}
await sleep(rand(minDelay, maxDelay));
}
} catch (error) {
console.error(`${label}: error`, error?.message || error);
} finally {
await context.close();
}
};
const main = async () => {
const browser = await chromium.launch({ headless: true });
try {
const runs = Array.from({ length: workers }, (_, idx) => runWorker(idx + 1, browser, idx * startStagger));
await Promise.all(runs);
} finally {
await browser.close();
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,285 @@
import { setTimeout as sleep } from "node:timers/promises";
import { performance } from "node:perf_hooks";
const args = process.argv.slice(2);
const getArg = (name, fallback) => {
const idx = args.indexOf(`--${name}`);
if (idx === -1 || idx + 1 >= args.length) return fallback;
return args[idx + 1];
};
const baseUrl = getArg("base", "http://localhost:8090/");
const workers = Number.parseInt(getArg("workers", "50"), 10);
const steps = Number.parseInt(getArg("steps", "200"), 10);
const minDelay = Number.parseInt(getArg("minDelay", "200"), 10);
const maxDelay = Number.parseInt(getArg("maxDelay", "2000"), 10);
const timeout = Number.parseInt(getArg("timeout", "8000"), 10);
const reportEvery = Number.parseInt(getArg("reportEvery", "50"), 10);
const progressEvery = Number.parseInt(getArg("progressEvery", "0"), 10);
const maxPool = Number.parseInt(getArg("maxPool", "500"), 10);
const maxTextBytes = Number.parseInt(getArg("maxTextBytes", "1048576"), 10);
const maxConcurrent = Number.parseInt(getArg("maxConcurrent", "6"), 10);
const minConcurrent = Number.parseInt(getArg("minConcurrent", "2"), 10);
const dropChance = Number.parseFloat(getArg("dropChance", "0.05"));
const concurrencyJitterSteps = Number.parseInt(getArg("concurrencyJitterSteps", "25"), 10);
const fetchImages = getArg("fetchImages", "1") !== "0";
const imageChance = Number.parseFloat(getArg("imageChance", "0.25"));
const maxImagePool = Number.parseInt(getArg("maxImagePool", "500"), 10);
const fetchAssets = getArg("fetchAssets", "1") !== "0";
const assetChance = Number.parseFloat(getArg("assetChance", "0.2"));
const maxAssetPool = Number.parseInt(getArg("maxAssetPool", "500"), 10);
const warmupMs = Number.parseInt(getArg("warmupMs", "15000"), 10);
const warmupConcurrent = Number.parseInt(getArg("warmupConcurrent", "1"), 10);
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const sleepRand = () => sleep(rand(minDelay, maxDelay));
const pool = [];
const poolSet = new Set();
const imagePool = [];
const imagePoolSet = new Set();
const assetPool = [];
const assetPoolSet = new Set();
const isSafeHref = (href) => {
if (!href) return false;
const raw = href.trim();
if (!raw || raw.startsWith("#")) return false;
if (raw.startsWith("mailto:")) return false;
if (raw.startsWith("javascript:")) return false;
return true;
};
const isSafePath = (path) => {
const p = path.toLowerCase();
if (p.includes("logout") || p.includes("delete") || p.includes("/login")) return false;
return true;
};
const addToPool = (url) => {
if (poolSet.has(url)) return;
if (pool.length >= maxPool) return;
poolSet.add(url);
pool.push(url);
};
const addToImagePool = (url) => {
if (imagePoolSet.has(url)) return;
if (imagePool.length >= maxImagePool) return;
imagePoolSet.add(url);
imagePool.push(url);
};
const addToAssetPool = (url) => {
if (assetPoolSet.has(url)) return;
if (assetPool.length >= maxAssetPool) return;
assetPoolSet.add(url);
assetPool.push(url);
};
const pickFromPool = () => {
if (!pool.length) return baseUrl;
return pool[rand(0, pool.length - 1)];
};
const pickImage = () => {
if (!imagePool.length) return null;
return imagePool[rand(0, imagePool.length - 1)];
};
const pickAsset = () => {
if (!assetPool.length) return null;
return assetPool[rand(0, assetPool.length - 1)];
};
const extractLinks = (html, origin, base) => {
const links = [];
const images = [];
const assets = [];
const re = /href\s*=\s*["']([^"']+)["']/gi;
let match;
while ((match = re.exec(html)) !== null) {
const href = match[1];
if (!isSafeHref(href)) continue;
try {
const url = new URL(href, base);
if (url.origin !== origin) continue;
const path = `${url.pathname}${url.search}`;
if (!isSafePath(path)) continue;
links.push(url.toString());
} catch {
// ignore bad urls
}
}
const imgRe = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi;
while ((match = imgRe.exec(html)) !== null) {
const src = match[1];
if (!isSafeHref(src)) continue;
try {
const url = new URL(src, base);
if (url.origin !== origin) continue;
images.push(url.toString());
} catch {
// ignore bad urls
}
}
const scriptRe = /<script[^>]+src\s*=\s*["']([^"']+)["']/gi;
while ((match = scriptRe.exec(html)) !== null) {
const src = match[1];
if (!isSafeHref(src)) continue;
try {
const url = new URL(src, base);
if (url.origin !== origin) continue;
assets.push(url.toString());
} catch {
// ignore bad urls
}
}
const cssRe = /<link[^>]+rel\\s*=\\s*["']stylesheet["'][^>]*href\\s*=\\s*["']([^"']+)["']/gi;
while ((match = cssRe.exec(html)) !== null) {
const href = match[1];
if (!isSafeHref(href)) continue;
try {
const url = new URL(href, base);
if (url.origin !== origin) continue;
assets.push(url.toString());
} catch {
// ignore bad urls
}
}
return { links, images, assets };
};
const stats = {
count: 0,
errors: 0,
totalMs: 0,
minMs: Infinity,
maxMs: 0,
samples: [],
sampleMax: 5000,
};
const addSample = (ms) => {
stats.count += 1;
stats.totalMs += ms;
if (ms < stats.minMs) stats.minMs = ms;
if (ms > stats.maxMs) stats.maxMs = ms;
if (stats.samples.length < stats.sampleMax) {
stats.samples.push(ms);
} else {
const idx = rand(0, stats.count - 1);
if (idx < stats.sampleMax) stats.samples[idx] = ms;
}
};
const percentile = (arr, p) => {
if (!arr.length) return null;
const sorted = [...arr].sort((a, b) => a - b);
const idx = Math.floor((sorted.length - 1) * p);
return sorted[idx];
};
const report = () => {
const avg = stats.count ? stats.totalMs / stats.count : 0;
const p50 = percentile(stats.samples, 0.5);
const p95 = percentile(stats.samples, 0.95);
console.log(
`report: count=${stats.count} errors=${stats.errors} avg=${avg.toFixed(1)}ms min=${stats.minMs.toFixed(1)}ms max=${stats.maxMs.toFixed(1)}ms p50=${p50?.toFixed(1)}ms p95=${p95?.toFixed(1)}ms pool=${pool.length}`,
);
};
const fetchWithTimeout = async (url) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const start = performance.now();
try {
const res = await fetch(url, { signal: controller.signal, headers: { "user-agent": "http-clicker" } });
const elapsed = performance.now() - start;
addSample(elapsed);
if (!res.ok) {
stats.errors += 1;
console.log(`error: ${url} status=${res.status}`);
}
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/html")) {
const buf = await res.arrayBuffer();
const sliced = buf.byteLength > maxTextBytes ? buf.slice(0, maxTextBytes) : buf;
const text = new TextDecoder("utf-8", { fatal: false }).decode(sliced);
const { links, images, assets } = extractLinks(text, new URL(baseUrl).origin, res.url);
for (const link of links) addToPool(link);
if (fetchImages) {
for (const img of images) addToImagePool(img);
}
if (fetchAssets) {
for (const asset of assets) addToAssetPool(asset);
}
}
} catch (error) {
stats.errors += 1;
const reason = error?.name || "error";
console.log(`error: ${url} ${reason}`);
} finally {
clearTimeout(id);
}
};
const runWorker = async (id) => {
const label = `worker-${id}`;
let currentMax = warmupConcurrent;
let inWarmup = warmupMs > 0;
const inFlight = new Set();
if (inWarmup) {
setTimeout(() => {
inWarmup = false;
}, warmupMs);
}
for (let i = 0; i < steps; i += 1) {
if (!inWarmup && concurrencyJitterSteps > 0 && i % concurrencyJitterSteps === 0) {
currentMax = rand(minConcurrent, maxConcurrent);
}
if (Math.random() < dropChance) {
await sleepRand();
continue;
}
while (inFlight.size < currentMax) {
let url = pickFromPool();
if (fetchImages && Math.random() < imageChance) {
url = pickImage() || url;
} else if (fetchAssets && Math.random() < assetChance) {
url = pickAsset() || url;
}
const task = fetchWithTimeout(url).finally(() => inFlight.delete(task));
inFlight.add(task);
if (inFlight.size >= currentMax) break;
if (Math.random() < 0.5) break;
}
if (progressEvery > 0 && (i + 1) % progressEvery === 0) {
console.log(`${label}: step ${i + 1}/${steps}`);
}
await sleepRand();
}
await Promise.allSettled(Array.from(inFlight));
};
const main = async () => {
addToPool(baseUrl);
const startedAt = Date.now();
const runs = Array.from({ length: workers }, (_, idx) => runWorker(idx + 1));
const reporter = setInterval(report, Math.max(1000, reportEvery * 50));
try {
await Promise.all(runs);
} finally {
clearInterval(reporter);
const elapsedMs = Date.now() - startedAt;
const elapsedSec = Math.round(elapsedMs / 1000);
console.log(`runtime: ${elapsedSec}s`);
report();
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

57
test/clicker/package-lock.json generated Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "musenalm-clicker",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "musenalm-clicker",
"dependencies": {
"playwright": "^1.50.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

11
test/clicker/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "musenalm-clicker",
"private": true,
"type": "module",
"scripts": {
"click": "node ./clicker.mjs"
},
"dependencies": {
"playwright": "^1.50.0"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@
</head>
<body class="w-full min-h-full" id="body" hx-ext="response-targets" hx-boost="true">
{{ template "_global_notice" . }}
<div class="pb-12">
{{ block "body" . }}
<!-- Default app body... -->

View File

@@ -10,6 +10,7 @@
<body id="body" class="w-full min-h-full" hx-ext="response-targets" hx-boost="true">
<div class="flex flex-col min-h-screen w-full">
{{ template "_global_notice" . }}
<header class="container-normal bg-slate-100 " id="header">
{{ template "_menu" . }}
</header>

View File

@@ -0,0 +1,12 @@
<div
id="global-notice"
class="global-notice hidden"
role="status"
aria-live="polite"
aria-atomic="true"
data-state="">
<div class="global-notice-inner">
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
<span data-role="global-notice-text">Laden&#8230;</span>
</div>
</div>

View File

@@ -11,6 +11,7 @@
<body id="body" class="w-full text-lg" hx-ext="response-targets" hx-boost="true">
<div class="flex flex-col min-h-screen w-full">
{{ template "_global_notice" . }}
<header class="container-normal pb-0" id="header">
{{ block "_menu" . }}
<!-- Default app menu... -->

View File

@@ -495,10 +495,12 @@ type AlmanachResult struct {
</div>
</div>
<div id="series-section" class="rel-section-container">
{{- $hasNonPreferredSeries := false -}}
{{- if $model.result.Series -}}
{{- range $i, $s := $model.result.Series -}}
{{- $rel := index $model.result.EntriesSeries $s.Id -}}
{{- if and $rel (ne $rel.Type "Bevorzugter Reihentitel") -}}
{{- $hasNonPreferredSeries = true -}}
<div data-rel-row class="entries-series-row rel-row">
<div class="rel-grid">
<div data-rel-strike class="relation-strike rel-name-col">
@@ -542,7 +544,8 @@ type AlmanachResult struct {
</div>
{{- end -}}
{{- end -}}
{{- else -}}
{{- end -}}
{{- if not $hasNonPreferredSeries -}}
<div class="rel-empty-text">Keine Reihen verknüpft.</div>
{{- end -}}
</div>
@@ -716,7 +719,7 @@ type AlmanachResult struct {
<label for="entries_agents_new_type" class="sr-only">Beziehung</label>
<select data-role="relation-type-select" name="entries_agents_new_type" id="entries_agents_new_type" autocomplete="off" class="inputselect font-bold w-full">
{{- range $t := $model.agent_relations -}}
<option value="{{- $t -}}">{{- $t -}}</option>
<option value="{{- $t -}}" {{ if eq $t "Herausgeber:in" }}selected{{ end }}>{{- $t -}}</option>
{{- end -}}
</select>
</div>

View File

@@ -260,7 +260,7 @@
<div data-rel-strike class="relation-strike">
<select name="{{ $agentsPrefix }}_new_type" class="inputselect font-bold w-full">
{{- range $t := $agentRelations -}}
<option value="{{- $t -}}" {{ if eq $r.Type $t }}selected{{ end }}>{{- $t -}}</option>
<option value="{{- $t -}}" {{ if or (eq $r.Type $t) (and (eq $r.Type "") (eq $t "Autor:in")) }}selected{{ end }}>{{- $t -}}</option>
{{- end -}}
</select>
</div>
@@ -300,7 +300,7 @@
<label for="{{ $agentsPrefix }}_new_type" class="sr-only">Beziehung</label>
<select data-role="relation-type-select" name="{{ $agentsPrefix }}_new_type" id="{{ $agentsPrefix }}_new_type" autocomplete="off" class="inputselect font-bold w-full">
{{- range $t := $agentRelations -}}
<option value="{{- $t -}}">{{- $t -}}</option>
<option value="{{- $t -}}" {{ if eq $t "Autor:in" }}selected{{ end }}>{{- $t -}}</option>
{{- end -}}
</select>
</div>

View File

@@ -324,6 +324,168 @@ function DisconnectNoEnters(textarea) {
textarea.removeEventListener("keydown", NoEnters);
}
function InitGlobalHtmxNotice() {
if (!window.htmx) {
return;
}
const ensureNotice = () => {
let noticeEl = document.getElementById("global-notice");
if (!noticeEl) {
noticeEl = document.createElement("div");
noticeEl.id = "global-notice";
noticeEl.className = "global-notice hidden";
noticeEl.setAttribute("role", "status");
noticeEl.setAttribute("aria-live", "polite");
noticeEl.setAttribute("aria-atomic", "true");
noticeEl.dataset.state = "";
noticeEl.innerHTML = `
<div class="global-notice-inner">
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
<span data-role="global-notice-text">Laden&#8230;</span>
</div>
`;
document.body?.appendChild(noticeEl);
}
return noticeEl;
};
let notice = ensureNotice();
let textEl = notice ? notice.querySelector("[data-role='global-notice-text']") : null;
let pending = 0;
let errorTimeout = null;
const setNoticeState = (state, message) => {
notice = ensureNotice();
if (notice && !textEl) {
textEl = notice.querySelector("[data-role='global-notice-text']");
}
if (textEl && message) {
textEl.textContent = message;
}
if (notice && state) {
notice.dataset.state = state;
} else if (notice) {
notice.removeAttribute("data-state");
}
};
const showNotice = (state, message) => {
notice = ensureNotice();
if (!notice) {
return;
}
setNoticeState(state, message);
notice.classList.remove("hidden");
};
const hideNotice = () => {
notice = ensureNotice();
if (!notice) {
return;
}
notice.classList.add("hidden");
notice.removeAttribute("data-state");
};
const setBodyBusy = (busy) => {
const root = document.documentElement;
if (busy) {
if (root) {
root.dataset.htmxBusy = "true";
}
if (document.body) {
document.body.dataset.htmxBusy = "true";
}
} else {
if (root) {
delete root.dataset.htmxBusy;
}
if (document.body) {
delete document.body.dataset.htmxBusy;
}
}
};
const markElementBusy = (element, busy) => {
if (!element || !(element instanceof HTMLElement)) {
return;
}
if (busy) {
element.dataset.htmxBusy = "true";
element.setAttribute("aria-busy", "true");
if (element instanceof HTMLButtonElement && !element.disabled) {
element.dataset.htmxDisabled = "true";
element.disabled = true;
}
} else if (element.dataset.htmxBusy === "true") {
delete element.dataset.htmxBusy;
element.removeAttribute("aria-busy");
if (element instanceof HTMLButtonElement && element.dataset.htmxDisabled === "true") {
element.disabled = false;
delete element.dataset.htmxDisabled;
}
}
};
const clearErrorTimeout = () => {
if (errorTimeout) {
clearTimeout(errorTimeout);
errorTimeout = null;
}
};
document.addEventListener("htmx:beforeRequest", (event) => {
pending += 1;
clearErrorTimeout();
setBodyBusy(true);
showNotice("loading", "Laden...");
markElementBusy(event.detail?.elt, true);
});
document.addEventListener("htmx:afterRequest", (event) => {
markElementBusy(event.detail?.elt, false);
pending = Math.max(0, pending - 1);
if (pending === 0) {
setBodyBusy(false);
if (notice.dataset.state !== "error") {
hideNotice();
}
}
});
document.addEventListener("htmx:responseError", () => {
setBodyBusy(false);
showNotice("error", "Laden fehlgeschlagen.");
clearErrorTimeout();
errorTimeout = setTimeout(() => {
if (pending === 0) {
hideNotice();
} else {
showNotice("loading", "Laden...");
}
}, 2000);
});
document.addEventListener("htmx:sendError", () => {
setBodyBusy(false);
showNotice("error", "Verbindung fehlgeschlagen.");
clearErrorTimeout();
errorTimeout = setTimeout(() => {
if (pending === 0) {
hideNotice();
} else {
showNotice("loading", "Laden...");
}
}, 2000);
});
document.addEventListener("htmx:afterSwap", () => {
notice = ensureNotice();
if (notice && !textEl) {
textEl = notice.querySelector("[data-role='global-notice-text']");
}
});
}
function MutateObserve(mutations, observer) {
const needsJSResize = !supportsFieldSizing();
@@ -476,5 +638,6 @@ window.PathPlusQuery = PathPlusQuery;
window.HookupRBChange = HookupRBChange;
window.FormLoad = FormLoad;
window.TextareaAutoResize = TextareaAutoResize;
InitGlobalHtmxNotice();
export { FilterList, ScrollButton, AbbreviationTooltips, MultiSelectSimple, MultiSelectRole, ToolTip, PopupImage, TabList, FilterPill, ImageReel, IntLink, ItemsEditor, SingleSelectRemote, AlmanachEditPage, RelationsEditor, EditPage, FabMenu, LookupField };

View File

@@ -624,6 +624,30 @@
@apply !inline-block;
}
.global-notice {
@apply fixed right-6 bottom-6 z-50 hidden;
}
.global-notice-inner {
@apply flex items-center gap-2 rounded-md border border-slate-200 bg-white/95 px-3 py-2 text-sm font-semibold text-gray-700 shadow-lg backdrop-blur;
}
.global-notice[data-state="error"] .global-notice-inner {
@apply border-red-200 bg-red-50 text-red-800;
}
html[data-htmx-busy="true"] .global-notice,
body[data-htmx-busy="true"] .global-notice {
@apply block;
}
html[data-htmx-busy="true"],
body[data-htmx-busy="true"],
[data-htmx-busy="true"] {
pointer-events: none;
opacity: 0.6;
}
.tab-list-head[aria-pressed="true"] {
@apply !text-slate-900 bg-stone-50;
}