| 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 | async function playFullConversation() { | 778 | async function playFullConversation() { |
| 706 | resetTtsPlayback(); | 779 | resetTtsPlayback(); |
| 707 | | 780 | |