Source code for flexicon.code.TextsWords.SegmentOperations

#
#   SegmentOperations.py
#
#   Class: SegmentOperations
#          Segment operations for FieldWorks Language Explorer projects
#          via SIL Language and Culture Model (LCM) API.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2025
#

import logging
import clr

clr.AddReference("System")

from SIL.LCModel import (
    ISegment,
    ISegmentFactory,
    IStTxtPara,
)

from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils

from ..FLExProject import (
    FP_NullParameterError,
    FP_ParameterError,
    FP_ReadOnlyError,
)
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable
from ..lcm_casting import cast_to_concrete

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# MergeSegments translation_policy constants
# Use these instead of bare strings to avoid silent routing to the wrong branch.
# ---------------------------------------------------------------------------

TRANSLATION_POLICY_MIGRATE = "migrate"
TRANSLATION_POLICY_DISCARD = "discard"
TRANSLATION_POLICY_REJECT = "reject"

_VALID_TRANSLATION_POLICIES = {
    TRANSLATION_POLICY_MIGRATE,
    TRANSLATION_POLICY_DISCARD,
    TRANSLATION_POLICY_REJECT,
}


[docs] class SegmentOperations(BaseOperations): """ This class provides operations for managing segments in a FieldWorks project. Segments represent the units of text analysis in FLEx, typically sentences or clauses within paragraphs. Each segment can have baseline text, analyses, translations (free and literal), and notes. The write path follows the LCM idiom: 1. Edit IStTxtPara.Contents (via TsStringBuilder) first. 2. Assign builder.GetString() to para.Contents - this fires ContentsSideEffects which triggers AnalysisAdjuster to reconcile segment offsets automatically. 3. Then update SegmentsOS (Add/Insert/Remove) as needed. Usage:: from flexicon import FLExProject project = FLExProject() project.OpenProject("my project", writeEnabled=True) # Get all segments in a paragraph para = project.Object(para_hvo) for segment in project.Segments.GetAll(para): baseline = project.Segments.GetBaselineText(segment) print(baseline) # Append a new sentence to a paragraph new_seg = project.Segments.AppendSentence(para, "A new sentence.") # Split a segment at a character offset within that segment seg1, seg2 = project.Segments.SplitSegment(new_seg, 5) # Merge two adjacent segments (migrates translations by default) merged = project.Segments.MergeSegments(seg1, seg2) # Set translations on a segment project.Segments.SetFreeTranslation(merged, "In the beginning...") project.Segments.SetLiteralTranslation(merged, "In-the beginning...") # Reparse a paragraph from its Contents (use only to repair corruption) project.Segments.ReparseParagraph(para) project.CloseProject() """ def __init__(self, project): """ Initialize SegmentOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ super().__init__(project) def _GetSequence(self, parent): """Specify which sequence to reorder: a paragraph's segments. ``parent`` must be an IStTxtPara -- the same type GetAll()/ AppendSentence() already require via __GetParagraphObject(). AnalysesRS has its own dedicated API (SetAnalysis/ReplaceAnalysis/ InsertAnalysis/AppendAnalysis/RemoveAnalysis, issue #215) and must not be targeted by Sort/MoveUp/MoveDown/MoveToIndex. """ return parent.SegmentsOS def __WSHandle(self, wsHandle): """ Internal helper for writing system handles (analysis). Args: wsHandle: Writing system handle or None for default analysis WS. Returns: int: The writing system handle to use. """ if wsHandle is None: return self.project.project.DefaultAnalWs return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs) def __WSHandleVern(self, wsHandle): """ Internal helper for writing system handles (vernacular). Args: wsHandle: Writing system handle or None for default vernacular WS. Returns: int: The writing system handle to use. """ if wsHandle is None: return self.project.project.DefaultVernWs return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultVernWs) def __GetParagraphObject(self, para_or_hvo): """ Internal helper to get paragraph object from HVO or object. Args: para_or_hvo: IStTxtPara object or HVO integer. Returns: IStTxtPara: The paragraph object. """ if isinstance(para_or_hvo, int): return cast_to_concrete(self.project.Object(para_or_hvo)) return cast_to_concrete(para_or_hvo) def __GetSegmentObject(self, segment_or_hvo): """ Internal helper to get segment object from HVO or object. Args: segment_or_hvo: ISegment object or HVO integer. Returns: ISegment: The segment object. """ if isinstance(segment_or_hvo, int): return cast_to_concrete(self.project.Object(segment_or_hvo)) return cast_to_concrete(segment_or_hvo) def __GetSegmentFactory(self): """Return the ISegmentFactory from the service locator.""" return self.project.project.ServiceLocator.GetService(ISegmentFactory) def __GetAnalysisObject(self, analysis_or_hvo): """ Internal helper to get an IAnalysis object from HVO or object. Args: analysis_or_hvo: An IAnalysis object (IWfiWordform, IWfiAnalysis, IWfiGloss, or IPunctuationForm) or its HVO integer. Returns: ``IAnalysis``: The analysis object. The HVO path returns ``project.Object(hvo)`` without ``cast_to_concrete`` (polymorphic wordform / analysis / gloss / punctuation types). Callers that need concrete analysis interfaces must cast per issue #212; this helper stays deliberately generic (issue #494). """ if isinstance(analysis_or_hvo, int): return self.project.Object(analysis_or_hvo) return analysis_or_hvo # ========== READ METHODS ========== @wrap_enumerable @OperationsMethod def GetAll(self, paragraph_or_hvo): """ Get all segments in a paragraph. Args: paragraph_or_hvo: The IStTxtPara object or HVO. Returns: EnumerableWrapper[ISegment]: Each segment in the paragraph. Raises: FP_NullParameterError: If paragraph_or_hvo is None. Example: >>> para = project.Object(para_hvo) >>> for segment in project.Segments.GetAll(para): ... text = project.Segments.GetBaselineText(segment) ... print(text) In the beginning God created the heavens and the earth. Now the earth was formless and empty. See Also: GetBaselineText, GetFreeTranslation, GetAnalyses """ self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo") para_obj = self.__GetParagraphObject(paragraph_or_hvo) segments = list(para_obj.SegmentsOS) for segment in segments: yield segment @OperationsMethod def GetAnalyses(self, segment_or_hvo): """ Get all analyses (wordforms and their linguistic analyses) for a segment. Args: segment_or_hvo: The ISegment object or HVO. Returns: list: List of IAnalysis objects representing the wordforms in the segment. Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> analyses = project.Segments.GetAnalyses(segment) >>> print(f"Segment has {len(analyses)} wordforms") Segment has 5 wordforms See Also: GetAll, GetBaselineText Notes: Each analysis can be an IWfiWordform, IWfiAnalysis, IWfiGloss, or IPunctuationForm object. IPunctuationForm is a legitimate member of this collection (punctuation tokens implement IAnalysis too), not an error case -- callers must not assume every entry is a word. These represent different levels of linguistic analysis for each word. """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) analyses = list(segment_obj.AnalysesRS) return analyses @OperationsMethod def GetGloss(self, analysis_or_hvo, wsHandle=None): """ Get the gloss text for a segment analysis token of any type. ``GetAnalyses()`` returns polymorphic ``IAnalysis`` objects -- a mix of ``IWfiWordform`` (unanalyzed), ``IWfiAnalysis`` (analyzed, no chosen gloss), ``IWfiGloss`` (fully glossed), and ``IPunctuationForm``. Only ``IWfiGloss`` tokens carry a chosen word gloss; reading it off the wrong subtype (or off an un-cast ``IAnalysis`` reference) silently yields '' (issue #212). This helper dispatches on the concrete type so callers stop hand-rolling ``IWfiGloss(tok)`` casts in interlinear loops. Args: analysis_or_hvo: An IAnalysis token (from GetAnalyses) or its HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The gloss text for an IWfiGloss token; '' for token types that carry no chosen word gloss (wordform, bare analysis, punctuation). Raises: FP_NullParameterError: If analysis_or_hvo is None. Example: >>> for tok in project.Segments.GetAnalyses(segment): ... gloss = project.Segments.GetGloss(tok, eng_ws) # no cast! ... print(gloss) See Also: GetAnalyses, WfiAnalyses.GetCategoryAbbrev, WfiAnalyses.GetMorphemeBundles """ self._ValidateParam(analysis_or_hvo, "analysis_or_hvo") if isinstance(analysis_or_hvo, int): token = self.project.Object(analysis_or_hvo) else: token = analysis_or_hvo # Only IWfiGloss carries a chosen word gloss. Dispatch on ClassName -- # pythonnet isinstance() against IWfiGloss is False for the base # IAnalysis-typed refs GetAnalyses returns (issue #212). WfiGlosses # .GetForm does the concrete IWfiGloss(...) cast internally. if getattr(token, "ClassName", None) == "WfiGloss": return self.project.WfiGlosses.GetForm(token, wsHandle) return "" @OperationsMethod def GetBaselineText(self, segment_or_hvo): """ Get the baseline text of a segment (the original text in the vernacular language). Args: segment_or_hvo: The ISegment object or HVO. Returns: str: The baseline text, or empty string if not set. Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> text = project.Segments.GetBaselineText(segment) >>> print(text) In the beginning God created the heavens and the earth. See Also: GetFreeTranslation, GetLiteralTranslation, GetAnalyses """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) # ISegment.BaselineText is ITsString (WS already resolved by LCM), # not ITsMultiString. Calling .get_String(ws) on it raises # AttributeError at runtime. See issue #170. return segment_obj.BaselineText.Text or "" @OperationsMethod def SetBaselineText(self, segment_or_hvo, text, wsHandle=None): """ Set the baseline text of a segment by editing the owning paragraph Contents. The correct write idiom is to edit IStTxtPara.Contents via a TsStrBldr; ContentsSideEffects then fires AnalysisAdjuster.AdjustAnalysis which reconciles segment offsets automatically. Args: segment_or_hvo: The ISegment object or HVO. text: The baseline text to set. wsHandle: Optional writing system handle. Defaults to vernacular WS. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. FP_ParameterError: If segment has no owning paragraph. Example: >>> segment = segments[0] >>> project.Segments.SetBaselineText(segment, "In the beginning...") See Also: GetBaselineText, SetFreeTranslation, SetLiteralTranslation """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) ws = self.__WSHandleVern(wsHandle) para = self.GetOwningParagraph(segment_obj) begin = segment_obj.BeginOffset end = segment_obj.EndOffset # The builder work is pure in-memory string assembly on a TsStrBldr; # only the Contents assignment touches the LCM, so that is what the # bracket covers. Reassigning Contents fires ContentsSideEffects -> # AnalysisAdjuster, which re-derives the paragraph's segments, so the # unit of work has to span that cascade rather than just the setter. bldr = para.Contents.GetBldr() new_run = TsStringUtils.MakeString(text, ws) bldr.ReplaceTsString(begin, end, new_run) with self._TransactionCM("Set segment baseline text"): para.Contents = bldr.GetString() @OperationsMethod def GetFreeTranslation(self, segment_or_hvo, wsHandle=None): """ Get the free translation for a segment. Args: segment_or_hvo: The ISegment object or HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The free translation text, or empty string if not set. Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> trans = project.Segments.GetFreeTranslation(segment) >>> print(trans) In the beginning God created the heavens and the earth. See Also: SetFreeTranslation, GetLiteralTranslation, GetBaselineText """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) ws = self.__WSHandle(wsHandle) if segment_obj.FreeTranslation: text = ITsString(segment_obj.FreeTranslation.get_String(ws)).Text return text or "" return "" @OperationsMethod def SetFreeTranslation(self, segment_or_hvo, text, wsHandle=None): """ Set the free translation for a segment. Args: segment_or_hvo: The ISegment object or HVO. text: The free translation text to set. wsHandle: Optional writing system handle. Defaults to analysis WS. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> project.Segments.SetFreeTranslation(segment, "In the beginning God created...") See Also: GetFreeTranslation, SetLiteralTranslation, SetBaselineText """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) ws = self.__WSHandle(wsHandle) with self._TransactionCM("Set free translation"): mkstr = TsStringUtils.MakeString(text, ws) segment_obj.FreeTranslation.set_String(ws, mkstr) @OperationsMethod def GetLiteralTranslation(self, segment_or_hvo, wsHandle=None): """ Get the literal (word-for-word) translation of a segment. Args: segment_or_hvo: The ISegment object or HVO. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The literal translation text, or empty string if not set. Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> trans = project.Segments.GetLiteralTranslation(segment) >>> print(trans) In-the beginning God created the-heavens and-the-earth See Also: SetLiteralTranslation, GetFreeTranslation, GetBaselineText """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) ws = self.__WSHandle(wsHandle) if segment_obj.LiteralTranslation: text = ITsString(segment_obj.LiteralTranslation.get_String(ws)).Text return text or "" return "" @OperationsMethod def SetLiteralTranslation(self, segment_or_hvo, text, wsHandle=None): """ Set the literal (word-for-word) translation of a segment. Args: segment_or_hvo: The ISegment object or HVO. text: The literal translation text to set. wsHandle: Optional writing system handle. Defaults to analysis WS. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> project.Segments.SetLiteralTranslation(segment, "In-the beginning God created...") See Also: GetLiteralTranslation, SetFreeTranslation, SetBaselineText """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) ws = self.__WSHandle(wsHandle) with self._TransactionCM("Set literal translation"): mkstr = TsStringUtils.MakeString(text, ws) segment_obj.LiteralTranslation.set_String(ws, mkstr) @OperationsMethod def GetNotes(self, segment_or_hvo): """ Get all notes associated with a segment. Args: segment_or_hvo: The ISegment object or HVO. Returns: list: List of note objects associated with the segment. Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> notes = project.Segments.GetNotes(segment) >>> print(f"Segment has {len(notes)} notes") Segment has 2 notes See Also: GetAll, GetBaselineText, GetFreeTranslation """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) if hasattr(segment_obj, "NotesOS"): notes = list(segment_obj.NotesOS) return notes return [] # ========== WRITE METHODS ========== @OperationsMethod def AppendSentence(self, paragraph_or_hvo, text, wsHandle=None, guid=None): """ Append a new sentence to a paragraph, creating a new segment. Edits IStTxtPara.Contents first (firing AnalysisAdjuster on existing segments), then adds the new ISegment to SegmentsOS. A sentence terminator is automatically inserted before the new text when the current paragraph Contents is non-empty and does not already end with one of {. ! ?}. The test is applied to the last NON-WHITESPACE character, and the terminator is anchored there, so trailing whitespace in the existing Contents is preserved rather than written over (#242 C12). What gets inserted depends on whether that trailing whitespace is present: '. ' when it is absent (the period supplies its own separating space), or '.' alone when it is present (the existing whitespace already separates the sentences). When the paragraph is empty, the text is written directly. See the Note: below for the full invariant. Args: paragraph_or_hvo: The IStTxtPara object or HVO. text: The sentence text to append. Must be non-empty. Note: leading/trailing whitespace in the value is preserved verbatim (#242); a value that is entirely whitespace still raises FP_ParameterError. wsHandle: Optional writing system handle. Defaults to project default vernacular WS. guid (optional): GUID to assign to the new segment, as a ``System.Guid`` or string. Use this when REPRODUCING a segment from another project so it keeps its original identity. None (the default) mints a fresh GUID. Returns: ISegment: The newly created segment object. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If paragraph_or_hvo or text is None. FP_ParameterError: If text is empty or paragraph is invalid. Example: >>> para = project.Object(para_hvo) >>> seg = project.Segments.AppendSentence(para, "A new sentence.") >>> print(project.Segments.GetBaselineText(seg)) A new sentence. See Also: SplitSegment, MergeSegments, ReparseParagraph Note: AppendSentence may INSERT at the join boundary between the existing contents and the appended text; it never DELETES there. If the existing contents end in whitespace, that whitespace is preserved and reused as the sentence separator (#242). """ self._EnsureWriteEnabled() self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo") self._ValidateParam(text, "text") # NOTE (#242): .strip() here is a THROWAWAY used only for the # emptiness check. The caller's ORIGINAL payload (text_str) is what # is persisted below -- leading/trailing/internal whitespace is # preserved, not stripped. Non-str branch is str(text) with NO # trailing .strip() (per the ruling, deliberately removed). text_str = text if isinstance(text, str) else str(text) if not text_str.strip(): raise FP_ParameterError("text cannot be empty") para = self.__GetParagraphObject(paragraph_or_hvo) ws = self.__WSHandleVern(wsHandle) with self._TransactionCM("Append sentence"): # --- Step 1: edit Contents --- bldr = para.Contents.GetBldr() current_length = para.Contents.Length if current_length > 0: # NOTE (#242 C12): AppendSentence may INSERT at the join # boundary. It may never DELETE at the join boundary. Now # that Create/SetText/InsertAt preserve caller-supplied # whitespace (#242), para.Contents can legitimately end in # whitespace, so the RAW last character is no longer a # reliable signal of "already terminated". rstrip() below # is used ONLY to compute an index (`anchor`); the # stripped string it returns is never written and never # passed to MakeString -- ZERO characters are removed by # this branch in any case. raw = para.Contents.Text or "" # anchor: index one past the last non-whitespace character. anchor = len(raw.rstrip()) # trail: count of trailing whitespace characters. trail = current_length - anchor if raw == "" and current_length > 0: # Degenerate case (Contents.Text is None/empty while # Length > 0) -- preserve today's behaviour exactly; # do not route this through the new logic below. terminator = TsStringUtils.MakeString(". ", ws) bldr.ReplaceTsString(current_length, current_length, terminator) insert_at = current_length + 2 elif anchor == 0: # Contents is non-empty but entirely whitespace -- no # sentence to terminate. Insert no terminator and no # separator; place the new text at the end. insert_at = current_length elif raw[anchor - 1] in (".", "!", "?"): # Already terminated at the last non-whitespace char. if trail > 0: # The existing trailing whitespace IS the # separator -- insert nothing. insert_at = current_length else: # Unchanged from today: insert a space separator. sep = TsStringUtils.MakeString(" ", ws) bldr.ReplaceTsString(current_length, current_length, sep) insert_at = current_length + 1 else: # Not yet terminated -- needs a terminator. if trail > 0: # Insert "." ONLY (one character, NOT ". ") at the # anchor -- the existing trailing whitespace # becomes the separator after it. terminator = TsStringUtils.MakeString(".", ws) bldr.ReplaceTsString(anchor, anchor, terminator) insert_at = current_length + 1 else: # Unchanged from today: insert ". " as terminator. terminator = TsStringUtils.MakeString(". ", ws) bldr.ReplaceTsString(current_length, current_length, terminator) insert_at = current_length + 2 new_text_ts = TsStringUtils.MakeString(text_str, ws) bldr.ReplaceTsString(insert_at, insert_at, new_text_ts) else: # Paragraph is empty — write text directly new_text_ts = TsStringUtils.MakeString(text_str, ws) bldr.ReplaceTsString(0, 0, new_text_ts) # Assign Contents — fires ContentsSideEffects -> AnalysisAdjuster on # existing segments, setting their BeginOffset/EndOffset correctly. para.Contents = bldr.GetString() # --- Step 2: add new segment to SegmentsOS --- factory = self.__GetSegmentFactory() seg = self._CreateWithGuid(factory, guid, "ISegment") para.SegmentsOS.Add(seg) # AnalysisAdjuster (fired by the Contents setter above) will set # seg.BeginOffset / seg.EndOffset when the paragraph is re-parsed. return seg @OperationsMethod def Delete(self, segment_or_hvo): """ Delete a segment from its paragraph. Removes the segment from its parent paragraph's segment collection. All analyses, translations, and notes associated with the segment are also deleted. Args: segment_or_hvo: The ISegment object or HVO. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. FP_ParameterError: If segment has no owning paragraph. Example: >>> segment = segments[0] >>> project.Segments.Delete(segment) See Also: AppendSentence, Exists, MergeSegments """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) owner = self._GetTypedOwner(segment_obj) if owner is None: raise FP_ParameterError("Segment has no owning paragraph") with self._TransactionCM("Delete segment"): owner.SegmentsOS.Remove(segment_obj) @OperationsMethod def SetAnalysis(self, segment_or_hvo, index, analysis_or_hvo): """ Replace the analysis token at a given index in a segment's AnalysesRS. Writes directly to ``ISegment.AnalysesRS`` (a reference sequence), which previously required dropping to raw LCM reflection (issue #215). Args: segment_or_hvo: The ISegment object or HVO. index: The 0-based index within AnalysesRS to replace. analysis_or_hvo: The replacement IAnalysis object (IWfiWordform, IWfiAnalysis, IWfiGloss, or IPunctuationForm) or its HVO. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo or analysis_or_hvo is None. FP_ParameterError: If index is out of range for AnalysesRS. Example: >>> segment = segments[0] >>> analyses = project.Segments.GetAnalyses(segment) >>> project.Segments.SetAnalysis(segment, 0, replacement_gloss) See Also: GetAnalyses, ReplaceAnalysis, InsertAnalysis """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") self._ValidateParam(analysis_or_hvo, "analysis_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) analysis_obj = self.__GetAnalysisObject(analysis_or_hvo) count = segment_obj.AnalysesRS.Count if not isinstance(index, int) or index < 0 or index >= count: raise FP_ParameterError( f"index must be between 0 and {count - 1} (inclusive); got {index!r}" ) with self._TransactionCM("Set segment analysis"): segment_obj.AnalysesRS[index] = analysis_obj @OperationsMethod def ReplaceAnalysis(self, segment_or_hvo, old_analysis_or_hvo, new_analysis_or_hvo): """ Find an analysis token in a segment's AnalysesRS and replace it. Writes directly to ``ISegment.AnalysesRS`` (a reference sequence), which previously required dropping to raw LCM reflection (issue #215). Args: segment_or_hvo: The ISegment object or HVO. old_analysis_or_hvo: The existing IAnalysis object or HVO to locate within AnalysesRS. new_analysis_or_hvo: The IAnalysis object or HVO to put in its place. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo, old_analysis_or_hvo, or new_analysis_or_hvo is None. FP_ParameterError: If old_analysis_or_hvo is not found in AnalysesRS. Example: >>> segment = segments[0] >>> analyses = project.Segments.GetAnalyses(segment) >>> project.Segments.ReplaceAnalysis(segment, analyses[2], new_gloss) See Also: GetAnalyses, SetAnalysis, InsertAnalysis """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") self._ValidateParam(old_analysis_or_hvo, "old_analysis_or_hvo") self._ValidateParam(new_analysis_or_hvo, "new_analysis_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) old_obj = self.__GetAnalysisObject(old_analysis_or_hvo) new_obj = self.__GetAnalysisObject(new_analysis_or_hvo) # Membership by HVO (issue #528). AnalysesRS can yield interface views # while __GetAnalysisObject may return a different Python wrapper for # the same token; ``in`` / list.index then false-negative. Elements # without an HVO never match -- they are skipped, not dereferenced. old_hvo = old_obj.Hvo index = None for i, token in enumerate(segment_obj.AnalysesRS): if getattr(token, "Hvo", None) == old_hvo: index = i break if index is None: raise FP_ParameterError( "old_analysis_or_hvo was not found in the segment's AnalysesRS" ) with self._TransactionCM("Replace segment analysis"): segment_obj.AnalysesRS[index] = new_obj @OperationsMethod def InsertAnalysis(self, segment_or_hvo, index, analysis_or_hvo): """ Insert an analysis token at a given index in a segment's AnalysesRS. Writes directly to ``ISegment.AnalysesRS`` (a reference sequence), which previously required dropping to raw LCM reflection (issue #215). Args: segment_or_hvo: The ISegment object or HVO. index: The 0-based index at which to insert. May equal the current AnalysesRS.Count to append at the end. analysis_or_hvo: The IAnalysis object or HVO to insert. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo or analysis_or_hvo is None. FP_ParameterError: If index is out of range for AnalysesRS. Example: >>> segment = segments[0] >>> project.Segments.InsertAnalysis(segment, 0, new_wordform) See Also: GetAnalyses, SetAnalysis, ReplaceAnalysis, AppendAnalysis """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") self._ValidateParam(analysis_or_hvo, "analysis_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) analysis_obj = self.__GetAnalysisObject(analysis_or_hvo) count = segment_obj.AnalysesRS.Count if not isinstance(index, int) or index < 0 or index > count: raise FP_ParameterError( f"index must be between 0 and {count} (inclusive); got {index!r}" ) with self._TransactionCM("Insert segment analysis"): segment_obj.AnalysesRS.Insert(index, analysis_obj) @OperationsMethod def AppendAnalysis(self, segment_or_hvo, analysis_or_hvo): """ Append an analysis token to the end of a segment's AnalysesRS. Convenience wrapper around ``InsertAnalysis`` at ``AnalysesRS.Count`` (issue #215). Args: segment_or_hvo: The ISegment object or HVO. analysis_or_hvo: The IAnalysis object or HVO to append. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo or analysis_or_hvo is None. Example: >>> segment = segments[0] >>> project.Segments.AppendAnalysis(segment, new_gloss) See Also: InsertAnalysis, SetAnalysis, RemoveAnalysis """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") self._ValidateParam(analysis_or_hvo, "analysis_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) analysis_obj = self.__GetAnalysisObject(analysis_or_hvo) with self._TransactionCM("Append segment analysis"): segment_obj.AnalysesRS.Add(analysis_obj) @OperationsMethod def RemoveAnalysis(self, segment_or_hvo, index): """ Remove the analysis token at a given index from a segment's AnalysesRS. Args: segment_or_hvo: The ISegment object or HVO. index: The 0-based index within AnalysesRS to remove. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. FP_ParameterError: If index is out of range for AnalysesRS. Example: >>> segment = segments[0] >>> project.Segments.RemoveAnalysis(segment, 0) See Also: InsertAnalysis, AppendAnalysis, SetAnalysis """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) count = segment_obj.AnalysesRS.Count if not isinstance(index, int) or index < 0 or index >= count: raise FP_ParameterError( f"index must be between 0 and {count - 1} (inclusive); got {index!r}" ) with self._TransactionCM("Remove segment analysis"): segment_obj.AnalysesRS.RemoveAt(index) @OperationsMethod def SplitSegment(self, segment_or_hvo, offset_within_segment): """ Split a segment at a character offset measured from the start of the segment. Inserts a sentence terminator ('. ') into IStTxtPara.Contents at the absolute position corresponding to the split, then adds the new ISegment to SegmentsOS. AnalysisAdjuster (fired by the Contents setter) shifts all subsequent segment offsets right by 2. Args: segment_or_hvo: The ISegment object or HVO to split. offset_within_segment: Character offset from the segment's BeginOffset where the split occurs. Must satisfy: 0 < offset_within_segment < (seg.EndOffset - seg.BeginOffset). Returns: tuple: (seg, new_seg) - the original (now shorter) segment and the newly inserted segment that follows it. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. FP_ParameterError: If offset_within_segment is out of range or segment has no owning paragraph. Example: >>> seg = segments[0] >>> print(project.Segments.GetBaselineText(seg)) In the beginning God created the heavens. >>> seg1, seg2 = project.Segments.SplitSegment(seg, 17) >>> print(project.Segments.GetBaselineText(seg1)) In the beginning >>> print(project.Segments.GetBaselineText(seg2)) God created the heavens. See Also: MergeSegments, AppendSentence, ReparseParagraph """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") seg = self.__GetSegmentObject(segment_or_hvo) seg_len = seg.EndOffset - seg.BeginOffset if offset_within_segment is None or not (0 < offset_within_segment < seg_len): raise FP_ParameterError( f"offset_within_segment must be between 1 and {seg_len - 1} " f"(exclusive); got {offset_within_segment}" ) para = self.GetOwningParagraph(seg) absolute_offset = seg.BeginOffset + offset_within_segment # --- Step 1: edit Contents --- # Resolve the paragraph's vernacular WS from the existing text run so # the inserted terminator uses the same writing system as its neighbours. ws = self.__WSHandleVern(None) with self._TransactionCM("Split segment"): bldr = para.Contents.GetBldr() terminator = TsStringUtils.MakeString(". ", ws) bldr.ReplaceTsString(absolute_offset, absolute_offset, terminator) # Assign — fires AnalysisAdjuster which shifts seg3+ offsets right by 2 para.Contents = bldr.GetString() # --- Step 2: insert new segment into SegmentsOS --- idx = para.SegmentsOS.IndexOf(seg) factory = self.__GetSegmentFactory() new_seg = factory.Create() para.SegmentsOS.Insert(idx + 1, new_seg) return (seg, new_seg) @OperationsMethod def MergeSegments(self, seg1_or_hvo, seg2_or_hvo, translation_policy="migrate"): """ Merge two adjacent segments into one, removing the inter-segment boundary. Edits IStTxtPara.Contents first (removing the terminator+whitespace between the two segments), then removes seg2 from SegmentsOS. AnalysisAdjuster (fired by the Contents setter) shifts all subsequent segment offsets left automatically. Args: seg1_or_hvo: The first (earlier) ISegment object or HVO. seg2_or_hvo: The second (later) ISegment object or HVO. Must be immediately after seg1 in SegmentsOS. translation_policy (str): One of: - TRANSLATION_POLICY_MIGRATE / 'migrate' (default): Concatenate seg2's FreeTranslation and LiteralTranslation into seg1's, joined by ' / ' per writing system that has content. Also moves all Notes from seg2.NotesOS into seg1.NotesOS via re-parenting. Migration happens BEFORE seg2 is removed. - TRANSLATION_POLICY_DISCARD / 'discard': Drop seg2's translations and notes silently. - TRANSLATION_POLICY_REJECT / 'reject': Raise FP_ParameterError if seg2 has any non-empty FreeTranslation, LiteralTranslation (across ALL writing systems), or Notes. Caller must explicitly pass 'migrate' or 'discard' to proceed. Returns: ISegment: seg1 (the survivor segment). Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If either segment is None. FP_ParameterError: If segments are not adjacent, not in the same paragraph, or 'reject' policy triggered. Example: >>> seg1 = segments[0] >>> seg2 = segments[1] >>> merged = project.Segments.MergeSegments(seg1, seg2) >>> print(project.Segments.GetBaselineText(merged)) In the beginning God created the heavens and the earth. See Also: SplitSegment, AppendSentence, ReparseParagraph Notes: All wordform analyses on seg2 are lost. Prefer 'migrate' (default) to preserve translations across the merge. """ self._EnsureWriteEnabled() self._ValidateParam(seg1_or_hvo, "seg1_or_hvo") self._ValidateParam(seg2_or_hvo, "seg2_or_hvo") seg1 = self.__GetSegmentObject(seg1_or_hvo) seg2 = self.__GetSegmentObject(seg2_or_hvo) # Validate translation_policy early — before any LCM calls — so a # typo like 'Migrate' raises immediately on a plain mock without needing # a real SegmentsOS collection. if translation_policy not in _VALID_TRANSLATION_POLICIES: raise FP_ParameterError( f"translation_policy must be one of {_VALID_TRANSLATION_POLICIES!r}, " f"got {translation_policy!r}" ) # Compare typed paragraph owners by HVO (issue #521). Raw seg.Owner is # ICmObject; reference inequality can spuriously fail adjacent segments. para1 = self._GetTypedOwner(seg1) para2 = self._GetTypedOwner(seg2) if para1 is None or para2 is None: raise FP_ParameterError("Segments have no valid owner paragraph") if para1.Hvo != para2.Hvo: raise FP_ParameterError("Segments must be in the same paragraph") para = para1 # Adjacent indices by HVO (issue #525). SegmentsOS can yield bare # interface views while __GetSegmentObject returns cast handles. seg1_hvo = seg1.Hvo seg2_hvo = seg2.Hvo idx1 = idx2 = None for i, seg in enumerate(para.SegmentsOS): if seg.Hvo == seg1_hvo: idx1 = i if seg.Hvo == seg2_hvo: idx2 = i if idx1 is None or idx2 is None: raise FP_ParameterError( "One or both segments are not in the paragraph SegmentsOS" ) # Ensure seg1 is the earlier segment if idx1 > idx2: seg1, seg2 = seg2, seg1 idx1, idx2 = idx2, idx1 if idx2 != idx1 + 1: raise FP_ParameterError( f"Segments must be adjacent; seg1 is at index {idx1}, " f"seg2 is at index {idx2}" ) # Enforce translation_policy if translation_policy == TRANSLATION_POLICY_REJECT: has_content = False # Check ALL writing systems in both FreeTranslation and LiteralTranslation, # not just the default WS, to honour the docstring promise. # IMultiString.GetStringFromIndex(i) returns (ITsString, ws_int) tuple. for attr in ("FreeTranslation", "LiteralTranslation"): ms = getattr(seg2, attr, None) if ms is None: continue for i in range(ms.StringCount): ts, _ws = ms.GetStringFromIndex(i) text_val = ts.Text if hasattr(ts, "Text") else None if text_val: has_content = True break if has_content: break # Also check Notes if not has_content: notes = list(seg2.NotesOS) if hasattr(seg2, "NotesOS") else [] if notes: has_content = True if has_content: raise FP_ParameterError( "seg2 has non-empty translations/notes and translation_policy='reject'. " "Pass translation_policy='migrate' to concatenate into seg1, or " "'discard' to drop them." ) with self._TransactionCM("Merge segments"): if translation_policy == TRANSLATION_POLICY_MIGRATE: # Migrate FreeTranslation, LiteralTranslation, and Notes for all WS self.__MigrateTranslations(seg1, seg2) # TRANSLATION_POLICY_DISCARD: do nothing — seg2's data is lost on removal # --- Step 1: edit Contents to remove the inter-segment boundary --- bldr = para.Contents.GetBldr() # Remove characters from end of seg1 (the terminator) through start of seg2 # seg1.EndOffset is the position just after seg1's last char (includes terminator) # seg2.BeginOffset is the first char of seg2's text bldr.Replace(seg1.EndOffset, seg2.BeginOffset, "", None) # Assign — fires AnalysisAdjuster which shifts seg3+ offsets left para.Contents = bldr.GetString() # --- Step 2: remove seg2 from SegmentsOS if it still exists --- # Membership by HVO (issue #525); same gap as Exists (#523). for seg in para.SegmentsOS: if seg.Hvo == seg2.Hvo: para.SegmentsOS.Remove(seg) break return seg1 def __MigrateTranslations(self, seg1, seg2): """ Concatenate seg2's FreeTranslation and LiteralTranslation into seg1 per writing system that has content, joined by ' / '. Also moves all Notes from seg2's NotesOS into seg1's NotesOS (re-parenting ownership). Args: seg1: The survivor segment. seg2: The segment being merged away. """ # Collect all WS handles present in either FreeTranslation or LiteralTranslation. # IMultiString.GetStringFromIndex(i) returns (ITsString, ws_int) tuple. ws_set = set() for seg in (seg1, seg2): for attr in ("FreeTranslation", "LiteralTranslation"): ms = getattr(seg, attr, None) if ms is not None: for i in range(ms.StringCount): _ts, ws = ms.GetStringFromIndex(i) ws_set.add(ws) # The only caller (MergeSegments) already holds a "Merge segments" # transaction when it calls this helper, so at runtime this bracket # simply joins that one -- _TransactionCM is nesting-aware, so no # second undo entry is opened. It is stated here anyway so the # mutations below are bracketed at the site per D5, and so a future # caller cannot reach them unbracketed. with self._TransactionCM("Migrate segment translations"): for ws in ws_set: # FreeTranslation if seg1.FreeTranslation and seg2.FreeTranslation: t1 = ITsString(seg1.FreeTranslation.get_String(ws)).Text or "" t2 = ITsString(seg2.FreeTranslation.get_String(ws)).Text or "" if t2: merged_text = (t1 + " / " + t2) if t1 else t2 mkstr = TsStringUtils.MakeString(merged_text, ws) seg1.FreeTranslation.set_String(ws, mkstr) # LiteralTranslation if seg1.LiteralTranslation and seg2.LiteralTranslation: t1 = ITsString(seg1.LiteralTranslation.get_String(ws)).Text or "" t2 = ITsString(seg2.LiteralTranslation.get_String(ws)).Text or "" if t2: merged_text = (t1 + " / " + t2) if t1 else t2 mkstr = TsStringUtils.MakeString(merged_text, ws) seg1.LiteralTranslation.set_String(ws, mkstr) # Notes: move all ICmBaseAnnotation objects from seg2.NotesOS to seg1.NotesOS. # ILcmOwningSequence.MoveTo(srcStart, srcEnd, dest, destStart) re-parents # ownership without creating new objects. if hasattr(seg2, "NotesOS") and hasattr(seg1, "NotesOS"): notes2 = list(seg2.NotesOS) if notes2: dest_index = seg1.NotesOS.Count seg2.NotesOS.MoveTo(0, len(notes2) - 1, seg1.NotesOS, dest_index) @OperationsMethod def ReparseParagraph(self, paragraph_or_hvo): """ Re-derive segment offsets and wordform analyses for a paragraph by reassigning para.Contents to itself; FreeTranslation, LiteralTranslation, and Notes on surviving segments are preserved. Implementation: assigns para.Contents back to itself. That single setter assignment fires ContentsSideEffects -> AnalysisAdjuster, which is an offset reconciler -- it rebuilds AnalysesRS (wordform analyses) but does NOT touch FreeTranslation, LiteralTranslation, or NotesOS on surviving segments (those are independently-owned LCM objects with no dependency on Contents). When the assignment is a self-assignment (the only path through this method), segment objects survive with identical offsets, so their translations are never cleared. No regex, no manual SegmentsOS.Clear(), no factory.Create() calls are needed or used. Args: paragraph_or_hvo: The IStTxtPara object or HVO. Returns: The freshly rebuilt SegmentsOS collection (ILcmOwningSequence). Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If paragraph_or_hvo is None. Example: >>> para = project.Object(para_hvo) >>> segs = project.Segments.ReparseParagraph(para) >>> print(f"Rebuilt {segs.Count} segments") Rebuilt 3 segments See Also: SplitSegment, MergeSegments, AppendSentence Warning: This destroys all existing wordform analyses (AnalysesRS) on the paragraph. FreeTranslation, LiteralTranslation, and Notes are NOT cleared. Use SplitSegment or MergeSegments for non-destructive structural edits. """ self._EnsureWriteEnabled() self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo") para = self.__GetParagraphObject(paragraph_or_hvo) # Snapshot the current Contents and reassign. The setter fires # ContentsSideEffects -> AnalysisAdjuster which re-derives SegmentsOS # entirely from the punctuation pattern in the text. snapshot = para.Contents with self._TransactionCM("Reparse paragraph segments"): para.Contents = snapshot return para.SegmentsOS @OperationsMethod def GetOwningParagraph(self, segment_or_hvo): """ Get the paragraph that owns a segment. Retrieves the ``IStTxtPara`` object that contains the segment via the typed owner chain (issue #521). Args: segment_or_hvo: The ISegment object or HVO. Returns: IStTxtPara: The paragraph that owns the segment. Raises: FP_NullParameterError: If segment_or_hvo is None. FP_ParameterError: If the segment does not exist or has no owner. Example: >>> para = project.Paragraphs.Create(text, "Hello world.") >>> seg = project.Segments.AppendSentence(para, "Second sentence.") >>> owner = project.Segments.GetOwningParagraph(seg) >>> assert owner.Hvo == para.Hvo Notes: - Accepts segment HVO and raw ``project.Object(hvo)`` views (#275) - Prefer this over ``segment.Paragraph`` / raw ``segment.Owner`` See Also: MergeSegments, SplitSegment, SetBaselineText, Delete """ segment_obj = self.__GetSegmentObject(segment_or_hvo) para = self._GetTypedOwner(segment_obj) if para is None: raise FP_ParameterError("Segment has no valid owning paragraph") return para # ========== QUERY/VALIDATION METHODS ========== @OperationsMethod def Exists(self, paragraph_or_hvo, segment_or_hvo): """ Check if a segment exists in a paragraph. Args: paragraph_or_hvo: The IStTxtPara object or HVO. segment_or_hvo: The ISegment object or HVO to check for. Returns: bool: True if the segment exists in the paragraph, False otherwise. Raises: FP_NullParameterError: If paragraph_or_hvo or segment_or_hvo is None. Example: >>> para = project.Object(para_hvo) >>> segment = segments[0] >>> exists = project.Segments.Exists(para, segment) >>> print(exists) True See Also: AppendSentence, Delete, GetAll """ self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo") self._ValidateParam(segment_or_hvo, "segment_or_hvo") para_obj = self.__GetParagraphObject(paragraph_or_hvo) segment_obj = self.__GetSegmentObject(segment_or_hvo) # Membership by HVO (issue #523). SegmentsOS yields bare interface # views while __GetSegmentObject may return cast_to_concrete handles; # Python ``in`` then false-negates for the same LCM segment. target_hvo = segment_obj.Hvo for seg in para_obj.SegmentsOS: if seg.Hvo == target_hvo: return True return False @OperationsMethod def GetBeginOffset(self, segment_or_hvo): """ Get the beginning character offset of a segment within its paragraph. Args: segment_or_hvo: The ISegment object or HVO. Returns: int: The beginning offset (0-based character position). Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> offset = project.Segments.GetBeginOffset(segment) >>> print(offset) 0 See Also: GetEndOffset """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) if hasattr(segment_obj, "BeginOffset"): return segment_obj.BeginOffset return 0 @OperationsMethod def GetEndOffset(self, segment_or_hvo): """ Get the ending character offset of a segment within its paragraph. Args: segment_or_hvo: The ISegment object or HVO. Returns: int: The ending offset (0-based character position). Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> offset = project.Segments.GetEndOffset(segment) >>> print(offset) 45 See Also: GetBeginOffset """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) if hasattr(segment_obj, "EndOffset"): return segment_obj.EndOffset return 0 @OperationsMethod def IsLabel(self, segment_or_hvo): """ Check if a segment is marked as a label (section header). Args: segment_or_hvo: The ISegment object or HVO. Returns: bool: True if the segment is a label, False otherwise. Raises: FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> is_header = project.Segments.IsLabel(segment) >>> print(is_header) False See Also: SetIsLabel """ self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) if hasattr(segment_obj, "IsLabel"): return segment_obj.IsLabel return False @OperationsMethod def SetIsLabel(self, segment_or_hvo, is_label): """ Set whether a segment is a label (section header). Args: segment_or_hvo: The ISegment object or HVO. is_label: Boolean value - True to mark as label, False for regular segment. Raises: FP_ReadOnlyError: If project is not opened with writeEnabled=True. FP_NullParameterError: If segment_or_hvo is None. Example: >>> segment = segments[0] >>> project.Segments.SetIsLabel(segment, True) See Also: IsLabel """ self._EnsureWriteEnabled() self._ValidateParam(segment_or_hvo, "segment_or_hvo") segment_obj = self.__GetSegmentObject(segment_or_hvo) # hasattr guard outside the bracket -- its absence is a true no-op. if hasattr(segment_obj, "IsLabel"): with self._TransactionCM("Set segment label flag"): segment_obj.IsLabel = bool(is_label) @OperationsMethod def ValidateSegments(self, paragraph_or_hvo): """ Validate the integrity of all segments in a paragraph. Checks that segments are properly structured with valid offsets, non-overlapping ranges, and complete coverage of the paragraph text. Args: paragraph_or_hvo: The IStTxtPara object or HVO. Returns: dict: Validation results with keys: - 'valid': bool - Overall validity - 'errors': list - List of error messages - 'warnings': list - List of warning messages - 'segment_count': int - Number of segments checked Raises: FP_NullParameterError: If paragraph_or_hvo is None. Example: >>> para = project.Object(para_hvo) >>> result = project.Segments.ValidateSegments(para) >>> if result['valid']: ... print("All segments are valid") ... else: ... for error in result['errors']: ... print(f"Error: {error}") See Also: ReparseParagraph, GetBeginOffset, GetEndOffset """ self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo") para_obj = self.__GetParagraphObject(paragraph_or_hvo) segments_list = list(para_obj.SegmentsOS) errors = [] warnings = [] if not segments_list: warnings.append("Paragraph has no segments") return {"valid": True, "errors": errors, "warnings": warnings, "segment_count": 0} prev_end = -1 for i, segment in enumerate(segments_list): if not hasattr(segment, "BeginOffset") or not hasattr(segment, "EndOffset"): warnings.append(f"Segment {i} missing offset attributes") continue begin = segment.BeginOffset end = segment.EndOffset if begin < 0 or end < 0: errors.append(f"Segment {i} has negative offset (begin={begin}, end={end})") if begin >= end: errors.append(f"Segment {i} has invalid offsets (begin={begin} >= end={end})") if prev_end >= 0: if begin > prev_end + 1: warnings.append( f"Gap between segment {i-1} and {i} " f"(prev_end={prev_end}, begin={begin})" ) elif begin < prev_end: errors.append( f"Overlap between segment {i-1} and {i} " f"(prev_end={prev_end}, begin={begin})" ) baseline = self.GetBaselineText(segment) if not baseline: warnings.append(f"Segment {i} has empty baseline text") prev_end = end valid = len(errors) == 0 return {"valid": valid, "errors": errors, "warnings": warnings, "segment_count": len(segments_list)} # ========== SYNC INTEGRATION METHODS ========== @OperationsMethod def GetSyncableProperties(self, item): """ Get all syncable properties of a segment. Args: item: The ISegment object. Returns: dict: Dictionary of syncable properties with their values. Example: >>> props = project.Segments.GetSyncableProperties(segment) >>> print(props['BaselineText']) {'en': 'In the beginning...'} >>> print(props['IsLabel']) False >>> print(props['BeginOffset']) 0 Notes: - MultiString properties: FreeTranslation, LiteralTranslation - BaselineText is ITsString (single-WS, read-only computed from para Contents) - Boolean property: IsLabel - Integer properties: BeginOffset, EndOffset - Does NOT include analyses (those are children) """ props = {} # BaselineText is ITsString (single-WS, read-only computed from para Contents). if hasattr(item, "BaselineText") and item.BaselineText: bt = item.BaselineText ws_handle = bt.get_Properties(0).GetIntPropValues(1, 0)[0] props["BaselineText"] = {ws_handle: bt.Text or ""} # FreeTranslation and LiteralTranslation are genuine IMultiString fields. if hasattr(item, "FreeTranslation") and item.FreeTranslation: props["FreeTranslation"] = self.project.GetMultiStringDict(item.FreeTranslation) if hasattr(item, "LiteralTranslation") and item.LiteralTranslation: props["LiteralTranslation"] = self.project.GetMultiStringDict(item.LiteralTranslation) if hasattr(item, "IsLabel"): props["IsLabel"] = bool(item.IsLabel) if hasattr(item, "BeginOffset"): props["BeginOffset"] = int(item.BeginOffset) if hasattr(item, "EndOffset"): props["EndOffset"] = int(item.EndOffset) return props @OperationsMethod def CompareTo(self, item1, item2, ops1=None, ops2=None): """ Compare two segments for differences. Args: item1: First segment object (from project 1) item2: Second segment object (from project 2) ops1: Optional SegmentOperations instance for project 1 (defaults to self) ops2: Optional SegmentOperations instance for project 2 (defaults to self) Returns: tuple: (is_different, differences_dict) - is_different (bool): True if segments differ, False if identical - differences_dict (dict): Maps property names to (value1, value2) tuples Example: >>> is_diff, diffs = ops1.CompareTo(seg1, seg2, ops1, ops2) >>> if is_diff: ... for prop, (val1, val2) in diffs.items(): ... print(f"{prop}: {val1} != {val2}") """ if ops1 is None: ops1 = self if ops2 is None: ops2 = self props1 = ops1.GetSyncableProperties(item1) props2 = ops2.GetSyncableProperties(item2) differences = {} all_keys = set(props1.keys()) | set(props2.keys()) for key in all_keys: val1 = props1.get(key) val2 = props2.get(key) # Inline comparison: FLExProject has no _CompareValues member # (calling it raised AttributeError on every compare; same # fix as MediaOperations.CompareTo). if val1 != val2: differences[key] = (val1, val2) is_different = len(differences) > 0 return (is_different, differences)