Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

272 lines
9.9 KiB
JavaScript

import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
/**
* Shared utilities for GeoGebra adapters.
*
* GeoGebra Geometry exposes a `ggbApplet` JavaScript API on the page after
* the GWT-compiled app initializes. All adapters share the same pattern:
* navigate → wait for applet → call API via page.evaluate().
*/
const GEOGEBRA_URL = 'https://www.geogebra.org/geometry';
const APPLET_WAIT_MS = 15_000;
export function unwrapBridgeEnvelope(value) {
if (value && typeof value === 'object' && 'data' in value && 'session' in value) {
return value.data;
}
return value;
}
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
export function normalizeLabel(value, label = 'label') {
const normalized = String(value ?? '').trim();
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(normalized)) {
throw new ArgumentError(`${label} must be an ASCII GeoGebra label like A, B1, or poly_1`);
}
return normalized;
}
export function normalizeLabelList(value, label, min, max = Infinity) {
const parts = String(value ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (parts.length < min || parts.length > max) {
throw new ArgumentError(`${label} must contain ${min === max ? min : `${min}-${max}`} comma-separated labels`);
}
return parts.map((part, idx) => normalizeLabel(part, `${label}[${idx + 1}]`));
}
export function normalizeNumber(value, label, { defaultValue, positive = false } = {}) {
const raw = value == null || value === '' ? defaultValue : value;
const number = Number(raw);
if (!Number.isFinite(number) || (positive && number <= 0)) {
throw new ArgumentError(`${label} must be a ${positive ? 'positive ' : ''}finite number`);
}
return number;
}
export function normalizeCoords(value) {
const parts = String(value ?? '').split(',').map(s => s.trim());
if (parts.length !== 2) {
throw new ArgumentError('coords must be in "x,y" format (e.g. "1,2")');
}
return parts.map((part, idx) => normalizeNumber(part, idx === 0 ? 'x' : 'y'));
}
export function requireGgbSuccess(result, message) {
if (!isPlainObject(result)) {
throw new CommandExecutionError(`${message}: malformed GeoGebra result`);
}
if (!result.ok) {
throw new CommandExecutionError(result.error || message);
}
return result;
}
/**
* Navigate to GeoGebra Geometry (if not already there) and wait for
* the ggbApplet API to become available.
*/
export async function ensureApplet(page) {
let currentUrl = '';
try {
currentUrl = await page.getCurrentUrl();
} catch {
currentUrl = '';
}
// If already on the geometry page, check if applet is ready without re-navigating
if (currentUrl?.includes('geogebra.org/geometry')) {
try {
const ready = unwrapBridgeEnvelope(await page.evaluate(`typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function'`));
if (ready) return;
} catch (err) {
throw new CommandExecutionError(`Failed to detect GeoGebra applet: ${err?.message || err}`);
}
}
// Navigate to GeoGebra Geometry
try {
await page.goto(GEOGEBRA_URL);
} catch (err) {
throw new CommandExecutionError(`Failed to load GeoGebra Geometry: ${err?.message || err}`);
}
let ready;
try {
ready = unwrapBridgeEnvelope(await page.evaluate(`
(async () => {
const deadline = Date.now() + ${APPLET_WAIT_MS};
while (Date.now() < deadline) {
if (typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function') {
return true;
}
await new Promise(r => setTimeout(r, 500));
}
return false;
})()
`));
} catch (err) {
throw new CommandExecutionError(`Failed to detect GeoGebra applet: ${err?.message || err}`);
}
if (ready !== true) {
throw new CommandExecutionError('ggbApplet not available after waiting. Make sure the GeoGebra Geometry page is fully loaded.');
}
}
/**
* Execute a GeoGebra command string via ggbApplet.evalCommandGetLabels.
* evalCommandGetLabels both executes the command and returns the created
* object label(s). We use it instead of evalCommand to avoid double-execution.
* Returns { ok, label } where label is the resulting object label(s).
*/
export async function ggbEval(page, cmd) {
let result;
try {
result = unwrapBridgeEnvelope(await page.evaluate(`
(cmd => {
if (typeof ggbApplet === 'undefined' || typeof ggbApplet.evalCommandGetLabels !== 'function') {
return { ok: false, label: '', beforeCount: 0, afterCount: 0, error: 'ggbApplet is not ready' };
}
const collectNames = () => {
let names = ggbApplet.getAllObjectNames();
if (typeof names === 'string') {
names = names.split(',').map(s => s.trim()).filter(Boolean);
}
return Array.isArray(names) ? names : [];
};
const beforeCount = collectNames().length;
const label = ggbApplet.evalCommandGetLabels(cmd);
const afterCount = collectNames().length;
const dialogText = [...document.querySelectorAll('[role="dialog"], .gwt-DialogBox')]
.map(node => node.textContent?.trim() || '')
.find(text => /error|unknown command|错误|未知的指令/i.test(text)) || '';
return {
ok: label !== '' || afterCount > beforeCount,
label,
beforeCount,
afterCount,
error: dialogText || null,
};
})(${JSON.stringify(cmd)})
`));
} catch (err) {
throw new CommandExecutionError(`Failed to execute GeoGebra command: ${err?.message || err}`);
}
if (!isPlainObject(result) || typeof result.ok !== 'boolean') {
throw new CommandExecutionError('GeoGebra command returned malformed result');
}
return result;
}
/**
* List all currently known GeoGebra objects, optionally filtered by type.
*/
export async function ggbListObjects(page, filterType) {
const normalizedFilter = filterType ? String(filterType).toLowerCase() : '';
let objects;
try {
objects = unwrapBridgeEnvelope(await page.evaluate(`
(filterType => {
const api = ggbApplet;
let names = api.getAllObjectNames();
if (typeof names === 'string') {
names = names.split(',').map(s => s.trim()).filter(Boolean);
}
if (!Array.isArray(names)) return { error: 'Object names are not an array' };
const result = [];
for (const name of names) {
try {
const type = api.getObjectType(name);
if (!type) return { error: 'Object has no type', name };
if (filterType && type.toLowerCase() !== filterType) continue;
result.push({
name,
type,
value: api.getValueString(name) || '',
visible: api.getVisible(name),
});
} catch (err) {
return { error: err?.message || String(err), name };
}
}
return result;
})(${JSON.stringify(normalizedFilter)})
`));
} catch (err) {
throw new CommandExecutionError(`Failed to list GeoGebra objects: ${err?.message || err}`);
}
if (objects && typeof objects === 'object' && !Array.isArray(objects) && objects.error) {
const nameSuffix = objects.name ? ` for ${objects.name}` : '';
throw new CommandExecutionError(`Failed to list GeoGebra objects${nameSuffix}: ${objects.error}`);
}
if (!Array.isArray(objects)) {
throw new CommandExecutionError('GeoGebra object list returned malformed result');
}
return objects;
}
/**
* Poll until the object count reaches the requested minimum.
*/
export async function ggbWaitForObjectCount(page, minCount, timeoutMs = 4_000) {
const normalizedMinCount = normalizeNumber(minCount, 'minCount', { positive: true });
const normalizedTimeoutMs = normalizeNumber(timeoutMs, 'timeoutMs', { positive: true });
let count;
try {
count = unwrapBridgeEnvelope(await page.evaluate(`
(async () => {
const deadline = Date.now() + ${normalizedTimeoutMs};
while (Date.now() < deadline) {
let names = ggbApplet.getAllObjectNames();
if (typeof names === 'string') {
names = names.split(',').map(s => s.trim()).filter(Boolean);
}
if (Array.isArray(names) && names.length >= ${normalizedMinCount}) {
return names.length;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
let names = ggbApplet.getAllObjectNames();
if (typeof names === 'string') {
names = names.split(',').map(s => s.trim()).filter(Boolean);
}
return Array.isArray(names) ? names.length : 0;
})()
`));
} catch (err) {
throw new CommandExecutionError(`Failed waiting for GeoGebra object count: ${err?.message || err}`);
}
if (!Number.isFinite(Number(count))) {
throw new CommandExecutionError('GeoGebra object count returned malformed result');
}
return Number(count);
}
/**
* Read a property from a GeoGebra object.
*/
export async function ggbGetProperty(page, objName, property) {
try {
return unwrapBridgeEnvelope(await page.evaluate(`
(objName, property) => {
const api = ggbApplet;
switch (property) {
case 'type': return api.getObjectType(objName);
case 'value': return api.getValueString(objName);
case 'color': return api.getColor(objName);
case 'visible': return api.getVisible(objName);
case 'caption': return api.getCaption(objName) || '';
case 'xcoord': return api.getXcoord(objName);
case 'ycoord': return api.getYcoord(objName);
case 'definition': return api.getDefinitionString(objName);
case 'command': return api.getCommandString(objName);
default: return null;
}
}
`, objName, property));
} catch (err) {
throw new CommandExecutionError(`Failed to read GeoGebra object property: ${err?.message || err}`);
}
}