diff --git a/web/js/utils.js b/web/js/utils.js index 5207d54..50204ff 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -1,4 +1,6 @@ // --- String Helper Functions --- +const REG_WILDCARD_WEIGHTED_TAG = /(\d+)[_\s]*::(.*?)(?=\||$)/g; +const REG_WILDCARD_SIMPLE_WORD = /[^{}_|]+/g; /** * Converts Hiragana to Katakana. @@ -179,16 +181,113 @@ export function isValidTag(tag) { return true; } +/** + * Recursively finds all words in a string, even those inside nested braces. + * This helps extract all possible tags from complex nested wildcards. + * @param {string} text The text to extract words from + * @param {number} baseStart The starting position of the text in the original string + * @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags + */ +function extractAllWords(text, baseStart) { + const result = []; + + // First, extract weighted tag patterns like "20::from above" + let weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text); + while (weightMatch !== null) { + const tagText = weightMatch[2].trim(); + + if (tagText) { + // Calculate position with original offsets + const fullMatchStart = baseStart + weightMatch.index; + const tagTextStart = fullMatchStart + weightMatch[0].indexOf(tagText); + const tagTextEnd = tagTextStart + tagText.length; + + result.push({ + start: tagTextStart, + end: tagTextEnd, + tag: tagText + }); + } + + weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text); + } + + // If no weighted tags were found, extract simple words + if (result.length === 0) { + // Regular expression to match words (sequences of non-whitespace characters) + // We consider a word to be any continuous sequence of characters that's not a space, pipe, or brace + const wordRegex = REG_WILDCARD_SIMPLE_WORD; + let match; + + // Find all standalone words in the text + while ((match = wordRegex.exec(text)) !== null) { + const wordStart = baseStart + match.index; + const wordEnd = wordStart + match[0].length; + + // Remove leading and trailing spaces from the matched word + const trimmedTag = match[0].trim(); + const leadingSpaces = match[0].length - match[0].trimStart().length; + const adjustedStart = wordStart + leadingSpaces; + const adjustedEnd = wordStart + leadingSpaces + trimmedTag.length; + + result.push({ + start: adjustedStart, + end: adjustedEnd, + tag: trimmedTag + }); + } + } + + return result; +} + +/** + * Parses a wildcard selection and returns individual tags. + * Supports syntax like {tag1|tag2|tag3} and {weight::tag1|weight::tag2}. + * Also handles nested wildcards like {tag1 {tag2|tag3}|tag4}. + * @param {string} tag The complete tag text that might contain a wildcard + * @param {number} startPos The starting position of the tag in the original text + * @param {number} endPos The ending position of the tag in the original text + * @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags or null + */ +function parseWildcardSelection(tag, startPos, endPos) { + // Trim the tag for matching but keep original position + const trimmedTag = tag.trim(); + + // Check if this is a wildcard selection + if (!trimmedTag.startsWith('{') || !trimmedTag.endsWith('}')) { + return null; // Not a wildcard + } + + // Calculate position offsets for the trim operation + const leadingSpaces = tag.length - tag.trimStart().length; + const tagStart = startPos + leadingSpaces; + + // For nested wildcards, we'll extract all words from the content + // This treats each word as a separate tag, regardless of nesting + // Extract the content between the outermost braces + const wildcardContent = trimmedTag.substring(1, trimmedTag.length - 1); + + // Extract all words from the wildcard content, including those in nested structures + const allTags = extractAllWords(wildcardContent, tagStart + 1); + + return allTags.length > 0 ? allTags : null; +} + /** * Finds all tag positions in the given text. * Searches for tags separated by commas or newlines. + * Also handles wildcard selections in the format {tag1|tag2|tag3}. * @param {string} text The text to search in * @returns {Array<{start: number, end: number, tag: string}>} Array of tag positions and content */ export function findAllTagPositions(text) { + if (!text) return []; + const positions = []; let startPos = 0; - + + // Process text segment by segment (comma or newline separated) while (startPos < text.length) { // Skip any leading whitespace, commas, or newlines while (startPos < text.length && @@ -206,14 +305,26 @@ export function findAllTagPositions(text) { if (endPosNewline === -1) endPosNewline = text.length; const endPos = Math.min(endPosComma, endPosNewline); - const tag = text.substring(startPos, endPos); + const tagText = text.substring(startPos, endPos); - if (tag.trim().length > 0) { - positions.push({ - start: startPos, - end: endPos, - tag: tag - }); + if (tagText.trim().length > 0) { + const trimmedTag = tagText.trim(); + + // Check if this is a wildcard selection + if (trimmedTag.startsWith('{') && trimmedTag.endsWith('}')) { + // Process wildcard using our existing wildcard parser + const wildcardTags = parseWildcardSelection(tagText, startPos, endPos); + if (wildcardTags) { + positions.push(...wildcardTags); + } + } else { + // Normal tag, add it directly + positions.push({ + start: startPos, + end: endPos, + tag: tagText + }); + } } // Move to the next tag @@ -222,6 +333,7 @@ export function findAllTagPositions(text) { return positions; } + /** * Extracts existing tags from the textarea with search normalization (possibly duplicated). * @param {HTMLTextAreaElement} textarea The textarea element to extract tags from