diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 5adc2c2..b8de028 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -4,7 +4,7 @@ import json import re import unicodedata from fractions import Fraction -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple try: # pragma: no cover - optional dependency guard from num2words import num2words @@ -16,6 +16,17 @@ if TYPE_CHECKING: # pragma: no cover - type checking only from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions +# ---------- Contraction Category Defaults ---------- + +CONTRACTION_CATEGORY_DEFAULTS: Dict[str, bool] = { + "contraction_aux_be": True, + "contraction_aux_have": True, + "contraction_modal_will": True, + "contraction_modal_would": True, + "contraction_negation_not": True, + "contraction_let_us": True, +} + # ---------- Configuration Dataclass ---------- @dataclass @@ -38,44 +49,44 @@ class ApostropheConfig: protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc. convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words number_lang: str = "en" # num2words language code + contraction_categories: Dict[str, bool] = field(default_factory=lambda: dict(CONTRACTION_CATEGORY_DEFAULTS)) + + def is_contraction_enabled(self, category: str) -> bool: + return self.contraction_categories.get(category, True) # ---------- Dictionaries / Patterns ---------- -# Common contraction expansions (straightforward unambiguous) -CONTRACTIONS_EXACT = { - "let's": "let us", - "i'm": "i am", - "you're": "you are", - "we're": "we are", - "they're": "they are", - "i've": "i have", - "you've": "you have", - "we've": "we have", - "they've": "they have", - "i'll": "i will", - "you'll": "you will", - "he'll": "he will", - "she'll": "she will", - "we'll": "we will", - "they'll": "they will", - "can't": "can not", # or "cannot" - "won't": "will not", - "don't": "do not", - "doesn't": "does not", - "didn't": "did not", - "isn't": "is not", - "aren't": "are not", - "wasn't": "was not", - "weren't": "were not", - "haven't": "have not", - "hasn't": "has not", - "hadn't": "had not", - "couldn't": "could not", - "shouldn't": "should not", - "wouldn't": "would not", - "mustn't": "must not", - "mightn't": "might not", - "shan't": "shall not", +# Common contraction expansions (type + expansion words) +CONTRACTION_LEXICON: Dict[str, Tuple[str, Tuple[str, ...]]] = { + "let's": ("contraction_let_us", ("let", "us")), + "can't": ("contraction_negation_not", ("can", "not")), + "won't": ("contraction_negation_not", ("will", "not")), + "don't": ("contraction_negation_not", ("do", "not")), + "doesn't": ("contraction_negation_not", ("does", "not")), + "didn't": ("contraction_negation_not", ("did", "not")), + "isn't": ("contraction_negation_not", ("is", "not")), + "aren't": ("contraction_negation_not", ("are", "not")), + "wasn't": ("contraction_negation_not", ("was", "not")), + "weren't": ("contraction_negation_not", ("were", "not")), + "haven't": ("contraction_negation_not", ("have", "not")), + "hasn't": ("contraction_negation_not", ("has", "not")), + "hadn't": ("contraction_negation_not", ("had", "not")), + "couldn't": ("contraction_negation_not", ("could", "not")), + "shouldn't": ("contraction_negation_not", ("should", "not")), + "wouldn't": ("contraction_negation_not", ("would", "not")), + "mustn't": ("contraction_negation_not", ("must", "not")), + "mightn't": ("contraction_negation_not", ("might", "not")), + "shan't": ("contraction_negation_not", ("shall", "not")), +} + +SUFFIX_CONTRACTION_RULES: Tuple[Tuple[str, str, str], ...] = ( + ("'ll", "will", "contraction_modal_will"), + ("'re", "are", "contraction_aux_be"), + ("'ve", "have", "contraction_aux_have"), +) + +SUFFIX_CONTRACTION_BASES: Dict[str, Tuple[str, ...]] = { + "'m": ("i",), } # For ambiguous 'd and 's we handle separately @@ -223,7 +234,22 @@ def _replace_fraction(match: re.Match[str], language: str) -> str: return match.group(0) return spoken AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"} -AMBIGUOUS_S_BASES = {"it","that","what","where","who","when","how","there","here"} +AMBIGUOUS_S_BASES = { + "it", + "that", + "what", + "where", + "who", + "when", + "how", + "there", + "here", + "he", + "she", + "we", + "they", + "you", +} def _is_ambiguous_d(token: str) -> bool: @@ -544,25 +570,127 @@ def is_cultural_name(token: str, cfg: ApostropheConfig) -> bool: return True return False + +def _case_preserving_words(original: str, words: Sequence[str]) -> str: + if not words: + return "" + if original.isupper(): + return " ".join(word.upper() for word in words) + + if original[:1].isupper(): + adjusted = [words[0].capitalize()] + if len(words) > 1: + adjusted.extend(words[1:]) + return " ".join(adjusted) + + return " ".join(words) + + +def _apply_contraction_policy( + token: str, + *, + category: str, + cfg: ApostropheConfig, + expand: Callable[[], str], + collapse: Optional[str] = None, +) -> str: + mode = cfg.contraction_mode + if mode == "collapse": + return collapse if collapse is not None else token.replace("'", "") + if mode != "expand": + return token + if not cfg.is_contraction_enabled(category): + return token + return expand() + + +def _assemble_contraction_expansion(base_text: str, surface_text: str, expansion_word: str) -> str: + if not expansion_word: + return base_text + + if surface_text.isupper() and expansion_word.isalpha(): + adjusted = expansion_word.upper() + elif len(surface_text) > 2 and surface_text[:-2].istitle() and expansion_word: + adjusted = expansion_word.lower() + else: + adjusted = expansion_word + + return f"{base_text} {adjusted}".strip() + + +def _classify_ambiguous_d(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: + base = token[:-2] + collapse_value = base + "d" + + if cfg.contraction_mode == "collapse": + return "contraction_modal_would", collapse_value + if cfg.contraction_mode != "expand": + return "contraction_modal_would", token + + mode = cfg.ambiguous_past_modal_mode + if mode == "expand_prefer_had": + candidates = [ + ("contraction_aux_have", "had"), + ("contraction_modal_would", "would"), + ] + elif mode == "expand_prefer_would": + candidates = [ + ("contraction_modal_would", "would"), + ("contraction_aux_have", "had"), + ] + else: # contextual + candidates = [ + ("contraction_modal_would", "would"), + ("contraction_aux_have", "had"), + ] + + for category, word in candidates: + if not cfg.is_contraction_enabled(category): + continue + expanded = _assemble_contraction_expansion(base, token, word) + return category, expanded + + # If every category is disabled, leave the token as-is but report default category + return candidates[0][0], token + + +def _classify_ambiguous_s(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: + base = token[:-2] + + if cfg.contraction_mode == "collapse": + return "contraction_aux_be", base + "s" + if cfg.contraction_mode != "expand": + return "contraction_aux_be", token + + candidates = [ + ("contraction_aux_be", "is"), + ("contraction_aux_have", "has"), + ] + + for category, word in candidates: + if not cfg.is_contraction_enabled(category): + continue + expanded = _assemble_contraction_expansion(base, token, word) + return category, expanded + + return candidates[0][0], token + def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: """ Classify apostrophe usage and propose normalized form. Returns (category, normalized_token_or_same). - Categories: contraction, ambiguous_contraction_s, ambiguous_contraction_d, - plural_possessive, irregular_possessive, sibilant_possessive, - singular_possessive, acronym_possessive, decade, leading_elision, - fantasy_internal, other + Categories include: contraction_* variants, plural_possessive, irregular_possessive, + sibilant_possessive, singular_possessive, acronym_possessive, decade, leading_elision, + fantasy_internal, cultural_name, other. """ if "'" not in token: return "other", token - raw = token low = token.lower() # 1. Decades if DECADE_RE.match(token): if cfg.decades_mode == "expand": - # '90s -> 1990s (you could also choose 90s) return "decade", f"19{token[2:4]}s" return "decade", token @@ -574,103 +702,108 @@ def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: # 3. Ambiguous 'd contractions if _is_ambiguous_d(token): - base = low[:-2] - mode = cfg.ambiguous_past_modal_mode - if cfg.contraction_mode == "collapse": - return "ambiguous_contraction_d", base + "d" - if cfg.contraction_mode == "expand": - if mode == "expand_prefer_would": - return "ambiguous_contraction_d", base + " would" - if mode == "expand_prefer_had": - return "ambiguous_contraction_d", base + " had" - if mode == "contextual": - return "ambiguous_contraction_d", base + " would" - return "ambiguous_contraction_d", token + return _classify_ambiguous_d(token, cfg) # 4. Ambiguous 's contractions if _is_ambiguous_s(token): - base = low[:-2] - if cfg.contraction_mode == "expand": - return "ambiguous_contraction_s", base + " is" - if cfg.contraction_mode == "collapse": - return "ambiguous_contraction_s", base + "s" - return "ambiguous_contraction_s", token + return _classify_ambiguous_s(token, cfg) - # 5. Exact contraction - if low in CONTRACTIONS_EXACT: - if cfg.contraction_mode == "expand": - return "contraction", CONTRACTIONS_EXACT[low] - elif cfg.contraction_mode == "collapse": - # collapse: remove apostrophe only (he's -> hes) - return "contraction", low.replace("'", "") - else: - return "contraction", token + # 5. Lexicon-based contractions + lex_entry = CONTRACTION_LEXICON.get(low) + if lex_entry is not None: + category, words = lex_entry - # 6. Irregular possessives (keep or expand logic) + def _expand() -> str: + return _case_preserving_words(token, words) + + collapse_value = token.replace("'", "") + normalized = _apply_contraction_policy(token, category=category, cfg=cfg, expand=_expand, collapse=collapse_value) + return category, normalized + + # 6. Suffix contractions ('m handled separately) + if low.endswith("'m") and low[:-2] in SUFFIX_CONTRACTION_BASES.get("'m", ()): # pronoun I'm + + def _expand_m() -> str: + base = token[:-2] + return _assemble_contraction_expansion(base, token, "am") + + normalized = _apply_contraction_policy( + token, + category="contraction_aux_be", + cfg=cfg, + expand=_expand_m, + collapse=token.replace("'", ""), + ) + return "contraction_aux_be", normalized + + for suffix, append_word, category in SUFFIX_CONTRACTION_RULES: + if low.endswith(suffix) and len(token) > len(suffix): + base = token[: -len(suffix)] + + def _expand_suffix() -> str: + return _assemble_contraction_expansion(base, token, append_word) + + normalized = _apply_contraction_policy( + token, + category=category, + cfg=cfg, + expand=_expand_suffix, + collapse=token.replace("'", ""), + ) + return category, normalized + + # 7. Irregular possessives (keep or expand logic) if low in IRREGULAR_POSSESSIVES: if cfg.irregular_possessive_mode == "keep": return "irregular_possessive", token - else: - # 'expand': we might keep same or optionally add marker - return "irregular_possessive", token + return "irregular_possessive", token - # 7. Plural possessive pattern dogs' + # 8. Plural possessive pattern dogs' if re.match(r"^[A-Za-z0-9]+s'$", token): if cfg.plural_possessive_mode == "collapse": - return "plural_possessive", token[:-1] # remove trailing apostrophe + return "plural_possessive", token[:-1] return "plural_possessive", token - # 8. Acronym possessive NASA's + # 9. Acronym possessive NASA's if ACRONYM_POSSESSIVE_RE.match(token): if cfg.acronym_possessive_mode == "collapse_add_s": return "acronym_possessive", token.replace("'", "") return "acronym_possessive", token - # 9. Sibilant singular possessive boss's, church's + # 10. Sibilant singular possessive boss's, church's if low.endswith("'s"): base = token[:-2] if SIBILANT_END_RE.search(base): if cfg.sibilant_possessive_mode == "keep": return "sibilant_possessive", token - elif cfg.sibilant_possessive_mode == "approx": - # convert to base + "es" (boss's -> bosses) - # risk: loses possessive semantics visually + if cfg.sibilant_possessive_mode == "approx": return "sibilant_possessive", base + "es" - elif cfg.sibilant_possessive_mode == "mark": - # remove apostrophe, add IZ marker + if cfg.sibilant_possessive_mode == "mark": normalized = base - if cfg.add_phoneme_hints: - normalized += cfg.sibilant_iz_marker - else: - normalized += "es" + normalized += cfg.sibilant_iz_marker if cfg.add_phoneme_hints else "es" return "sibilant_possessive", normalized - # 10. Generic singular possessive (\w+'s) + # 11. Generic singular possessive (\w+'s) if re.match(r"^[A-Za-z0-9]+'s$", token): if cfg.possessive_mode == "collapse": - # Just remove apostrophe return "singular_possessive", token.replace("'", "") return "singular_possessive", token - # 11. Cultural names or fantasy internal + # 12. Cultural names or fantasy internal if is_cultural_name(token, cfg): return "cultural_name", token - # 12. Fantasy internal apostrophes if INTERNAL_APOSTROPHE_RE.search(token): if cfg.fantasy_mode == "keep": return "fantasy_internal", token - elif cfg.fantasy_mode == "mark": + if cfg.fantasy_mode == "mark": out = token + (cfg.fantasy_marker if cfg.add_phoneme_hints else "") return "fantasy_internal", out - elif cfg.fantasy_mode == "collapse_internal": - # Remove internal apostrophes only + if cfg.fantasy_mode == "collapse_internal": inner = re.sub(r"(?<=\w)'+(?=\w)", cfg.joiner, token) return "fantasy_internal", inner - # 13. Fallback: treat as other (maybe stray apostrophe) if cfg.fantasy_mode == "collapse_internal": - # Remove any internal apostrophes return "other", token.replace("'", cfg.joiner) return "other", token @@ -709,13 +842,12 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup category, norm = classify_token(tok, cfg) resolution = contextual_resolutions.get((start, end)) if contextual_resolutions else None - if resolution is not None: - if resolution.category == "ambiguous_contraction_s" and use_contextual_s: - category = resolution.category - norm = resolution.expansion - elif resolution.category == "ambiguous_contraction_d" and use_contextual_d: + if resolution is not None and cfg.contraction_mode == "expand": + if cfg.is_contraction_enabled(resolution.category): category = resolution.category norm = resolution.expansion + else: + norm = tok results.append((tok, category, norm)) normalized_tokens.append(norm) diff --git a/abogen/normalization_settings.py b/abogen/normalization_settings.py index 4d6941b..4a2fb3c 100644 --- a/abogen/normalization_settings.py +++ b/abogen/normalization_settings.py @@ -5,7 +5,7 @@ from dataclasses import replace from functools import lru_cache from typing import Any, Dict, Mapping, Optional -from abogen.kokoro_text_normalization import ApostropheConfig +from abogen.kokoro_text_normalization import ApostropheConfig, CONTRACTION_CATEGORY_DEFAULTS from abogen.llm_client import LLMConfiguration from abogen.utils import load_config @@ -40,6 +40,21 @@ _SETTINGS_DEFAULTS: Dict[str, Any] = { "normalization_apostrophes_decades": True, "normalization_apostrophes_leading_elisions": True, "normalization_apostrophe_mode": "spacy", + "normalization_contraction_aux_be": True, + "normalization_contraction_aux_have": True, + "normalization_contraction_modal_will": True, + "normalization_contraction_modal_would": True, + "normalization_contraction_negation_not": True, + "normalization_contraction_let_us": True, +} + +_CONTRACTION_SETTING_MAP: Dict[str, str] = { + "normalization_contraction_aux_be": "contraction_aux_be", + "normalization_contraction_aux_have": "contraction_aux_have", + "normalization_contraction_modal_will": "contraction_modal_will", + "normalization_contraction_modal_would": "contraction_modal_would", + "normalization_contraction_negation_not": "contraction_negation_not", + "normalization_contraction_let_us": "contraction_let_us", } _ENVIRONMENT_KEYS: Dict[str, str] = { @@ -168,6 +183,12 @@ def build_apostrophe_config( "expand" if settings.get("normalization_apostrophes_leading_elisions", True) else "keep" ) config.ambiguous_past_modal_mode = "contextual" if config.contraction_mode == "expand" else "keep" + category_flags = dict(CONTRACTION_CATEGORY_DEFAULTS) + for setting_key, category in _CONTRACTION_SETTING_MAP.items(): + default_value = bool(_SETTINGS_DEFAULTS.get(setting_key, True)) + raw_value = settings.get(setting_key, default_value) + category_flags[category] = _coerce_bool(raw_value, default_value) + config.contraction_categories = category_flags return config diff --git a/abogen/spacy_contraction_resolver.py b/abogen/spacy_contraction_resolver.py index 00442cb..3b9e679 100644 --- a/abogen/spacy_contraction_resolver.py +++ b/abogen/spacy_contraction_resolver.py @@ -132,25 +132,25 @@ def _resolve_apostrophe_s(token: Token) -> Optional[ContractionResolution]: surface = token.doc.text[prev.idx : token.idx + len(token.text)] if prev_lower == "let": - return _resolution(prev, token, "us", "contraction", "us") + return _resolution(prev, token, "us", "contraction_let_us", "us") lemma = token.lemma_.lower() if not lemma: lemma = "be" if _favors_be(token) else "have" if _favors_have(token) else "be" if lemma == "be": - return _resolution(prev, token, "is", "ambiguous_contraction_s", "be") + return _resolution(prev, token, "is", "contraction_aux_be", "be") if lemma == "have": - return _resolution(prev, token, "has", "ambiguous_contraction_s", "have") + return _resolution(prev, token, "has", "contraction_aux_have", "have") if _favors_have(token): - return _resolution(prev, token, "has", "ambiguous_contraction_s", "have") + return _resolution(prev, token, "has", "contraction_aux_have", "have") if _favors_be(token): - return _resolution(prev, token, "is", "ambiguous_contraction_s", "be") + return _resolution(prev, token, "is", "contraction_aux_be", "be") # Default to copula expansion. - return _resolution(prev, token, "is", "ambiguous_contraction_s", lemma or "be") + return _resolution(prev, token, "is", "contraction_aux_be", lemma or "be") def _resolve_apostrophe_d(token: Token) -> Optional[ContractionResolution]: @@ -164,38 +164,35 @@ def _resolve_apostrophe_d(token: Token) -> Optional[ContractionResolution]: lemma = token.lemma_.lower() tense = set(token.morph.get("Tense")) + next_content = _next_content_token(token) + prefers_had = _context_prefers_had(token) + + if prefers_had: + return _resolution(prev, token, "had", "contraction_aux_have", "have") if "Past" in tense and lemma in {"have", "had"}: - return _resolution(prev, token, "had", "ambiguous_contraction_d", "have") + return _resolution(prev, token, "had", "contraction_aux_have", "have") - if token.tag_ == "MD" or lemma in {"will", "would", "shall"}: - return _resolution(prev, token, "would", "ambiguous_contraction_d", lemma or "will") - - head = token.head if token.head is not None else None - if head is not None and head.i > token.i: - head_tag = head.tag_ - head_lemma = head.lemma_.lower() - if head_tag in {"VBN", "VBD"} or head_lemma in {"gone", "been", "had"}: - return _resolution(prev, token, "had", "ambiguous_contraction_d", "have") - if head_lemma == "better": - return _resolution(prev, token, "had", "ambiguous_contraction_d", "have") - if head_tag == "VB" or head.pos_ in {"VERB", "AUX"}: - return _resolution(prev, token, "would", "ambiguous_contraction_d", lemma or "will") - - next_content = _next_content_token(token) if next_content is not None: next_tag = next_content.tag_ - if next_tag in {"VBN", "VBD"} or next_content.lemma_.lower() in {"been", "gone", "had"}: - return _resolution(prev, token, "had", "ambiguous_contraction_d", "have") - if next_content.lemma_.lower() == "better": - return _resolution(prev, token, "had", "ambiguous_contraction_d", "have") - if next_tag == "VB": - return _resolution(prev, token, "would", "ambiguous_contraction_d", lemma or "will") + next_lemma = next_content.lemma_.lower() + else: + next_tag = "" + next_lemma = "" + + if next_tag == "VB": + return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + + if token.tag_ == "MD" or lemma in {"will", "would", "shall"}: + return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + + if next_lemma in {"been", "gone", "had", "better"} or next_tag in {"VBN", "VBD"}: + return _resolution(prev, token, "had", "contraction_aux_have", "have") - # Fallback: if lemma hints at perfect aspect use "had", otherwise "would". if lemma in {"have", "had"}: - return _resolution(prev, token, "had", "ambiguous_contraction_d", lemma) - return _resolution(prev, token, "would", "ambiguous_contraction_d", lemma or "will") + return _resolution(prev, token, "had", "contraction_aux_have", lemma) + + return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") def _next_content_token(token: Token) -> Optional[Token]: @@ -228,4 +225,29 @@ def _favors_be(token: Token) -> bool: return True if next_content.tag_ in {"VBG", "JJ", "RB", "DT", "IN"}: return True - return False \ No newline at end of file + return False + + +def _context_prefers_had(token: Token) -> bool: + head = token.head if token.head is not None else None + if head is not None and head.i > token.i: + head_tag = head.tag_ + head_lemma = head.lemma_.lower() + if head_tag in {"VBN", "VBD"} or head_lemma in {"gone", "been", "had"}: + return True + if head_lemma == "better": + return True + + next_content = _next_content_token(token) + if next_content is None: + return False + next_tag = next_content.tag_ + next_lemma = next_content.lemma_.lower() + if next_tag in {"VBN", "VBD"}: + return True + if next_lemma in {"been", "gone", "had"}: + return True + if next_lemma == "better": + return True + return False + diff --git a/abogen/web/routes.py b/abogen/web/routes.py index fccf30b..11f9e1d 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2100,6 +2100,12 @@ BOOLEAN_SETTINGS = { "normalization_apostrophes_sibilant_possessives", "normalization_apostrophes_decades", "normalization_apostrophes_leading_elisions", + "normalization_contraction_aux_be", + "normalization_contraction_aux_have", + "normalization_contraction_modal_will", + "normalization_contraction_modal_would", + "normalization_contraction_negation_not", + "normalization_contraction_let_us", } FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"} @@ -2111,6 +2117,12 @@ _APOSTROPHE_OVERRIDE_KEYS = ( "normalization_apostrophes_sibilant_possessives", "normalization_apostrophes_decades", "normalization_apostrophes_leading_elisions", + "normalization_contraction_aux_be", + "normalization_contraction_aux_have", + "normalization_contraction_modal_will", + "normalization_contraction_modal_would", + "normalization_contraction_negation_not", + "normalization_contraction_let_us", ) @@ -3376,6 +3388,12 @@ def api_normalization_preview() -> ResponseReturnValue: "normalization_apostrophes_sibilant_possessives", "normalization_apostrophes_decades", "normalization_apostrophes_leading_elisions", + "normalization_contraction_aux_be", + "normalization_contraction_aux_have", + "normalization_contraction_modal_will", + "normalization_contraction_modal_would", + "normalization_contraction_negation_not", + "normalization_contraction_let_us", ) for key in boolean_keys: if key in normalization_payload: diff --git a/abogen/web/static/settings.js b/abogen/web/static/settings.js index 827db2d..8cbedcd 100644 --- a/abogen/web/static/settings.js +++ b/abogen/web/static/settings.js @@ -28,6 +28,11 @@ let folderModalOpener = null; let folderModalPreviousFocus = null; let audiobookshelfFolderSource = []; +const contractionModal = document.querySelector('[data-role="contraction-modal"]'); +const contractionModalOverlay = contractionModal ? contractionModal.querySelector('[data-role="contraction-modal-overlay"]') : null; +let contractionModalOpener = null; +let contractionModalPreviousFocus = null; + function setStatus(target, message, state) { if (!target) { return; @@ -177,6 +182,66 @@ function closeFolderModal(event) { folderModalOpener = null; } +function openContractionModal(opener) { + if (!contractionModal) { + return; + } + contractionModalOpener = opener || null; + contractionModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + contractionModal.hidden = false; + contractionModal.dataset.open = 'true'; + document.body.classList.add('modal-open'); + const focusTarget = contractionModal.querySelector('input, button, select, textarea'); + if (focusTarget instanceof HTMLElement) { + focusTarget.focus({ preventScroll: true }); + } +} + +function closeContractionModal(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (!contractionModal || contractionModal.hidden) { + return; + } + contractionModal.dataset.open = 'false'; + contractionModal.hidden = true; + document.body.classList.remove('modal-open'); + const focusTarget = + (contractionModalPreviousFocus && typeof contractionModalPreviousFocus.focus === 'function' + ? contractionModalPreviousFocus + : contractionModalOpener) || null; + if (focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus({ preventScroll: true }); + } + contractionModalPreviousFocus = null; + contractionModalOpener = null; +} + +function initContractionModal() { + if (!contractionModal) { + return; + } + const openButton = document.querySelector('[data-action="contraction-modal-open"]'); + if (openButton) { + openButton.addEventListener('click', () => openContractionModal(openButton)); + } + const closeButtons = contractionModal.querySelectorAll('[data-action="contraction-modal-close"]'); + closeButtons.forEach((button) => { + button.addEventListener('click', closeContractionModal); + }); + if (contractionModalOverlay) { + contractionModalOverlay.addEventListener('click', closeContractionModal); + } + contractionModal.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeContractionModal(event); + } + }); +} + function renderFolderList(query) { if (!folderList) { return; @@ -461,6 +526,9 @@ async function previewLLM(button) { } function collectNormalizationSettings() { + if (!form) { + return null; + } const normalization = { normalization_numbers: Boolean(form.querySelector('input[name="normalization_numbers"]')?.checked), normalization_titles: Boolean(form.querySelector('input[name="normalization_titles"]')?.checked), @@ -471,6 +539,12 @@ function collectNormalizationSettings() { normalization_apostrophes_sibilant_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_sibilant_possessives"]')?.checked), normalization_apostrophes_decades: Boolean(form.querySelector('input[name="normalization_apostrophes_decades"]')?.checked), normalization_apostrophes_leading_elisions: Boolean(form.querySelector('input[name="normalization_apostrophes_leading_elisions"]')?.checked), + normalization_contraction_aux_be: Boolean(form.querySelector('input[name="normalization_contraction_aux_be"]')?.checked), + normalization_contraction_aux_have: Boolean(form.querySelector('input[name="normalization_contraction_aux_have"]')?.checked), + normalization_contraction_modal_will: Boolean(form.querySelector('input[name="normalization_contraction_modal_will"]')?.checked), + normalization_contraction_modal_would: Boolean(form.querySelector('input[name="normalization_contraction_modal_would"]')?.checked), + normalization_contraction_negation_not: Boolean(form.querySelector('input[name="normalization_contraction_negation_not"]')?.checked), + normalization_contraction_let_us: Boolean(form.querySelector('input[name="normalization_contraction_let_us"]')?.checked), normalization_apostrophe_mode: form.querySelector('input[name="normalization_apostrophe_mode"]:checked')?.value || 'spacy', }; return normalization; @@ -687,10 +761,13 @@ async function previewNormalization(button) { normalizationAudio.removeAttribute('src'); } setStatus(status, 'Building preview…'); - button.disabled = true; + const normalization = collectNormalizationSettings(); + if (!normalization) { + setStatus(status, 'Unable to gather normalization settings.', 'error'); + return; + } + const llmFields = collectLLMFields(); try { - const normalization = collectNormalizationSettings(); - const llmFields = collectLLMFields(); const response = await fetch('/api/normalization/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -797,5 +874,6 @@ if (form) { initSampleSelector(); initActions(); initFolderPicker(); + initContractionModal(); initLLMStateWatchers(); } diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 8f1f59d..393128d 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -324,6 +324,10 @@ Expand leading elisions ('tis -> it is) +
+ +

Choose which contraction families are expanded.

+
Apostrophe strategy
@@ -494,6 +498,49 @@
+ +