mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-14 18:04:58 +03:00
3a6d5abffc
Перенос selectValue + helpers из browser.mjs (~960 LOC):
- scanGridRows, dblclickAndVerify, advancedSearchInline
- pickFromSelectionForm, isTypeDialog, pickFromTypeDialog (экспортируются —
вызываются из fillFields/fillTableRow в browser.mjs)
- fillReferenceField (экспортируется — вызывается из fillFields)
- selectValue
Двумя слайсами вокруг fillFields/fillField/clickElement/closeForm, которые
остаются в browser.mjs до этапов C.9/C.10.
browser.mjs: 4095 → 2933 LOC. 56 публичных экспортов.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2938 lines
129 KiB
JavaScript
2938 lines
129 KiB
JavaScript
// web-test browser v1.16 — Playwright browser management for 1C web client
|
||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
/**
|
||
* Playwright browser management for 1C web client.
|
||
*
|
||
* Maintains a single browser instance across MCP tool calls.
|
||
* Handles connection, navigation, waiting, screenshots.
|
||
*/
|
||
import { chromium } from 'playwright';
|
||
import { spawn, execFileSync } from 'child_process';
|
||
import { statSync, mkdirSync, existsSync as fsExistsSync, writeFileSync, readFileSync, rmSync, readdirSync } from 'fs';
|
||
import { dirname, resolve as pathResolve, join as pathJoin, basename, extname } from 'path';
|
||
import { tmpdir } from 'os';
|
||
import { fileURLToPath, pathToFileURL } from 'url';
|
||
import {
|
||
readSectionsScript, readTabsScript, readCommandsScript,
|
||
readFormScript, navigateSectionScript, openCommandScript,
|
||
findClickTargetScript, findFieldButtonScript, readSubmenuScript,
|
||
resolveFieldsScript, getFormStateScript,
|
||
detectFormScript, readTableScript, checkErrorsScript,
|
||
switchTabScript, resolveGridScript
|
||
} from './dom.mjs';
|
||
|
||
// Module-level state, constants, normYo and resolveProjectPath live in core/state.mjs.
|
||
// Imported as live bindings — reads stay current; writes go through setters.
|
||
import {
|
||
browser, page, sessionPrefix, seanceId, recorder,
|
||
lastCaptions, lastRecordingDuration, highlightMode,
|
||
persistentUserDataDir, preserveClipboard, clipboardWarnLogged,
|
||
contexts, activeContextName, activeMode,
|
||
setBrowser, setPage, setSessionPrefix, setSeanceId, setRecorder,
|
||
setLastCaptions, setLastRecordingDuration, setHighlightMode,
|
||
setPersistentUserDataDir, setActiveContextName, setActiveMode,
|
||
setClipboardWarnLogged,
|
||
LOAD_TIMEOUT, INIT_TIMEOUT, ACTION_WAIT, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES,
|
||
EXT_ID, projectRoot, resolveProjectPath, normYo,
|
||
isConnected, ensureConnected, getPage, setPreserveClipboard,
|
||
} from './core/state.mjs';
|
||
|
||
export { isConnected, getPage, setPreserveClipboard, ensureConnected };
|
||
export async function saveClipboard() {
|
||
if (!page) return;
|
||
try {
|
||
await page.evaluate(async () => {
|
||
try {
|
||
const items = await navigator.clipboard.read();
|
||
const saved = [];
|
||
for (const item of items) {
|
||
const types = {};
|
||
for (const t of item.types) types[t] = await item.getType(t);
|
||
saved.push(types);
|
||
}
|
||
window.__webTestSavedClipboard = saved;
|
||
delete window.__webTestClipboardError;
|
||
} catch (e) {
|
||
window.__webTestSavedClipboard = null;
|
||
window.__webTestClipboardError = e?.name || String(e);
|
||
}
|
||
});
|
||
} catch {
|
||
// page.evaluate itself failed (closed page, navigation in flight) — skip.
|
||
}
|
||
}
|
||
export async function restoreClipboard() {
|
||
if (!page) return;
|
||
let err = null;
|
||
try {
|
||
err = await page.evaluate(async () => {
|
||
const saved = window.__webTestSavedClipboard;
|
||
const captured = window.__webTestClipboardError || null;
|
||
delete window.__webTestSavedClipboard;
|
||
delete window.__webTestClipboardError;
|
||
try {
|
||
if (!saved || saved.length === 0) {
|
||
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
|
||
// or buffer was empty. Either way, the test's writeText already destroyed
|
||
// any prior native formats in the OS clipboard, so explicitly clear here
|
||
// to avoid leaking the test value into the user's clipboard.
|
||
await navigator.clipboard.writeText('');
|
||
return captured;
|
||
}
|
||
const items = saved.map(types => new ClipboardItem(types));
|
||
await navigator.clipboard.write(items);
|
||
return null;
|
||
} catch (e) {
|
||
return e?.name || String(e);
|
||
}
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
if (err && !clipboardWarnLogged) {
|
||
setClipboardWarnLogged(true);
|
||
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
|
||
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
|
||
* narrow save/restore so a user's clipboard survives the test run — the window
|
||
* between save and restore is microseconds.
|
||
*
|
||
* - `confirm` — key (string) or sequence (array) to press after writeText.
|
||
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
|
||
* or 'Shift+F11' for the goto-link dialog.
|
||
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
|
||
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
|
||
*/
|
||
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
|
||
if (!page) return;
|
||
if (preserveClipboard) await saveClipboard();
|
||
try {
|
||
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
|
||
if (Array.isArray(confirm)) {
|
||
for (const key of confirm) await page.keyboard.press(key);
|
||
} else if (confirm) {
|
||
await page.keyboard.press(confirm);
|
||
}
|
||
if (postDelay) await page.waitForTimeout(postDelay);
|
||
} finally {
|
||
if (preserveClipboard) await restoreClipboard();
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Session lifecycle + multi-context — extracted to core/session.mjs
|
||
// ============================================================
|
||
export {
|
||
connect, disconnect, attach, detach, getSession,
|
||
createContext, setActiveContext, listContexts, getActiveContext,
|
||
hasContext, closeContext,
|
||
} from './core/session.mjs';
|
||
|
||
// ============================================================
|
||
// Wait + error/modal handling — extracted to core/{wait,errors}.mjs
|
||
// ============================================================
|
||
import {
|
||
waitForStable, waitForCondition, startNetworkMonitor,
|
||
} from './core/wait.mjs';
|
||
import {
|
||
closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack,
|
||
_detectPlatformDialogs, _closePlatformDialogs,
|
||
} from './core/errors.mjs';
|
||
import {
|
||
safeClick, findFieldInputId, readEdd, returnFormState,
|
||
detectNewForm as helperDetectNewForm,
|
||
} from './core/helpers.mjs';
|
||
import { getGridToggleIcon, shouldClickToggle } from './table/grid-toggle.mjs';
|
||
// Re-export only what was publicly exported before the refactor.
|
||
// waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/
|
||
// dismissPendingErrors are internal helpers — imported above for local use only.
|
||
export { fetchErrorStack } from './core/errors.mjs';
|
||
|
||
/* getPage moved to core/state.mjs */
|
||
|
||
// ============================================================
|
||
// Navigation — extracted to nav/navigation.mjs
|
||
// ============================================================
|
||
export {
|
||
getPageState, getSections, navigateSection, getCommands,
|
||
openCommand, switchTab, openFile, navigateLink,
|
||
} from './nav/navigation.mjs';
|
||
|
||
/** Read current form state. Single evaluate call via combined script. */
|
||
export async function getFormState() {
|
||
ensureConnected();
|
||
const state = await page.evaluate(getFormStateScript());
|
||
const err = await checkForErrors();
|
||
if (err) {
|
||
state.errors = err;
|
||
if (err.confirmation) {
|
||
state.confirmation = err.confirmation;
|
||
state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
|
||
}
|
||
}
|
||
// Detect platform-level dialogs (About, Support Info, Error Report)
|
||
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
|
||
const pd = await _detectPlatformDialogs();
|
||
if (pd.length) state.platformDialogs = pd;
|
||
return state;
|
||
}
|
||
|
||
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||
ensureConnected();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error('readTable: no form found');
|
||
let gridSelector;
|
||
if (table) {
|
||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||
gridSelector = resolved.gridSelector;
|
||
}
|
||
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||
}
|
||
|
||
// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---
|
||
|
||
/**
|
||
* Scan spreadsheet iframes for the current form and collect all cells.
|
||
* Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> }
|
||
* where frameIndex is the Playwright frames[] index (1-based, 0 = main).
|
||
*/
|
||
async function scanSpreadsheetCells(formNum) {
|
||
const prefix = `form${formNum ?? 0}_`;
|
||
const iframeHandles = await page.$$('iframe');
|
||
|
||
const allCells = new Map();
|
||
const frameMap = new Map(); // key 'r_c' → Playwright Frame object
|
||
|
||
for (const handle of iframeHandles) {
|
||
const ok = await handle.evaluate((f, pfx) => {
|
||
if (f.offsetWidth < 100) return false;
|
||
let el = f.parentElement;
|
||
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||
if (el.id && el.id.startsWith(pfx)) return true;
|
||
}
|
||
return false;
|
||
}, prefix);
|
||
if (!ok) continue;
|
||
|
||
const frame = await handle.contentFrame();
|
||
if (!frame) continue;
|
||
|
||
try {
|
||
const cells = await frame.evaluate(`(() => {
|
||
const cells = [];
|
||
document.querySelectorAll('div[x]').forEach(d => {
|
||
const span = d.querySelector('span');
|
||
const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || '';
|
||
if (!text) return;
|
||
const rowDiv = d.parentElement;
|
||
const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null;
|
||
const col = d.getAttribute('x');
|
||
if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text });
|
||
});
|
||
return cells;
|
||
})()`);
|
||
for (const cell of cells) {
|
||
const key = `${cell.r}_${cell.c}`;
|
||
if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) {
|
||
allCells.set(key, cell);
|
||
frameMap.set(key, frame);
|
||
}
|
||
}
|
||
} catch { /* skip inaccessible frames */ }
|
||
}
|
||
return { allCells, frameMap };
|
||
}
|
||
|
||
/**
|
||
* Build structured mapping from raw cells: headers, column map, data/totals row indices.
|
||
* Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap }
|
||
* or null if header detection fails.
|
||
*/
|
||
function buildSpreadsheetMapping(allCells) {
|
||
const rowMap = new Map();
|
||
let maxCol = 0;
|
||
for (const cell of allCells.values()) {
|
||
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
|
||
rowMap.get(cell.r).set(cell.c, cell.t);
|
||
if (cell.c > maxCol) maxCol = cell.c;
|
||
}
|
||
|
||
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||
const rows = sortedRows.map(r => {
|
||
const cm = rowMap.get(r);
|
||
const arr = [];
|
||
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||
return arr;
|
||
});
|
||
|
||
// Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78"
|
||
// Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification.
|
||
const isNumericVal = (c) => {
|
||
if (!c || !/\d/.test(c)) return false;
|
||
const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, '');
|
||
return /^\d[\d,]*$/.test(s);
|
||
};
|
||
// Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus).
|
||
// Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51".
|
||
const isDataNumericVal = (c) => {
|
||
if (!isNumericVal(c)) return false;
|
||
return /[\s\u00a0,]/.test(c) || /^-/.test(c);
|
||
};
|
||
const hasNumber = (row) => row.some(c => isNumericVal(c));
|
||
const nonEmpty = (row) => row.filter(c => c !== '').length;
|
||
|
||
// Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx.
|
||
// Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor).
|
||
const buildRichMapping = (detailIdx, firstDataIdx) => {
|
||
let groupIdx = -1;
|
||
if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;
|
||
|
||
const detailRow = rows[detailIdx];
|
||
const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;
|
||
|
||
// Detect optional third header level above group row (bounds carry-forward)
|
||
let superRow = null;
|
||
if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) {
|
||
superRow = rows[groupIdx - 1];
|
||
}
|
||
|
||
// Build column names (group + detail merge)
|
||
const groupFilled = new Array(maxCol + 1).fill('');
|
||
if (groupRow) {
|
||
let cur = '';
|
||
for (let c = 0; c <= maxCol; c++) {
|
||
if (groupRow[c]) {
|
||
cur = groupRow[c];
|
||
} else if (superRow && superRow[c]) {
|
||
// New top-level header starts here — stop carry-forward
|
||
cur = '';
|
||
}
|
||
groupFilled[c] = cur;
|
||
}
|
||
}
|
||
|
||
const detailCounts = {};
|
||
for (let c = 0; c <= maxCol; c++) {
|
||
const n = detailRow[c];
|
||
if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
|
||
}
|
||
|
||
// Detect DCS column codes (К1, К2, ...) — always prefix with group when present
|
||
const detailNonEmpty = detailRow.filter(c => c);
|
||
const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c));
|
||
|
||
const colNames = [];
|
||
for (let c = 0; c <= maxCol; c++) {
|
||
const detail = detailRow[c];
|
||
const group = groupFilled[c];
|
||
const sup = superRow ? superRow[c] : '';
|
||
if (detail) {
|
||
// Prefer group prefix; fall back to superRow for DCS code columns without sub-group
|
||
const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : '');
|
||
const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
|
||
colNames.push(needPrefix ? `${prefix} / ${detail}` : detail);
|
||
} else if (group) {
|
||
colNames.push(group);
|
||
} else if (sup) {
|
||
colNames.push(sup);
|
||
} else {
|
||
colNames.push(null);
|
||
}
|
||
}
|
||
|
||
const colMap = new Map();
|
||
for (let c = 0; c < colNames.length; c++) {
|
||
if (colNames[c]) colMap.set(colNames[c], c);
|
||
}
|
||
|
||
// Classify data rows: separate data indices and totals index
|
||
const dataRowIndices = [];
|
||
let totalsRowIdx = -1;
|
||
for (let i = firstDataIdx; i < rows.length; i++) {
|
||
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||
const first = rows[i][0]?.trim().toLowerCase();
|
||
if (first === 'итого' || first === 'всего') {
|
||
totalsRowIdx = i;
|
||
} else {
|
||
dataRowIndices.push(i);
|
||
}
|
||
}
|
||
|
||
const superRowIdx = superRow ? groupIdx - 1 : -1;
|
||
|
||
return {
|
||
rows, sortedRows, maxCol, colNames, colMap,
|
||
headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx,
|
||
dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
|
||
rowMap, hasNumber, nonEmpty,
|
||
};
|
||
};
|
||
|
||
// --- Level 1: DCS-code row anchor ---
|
||
// ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker.
|
||
// Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб").
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const detailNonEmpty = rows[i].filter(c => c);
|
||
if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) {
|
||
// Find first non-empty row after the К-codes row as data start
|
||
let firstDataIdx = rows.length;
|
||
for (let j = i + 1; j < rows.length; j++) {
|
||
if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; }
|
||
}
|
||
return buildRichMapping(i, firstDataIdx);
|
||
}
|
||
}
|
||
|
||
// --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) ---
|
||
let firstDataIdx = rows.length;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; }
|
||
}
|
||
if (firstDataIdx === rows.length) {
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; }
|
||
}
|
||
}
|
||
|
||
if (firstDataIdx < rows.length) {
|
||
let detailIdx = -1;
|
||
for (let i = firstDataIdx - 1; i >= 0; i--) {
|
||
if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; }
|
||
}
|
||
if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx);
|
||
}
|
||
|
||
// --- Level 3: single-row header fallback (text-only data, query console) ---
|
||
// First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition.
|
||
let headerIdx = -1;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; }
|
||
}
|
||
// Single-column tables: accept nonEmpty >= 1
|
||
if (headerIdx === -1 && maxCol === 0) {
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; }
|
||
}
|
||
}
|
||
if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total }
|
||
|
||
const detailRow = rows[headerIdx];
|
||
const colNames = [];
|
||
for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null);
|
||
const colMap = new Map();
|
||
for (let c = 0; c < colNames.length; c++) {
|
||
if (colNames[c]) colMap.set(colNames[c], c);
|
||
}
|
||
|
||
const dataRowIndices = [];
|
||
let totalsRowIdx = -1;
|
||
for (let i = headerIdx + 1; i < rows.length; i++) {
|
||
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||
const first = rows[i][0]?.trim().toLowerCase();
|
||
if (first === 'итого' || first === 'всего') {
|
||
totalsRowIdx = i;
|
||
} else {
|
||
dataRowIndices.push(i);
|
||
}
|
||
}
|
||
|
||
return {
|
||
rows, sortedRows, maxCol, colNames, colMap,
|
||
headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1,
|
||
dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx,
|
||
rowMap, hasNumber, nonEmpty,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Scroll SpreadsheetDocument to make a cell visible using arrow keys.
|
||
* Uses native platform scroll — keeps headers, data, and scrollbar synchronized.
|
||
*
|
||
* How it works:
|
||
* 1. Check target cell visibility via Playwright boundingBox (page-level coords).
|
||
* 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay.
|
||
* This is the same native click that clickSpreadsheetCell uses — it gives keyboard
|
||
* focus to the spreadsheet and keeps headers/data/scrollbar in sync.
|
||
* (frame.locator().click() bypasses overlay → desyncs frozen headers;
|
||
* page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.)
|
||
* 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport.
|
||
*
|
||
* @param {Frame} frame - Playwright Frame containing the spreadsheet cells
|
||
* @param {number} physRow - physical row (y attribute) in the frame
|
||
* @param {number} physCol - physical column (x attribute) in the frame
|
||
* @param {Locator} cellLoc - Playwright locator for the target cell (from caller)
|
||
*/
|
||
async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) {
|
||
const pageVw = await page.evaluate('window.innerWidth');
|
||
// Get iframe bounds — the actual visible region on page.
|
||
// The iframe may extend behind the section panel on the left, so cells with
|
||
// x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel.
|
||
const frameElm = await frame.frameElement();
|
||
const frameBox = await frameElm.boundingBox();
|
||
const visLeft = frameBox ? frameBox.x : 0;
|
||
const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw;
|
||
|
||
const getBox = async () => {
|
||
try { return await cellLoc.boundingBox({ timeout: 500 }); }
|
||
catch { return null; }
|
||
};
|
||
const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight;
|
||
|
||
let box = await getBox();
|
||
if (!box) return; // cell not in DOM
|
||
if (isFullyVisible(box)) return;
|
||
|
||
const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft';
|
||
|
||
// Find a fully-visible cell to click for focus.
|
||
// Prefer cells in the target row (scrollable area), fall back to any row.
|
||
const targetRowSel = `div[y="${physRow}"] div[x]`;
|
||
const anyRowSel = 'div[x]';
|
||
let focusClicked = false;
|
||
for (const sel of [targetRowSel, anyRowSel]) {
|
||
const locs = frame.locator(sel);
|
||
const count = await locs.count();
|
||
const candidates = [];
|
||
for (let ci = 0; ci < count; ci++) {
|
||
const b = await locs.nth(ci).boundingBox();
|
||
if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) {
|
||
candidates.push({ ci, box: b });
|
||
}
|
||
}
|
||
if (candidates.length === 0) continue;
|
||
candidates.sort((a, b) => a.box.x - b.box.x);
|
||
// ArrowRight → rightmost fully-visible (each press scrolls right immediately)
|
||
// ArrowLeft → leftmost fully-visible (each press scrolls left immediately)
|
||
const pick = direction === 'ArrowRight'
|
||
? candidates[candidates.length - 1]
|
||
: candidates[0];
|
||
// Native click through overlay — gives keyboard focus + no header desync.
|
||
await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2);
|
||
await page.waitForTimeout(100);
|
||
focusClicked = true;
|
||
break;
|
||
}
|
||
if (!focusClicked) return; // no visible cells — can't scroll
|
||
|
||
// Arrow keys until cell is fully visible or we detect no progress.
|
||
const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?)
|
||
let prevCx = box.x + box.width / 2;
|
||
let staleCount = 0;
|
||
for (let i = 0; i < 100; i++) {
|
||
await page.keyboard.press(direction);
|
||
await page.waitForTimeout(50);
|
||
box = await getBox();
|
||
if (!box) break;
|
||
if (isFullyVisible(box)) break;
|
||
const cx = box.x + box.width / 2;
|
||
if (Math.abs(cx - prevCx) >= 1) {
|
||
staleCount = 0;
|
||
} else {
|
||
staleCount++;
|
||
if (staleCount >= MAX_STALE) break;
|
||
}
|
||
prevCx = cx;
|
||
}
|
||
await page.waitForTimeout(200);
|
||
}
|
||
|
||
/**
|
||
* Click a cell in SpreadsheetDocument by logical coordinates.
|
||
* target: { row: number|'totals'|{colName: value}, column: string }
|
||
* Internal helper — called from clickElement when first arg is an object.
|
||
*/
|
||
async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) {
|
||
ensureConnected();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
|
||
if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.');
|
||
|
||
const mapping = buildSpreadsheetMapping(allCells);
|
||
if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.');
|
||
|
||
const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping;
|
||
|
||
// Resolve column (exact → endsWith " / X" → includes)
|
||
let colName = target.column;
|
||
if (!colMap.has(colName)) {
|
||
const available = colNames.filter(n => n);
|
||
const suffix = ' / ' + colName;
|
||
const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName));
|
||
if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
|
||
colName = match;
|
||
}
|
||
const physCol = colMap.get(colName);
|
||
|
||
// Resolve row → index into rows[] array
|
||
let rowIdx;
|
||
const row = target.row;
|
||
if (row === 'totals') {
|
||
if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.');
|
||
rowIdx = totalsRowIdx;
|
||
} else if (typeof row === 'number') {
|
||
if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`);
|
||
rowIdx = dataRowIndices[row];
|
||
} else if (typeof row === 'object') {
|
||
// Filter: { colName: value } — find first data row where column matches
|
||
const filterEntries = Object.entries(row);
|
||
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
|
||
const resolveCol = (name) => {
|
||
if (colMap.has(name)) return colMap.get(name);
|
||
const suffix = ' / ' + name;
|
||
const available = colNames.filter(n => n);
|
||
const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name));
|
||
return m ? colMap.get(m) : null;
|
||
};
|
||
rowIdx = dataRowIndices.find(i => {
|
||
return filterEntries.every(([fCol, fVal]) => {
|
||
const fColIdx = resolveCol(fCol);
|
||
if (fColIdx == null) return false;
|
||
const cellText = norm(rows[i][fColIdx]);
|
||
const search = norm(fVal);
|
||
return cellText === search || cellText.includes(search);
|
||
});
|
||
});
|
||
if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`);
|
||
} else {
|
||
throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.');
|
||
}
|
||
|
||
// Map rows[] index → physical row number
|
||
const physRow = sortedRows[rowIdx];
|
||
const cellKey = `${physRow}_${physCol}`;
|
||
const frame = frameMap.get(cellKey);
|
||
if (!frame) {
|
||
// Cell exists in mapping but might be empty — try clicking anyway
|
||
throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`);
|
||
}
|
||
// Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs.
|
||
const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first();
|
||
// Scroll cell into view using arrow keys — the only reliable way to scroll
|
||
// 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar.
|
||
await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv);
|
||
const box = await cellDiv.boundingBox();
|
||
if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`);
|
||
|
||
const x = box.x + box.width / 2;
|
||
const y = box.y + box.height / 2;
|
||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||
if (modKey) await page.keyboard.down(modKey);
|
||
if (dbl) {
|
||
await page.mouse.dblclick(x, y);
|
||
} else {
|
||
await page.mouse.click(x, y);
|
||
}
|
||
if (modKey) await page.keyboard.up(modKey);
|
||
|
||
await waitForStable();
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) };
|
||
return state;
|
||
}
|
||
|
||
/**
|
||
* Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
|
||
* Returns { frameIndex, physRow, physCol, box } or null if not found.
|
||
*/
|
||
async function findSpreadsheetCellByText(formNum, searchText) {
|
||
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
|
||
if (allCells.size === 0) return null;
|
||
|
||
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
|
||
const target = norm(searchText);
|
||
|
||
// Exact match first, then includes
|
||
let found = null;
|
||
for (const [key, cell] of allCells) {
|
||
if (norm(cell.t) === target) { found = { key, cell }; break; }
|
||
}
|
||
if (!found) {
|
||
for (const [key, cell] of allCells) {
|
||
if (norm(cell.t).includes(target)) { found = { key, cell }; break; }
|
||
}
|
||
}
|
||
if (!found) return null;
|
||
|
||
const frame = frameMap.get(found.key);
|
||
if (!frame) return null;
|
||
|
||
// Scroll cell into view using native arrow-key mechanism
|
||
const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first();
|
||
await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv);
|
||
const box = await cellDiv.boundingBox();
|
||
if (!box) return null;
|
||
|
||
return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box };
|
||
}
|
||
|
||
/**
|
||
* Read report output (SpreadsheetDocumentField) rendered in iframes.
|
||
* 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes.
|
||
* Each cell is a div[x] inside a row div[y], text content in <span>.
|
||
*
|
||
* Returns structured data:
|
||
* { title, headers, data: [{col: val}], totals: {col: val}, total }
|
||
* If header detection fails, falls back to { rows: string[][], total }.
|
||
*/
|
||
export async function readSpreadsheet() {
|
||
ensureConnected();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
|
||
const { allCells } = await scanSpreadsheetCells(formNum);
|
||
|
||
if (allCells.size === 0) {
|
||
// Check for state window messages (info bar) that explain why the report is empty
|
||
const err = await checkForErrors();
|
||
const hint = err?.stateText?.length ? err.stateText.join('; ') : '';
|
||
throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.'));
|
||
}
|
||
|
||
const mapping = buildSpreadsheetMapping(allCells);
|
||
if (!mapping) {
|
||
// Fallback: return raw rows
|
||
const rowMap = new Map();
|
||
let maxCol = 0;
|
||
for (const cell of allCells.values()) {
|
||
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
|
||
rowMap.get(cell.r).set(cell.c, cell.t);
|
||
if (cell.c > maxCol) maxCol = cell.c;
|
||
}
|
||
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||
const rows = sortedRows.map(r => {
|
||
const cm = rowMap.get(r);
|
||
const arr = [];
|
||
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||
return arr;
|
||
});
|
||
return { rows, total: rows.length };
|
||
}
|
||
|
||
const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping;
|
||
|
||
// Convert data rows to objects
|
||
const data = [];
|
||
let totals = null;
|
||
const toObj = (row) => {
|
||
const obj = {};
|
||
for (let c = 0; c < colNames.length; c++) {
|
||
if (colNames[c] && row[c]) obj[colNames[c]] = row[c];
|
||
}
|
||
return obj;
|
||
};
|
||
|
||
for (let i = dataStartIdx; i < rows.length; i++) {
|
||
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||
const first = rows[i][0]?.trim().toLowerCase();
|
||
if (first === 'итого' || first === 'всего') {
|
||
totals = toObj(rows[i]);
|
||
} else {
|
||
data.push(toObj(rows[i]));
|
||
}
|
||
}
|
||
|
||
// Meta: title, params, filters from rows before header (superRow is part of header, not meta)
|
||
const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx);
|
||
let title = '';
|
||
const meta = [];
|
||
for (let i = 0; i < metaEnd; i++) {
|
||
const parts = rows[i].filter(c => c);
|
||
if (!parts.length) continue;
|
||
if (!title) { title = parts.join(' '); continue; }
|
||
meta.push(parts.join(' '));
|
||
}
|
||
|
||
return {
|
||
title: title || undefined,
|
||
meta: meta.length ? meta : undefined,
|
||
headers: colNames.filter(n => n),
|
||
data,
|
||
totals: totals || undefined,
|
||
total: data.length,
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// Value selection (DLB/CB) — extracted to forms/select-value.mjs
|
||
// ============================================================
|
||
export { selectValue } from './forms/select-value.mjs';
|
||
import {
|
||
selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
|
||
fillReferenceField,
|
||
} from './forms/select-value.mjs';
|
||
|
||
|
||
|
||
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
|
||
export async function fillFields(fields) {
|
||
ensureConnected();
|
||
await dismissPendingErrors();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error('fillFields: no form found');
|
||
|
||
// Resolve field names to element IDs
|
||
const resolved = await page.evaluate(resolveFieldsScript(formNum, fields));
|
||
const results = [];
|
||
|
||
for (const r of resolved) {
|
||
if (r.error) {
|
||
results.push(r);
|
||
continue;
|
||
}
|
||
// Auto-highlight the field input before filling
|
||
if (highlightMode && r.inputId) {
|
||
try {
|
||
await page.evaluate(({ id }) => {
|
||
const target = document.getElementById(id);
|
||
if (!target) return;
|
||
let div = document.getElementById('__web_test_highlight');
|
||
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||
const r = target.getBoundingClientRect();
|
||
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
|
||
}, { id: r.inputId });
|
||
await page.waitForTimeout(500);
|
||
await unhighlight();
|
||
} catch {}
|
||
}
|
||
try {
|
||
// Auto-enable DCS checkbox if resolved via label
|
||
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
|
||
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
|
||
await waitForStable();
|
||
}
|
||
const selector = `[id="${r.inputId}"]`;
|
||
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
|
||
const rawValue = fields[r.field];
|
||
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
|
||
if (isEmpty && !r.isCheckbox && !r.isRadio) {
|
||
await page.click(selector);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Shift+F4');
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Tab');
|
||
await waitForStable();
|
||
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
|
||
continue;
|
||
}
|
||
if (r.isCheckbox) {
|
||
// Checkbox: compare desired with current, toggle if mismatch
|
||
const desired = String(fields[r.field]).toLowerCase();
|
||
const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired);
|
||
if (wantChecked !== r.checked) {
|
||
await page.click(selector);
|
||
await waitForStable();
|
||
}
|
||
results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' });
|
||
} else if (r.isRadio) {
|
||
// Radio button: find option by label (fuzzy match) and click it
|
||
const desired = normYo(String(fields[r.field]).toLowerCase());
|
||
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|
||
|| r.options.find(o => normYo(o.label.toLowerCase()).includes(desired));
|
||
if (opt) {
|
||
// Option 0 = base element (no suffix), options 1+ = #N#radio
|
||
const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`;
|
||
await page.click(`[id="${radioId}"]`);
|
||
await waitForStable();
|
||
results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' });
|
||
} else {
|
||
results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) });
|
||
}
|
||
} else if (r.hasSelect) {
|
||
// Combobox/reference with DLB: DLB-first, then paste fallback
|
||
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
|
||
results.push(refResult);
|
||
} else if (r.hasPick && r.isDate) {
|
||
// Date/time field with calendar CB — use paste (calendar is not a selection form)
|
||
await page.click(selector);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Control+A');
|
||
await pasteText(fields[r.field]);
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Tab');
|
||
await waitForStable();
|
||
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
|
||
} else if (r.hasPick) {
|
||
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
|
||
const svResult = await selectValue(r.field, String(fields[r.field]));
|
||
if (svResult?.error) {
|
||
results.push({ field: r.field, error: svResult.error, message: svResult.message });
|
||
} else {
|
||
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
|
||
}
|
||
} else {
|
||
// Plain field: clipboard paste + Tab to commit
|
||
// page.fill() sets DOM value but doesn't trigger 1C input events;
|
||
// clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly.
|
||
await page.click(selector);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Control+A');
|
||
await pasteText(fields[r.field]);
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Tab');
|
||
await waitForStable();
|
||
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
|
||
}
|
||
} catch (e) {
|
||
results.push({ field: r.field, error: e.message });
|
||
}
|
||
if (highlightMode) try { await unhighlight(); } catch {}
|
||
}
|
||
|
||
const formData = await page.evaluate(readFormScript(formNum));
|
||
const failed = results.filter(r => r.error);
|
||
if (failed.length > 0) {
|
||
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
|
||
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
|
||
}
|
||
return { filled: results, form: formData };
|
||
}
|
||
|
||
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
|
||
export async function fillField(name, value) {
|
||
return fillFields({ [name]: value });
|
||
}
|
||
|
||
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
|
||
* First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */
|
||
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) {
|
||
ensureConnected();
|
||
// Dispatch to spreadsheet cell handler when first arg is { row, column }
|
||
if (typeof text === 'object' && text !== null && text.column != null) {
|
||
await dismissPendingErrors();
|
||
return clickSpreadsheetCell(text, { dblclick, modifier });
|
||
}
|
||
await dismissPendingErrors();
|
||
if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||
let netMonitor = null;
|
||
try {
|
||
|
||
// First check if there's a confirmation dialog — click matching button
|
||
const pending = await checkForErrors();
|
||
if (pending?.confirmation) {
|
||
const btnResult = await page.evaluate(`(() => {
|
||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
|
||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||
const target = ny(${JSON.stringify(text.toLowerCase())});
|
||
const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
|
||
let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target);
|
||
if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target));
|
||
if (best) {
|
||
const r = best.getBoundingClientRect();
|
||
return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||
}
|
||
return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) };
|
||
})()`);
|
||
if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`);
|
||
await page.mouse.click(btnResult.x, btnResult.y);
|
||
await waitForStable();
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'confirmation', name: btnResult.name };
|
||
return state;
|
||
}
|
||
|
||
// Check if there's an open popup — if so, try to click inside it
|
||
const popupItems = await page.evaluate(readSubmenuScript());
|
||
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||
const target = normYo(text.toLowerCase());
|
||
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||
if (found) {
|
||
// submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu
|
||
if (found.kind === 'submenuArrow') {
|
||
// page.hover(selector) is more reliable than page.mouse.move(x,y) —
|
||
// some submenu groups don't expand with plain mouse.move
|
||
if (found.id) {
|
||
await page.hover(`[id="${found.id}"]`);
|
||
} else {
|
||
await page.mouse.move(found.x, found.y);
|
||
}
|
||
await page.waitForTimeout(ACTION_WAIT);
|
||
const nestedItems = await page.evaluate(readSubmenuScript());
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'submenuArrow', name: found.name };
|
||
if (Array.isArray(nestedItems)) {
|
||
state.submenu = nestedItems.map(i => i.name);
|
||
state.hint = 'Call web_click again with a submenu item name to select it';
|
||
}
|
||
return state;
|
||
}
|
||
// Regular submenu/dropdown items — trusted events required.
|
||
// Use mouse.click(x,y) when in viewport; use :visible selector for clipped items
|
||
// (same ID can exist hidden in parent cloud AND visible in nested cloud).
|
||
const vpHeight = await page.evaluate('window.innerHeight');
|
||
if (found.x && found.y && found.y > 0 && found.y < vpHeight) {
|
||
await page.mouse.click(found.x, found.y);
|
||
} else if (found.id) {
|
||
await page.click(`[id="${found.id}"]:visible`);
|
||
} else if (found.x && found.y) {
|
||
await page.mouse.click(found.x, found.y);
|
||
}
|
||
await waitForStable();
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'popupItem', name: found.name };
|
||
const err = await checkForErrors();
|
||
if (err) state.errors = err;
|
||
return state;
|
||
}
|
||
// No match in popup — fall through to form elements
|
||
}
|
||
|
||
let formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error(`clickElement: no form found`);
|
||
|
||
// Pre-resolve grid when table is specified
|
||
let gridSelector;
|
||
if (table) {
|
||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||
gridSelector = resolved.gridSelector;
|
||
}
|
||
|
||
// Find the target element ID
|
||
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||
|
||
// Retry: if not found, a modal form may still be loading (e.g. after F4).
|
||
// Wait up to 2s for a new form to appear and re-detect.
|
||
if (target?.error) {
|
||
for (let retry = 0; retry < 4; retry++) {
|
||
await page.waitForTimeout(500);
|
||
const newForm = await page.evaluate(detectFormScript());
|
||
if (newForm !== null && newForm !== formNum) {
|
||
formNum = newForm;
|
||
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||
if (!target?.error) break;
|
||
}
|
||
}
|
||
}
|
||
// Fallback: search spreadsheet iframes for text match before giving up
|
||
if (target?.error) {
|
||
const ssCell = await findSpreadsheetCellByText(formNum, text);
|
||
if (ssCell) {
|
||
const cx = ssCell.box.x + ssCell.box.width / 2;
|
||
const cy = ssCell.box.y + ssCell.box.height / 2;
|
||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||
if (modKey) await page.keyboard.down(modKey);
|
||
if (dblclick) await page.mouse.dblclick(cx, cy);
|
||
else await page.mouse.click(cx, cy);
|
||
if (modKey) await page.keyboard.up(modKey);
|
||
await waitForStable();
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) };
|
||
return state;
|
||
}
|
||
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
||
}
|
||
|
||
// Helper: click with optional modifier key (Ctrl/Shift for multi-select)
|
||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||
async function modClick(x, y) {
|
||
if (modKey) await page.keyboard.down(modKey);
|
||
await page.mouse.click(x, y);
|
||
if (modKey) await page.keyboard.up(modKey);
|
||
}
|
||
async function modDblClick(x, y) {
|
||
if (modKey) await page.keyboard.down(modKey);
|
||
await page.mouse.dblclick(x, y);
|
||
if (modKey) await page.keyboard.up(modKey);
|
||
}
|
||
|
||
// Grid row targets — use coordinate click (single or double)
|
||
if (target.kind === 'gridGroup' || target.kind === 'gridParent') {
|
||
if (expand != null || toggle) {
|
||
// Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV).
|
||
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
|
||
const levelIconInfo = await getGridToggleIcon(target, formNum, {
|
||
iconSelector: '.gridListH, .gridListV',
|
||
isExpandedExpr: "icon.classList.contains('gridListV')",
|
||
});
|
||
const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle);
|
||
if (shouldClick) {
|
||
if (levelIconInfo) {
|
||
await modClick(levelIconInfo.x, levelIconInfo.y);
|
||
} else {
|
||
// Fallback: dblclick (standard hierarchy navigation)
|
||
await modDblClick(target.x, target.y);
|
||
}
|
||
}
|
||
await waitForStable(formNum);
|
||
const state = await getFormState();
|
||
state.clicked = { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) };
|
||
state.hint = shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.';
|
||
return state;
|
||
}
|
||
// Default: dblclick to enter group / go up to parent
|
||
await modDblClick(target.x, target.y);
|
||
await waitForStable(formNum);
|
||
const state = await getFormState();
|
||
state.clicked = { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) };
|
||
return state;
|
||
}
|
||
if (target.kind === 'gridTreeNode') {
|
||
if (expand != null || toggle) {
|
||
// Expand/collapse tree node — click the tree icon [tree="true"].
|
||
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
|
||
const treeIconInfo = await getGridToggleIcon(target, formNum, {
|
||
iconSelector: '.gridBoxImg [tree="true"]',
|
||
isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")',
|
||
});
|
||
const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle);
|
||
if (shouldClick) {
|
||
if (treeIconInfo) {
|
||
await modClick(treeIconInfo.x, treeIconInfo.y);
|
||
} else {
|
||
// Fallback: dblclick on row (works for trees without clickable +/- icons)
|
||
await modDblClick(target.x, target.y);
|
||
}
|
||
}
|
||
await waitForStable(formNum);
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) };
|
||
state.hint = shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.';
|
||
return state;
|
||
}
|
||
// Default: select row (click text, no expand/collapse)
|
||
await modClick(target.x, target.y);
|
||
await waitForStable(formNum);
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) };
|
||
state.hint = 'Row selected. Use { expand: true } to expand/collapse.';
|
||
return state;
|
||
}
|
||
if (target.kind === 'gridRow') {
|
||
if (dblclick) {
|
||
await modDblClick(target.x, target.y);
|
||
await waitForStable();
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) };
|
||
return state;
|
||
}
|
||
await modClick(target.x, target.y);
|
||
await waitForStable();
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) };
|
||
return state;
|
||
}
|
||
|
||
// Start CDP network monitor BEFORE the click for buttons —
|
||
// so we capture all server requests triggered by the click.
|
||
if (target.kind === 'button') {
|
||
try { netMonitor = await startNetworkMonitor(); } catch {}
|
||
}
|
||
|
||
// Tabs without ID — use coordinate click to avoid global [data-content] ambiguity
|
||
if (target.kind === 'tab' && !target.id && target.x && target.y) {
|
||
await page.mouse.click(target.x, target.y);
|
||
} else {
|
||
const selector = `[id="${target.id}"]`;
|
||
// Use Playwright click for proper mousedown/mouseup events
|
||
await safeClick(selector, { timeout: 5000 });
|
||
}
|
||
|
||
// If submenu button — read popup items and return them as hints
|
||
if (target.kind === 'submenu') {
|
||
await page.waitForTimeout(ACTION_WAIT);
|
||
const submenuItems = await page.evaluate(readSubmenuScript());
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'submenu', name: target.name };
|
||
if (Array.isArray(submenuItems)) {
|
||
state.submenu = submenuItems.map(i => i.name);
|
||
state.hint = 'Call web_click again with a submenu item name to select it';
|
||
}
|
||
return state;
|
||
}
|
||
|
||
await waitForStable(formNum);
|
||
|
||
// Check if the click opened a popup/submenu (split buttons like "Создать на основании")
|
||
const openedPopup = await page.evaluate(readSubmenuScript());
|
||
if (Array.isArray(openedPopup) && openedPopup.length > 0) {
|
||
const state = await getFormState();
|
||
state.clicked = { kind: 'submenu', name: target.name };
|
||
state.submenu = openedPopup.map(i => i.name);
|
||
state.hint = 'Call web_click again with a submenu item name to select it';
|
||
return state;
|
||
}
|
||
|
||
// For buttons that trigger server-side operations (post, write, etc.),
|
||
// the DOM may stabilize BEFORE the server response arrives.
|
||
// Use waitForSelector to detect error modal — this doesn't block the JS event loop.
|
||
// Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected.
|
||
if (target.kind === 'button') {
|
||
const postForm = await page.evaluate(detectFormScript());
|
||
if (postForm === formNum) {
|
||
const inGridEdit = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
|
||
return false;
|
||
})()`);
|
||
if (!inGridEdit && netMonitor) {
|
||
// Form didn't change — server might still be processing.
|
||
// CDP monitor was started before click — wait for all requests to complete
|
||
// (300ms debounce) or for a modal/balloon/confirm to appear.
|
||
await netMonitor.waitDone(timeout);
|
||
await waitForStable();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Form may have changed — re-detect
|
||
const state = await getFormState();
|
||
state.clicked = { kind: target.kind, name: target.name };
|
||
const err = await checkForErrors();
|
||
if (err) {
|
||
state.errors = err;
|
||
if (err.confirmation) {
|
||
state.confirmation = err.confirmation;
|
||
state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
|
||
}
|
||
}
|
||
return state;
|
||
|
||
} finally {
|
||
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
|
||
if (highlightMode) try { await unhighlight(); } catch {}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close the current form/dialog via Escape.
|
||
* @param {Object} [opts]
|
||
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
|
||
* true → click "Да" (save and close)
|
||
* false → click "Нет" (discard and close)
|
||
* undefined → return confirmation as hint for caller to decide
|
||
*/
|
||
export async function closeForm({ save } = {}) {
|
||
ensureConnected();
|
||
await dismissPendingErrors();
|
||
// If platform dialogs are open, close them instead of pressing Escape
|
||
const pd = await _detectPlatformDialogs();
|
||
if (pd.length) {
|
||
await _closePlatformDialogs();
|
||
await page.waitForTimeout(300);
|
||
const state = await getFormState();
|
||
state.closed = true;
|
||
state.closedPlatformDialogs = pd;
|
||
return state;
|
||
}
|
||
const beforeForm = await page.evaluate(detectFormScript());
|
||
await page.keyboard.press('Escape');
|
||
await waitForStable(beforeForm);
|
||
const state = await getFormState();
|
||
const err = await checkForErrors();
|
||
if (err?.confirmation) {
|
||
if (save === true || save === false) {
|
||
const label = save ? 'Да' : 'Нет';
|
||
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||
const btns = await page.$$(btnSel);
|
||
for (const b of btns) {
|
||
const txt = (await b.textContent()).trim();
|
||
if (txt === label) {
|
||
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
|
||
await b.click({ force: true });
|
||
await waitForStable(beforeForm);
|
||
break;
|
||
}
|
||
}
|
||
const afterState = await getFormState();
|
||
afterState.closed = afterState.form !== beforeForm;
|
||
return afterState;
|
||
}
|
||
state.confirmation = err.confirmation;
|
||
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||
return state;
|
||
}
|
||
state.closed = state.form !== beforeForm;
|
||
return state;
|
||
}
|
||
|
||
|
||
/**
|
||
* Fill cells in the current table row via Tab navigation.
|
||
* Grid cells are only accessible sequentially (Tab) — no random access.
|
||
*
|
||
* After "Добавить", 1C enters inline edit mode on the first cell.
|
||
* All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
|
||
* Tab moves through cells in a fixed order determined by the form configuration.
|
||
*
|
||
* @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура")
|
||
* @param {Object} [options]
|
||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
|
||
* @returns {{ filled[], notFilled[]?, form }}
|
||
*/
|
||
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
|
||
ensureConnected();
|
||
await dismissPendingErrors();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error('fillTableRow: no form found');
|
||
|
||
// Pre-resolve grid when table is specified
|
||
let gridSelector;
|
||
if (table) {
|
||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||
gridSelector = resolved.gridSelector;
|
||
}
|
||
|
||
try {
|
||
// 1. Switch tab if requested
|
||
if (tab) {
|
||
await clickElement(tab);
|
||
}
|
||
|
||
// 2. Add new row if requested
|
||
let addedRowIdx = -1;
|
||
if (add) {
|
||
// Count rows before add — new row will be appended at this index
|
||
addedRowIdx = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
const body = grid?.querySelector('.gridBody');
|
||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||
})()`);
|
||
await clickElement('Добавить', { table });
|
||
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
|
||
for (let aw = 0; aw < 6; aw++) {
|
||
await page.waitForTimeout(150);
|
||
const ready = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
|
||
return false;
|
||
})()`);
|
||
if (ready) break;
|
||
}
|
||
}
|
||
|
||
// 2b. Enter edit mode on existing row by dblclick
|
||
if (row != null) {
|
||
// Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
|
||
const sortedKeys = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return null;
|
||
const head = grid.querySelector('.gridHead');
|
||
if (!head) return null;
|
||
const headLine = head.querySelector('.gridLine') || head;
|
||
const cols = [];
|
||
[...headLine.children].forEach(box => {
|
||
if (box.offsetWidth === 0) return;
|
||
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
||
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
||
if (t) cols.push({ text: t, colindex: ci });
|
||
});
|
||
const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))};
|
||
const mapped = keys.map(k => {
|
||
const exact = cols.find(c => c.text === k);
|
||
if (exact) return { key: k, colindex: exact.colindex };
|
||
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
|
||
return { key: k, colindex: inc ? inc.colindex : 999 };
|
||
});
|
||
mapped.sort((a, b) => a.colindex - b.colindex);
|
||
return mapped.map(m => m.key);
|
||
})()`);
|
||
if (sortedKeys) {
|
||
// Rebuild fields in sorted order
|
||
const sortedFields = {};
|
||
for (const kl of sortedKeys) {
|
||
const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
|
||
if (origKey) sortedFields[origKey] = fields[origKey];
|
||
}
|
||
// Add any keys not matched in header (preserve original order for those)
|
||
for (const k of Object.keys(fields)) {
|
||
if (!(k in sortedFields)) sortedFields[k] = fields[k];
|
||
}
|
||
fields = sortedFields;
|
||
}
|
||
|
||
const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()));
|
||
const cellCoords = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return { error: 'no_grid' };
|
||
const head = grid.querySelector('.gridHead');
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!head || !body) return { error: 'no_grid_body' };
|
||
|
||
// Read column headers to find target colindex
|
||
const headLine = head.querySelector('.gridLine') || head;
|
||
const cols = [];
|
||
[...headLine.children].forEach(box => {
|
||
if (box.offsetWidth === 0) return;
|
||
const t = box.querySelector('.gridBoxText');
|
||
const ci = box.getAttribute('colindex');
|
||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||
});
|
||
|
||
const keys = ${fieldKeys};
|
||
let targetColindex = null;
|
||
for (const key of keys) {
|
||
const exact = cols.find(c => c.text === key);
|
||
if (exact) { targetColindex = exact.colindex; break; }
|
||
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
||
if (inc) { targetColindex = inc.colindex; break; }
|
||
}
|
||
|
||
const rows = [...body.querySelectorAll('.gridLine')];
|
||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||
const line = rows[${row}];
|
||
|
||
// Find body cell by colindex (reliable across merged headers)
|
||
let box = null;
|
||
if (targetColindex != null) {
|
||
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||
}
|
||
// Fallback: second visible box (skip checkbox/N column)
|
||
if (!box) {
|
||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||
box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||
}
|
||
if (!box) return { error: 'no_cell' };
|
||
// Scroll into view if off-screen
|
||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
const cell = box.querySelector('.gridBoxText') || box;
|
||
const r = cell.getBoundingClientRect();
|
||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||
})()`);
|
||
|
||
if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||
|
||
// Skip if cell already contains the desired value (single-field optimization)
|
||
const firstKey0 = Object.keys(fields)[0];
|
||
const rawFirstVal = fields[firstKey0];
|
||
const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
|
||
? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
|
||
let firstFieldSkipped = false;
|
||
if (cellCoords.currentText && firstVal0 &&
|
||
cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
|
||
firstFieldSkipped = true;
|
||
if (Object.keys(fields).length === 1) {
|
||
return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }];
|
||
}
|
||
}
|
||
|
||
// Click first (tree grids enter edit on single click; dblclick toggles expand/collapse).
|
||
// Then escalate: dblclick → F4 if needed.
|
||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||
|
||
// Clear cell via Shift+F4 if value is empty
|
||
if (firstVal0 === '') {
|
||
await page.waitForTimeout(500);
|
||
// Check if click opened a selection form — close it first
|
||
let openedForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (openedForm !== null) {
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(500);
|
||
} else {
|
||
// No form opened — need to enter edit mode first (dblclick), then close any form that opens
|
||
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||
await page.waitForTimeout(500);
|
||
openedForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (openedForm !== null) {
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(500);
|
||
}
|
||
}
|
||
await page.keyboard.press('Shift+F4');
|
||
await page.waitForTimeout(300);
|
||
const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
|
||
// If more fields remain, process them on the same row
|
||
const remaining = { ...fields };
|
||
delete remaining[firstKey0];
|
||
if (Object.keys(remaining).length > 0) {
|
||
const more = await fillTableRow(remaining, { row, table });
|
||
if (Array.isArray(more)) results.push(...more);
|
||
else if (more?.filled) results.push(...more.filled);
|
||
}
|
||
const formData = await getFormState();
|
||
return { filled: results, form: formData };
|
||
}
|
||
|
||
// Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
|
||
const checkboxInfo = await page.evaluate(`(() => {
|
||
const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y});
|
||
const cell = el?.closest('.gridBox');
|
||
if (!cell) return null;
|
||
const chk = cell.querySelector('.checkbox');
|
||
if (!chk) return null;
|
||
const r = chk.getBoundingClientRect();
|
||
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||
})()`);
|
||
if (checkboxInfo !== null) {
|
||
// Checkbox cell found — click directly on the checkbox icon (not cell center)
|
||
const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
|
||
if (checkboxInfo.checked !== desired) {
|
||
await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
|
||
await page.waitForTimeout(300);
|
||
}
|
||
const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
|
||
await waitForStable(formNum);
|
||
// If more fields remain, process them on the same row
|
||
const remaining = { ...fields };
|
||
delete remaining[firstKey0];
|
||
if (Object.keys(remaining).length > 0) {
|
||
const more = await fillTableRow(remaining, { row, table });
|
||
results.push(...more);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
let inEdit = false;
|
||
let directEditForm = null;
|
||
for (let dw = 0; dw < 4; dw++) {
|
||
await page.waitForTimeout(150);
|
||
inEdit = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && f.tagName === 'INPUT';
|
||
})()`);
|
||
if (inEdit) break;
|
||
directEditForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (directEditForm !== null) break;
|
||
}
|
||
// Click didn't enter edit — try dblclick (works for flat grids)
|
||
if (!inEdit && directEditForm === null) {
|
||
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||
for (let dw = 0; dw < 4; dw++) {
|
||
await page.waitForTimeout(150);
|
||
inEdit = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && f.tagName === 'INPUT';
|
||
})()`);
|
||
if (inEdit) break;
|
||
directEditForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (directEditForm !== null) break;
|
||
}
|
||
}
|
||
// Still nothing — try F4 (opens selection for direct-edit cells)
|
||
if (!inEdit && directEditForm === null) {
|
||
await page.keyboard.press('F4');
|
||
for (let fw = 0; fw < 8; fw++) {
|
||
await page.waitForTimeout(200);
|
||
inEdit = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && f.tagName === 'INPUT';
|
||
})()`);
|
||
if (inEdit) break;
|
||
directEditForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (directEditForm !== null) break;
|
||
}
|
||
}
|
||
|
||
// When click entered INPUT mode but no selection form yet — try F4 only for tree grids
|
||
// (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
|
||
if (inEdit && directEditForm === null) {
|
||
const isTreeGrid = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
return grid ? !!grid.querySelector('.gridBoxTree') : false;
|
||
})()`);
|
||
if (isTreeGrid) {
|
||
await page.keyboard.press('F4');
|
||
for (let fw = 0; fw < 8; fw++) {
|
||
await page.waitForTimeout(200);
|
||
directEditForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (directEditForm !== null) break;
|
||
}
|
||
// If F4 didn't open a selection form, fall through to Tab loop
|
||
}
|
||
}
|
||
|
||
// Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing).
|
||
// Handle each field by picking from selection form, then dblclick next cell.
|
||
if (directEditForm !== null) {
|
||
const pending = new Map();
|
||
for (const [key, val] of Object.entries(fields)) {
|
||
if (val && typeof val === 'object' && 'value' in val) {
|
||
pending.set(key, { value: String(val.value), type: val.type || null, filled: false });
|
||
} else {
|
||
pending.set(key, { value: String(val), type: null, filled: false });
|
||
}
|
||
}
|
||
const results = [];
|
||
|
||
// Helper: handle type dialog + pick from selection form
|
||
async function directEditPick(openedForm, key, info) {
|
||
let selForm = openedForm;
|
||
// Check if opened form is a type selection dialog (composite type field)
|
||
if (await isTypeDialog(selForm)) {
|
||
if (info.type) {
|
||
await pickFromTypeDialog(selForm, info.type);
|
||
await waitForStable(selForm);
|
||
// After type selection, detect the actual selection form
|
||
selForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (selForm === null) {
|
||
return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
|
||
}
|
||
} else {
|
||
// No type specified — close type dialog and report error
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(300);
|
||
return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` };
|
||
}
|
||
}
|
||
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
|
||
return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message };
|
||
}
|
||
|
||
// First field: selection form is already open from the dblclick above
|
||
const firstKey = Object.keys(fields)[0];
|
||
const firstInfo = pending.get(firstKey);
|
||
if (firstFieldSkipped) {
|
||
firstInfo.filled = true;
|
||
results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText });
|
||
// Close the selection form that opened from the click
|
||
await page.keyboard.press('Escape');
|
||
await waitForStable(formNum);
|
||
} else {
|
||
const pickResult = await directEditPick(directEditForm, firstKey, firstInfo);
|
||
firstInfo.filled = true;
|
||
results.push(pickResult);
|
||
}
|
||
|
||
// Remaining fields: dblclick on each column cell individually
|
||
for (const [key, info] of pending) {
|
||
if (info.filled) continue;
|
||
// Find column for this key and dblclick on it
|
||
const nextCoords = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return null;
|
||
const head = grid.querySelector('.gridHead');
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!head || !body) return null;
|
||
const headLine = head.querySelector('.gridLine') || head;
|
||
const cols = [];
|
||
[...headLine.children].forEach(box => {
|
||
if (box.offsetWidth === 0) return;
|
||
const t = box.querySelector('.gridBoxText');
|
||
const ci = box.getAttribute('colindex');
|
||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||
});
|
||
const kl = ${JSON.stringify(key.toLowerCase())};
|
||
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
||
let targetColindex = null;
|
||
const exact = cols.find(c => c.text === kl);
|
||
if (exact) targetColindex = exact.colindex;
|
||
else {
|
||
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
||
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
||
if (inc) targetColindex = inc.colindex;
|
||
}
|
||
if (targetColindex == null) return null;
|
||
const rows = [...body.querySelectorAll('.gridLine')];
|
||
if (${row} >= rows.length) return null;
|
||
const line = rows[${row}];
|
||
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||
if (!box) return null;
|
||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
const cell = box.querySelector('.gridBoxText') || box;
|
||
const r = cell.getBoundingClientRect();
|
||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||
})()`);
|
||
if (!nextCoords) {
|
||
info.filled = true;
|
||
results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` });
|
||
continue;
|
||
}
|
||
// Skip if cell already contains the desired value
|
||
if (nextCoords.currentText && info.value &&
|
||
nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) {
|
||
info.filled = true;
|
||
results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText });
|
||
continue;
|
||
}
|
||
await page.mouse.dblclick(nextCoords.x, nextCoords.y);
|
||
await page.waitForTimeout(300);
|
||
// Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
|
||
const inInputAfterDblclick = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
|
||
return false;
|
||
})()`);
|
||
// Also check if a selection form already appeared
|
||
let selForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (selForm === null && inInputAfterDblclick) {
|
||
// Plain text/numeric field — fill via clipboard paste
|
||
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
|
||
await page.waitForTimeout(400);
|
||
// Dismiss EDD autocomplete if it appeared
|
||
const hasEdd = await page.evaluate(`(() => {
|
||
const edd = document.getElementById('editDropDown');
|
||
return edd && edd.offsetWidth > 0;
|
||
})()`);
|
||
if (hasEdd) {
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(200);
|
||
}
|
||
info.filled = true;
|
||
results.push({ field: key, ok: true, method: 'paste' });
|
||
continue;
|
||
}
|
||
// Poll for selection form (with F4 fallback if dblclick didn't open it)
|
||
if (selForm === null) {
|
||
for (let attempt = 0; attempt < 2 && selForm === null; attempt++) {
|
||
if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback
|
||
for (let sw = 0; sw < 6; sw++) {
|
||
await page.waitForTimeout(200);
|
||
selForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (selForm !== null) break;
|
||
}
|
||
}
|
||
}
|
||
if (selForm === null) {
|
||
info.filled = true;
|
||
results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` });
|
||
continue;
|
||
}
|
||
const pr = await directEditPick(selForm, key, info);
|
||
info.filled = true;
|
||
results.push(pr);
|
||
}
|
||
// Commit the edit: click on a different row (Escape cancels in tree grids).
|
||
// Find the first visible row that is NOT the edited row and click it.
|
||
const commitCoords = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return null;
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!body) return null;
|
||
const rows = [...body.querySelectorAll('.gridLine')];
|
||
const otherIdx = ${row} === 0 ? 1 : 0;
|
||
const other = rows[otherIdx];
|
||
if (!other) return null;
|
||
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
|
||
if (!box) return null;
|
||
const r = box.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`);
|
||
if (commitCoords) {
|
||
await page.mouse.click(commitCoords.x, commitCoords.y);
|
||
} else {
|
||
await page.keyboard.press('Escape');
|
||
}
|
||
await waitForStable(formNum);
|
||
return results;
|
||
}
|
||
|
||
if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`);
|
||
} else {
|
||
// No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent)
|
||
const editCheck = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
|
||
let node = f;
|
||
while (node) {
|
||
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
|
||
node = node.parentElement;
|
||
}
|
||
return { inEdit: false, hint: 'input not inside grid' };
|
||
})()`);
|
||
|
||
if (!editCheck.inEdit) {
|
||
throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.');
|
||
}
|
||
}
|
||
|
||
// 4. Prepare pending fields for fuzzy matching
|
||
const pending = new Map();
|
||
for (const [key, val] of Object.entries(fields)) {
|
||
if (val === null || val === undefined || val === '') {
|
||
pending.set(key, { value: '', type: null, filled: false });
|
||
} else if (val && typeof val === 'object' && 'value' in val) {
|
||
const innerVal = val.value;
|
||
pending.set(key, {
|
||
value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
|
||
type: val.type || null, filled: false
|
||
});
|
||
} else {
|
||
pending.set(key, { value: String(val), type: null, filled: false });
|
||
}
|
||
}
|
||
|
||
const results = [];
|
||
const MAX_ITER = 40;
|
||
let prevCellId = null;
|
||
let nonInputCount = 0;
|
||
let firstCellId = null;
|
||
|
||
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||
// Read focused element (INPUT or TEXTAREA inside grid = editable cell)
|
||
const cell = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
if (!f) return { tag: 'none' };
|
||
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
|
||
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
|
||
if (inGrid) {
|
||
let headerText = '';
|
||
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
|
||
if (grid) {
|
||
const fr = f.getBoundingClientRect();
|
||
const head = grid.querySelector('.gridHead');
|
||
const hl = head?.querySelector('.gridLine') || head;
|
||
if (hl) for (const h of hl.children) {
|
||
if (h.offsetWidth === 0) continue;
|
||
const hr = h.getBoundingClientRect();
|
||
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
|
||
const t = h.querySelector('.gridBoxText');
|
||
headerText = (t || h).innerText?.trim() || '';
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
tag: 'INPUT', id: f.id,
|
||
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
|
||
headerText
|
||
};
|
||
}
|
||
}
|
||
return { tag: f.tagName || 'none' };
|
||
})()`);
|
||
|
||
if (cell.tag !== 'INPUT' || !cell.fullName) {
|
||
// Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
|
||
nonInputCount++;
|
||
// If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
|
||
const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
|
||
['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
|
||
if (nonInputCount > 3 || onlyCheckboxLeft) break;
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(300);
|
||
continue;
|
||
}
|
||
nonInputCount = 0;
|
||
|
||
// Track first cell to detect wrap-around (Tab looped back to row start)
|
||
if (firstCellId === null) firstCellId = cell.id;
|
||
else if (cell.id === firstCellId) break; // wrapped around — all cells visited
|
||
|
||
// Stuck detection: same cell twice in a row → force Tab
|
||
if (cell.id === prevCellId) {
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
prevCellId = null;
|
||
continue;
|
||
}
|
||
prevCellId = cell.id;
|
||
|
||
// Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
|
||
const cellLower = cell.fullName.toLowerCase();
|
||
let matchedKey = null;
|
||
for (const [key, info] of pending) {
|
||
if (info.filled) continue;
|
||
const kl = key.toLowerCase();
|
||
if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
|
||
matchedKey = key;
|
||
break;
|
||
}
|
||
// CamelCase cell names have no spaces/dashes — try matching without spaces and dashes
|
||
const klNoSpace = kl.replace(/[\s\-]+/g, '');
|
||
if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) {
|
||
matchedKey = key;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Fallback: match by column header text (handles metadata typos in cell id)
|
||
if (!matchedKey && cell.headerText) {
|
||
const htLower = cell.headerText.toLowerCase();
|
||
for (const [key, info] of pending) {
|
||
if (info.filled) continue;
|
||
const kl = key.toLowerCase();
|
||
if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
|
||
matchedKey = key;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!matchedKey) {
|
||
// Skip this cell
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(300);
|
||
continue;
|
||
}
|
||
|
||
const info = pending.get(matchedKey);
|
||
const text = info.value;
|
||
|
||
// Clear cell if value is empty (Shift+F4 = native 1C clear)
|
||
if (text === '') {
|
||
await page.keyboard.press('Shift+F4');
|
||
await page.waitForTimeout(300);
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
|
||
if ([...pending.values()].every(p => p.filled)) break;
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
continue;
|
||
}
|
||
|
||
// If user specified a type, always clear and use type selection flow
|
||
if (info.type) {
|
||
await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('F4');
|
||
// Poll for type dialog form to appear
|
||
let typeForm = null;
|
||
for (let tw = 0; tw < 6; tw++) {
|
||
await page.waitForTimeout(200);
|
||
typeForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (typeForm !== null) break;
|
||
}
|
||
if (typeForm !== null && await isTypeDialog(typeForm)) {
|
||
await pickFromTypeDialog(typeForm, info.type);
|
||
await waitForStable(typeForm);
|
||
// After type selection, check if a selection form opened (ref types)
|
||
const selForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (selForm === null) {
|
||
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||
let hasPopup = null;
|
||
for (let pw = 0; pw < 5; pw++) {
|
||
await page.waitForTimeout(200);
|
||
hasPopup = await page.evaluate(`(() => {
|
||
const calc = document.querySelector('.calculate');
|
||
if (calc && calc.offsetWidth > 0) return 'calculator';
|
||
const cal = document.querySelector('.frameCalendar');
|
||
if (cal && cal.offsetWidth > 0) return 'calendar';
|
||
return null;
|
||
})()`);
|
||
if (hasPopup) break;
|
||
}
|
||
if (hasPopup) {
|
||
await page.keyboard.press('Escape');
|
||
// Poll for popup to disappear
|
||
for (let dw = 0; dw < 4; dw++) {
|
||
await page.waitForTimeout(150);
|
||
const gone = await page.evaluate(`(() => {
|
||
const calc = document.querySelector('.calculate');
|
||
if (calc && calc.offsetWidth > 0) return false;
|
||
const cal = document.querySelector('.frameCalendar');
|
||
if (cal && cal.offsetWidth > 0) return false;
|
||
return true;
|
||
})()`);
|
||
if (gone) break;
|
||
}
|
||
}
|
||
// Ensure we are in an editable INPUT for this cell
|
||
const inInput = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
|
||
})()`);
|
||
if (!inInput) {
|
||
const cellRect = await page.evaluate(`(() => {
|
||
const el = document.getElementById(${JSON.stringify(cell.id)});
|
||
if (!el) return null;
|
||
const r = el.getBoundingClientRect();
|
||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||
})()`);
|
||
if (cellRect) {
|
||
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||
// Poll for INPUT focus
|
||
for (let fw = 0; fw < 4; fw++) {
|
||
await page.waitForTimeout(150);
|
||
const ok = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
|
||
})()`);
|
||
if (ok) break;
|
||
}
|
||
}
|
||
}
|
||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||
await page.waitForTimeout(400);
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(300);
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||
continue;
|
||
}
|
||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||
info.filled = true;
|
||
results.push(pickResult.ok
|
||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||
: { field: matchedKey, cell: cell.fullName,
|
||
error: pickResult.error, message: pickResult.message });
|
||
continue;
|
||
}
|
||
// F4 opened something but not a type dialog — close and report
|
||
if (typeForm !== null) {
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(300);
|
||
}
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: 'type_dialog_failed',
|
||
message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
continue;
|
||
}
|
||
|
||
// === Fill this cell: clipboard paste (trusted event) ===
|
||
await page.keyboard.press('Control+A');
|
||
await pasteText(text);
|
||
await page.waitForTimeout(1500);
|
||
|
||
// Check if paste was rejected (composite-type cell blocks text input until type is selected)
|
||
const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
|
||
if (!inputAfterPaste && text) {
|
||
// No type specified — can't fill this composite-type cell
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: 'type_required',
|
||
message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
continue;
|
||
}
|
||
|
||
// Check for EDD autocomplete (indicates reference field)
|
||
const eddItems = await page.evaluate(`(() => {
|
||
const edd = document.getElementById('editDropDown');
|
||
if (!edd || edd.offsetWidth === 0) return null;
|
||
return [...edd.querySelectorAll('.eddText')]
|
||
.filter(el => el.offsetWidth > 0)
|
||
.map(el => el.innerText?.trim() || '');
|
||
})()`);
|
||
|
||
if (eddItems && eddItems.length > 0) {
|
||
// Reference field with autocomplete — click best match
|
||
// Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
|
||
// but keep standalone enum values like "Создать" (no space/colon after)
|
||
const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));
|
||
|
||
if (realItems.length > 0) {
|
||
const tgt = normYo(text.toLowerCase());
|
||
let pick = realItems.find(i =>
|
||
normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
|
||
if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
|
||
if (!pick) pick = realItems[0];
|
||
|
||
// Click EDD item via dispatchEvent (bypasses div.surface overlay)
|
||
const pickLower = pick.toLowerCase();
|
||
await page.evaluate(`(() => {
|
||
const edd = document.getElementById('editDropDown');
|
||
if (!edd) return;
|
||
for (const el of edd.querySelectorAll('.eddText')) {
|
||
if (el.offsetWidth === 0) continue;
|
||
if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) {
|
||
const r = el.getBoundingClientRect();
|
||
const opts = { bubbles:true, cancelable:true,
|
||
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||
el.dispatchEvent(new MouseEvent('click', opts));
|
||
return;
|
||
}
|
||
}
|
||
})()`);
|
||
await waitForStable();
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName, ok: true,
|
||
method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
|
||
} else {
|
||
// Only "Создать:" items — value not found in autocomplete
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(300);
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: 'not_found', message: `No match for "${text}"` });
|
||
}
|
||
|
||
// Done? If so, don't Tab (avoids creating a new row after last cell)
|
||
if ([...pending.values()].every(p => p.filled)) break;
|
||
// Tab to move to next cell
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
continue;
|
||
}
|
||
|
||
// No EDD — press Tab to commit the value
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// Check for "нет в списке" cloud popup (reference field, value not found)
|
||
const notInList = await page.evaluate(`(() => {
|
||
for (const el of document.querySelectorAll('div')) {
|
||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||
const s = getComputedStyle(el);
|
||
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
|
||
if ((parseInt(s.zIndex) || 0) < 100) continue;
|
||
if ((el.innerText || '').includes('нет в списке')) return true;
|
||
}
|
||
return false;
|
||
})()`);
|
||
|
||
if (notInList) {
|
||
// Cloud has "Показать все" link — try to open selection form via it
|
||
const clickedShowAll = await page.evaluate(`(() => {
|
||
for (const el of document.querySelectorAll('div')) {
|
||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||
const s = getComputedStyle(el);
|
||
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
|
||
if ((parseInt(s.zIndex) || 0) < 100) continue;
|
||
if (!(el.innerText || '').includes('нет в списке')) continue;
|
||
// Found the cloud — look for "Показать все" hyperlink inside
|
||
const links = [...el.querySelectorAll('a, span, div')]
|
||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||
const showAll = links.find(e => {
|
||
const t = (e.innerText?.trim() || '').toLowerCase();
|
||
return t === 'показать все' || t === 'show all';
|
||
});
|
||
if (showAll) {
|
||
const r = showAll.getBoundingClientRect();
|
||
const opts = { bubbles:true, cancelable:true,
|
||
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
|
||
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
|
||
showAll.dispatchEvent(new MouseEvent('click', opts));
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
return false;
|
||
})()`);
|
||
|
||
if (clickedShowAll) {
|
||
await waitForStable(formNum);
|
||
// Check if selection form opened
|
||
const selForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => {
|
||
if (el.offsetWidth === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
|
||
if (selForm !== null) {
|
||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||
info.filled = true;
|
||
if (pickResult.ok) {
|
||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
|
||
continue;
|
||
}
|
||
// Not found in selection form — fall through to clear + skip
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: pickResult.error, message: pickResult.message });
|
||
} else {
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: 'not_found', message: `Value "${text}" not in list` });
|
||
}
|
||
} else {
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: 'not_found', message: `Value "${text}" not in list` });
|
||
}
|
||
|
||
// 1C won't let us Tab away from an invalid ref value.
|
||
// Must clear the field first, then Tab to move on.
|
||
// Escape dismisses the cloud; Ctrl+A + Delete clears the text.
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Control+A');
|
||
await page.keyboard.press('Delete');
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
continue;
|
||
}
|
||
|
||
// Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
|
||
const newForm = await helperDetectNewForm(formNum);
|
||
|
||
if (newForm !== null) {
|
||
if (await isTypeDialog(newForm)) {
|
||
// Composite-type cell — need type to proceed
|
||
if (info.type) {
|
||
await pickFromTypeDialog(newForm, info.type);
|
||
await waitForStable(newForm);
|
||
// After type selection, the actual selection form should open
|
||
const selForm = await page.evaluate(`(() => {
|
||
const forms = {};
|
||
document.querySelectorAll('[id]').forEach(el => {
|
||
if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
|
||
const m = el.id.match(/^form(\\d+)_/);
|
||
if (m) forms[m[1]] = true;
|
||
});
|
||
const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
|
||
return nums.length > 0 ? Math.max(...nums) : null;
|
||
})()`);
|
||
if (selForm === null) {
|
||
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||
let hasPopup = null;
|
||
for (let pw = 0; pw < 5; pw++) {
|
||
await page.waitForTimeout(200);
|
||
hasPopup = await page.evaluate(`(() => {
|
||
const calc = document.querySelector('.calculate');
|
||
if (calc && calc.offsetWidth > 0) return 'calculator';
|
||
const cal = document.querySelector('.frameCalendar');
|
||
if (cal && cal.offsetWidth > 0) return 'calendar';
|
||
return null;
|
||
})()`);
|
||
if (hasPopup) break;
|
||
}
|
||
if (hasPopup) {
|
||
await page.keyboard.press('Escape');
|
||
for (let dw = 0; dw < 4; dw++) {
|
||
await page.waitForTimeout(150);
|
||
const gone = await page.evaluate(`(() => {
|
||
const calc = document.querySelector('.calculate');
|
||
if (calc && calc.offsetWidth > 0) return false;
|
||
const cal = document.querySelector('.frameCalendar');
|
||
if (cal && cal.offsetWidth > 0) return false;
|
||
return true;
|
||
})()`);
|
||
if (gone) break;
|
||
}
|
||
}
|
||
const inInput = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
|
||
})()`);
|
||
if (!inInput) {
|
||
const cellRect = await page.evaluate(`(() => {
|
||
const el = document.getElementById(${JSON.stringify(cell.id)});
|
||
if (!el) return null;
|
||
const r = el.getBoundingClientRect();
|
||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||
})()`);
|
||
if (cellRect) {
|
||
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||
for (let fw = 0; fw < 4; fw++) {
|
||
await page.waitForTimeout(150);
|
||
const ok = await page.evaluate(`(() => {
|
||
const f = document.activeElement;
|
||
return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
|
||
})()`);
|
||
if (ok) break;
|
||
}
|
||
}
|
||
}
|
||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||
await page.waitForTimeout(400);
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(300);
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||
continue;
|
||
}
|
||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||
info.filled = true;
|
||
results.push(pickResult.ok
|
||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||
: { field: matchedKey, cell: cell.fullName,
|
||
error: pickResult.error, message: pickResult.message });
|
||
continue;
|
||
} else {
|
||
// No type specified — close dialog, clear cell, report error
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Control+A');
|
||
await page.keyboard.press('Delete');
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName,
|
||
error: 'type_required',
|
||
message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
|
||
continue;
|
||
}
|
||
}
|
||
// Not a type dialog — normal selection form
|
||
const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
|
||
info.filled = true;
|
||
results.push(pickResult.ok
|
||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
|
||
: { field: matchedKey, cell: cell.fullName,
|
||
error: pickResult.error, message: pickResult.message });
|
||
continue;
|
||
}
|
||
|
||
// Plain field — value committed via Tab
|
||
info.filled = true;
|
||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });
|
||
|
||
// All done?
|
||
if ([...pending.values()].every(p => p.filled)) break;
|
||
// Tab already pressed — we're on next cell
|
||
}
|
||
|
||
// Commit the new row: click on the grid header to exit edit mode.
|
||
// Clicking a different data row would re-enter edit mode on that row.
|
||
// Without this commit click, the row stays in "uncommitted add" state
|
||
// and a subsequent Escape (e.g. from closeForm) would cancel the entire row.
|
||
const commitTarget = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return null;
|
||
const head = grid.querySelector('.gridHead');
|
||
if (head) {
|
||
const r = head.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
}
|
||
return null;
|
||
})()`);
|
||
if (commitTarget) {
|
||
await page.mouse.click(commitTarget.x, commitTarget.y);
|
||
await page.waitForTimeout(500);
|
||
} else {
|
||
// Fallback: Tab out of the last cell to commit the row
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
// Dismiss any leftover error modals
|
||
const err = await checkForErrors();
|
||
if (err?.modal) {
|
||
try {
|
||
const btn = await page.$('a.press.pressDefault');
|
||
if (btn) { await btn.click(); await page.waitForTimeout(500); }
|
||
} catch { /* OK */ }
|
||
}
|
||
|
||
const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);
|
||
|
||
// Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
|
||
if (notFilled.length > 0) {
|
||
const checkboxFields = {};
|
||
for (const key of notFilled) {
|
||
const val = String(pending.get(key).value).toLowerCase().trim();
|
||
if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
|
||
checkboxFields[key] = pending.get(key).value;
|
||
}
|
||
}
|
||
if (Object.keys(checkboxFields).length > 0) {
|
||
// Use row index: addedRowIdx (from add mode) or fallback to selected row
|
||
const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return -1;
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!body) return -1;
|
||
const lines = [...body.querySelectorAll('.gridLine')];
|
||
const sel = lines.findIndex(l => l.classList.contains('selected'));
|
||
return sel >= 0 ? sel : lines.length - 1;
|
||
})()`)
|
||
);
|
||
if (currentRow >= 0) {
|
||
const more = await fillTableRow(checkboxFields, { row: currentRow, table });
|
||
if (Array.isArray(more)) {
|
||
results.push(...more);
|
||
} else if (more?.filled) {
|
||
results.push(...more.filled);
|
||
}
|
||
for (const key of Object.keys(checkboxFields)) {
|
||
const idx = notFilled.indexOf(key);
|
||
if (idx >= 0) notFilled.splice(idx, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const formData = await getFormState();
|
||
const result = { filled: results };
|
||
if (notFilled.length > 0) result.notFilled = notFilled;
|
||
result.form = formData;
|
||
return result;
|
||
|
||
} catch (e) {
|
||
if (e.message.startsWith('fillTableRow:')) throw e;
|
||
throw new Error(`fillTableRow: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete a row from the current table part.
|
||
* Single click to select the row, then Delete key to remove it.
|
||
*
|
||
* @param {number} row - 0-based row index to delete
|
||
* @param {Object} [options]
|
||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||
* @returns {{ deleted, rowsBefore, rowsAfter, form }}
|
||
*/
|
||
export async function deleteTableRow(row, { tab, table } = {}) {
|
||
ensureConnected();
|
||
await dismissPendingErrors();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error('deleteTableRow: no form found');
|
||
|
||
// Pre-resolve grid when table is specified
|
||
let gridSelector;
|
||
if (table) {
|
||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||
gridSelector = resolved.gridSelector;
|
||
}
|
||
|
||
// 1. Switch tab if requested
|
||
if (tab) {
|
||
await clickElement(tab);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
// 2. Find the target row and click to select it
|
||
const cellCoords = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return { error: 'no_grid' };
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!body) return { error: 'no_grid_body' };
|
||
const rows = [...body.querySelectorAll('.gridLine')];
|
||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||
const line = rows[${row}];
|
||
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
|
||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||
// Skip first column (row number / checkbox) — pick second visible box
|
||
const box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||
if (!box) return { error: 'no_cell' };
|
||
const cell = box.querySelector('.gridBoxText') || box;
|
||
const r = cell.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
|
||
})()`);
|
||
|
||
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||
|
||
const rowsBefore = cellCoords.total;
|
||
|
||
// Single click to select the row
|
||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||
await page.waitForTimeout(300);
|
||
|
||
// 3. Press Delete to remove the row
|
||
await page.keyboard.press('Delete');
|
||
await waitForStable();
|
||
|
||
// 4. Count rows after deletion
|
||
const rowsAfter = await page.evaluate(`(() => {
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||
if (!grid) return 0;
|
||
const body = grid.querySelector('.gridBody');
|
||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||
})()`);
|
||
|
||
const formData = await getFormState();
|
||
return { deleted: row, rowsBefore, rowsAfter, form: formData };
|
||
}
|
||
|
||
/**
|
||
* Filter the current list by field value, or search via search bar.
|
||
*
|
||
* Without field: simple search via the search bar (filters by all columns, no badge).
|
||
* With field: advanced search — clicks target column cell to auto-populate FieldSelector,
|
||
* opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
|
||
* Handles text, reference (with Tab autocomplete), and date fields automatically.
|
||
* Multiple filters can be chained by calling filterList multiple times.
|
||
*
|
||
* @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
|
||
* @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
|
||
*/
|
||
export async function filterList(text, { field, exact } = {}) {
|
||
ensureConnected();
|
||
await dismissPendingErrors();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error('filterList: no form found');
|
||
|
||
if (!field) {
|
||
// --- Simple search: fill search input + Enter ---
|
||
const searchId = await page.evaluate(`(() => {
|
||
const p = 'form${formNum}_';
|
||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||
return el ? el.id : null;
|
||
})()`);
|
||
|
||
if (searchId) {
|
||
await page.click(`[id="${searchId}"]`);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Control+A');
|
||
await pasteText(text);
|
||
await page.waitForTimeout(300);
|
||
await page.keyboard.press('Enter');
|
||
await waitForStable(formNum);
|
||
|
||
const state = await getFormState();
|
||
state.filtered = { type: 'search', text };
|
||
return state;
|
||
}
|
||
|
||
// No search input — Ctrl+F opens advanced search on such forms.
|
||
// Click first grid cell then fall through to advanced search path below.
|
||
const firstCell = await page.evaluate(`(() => {
|
||
const p = 'form${formNum}_';
|
||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||
.find(g => g.offsetWidth > 0);
|
||
if (!grid) return null;
|
||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||
if (!rows.length) return null;
|
||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||
if (!cells.length) return null;
|
||
const r = cells[0].getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`);
|
||
if (!firstCell) throw new Error('filterList: no search input and no grid found on this form');
|
||
await page.mouse.click(firstCell.x, firstCell.y);
|
||
await page.waitForTimeout(300);
|
||
field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
|
||
}
|
||
|
||
// --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
|
||
// Clicking a cell in the target column makes it active, so when Alt+F opens the
|
||
// advanced search dialog, FieldSelector is auto-populated with the correct field name.
|
||
// This avoids changing FieldSelector programmatically (which can cause errors).
|
||
const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());
|
||
|
||
// 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
|
||
// If the column isn't visible in the grid, click any cell and use DLB fallback later.
|
||
let needDlb = false;
|
||
const gridEl = await page.evaluate(`(() => {
|
||
const p = 'form${formNum}_';
|
||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||
.find(g => g.offsetWidth > 0);
|
||
if (!grid) return { error: 'no_grid' };
|
||
const targetField = ${JSON.stringify(field)};
|
||
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
|
||
let colIndex = -1;
|
||
let startsWithIdx = -1;
|
||
let includesIdx = -1;
|
||
for (let i = 0; i < headers.length; i++) {
|
||
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
|
||
if (!t) continue;
|
||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
|
||
if (tl === fl) { colIndex = i; break; }
|
||
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
|
||
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
|
||
}
|
||
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
|
||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||
if (!rows.length) return { error: 'no_rows' };
|
||
if (colIndex < 0) {
|
||
// Column not in grid — click first cell of first row, will use DLB to change field
|
||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||
if (!cells.length) return { error: 'no_cells' };
|
||
const r = cells[0].getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
|
||
}
|
||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||
if (colIndex >= cells.length) return { error: 'cell_not_found' };
|
||
const r = cells[colIndex].getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`);
|
||
if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
|
||
needDlb = !!gridEl.needDlb;
|
||
await page.mouse.click(gridEl.x, gridEl.y);
|
||
await page.waitForTimeout(500);
|
||
|
||
// 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
|
||
await page.keyboard.press('Alt+f');
|
||
await page.waitForTimeout(2000);
|
||
|
||
let dialogForm = await page.evaluate(detectFormScript());
|
||
if (dialogForm === formNum) {
|
||
// Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
|
||
await clickElement('Еще');
|
||
await page.waitForTimeout(500);
|
||
const menu = await page.evaluate(readSubmenuScript());
|
||
const searchItem = Array.isArray(menu) && menu.find(i =>
|
||
i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск'));
|
||
if (!searchItem) {
|
||
await page.keyboard.press('Escape');
|
||
throw new Error('filterList: advanced search dialog could not be opened');
|
||
}
|
||
await page.mouse.click(searchItem.x, searchItem.y);
|
||
await page.waitForTimeout(2000);
|
||
dialogForm = await page.evaluate(detectFormScript());
|
||
if (dialogForm === formNum) {
|
||
throw new Error('filterList: advanced search dialog did not open');
|
||
}
|
||
}
|
||
|
||
// 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
|
||
// Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
|
||
if (needDlb && field) {
|
||
const fsInfo = await page.evaluate(`(() => {
|
||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||
const dlb = document.getElementById(p + 'FieldSelector_DLB');
|
||
return {
|
||
current: fsInput?.value?.trim() || '',
|
||
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
|
||
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
|
||
};
|
||
})()`);
|
||
|
||
if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
|
||
await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
|
||
await page.waitForTimeout(1500);
|
||
|
||
const ddResult = await page.evaluate(`(() => {
|
||
const edd = document.getElementById('editDropDown');
|
||
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
|
||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||
const items = [...edd.querySelectorAll('div')].filter(el =>
|
||
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
|
||
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|
||
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
|
||
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
|
||
const r = match.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
|
||
})()`);
|
||
|
||
if (ddResult.error) {
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(500);
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(500);
|
||
throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
|
||
}
|
||
await page.mouse.click(ddResult.x, ddResult.y);
|
||
await page.waitForTimeout(3000);
|
||
}
|
||
}
|
||
|
||
// 3. Read dialog state and fill Pattern
|
||
// Detect field type by Pattern's sibling buttons:
|
||
// - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
|
||
// - iDLB on Pattern → reference field (paste + Tab for autocomplete)
|
||
// - neither → plain text field (just paste)
|
||
const dialogInfo = await page.evaluate(`(() => {
|
||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||
const ptLabel = ptInput?.closest('label');
|
||
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
|
||
const isDate = btns.some(c => c.includes('iCalendB'));
|
||
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
|
||
return {
|
||
fieldSelector: fsInput?.value?.trim() || '',
|
||
patternValue: ptInput?.value?.trim() || '',
|
||
patternId: ptInput?.id || '',
|
||
isDate,
|
||
isRef
|
||
};
|
||
})()`);
|
||
|
||
if (dialogInfo.isDate) {
|
||
// Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
|
||
if (isDateValue && dialogInfo.patternValue !== text.trim()) {
|
||
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Home');
|
||
await page.waitForTimeout(100);
|
||
await page.keyboard.press('Shift+End');
|
||
await page.waitForTimeout(100);
|
||
await pasteText(text);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
} else {
|
||
// Text or reference field: fill Pattern via clipboard paste
|
||
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Control+A');
|
||
await pasteText(text);
|
||
await page.waitForTimeout(300);
|
||
|
||
if (dialogInfo.isRef) {
|
||
// Reference field: Tab triggers autocomplete to resolve text → reference value
|
||
await page.keyboard.press('Tab');
|
||
await page.waitForTimeout(2000);
|
||
}
|
||
}
|
||
|
||
// 3b. Switch CompareType if exact match requested (text fields only).
|
||
// Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
|
||
if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
|
||
const exactRadio = await page.evaluate(`(() => {
|
||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||
// Check if CompareType group is disabled (dates, numbers)
|
||
const group = document.getElementById(p + 'CompareType');
|
||
if (group && group.classList.contains('disabled')) return { already: true };
|
||
const el = document.getElementById(p + 'CompareType#2#radio');
|
||
if (!el || el.offsetWidth === 0) return null;
|
||
if (el.classList.contains('select')) return { already: true };
|
||
const r = el.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`);
|
||
if (exactRadio && !exactRadio.already) {
|
||
await page.mouse.click(exactRadio.x, exactRadio.y);
|
||
await page.waitForTimeout(300);
|
||
}
|
||
}
|
||
|
||
// 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
|
||
const findBtnCoords = await page.evaluate(`(() => {
|
||
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
|
||
const btn = btns.find(el => el.innerText?.trim() === 'Найти');
|
||
if (!btn) return null;
|
||
const r = btn.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`);
|
||
if (findBtnCoords) {
|
||
await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
|
||
} else {
|
||
await clickElement('Найти');
|
||
}
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
|
||
// Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
|
||
// (e.g. a selection form that opened this advanced search).
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
const dialogVisible = await page.evaluate(`(() => {
|
||
const p = 'form${dialogForm}_';
|
||
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
|
||
})()`);
|
||
if (!dialogVisible) break;
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(500);
|
||
}
|
||
await waitForStable(formNum);
|
||
|
||
const state = await getFormState();
|
||
state.filtered = { type: 'advanced', field, text, exact: !!exact };
|
||
return state;
|
||
}
|
||
|
||
/**
|
||
* Remove active filters/search from the current list.
|
||
*
|
||
* Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
|
||
* With field: clicks the × button on the specific filter badge (selective removal).
|
||
*
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
|
||
*/
|
||
export async function unfilterList({ field } = {}) {
|
||
ensureConnected();
|
||
await dismissPendingErrors();
|
||
const formNum = await page.evaluate(detectFormScript());
|
||
if (formNum === null) throw new Error('unfilterList: no form found');
|
||
|
||
if (field) {
|
||
// --- Selective: click × on specific filter badge ---
|
||
const closeBtn = await page.evaluate(`(() => {
|
||
const p = 'form${formNum}_';
|
||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
|
||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
|
||
for (const item of items) {
|
||
const titleEl = item.querySelector('.trainName');
|
||
const title = ny(norm(titleEl?.innerText).toLowerCase());
|
||
if (title === target || title.includes(target)) {
|
||
const close = item.querySelector('.trainClose');
|
||
if (close) {
|
||
const r = close.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
|
||
}
|
||
}
|
||
}
|
||
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
|
||
return { error: 'not_found', available };
|
||
})()`);
|
||
|
||
if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
|
||
await page.mouse.click(closeBtn.x, closeBtn.y);
|
||
await waitForStable(formNum);
|
||
|
||
const state = await getFormState();
|
||
state.unfiltered = { field: closeBtn.field };
|
||
return state;
|
||
}
|
||
|
||
// --- Clear ALL filters ---
|
||
|
||
// 1. Remove all advanced filter badges (.trainItem × buttons)
|
||
for (let attempt = 0; attempt < 20; attempt++) {
|
||
const badge = await page.evaluate(`(() => {
|
||
const p = 'form${formNum}_';
|
||
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
|
||
.find(el => el.offsetWidth > 0);
|
||
if (!item) return null;
|
||
const close = item.querySelector('.trainClose');
|
||
if (!close) return null;
|
||
const r = close.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`);
|
||
if (!badge) break;
|
||
await page.mouse.click(badge.x, badge.y);
|
||
await waitForStable(formNum);
|
||
}
|
||
|
||
// 2. Cancel active search via Ctrl+Q
|
||
await page.keyboard.press('Control+q');
|
||
await waitForStable(formNum);
|
||
|
||
// 3. Clear simple search field if it has a value
|
||
const searchInfo = await page.evaluate(`(() => {
|
||
const p = 'form${formNum}_';
|
||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||
return el ? { id: el.id, value: el.value || '' } : null;
|
||
})()`);
|
||
|
||
if (searchInfo?.value) {
|
||
await page.click(`[id="${searchInfo.id}"]`);
|
||
await page.waitForTimeout(200);
|
||
await page.keyboard.press('Control+A');
|
||
await page.keyboard.press('Delete');
|
||
await page.keyboard.press('Enter');
|
||
await waitForStable(formNum);
|
||
}
|
||
|
||
const state = await getFormState();
|
||
state.unfiltered = true;
|
||
return state;
|
||
}
|
||
|
||
// ============================================================
|
||
// Recording, captions, narration, highlight — extracted to recording/*
|
||
// ============================================================
|
||
export {
|
||
screenshot, wait, isRecording, startRecording, stopRecording,
|
||
} from './recording/capture.mjs';
|
||
export {
|
||
showCaption, hideCaption, getCaptions,
|
||
showTitleSlide, hideTitleSlide,
|
||
showImage, hideImage,
|
||
} from './recording/captions.mjs';
|
||
export {
|
||
highlight, unhighlight, setHighlight, isHighlightMode,
|
||
} from './recording/highlight.mjs';
|
||
export { addNarration } from './recording/narration.mjs';
|
||
|
||
/* ensureConnected moved to core/state.mjs */
|