pdf-tei-editor / app /src /modules /utils.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
17.6 kB
export class ApiError extends Error {}
export class UserAbortException extends Error {}
export function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[$]/g, '\\$&');
}
/**
* @typedef {object} ParsedXPathStepPartsSimple
* @property {string} parentPath - The XPath path expression leading up to this specific step (e.g., '/root/child').
* @property {string} finalStep - The core name part of the XPath step (e.g., 'elementName', 'prefix:elementName',
* '@attribute', 'text()', '*').
* @property {string} prefix - The namespace prefix associated with the 'finalStep' or the node test,
* if present (e.g., 'prefix' from 'prefix:elementName').
* @property {string} tagName - The local name part of the node test (e.g., 'elementName' from 'prefix:elementName', or
* 'attribute' from '@attribute'). Note: May be empty for node tests that don't have a name like `text()`.
* @property {string} positionalPredicate - The final positional predicate
* @property {number} index - The primary numerical index extracted from a positional predicate like `[1]` or
* `[position()=n]`. Defaults to null if no explicit index predicate is found for the node type where position matters.
* @property {string} indexParent - The xpath leading up to, but not including any existing positional predicate
*/
/**
* @typedef {object} ParsedXPathStepParts
* @property {string} input - The XPath as passed in, for debugging purposes only
* @property {string} parentPath - The XPath path expression leading up to this specific step (e.g., '/root/child'). Includes the trailing separator if present, or is empty for relative paths starting with nodeTest.
* @property {string} finalStep - The complete string representation of the last step, including node test and predicates (e.g., 'elementName[1]', '@attribute', 'text()').
* @property {string} nodeTest - The core part of the XPath step before predicates (e.g., 'elementName', 'prefix:elementName', '@attribute', 'text()', '*', '.', '..').
* @property {string} prefix - The namespace prefix associated with the node test, if present (e.g., 'prefix' from 'prefix:elementName'). Empty string if none.
* @property {string} tagName - The local name part of the node test (e.g., 'elementName' from 'prefix:elementName', 'attribute' from '@attribute'). For functions like text(), this will be the function name ('text()'). For wildcards, '*'. For self/parent, '.' or '..'. Empty string if the node test structure doesn't have a name part.
* @property {string} predicates - The full string containing all predicates associated with the last step (e.g., '[1][@id="xyz"]'). Empty string if none.
* @property {string} nonIndexPredicates - The predicates that qualify the tagName but do not contain the index of the node among siblings
* @property {number | null} index - The primary numerical index extracted from the *first* positional predicate like `[1]` or `[position()=n]` found in the predicates. Defaults to null if no such predicate is found or can't be parsed.
* @property {string} pathBeforePredicates - The xpath leading up to, but not including any predicates for this step (i.e., parentPath + nodeTest).
*/
/**
* Parses an XPath expression focusing on the last step.
* Identifies the parent path, the full string representation of the last step,
* separates the node test from predicates, identifies node test components
* (prefix, local name/wildcard/function/special name), and extracts
* the index from the first simple positional predicate if present.
*
* @param {string} xpath An XPath expression (e.g., '/html/body/div[1]/span[@class="text"][position()=2]/text()')
* @returns {ParsedXPathStepParts} An object containing the parsed components of the XPath step.
* @throws {Error} If the xpath is empty, ends in a separator, or cannot be parsed due to syntax errors (mismatched brackets, invalid node test).
*/
export function parseXPath(xpath) {
if (!xpath) {
throw new Error("No xpath given");
}
// --- Step 1: Find the split point for the last step ---
// Iterate backwards to find the last '/' or '//' that is outside quotes and brackets.
// This correctly identifies the full final step string including all its predicates.
let lastStepStartIndex = 0; // Assume relative path by default
let bracketDepth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = xpath.length - 1; i >= 0; i--) {
const char = xpath[i];
// Check for quotes first
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue; // Move to the next character
} else if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue; // Move to the next character
}
// If not inside quotes, check for brackets and separators
if (!inSingleQuote && !inDoubleQuote) {
if (char === ']') {
bracketDepth++;
} else if (char === '[') {
bracketDepth--;
if (bracketDepth < 0) {
throw new Error(`Cannot parse xpath: Mismatched '[' detected near index ${i} in "${xpath}"`);
}
} else if (bracketDepth === 0) {
// Found a character outside quotes and depth-0 brackets
if (char === '/') {
// Found a separator
if (i > 0 && xpath[i-1] === '/') {
lastStepStartIndex = i - 1; // Start of //
} else {
lastStepStartIndex = i; // Start of /
}
break; // Found the last separator, we can stop
}
}
}
}
// If bracketDepth is not zero at the end, quotes/brackets are mismatched.
if (bracketDepth !== 0) {
throw new Error(`Cannot parse xpath: Mismatched brackets detected in "${xpath}"`);
}
// --- Step 1.5: Determine parentPath and finalStepString based on the split point ---
let finalStepString;
let parentPath;
let separatorLength = 0; // Length of the separator found (1 for '/', 2 for '//')
// If lastStepStartIndex is > 0, the loop found the start of the separator ('/' or '//')
if (lastStepStartIndex > 0) {
// Determine if it was '/' or '//'
if (xpath[lastStepStartIndex] === '/') { // It must be a '/'
if (lastStepStartIndex > 0 && xpath[lastStepStartIndex - 1] === '/') {
separatorLength = 2; // Was '//', lastStepStartIndex was set to i-1
} else {
separatorLength = 1; // Was '/', lastStepStartIndex was set to i
}
} else {
// Should not happen based on loop logic finding '/' at depth 0
throw new Error(`Internal parsing error: Separator character expected at index ${lastStepStartIndex} in "${xpath}"`);
}
parentPath = xpath.substring(0, lastStepStartIndex + separatorLength);
finalStepString = xpath.substring(lastStepStartIndex + separatorLength);
} else if (xpath.startsWith('/') || xpath.startsWith('//')) {
// No separator found by the loop > index 0, but xpath starts with '/' or '//'.
// This means the whole path is the last step, but it's absolute or descendant.
// The separator is at the beginning.
if (xpath.startsWith('//')) {
separatorLength = 2;
} else { // Starts with single /
separatorLength = 1;
}
// parentPath includes the leading separator(s)
parentPath = xpath.substring(0, separatorLength);
// finalStepString is everything after the leading separator(s)
finalStepString = xpath.substring(separatorLength);
} else {
// No separator found > index 0, and doesn't start with / or //.
// It's a relative path step (e.g., "div", "@id", "../foo").
parentPath = "";
finalStepString = xpath;
}
// Handle the edge case where finalStepString is empty, which is not a valid step itself.
if (!finalStepString) {
// This happens for paths like '/', '/a/b/', '/a/b//'
throw new Error(`Cannot parse xpath: "${xpath}" results in an empty final step.`);
}
// --- Step 2: Split finalStepString into node test and predicates ---
// Find the index of the *first* '[' that is not inside quotes or brackets *within* finalStepString.
let predicatesStartIndex = finalStepString.length; // Assume no predicates initially
bracketDepth = 0; // Reset for scanning finalStepString
inSingleQuote = false; // Reset
inDoubleQuote = false; // Reset
for (let i = 0; i < finalStepString.length; i++) {
const char = finalStepString[i];
// Check for quotes
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
} else if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
// If not inside quotes, check for brackets
if (!inSingleQuote && !inDoubleQuote) {
if (char === '[') {
if (bracketDepth === 0) {
// Found the first top-level predicate start
predicatesStartIndex = i;
break; // Stop scanning, found the split point
}
bracketDepth++;
} else if (char === ']') {
bracketDepth--;
// Basic check, more rigorous validation of predicate syntax isn't the goal here.
if (bracketDepth < 0) {
throw new Error(`Cannot parse xpath: Mismatched ']' detected in predicates "${finalStepString.substring(predicatesStartIndex)}" from "${xpath}"`);
}
}
}
}
// Check for mismatched brackets within the predicates part (only if predicates exist)
if (predicatesStartIndex < finalStepString.length && bracketDepth !== 0) {
throw new Error(`Cannot parse xpath: Unbalanced brackets detected in predicates "${finalStepString.substring(predicatesStartIndex)}" from "${xpath}"`);
}
const nodeTestString = finalStepString.substring(0, predicatesStartIndex);
const predicates = finalStepString.substring(predicatesStartIndex);
// --- Step 3: Parse nodeTestString ---
// Regex specifically for the node test part (no predicates).
// Catches: @?, prefix:?, name, *, text(), node(), comment(), processing-instruction(), ., ..
// Group indices (mapping to nodeTestMatch array indices):
// [0]: Full match (same as nodeTestString)
// [1]: (@?) --> nodeTestMatch[1] (isAttribute)
// [2]: ([a-zA-Z0-9-_.:*]+) --> nodeTestMatch[2] (prefix)
// [3]: ([a-zA-Z0-9-_.:*]+|\*) --> nodeTestMatch[3] (name/wildcard)
// [4]: (text\(\)|comment\(\)|processing-instruction\(\)|node\(\)) --> nodeTestMatch[4] (function name)
// [5]: (\.|\.\.) --> nodeTestMatch[5] (. or ..)
const nodeTestRegex = /^(?:(@?)(?:(?:([a-zA-Z0-9-_.:*]+):)?([a-zA-Z0-9-_.:*]+|\*))|(text\(\)|comment\(\)|processing-instruction\(\)|node\(\))|(\.|\.\.))$/;
const nodeTestMatch = nodeTestString.match(nodeTestRegex);
if (!nodeTestMatch) {
// This means the nodeTestString part (e.g., "biblStruct" or "@id" or "text()")
// did not match any known node test pattern. This indicates a syntax error
// in the node test part itself.
throw new TypeError(`Cannot parse node test "${nodeTestString}" from XPath "${xpath}". Node test did not match expected pattern.`);
}
// Correctly assign variables based on the regex groups
// Use default values for safety if a group is unexpectedly missing
const matchedPrefix = nodeTestMatch[2] || ''; // Group 2: Optional prefix (if element/attribute)
const matchedName = nodeTestMatch[3]; // Group 3: Local name / wildcard (if element/attribute)
const matchedFunctionName = nodeTestMatch[4]; // Group 4: Function name (if node type function)
const matchedSpecialStep = nodeTestMatch[5]; // Group 5: '.' or '..'
// Determine the 'tagName' property value based on which part matched
let finalTagName = ''; // Initialise as empty string as requested
if (matchedSpecialStep) {
finalTagName = matchedSpecialStep; // Assigns '.', '..'
} else if (matchedFunctionName) {
finalTagName = matchedFunctionName; // Assigns 'text()', 'node()', etc.
} else if (matchedName !== undefined) {
finalTagName = matchedName; // Assigns 'elementName', 'attributeName', '*'
}
// If none matched (shouldn't happen for valid nodeTestString), finalTagName remains ''.
// --- Step 4: Extract index from predicates string ---
// Look for the *first* occurrence of [number] or [position()=number]
// within the 'predicates' string.
const indexRegex = /\[\s*(?:(\d+)|position\(\)\s*=\s*(\d+))\s*\]/;
const indexMatch = predicates.match(indexRegex);
let nonIndexPredicates = predicates
let finalIndex = null; // Use a different variable name for the final property value
if (indexMatch) {
// indexMatch[1] is the number from [number]
// indexMatch[2] is the number from [position()=number]
const indexStr = indexMatch[1] || indexMatch[2];
const parsedIndex = parseInt(indexStr, 10);
if (!isNaN(parsedIndex)) {
finalIndex = parsedIndex;
}
// remove index predicates
nonIndexPredicates = predicates.replace(indexMatch[0], '')
}
// --- Step 5: Calculate pathBeforePredicates ---
const finalPathBeforePredicates = parentPath + nodeTestString;
return {
input: xpath,
parentPath,
finalStep: finalStepString,
nodeTest: nodeTestString,
prefix: matchedPrefix,
tagName: finalTagName,
predicates,
nonIndexPredicates,
index: finalIndex,
pathBeforePredicates: finalPathBeforePredicates,
};
}
/**
* Checks if the set of nodes selected by xpath1 is a subset of the
* set of nodes selected by xpath2, within the context of a specific
* XML document root node.
*
* @param {string} xpath1 The XPath expression whose result set is checked for being a subset.
* @param {string} xpath2 The XPath expression whose result set is checked for being a superset.
* @param {Document | Element} rootNode The XML document or element node within which to evaluate the XPath expressions.
* @param {XPathNSResolver | null} [namespaceResolver=null] An optional function to resolve namespaces (important for XML).
* @returns {boolean} True if every node selected by xpath1 is also selected by xpath2, false otherwise or if evaluation fails.
*/
export function isXPathSubset(xpath1, xpath2, rootNode, namespaceResolver = null) {
if (!rootNode) {
console.error("XPath subset check requires a root node (Document or Element).");
return false;
}
try {
// Evaluate the first XPath expression (potential subset)
// @ts-ignore
const result1 = rootNode.evaluate(
xpath1,
rootNode, // Context node
namespaceResolver,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, // Get nodes as a snapshot
null // No need to reuse a result object
);
// If xpath1 selects no nodes, its result set is the empty set,
// which is a subset of any set, so return true.
if (result1.snapshotLength === 0) {
return true;
}
// Evaluate the second XPath expression (potential superset)
// @ts-ignore
const result2 = rootNode.evaluate(
xpath2,
rootNode, // Context node
namespaceResolver,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, // Get nodes as a snapshot
null // No need to reuse a result object
);
// If xpath1 selected nodes, but xpath2 selected no nodes,
// xpath1's set cannot be a subset of xpath2's empty set.
if (result2.snapshotLength === 0) {
return false;
}
// Create a Set of nodes from the second result for efficient lookup
const nodes2Set = new Set();
for (let i = 0; i < result2.snapshotLength; i++) {
nodes2Set.add(result2.snapshotItem(i));
}
// Check if every node in the first result is present in the set of the second result
for (let i = 0; i < result1.snapshotLength; i++) {
const node1 = result1.snapshotItem(i);
if (!nodes2Set.has(node1)) {
// Found a node from xpath1 that is NOT in xpath2's set
return false;
}
}
// If the loop completed, all nodes from xpath1 were found in xpath2's set
return true;
} catch (error) {
// Handle potential errors during XPath evaluation (e.g., invalid syntax)
console.error(`Error evaluating XPath expressions: ${xpath1} or ${xpath2}`, error);
// If evaluation fails for either, we can't determine the subset relationship
return false;
}
}
function dashToCamelCase(str) {
if (typeof str !== 'string' || str.length === 0) {
return "";
}
const parts = str.split('-');
let camelCaseResult = "";
let isFirstPartProcessed = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.length === 0) {
continue;
}
if (!isFirstPartProcessed) {
camelCaseResult += part;
isFirstPartProcessed = true;
} else {
camelCaseResult += part[0].toUpperCase() + part.slice(1);
}
}
return camelCaseResult;
}
// DOI and filename encoding utilities — implementations live in doi-utils.js
export {
DOI_REGEX,
isDoi,
validateDoi,
normalizeDoi,
extractDoi,
encodeFilename,
decodeFilename,
isFilenameEncoded,
encodeForXmlId,
decodeFromXmlId,
normalizeLegacyEncoding,
} from './doi-utils.js'