package validators import ( "fmt" "regexp" "sort" "strings" "gitea.maximumdirect.net/eric/audita/v3/internal/core/glossary" ) type protectedTermDef struct { identities []glossary.Identity canonical string } type protectedOccurrence struct { text string identity glossary.Identity canonical string } // ProtectedVocabulary is a deterministic glossary-derived protected term set. type ProtectedVocabulary struct { termsByFolded map[string]protectedTermDef pattern *regexp.Regexp } // NewProtectedVocabulary prepares the independently selected terms for matching. func NewProtectedVocabulary(terms []glossary.Term) ProtectedVocabulary { termsByFolded := make(map[string]protectedTermDef, len(terms)) alternatives := make([]string, 0, len(terms)) for _, term := range terms { if !term.Exempt { termsByFolded[strings.ToLower(term.Spelling)] = protectedTermDef{identities: term.Identities, canonical: term.Spelling} } alternatives = append(alternatives, regexp.QuoteMeta(term.Spelling)) } if len(termsByFolded) == 0 { return ProtectedVocabulary{} } sort.SliceStable(alternatives, func(i, j int) bool { return len(alternatives[i]) > len(alternatives[j]) }) pattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(alternatives, "|") + `)\b`) return ProtectedVocabulary{termsByFolded: termsByFolded, pattern: pattern} } // AllowsChange checks a proposed segment against the effective protection view. // Spoken-word cleanup may reduce repeated mentions while preserving each identity. func (v ProtectedVocabulary) AllowsChange(before, after string, allowRepetitionCleanup bool) bool { if v.violationReason(before, after) == "" { return true } return allowRepetitionCleanup && v.preservesRepeatedIdentities(before, after) } func (v ProtectedVocabulary) violationReason(before, after string) string { beforeByID := v.occurrencesByIdentity(before) afterByID := v.occurrencesByIdentity(after) if reason := validateIdentityPreservation(beforeByID, afterByID); reason != "" { return reason } if reason := validateCapitalizationTransitions(beforeByID, afterByID); reason != "" { return reason } return "" } func (v ProtectedVocabulary) occurrencesByIdentity(text string) map[glossary.Identity][]protectedOccurrence { byID := make(map[glossary.Identity][]protectedOccurrence) for _, o := range v.occurrences(text) { byID[o.identity] = append(byID[o.identity], o) } return byID } func (v ProtectedVocabulary) occurrences(text string) []protectedOccurrence { if v.pattern == nil { return nil } matches := v.pattern.FindAllStringIndex(text, -1) if len(matches) == 0 { return nil } out := make([]protectedOccurrence, 0, len(matches)) for _, idx := range matches { matched := text[idx[0]:idx[1]] def, ok := v.termsByFolded[strings.ToLower(matched)] if !ok { continue } for _, identity := range def.identities { out = append(out, protectedOccurrence{text: matched, identity: identity, canonical: def.canonical}) } } return out } func validateIdentityPreservation(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string { var selected string found := false for identity, beforeItems := range beforeByID { if len(afterByID[identity]) < len(beforeItems) && len(beforeItems) > 0 { if spelling := beforeItems[0].canonical; !found || spelling < selected { selected, found = spelling, true } } } return protectedSpellingMessage(selected, found) } func validateCapitalizationTransitions(beforeByID, afterByID map[glossary.Identity][]protectedOccurrence) string { var selected string found := false for identity, afterItems := range afterByID { beforeItems := beforeByID[identity] for i, afterItem := range afterItems { if i >= len(beforeItems) || afterItem.text == beforeItems[i].text || afterItem.text == afterItem.canonical { continue } if !found || afterItem.canonical < selected { selected, found = afterItem.canonical, true } } } return protectedSpellingMessage(selected, found) } func protectedSpellingMessage(spelling string, found bool) string { if !found { return "" } return fmt.Sprintf("proposal may alter protected glossary spelling %q", spelling) } // preservesRepeatedIdentities permits a spoken-word proposal to reduce repeated // mentions, but not remove an identity or replace its surviving spelling. // Editorial and meaning validators still decide whether the cleanup is warranted. func (v ProtectedVocabulary) preservesRepeatedIdentities(before, after string) bool { original := v.occurrencesByIdentity(before) result := v.occurrencesByIdentity(after) reduced := false for identity, occurrences := range original { remaining := result[identity] if len(remaining) == 0 { return false } if len(remaining) < len(occurrences) { reduced = true } } return reduced && validateCapitalizationTransitions(original, result) == "" }