mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-05 18:58:57 +03:00
refactor(web-test): этап C.11 — table/spreadsheet.mjs + table/filter.mjs
table/spreadsheet.mjs (~580 LOC):
- readTable, readSpreadsheet
- scanSpreadsheetCells, buildSpreadsheetMapping (private)
- scrollSpreadsheetToCell, clickSpreadsheetCell, findSpreadsheetCellByText
table/filter.mjs (~390 LOC): filterList, unfilterList
После C.11 + C.10 clean-up:
- core/click.mjs импортит clickSpreadsheetCell/findSpreadsheetCellByText
напрямую из table/spreadsheet.mjs (а не из browser.mjs)
- browser.mjs больше не реэкспортирует эти два — публичный API
остаётся 56 экспортов как до рефакторинга
- Добавил import { clickElement } в browser.mjs для внутренних вызовов
из fillTableRow/deleteTableRow
browser.mjs: 2470 → 1532 LOC (≈75% от исходных 6293).
05-table + 09-filter регресс зелёный.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,581 +181,11 @@ export async function getFormState() {
|
||||
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 }));
|
||||
}
|
||||
// ============================================================
|
||||
// Table reading + SpreadsheetDocument — extracted to table/spreadsheet.mjs
|
||||
// ============================================================
|
||||
export { readTable, readSpreadsheet } from './table/spreadsheet.mjs';
|
||||
|
||||
// --- 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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
|
||||
@@ -778,6 +208,7 @@ export { fillFields, fillField } from './forms/fill.mjs';
|
||||
// clickElement dispatcher — extracted to core/click.mjs
|
||||
// ============================================================
|
||||
export { clickElement } from './core/click.mjs';
|
||||
import { clickElement } from './core/click.mjs';
|
||||
|
||||
// ============================================================
|
||||
// Close form — extracted to forms/close.mjs
|
||||
@@ -2074,382 +1505,11 @@ export async function deleteTableRow(row, { tab, table } = {}) {
|
||||
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');
|
||||
// ============================================================
|
||||
// List filters — extracted to table/filter.mjs
|
||||
// ============================================================
|
||||
export { filterList, unfilterList } from './table/filter.mjs';
|
||||
|
||||
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/*
|
||||
|
||||
@@ -12,10 +12,11 @@ import { waitForStable, startNetworkMonitor } from './wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { safeClick } from './helpers.mjs';
|
||||
import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs';
|
||||
// Spreadsheet cell handlers and getFormState live in browser.mjs.
|
||||
import {
|
||||
clickSpreadsheetCell, findSpreadsheetCellByText, getFormState,
|
||||
} from '../browser.mjs';
|
||||
clickSpreadsheetCell, findSpreadsheetCellByText,
|
||||
} from '../table/spreadsheet.mjs';
|
||||
// getFormState still in browser.mjs.
|
||||
import { getFormState } from '../browser.mjs';
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// web-test table/filter v1.16 — filterList / unfilterList — simple search + advanced-column filter badges.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs';
|
||||
import { detectFormScript, resolveGridScript } from '../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { safeClick } from '../core/helpers.mjs';
|
||||
import { selectValue, fillReferenceField } from '../forms/select-value.mjs';
|
||||
// pasteText + getFormState + clickElement still in browser.mjs.
|
||||
import { pasteText, getFormState, clickElement } from '../browser.mjs';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// web-test table/spreadsheet v1.16 — readTable, readSpreadsheet, scanSpreadsheetCells, scroll/click helpers for SpreadsheetDocument.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, ensureConnected, normYo } from '../core/state.mjs';
|
||||
import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user