| 702 | 699 | } |
| 703 | 700 | } |
| 704 | 701 | |
| 702 | +/** |
| 703 | + * Extract and join quoted blocks with proper matching pairs and nesting. |
| 704 | + * - Captures outermost quotes and everything inside (including different inner quote styles). |
| 705 | + * - Requires matching opener/closer style (e.g., “ ... ”, 「 ... 」, « ... », etc.). |
| 706 | + * - Ignores incomplete/unclosed quotes (doesn't include them in the result). |
| 707 | + * - Symmetric quotes like "..." and "..." are supported (not nesting the same symmetric style). |
| 708 | + * |
| 709 | + * @param {string} text - The text to process |
| 710 | + * @param {object} [opts={}] - Optional options object |
| 711 | + * @param {string} [opts.separator=' ... '] - String to join multiple quoted blocks |
| 712 | + * @param {boolean} [opts.includeQuotes=true] - Keep the quote chars around the captured text |
| 713 | + * @param {boolean} [opts.returnEmptyOnNoQuotes=false] - Return an empty string if no quotes are found |
| 714 | + * @param {Array<[string,string]>} [opts.pairs] - Custom quote pairs; defaults cover EN/DE/FR/JP |
| 715 | + * @returns {string} The joined quoted blocks, or the original text if no quotes found |
| 716 | + */ |
| 717 | +function joinQuotedBlocks(text, opts = {}) { |
| 718 | + const { |
| 719 | + separator = ' ... ', |
| 720 | + includeQuotes = true, |
| 721 | + returnEmptyOnNoQuotes = false, |
| 722 | + pairs = [ |
| 723 | + // typographic doubles |
| 724 | + ['„', '“'], // DE low-high |
| 725 | + ['“', '”'], // EN |
| 726 | + ['«', '»'], // FR open « close » |
| 727 | + ['»', '«'], // Some locales open » |
| 728 | + // typographic singles |
| 729 | + ['‘', '’'], |
| 730 | + ['‚', '‘'], |
| 731 | + // Japanese corner quotes |
| 732 | + ['「', '」'], |
| 733 | + ['『', '』'], |
| 734 | + // symmetric doubles |
| 735 | + ['"', '"'], |
| 736 | + ['"', '"'], |
| 737 | + ], |
| 738 | + } = opts; |
| 739 | + |
| 740 | + if (!text || typeof text !== 'string') return text; |
| 741 | + |
| 742 | + const openToClose = Object.fromEntries(pairs); |
| 743 | + |
| 744 | + const segments = []; |
| 745 | + const stack = []; // [{ opener, expectedClose, start }] |
| 746 | + for (let i = 0; i < text.length; i++) { |
| 747 | + const ch = text[i]; |
| 748 | + const top = stack[stack.length - 1]; |
| 749 | + |
| 750 | + // Prefer closing the current open pair if the char matches its expected closer |
| 751 | + if (top && ch === top.expectedClose) { |
| 752 | + const finished = stack.pop(); |
| 753 | + if (stack.length === 0) { |
| 754 | + // Only collect outermost quotes (contains all nested content) |
| 755 | + segments.push(text.slice(finished.start, i + 1)); |
| 756 | + } |
| 757 | + continue; |
| 758 | + } |
| 759 | + |
| 760 | + // Otherwise, see if this is a new opener |
| 761 | + if (openToClose[ch]) { |
| 762 | + stack.push({ opener: ch, expectedClose: openToClose[ch], start: i }); |
| 763 | + continue; |
| 764 | + } |
| 765 | + |
| 766 | + // If it's a stray closer that doesn't match current top, ignore |
| 767 | + } |
| 768 | + |
| 769 | + if (!segments.length) return returnEmptyOnNoQuotes ? '' : text; |
| 770 | + |
| 771 | + const cleaned = includeQuotes |
| 772 | + ? segments |
| 773 | + : segments.map(s => s.slice(1, -1)); // all defined pairs are single-char quotes |
| 774 | + |
| 775 | + return cleaned.join(separator); |
| 776 | +} |
| 777 | + |
| 705 | 778 | async function playFullConversation() { |
| 706 | 779 | resetTtsPlayback(); |
| 707 | 780 | |