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
272 lines
9.9 KiB
JavaScript
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}`);
|
|
}
|
|
}
|