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

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 flexlibs2 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 for segment analyses (reference sequence).""" return parent.AnalysesRS 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 self.project.Object(para_or_hvo) return 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 self.project.Object(segment_or_hvo) return segment_or_hvo def __GetSegmentFactory(self): """Return the ISegmentFactory from the service locator.""" return self.project.project.ServiceLocator.GetService(ISegmentFactory) # ========== 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. Yields: 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, IWfiGloss, or IWfiAnalysis object. 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 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 = segment_obj.Paragraph if para is None: raise FP_ParameterError("Segment has no owning paragraph; cannot write BaselineText") begin = segment_obj.BeginOffset end = segment_obj.EndOffset bldr = para.Contents.GetBldr() new_run = TsStringUtils.MakeString(text, ws) bldr.ReplaceTsString(begin, end, new_run) 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) 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) 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): """ 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 {. ! ?}. When the paragraph is empty, the text is written directly. Args: paragraph_or_hvo: The IStTxtPara object or HVO. text: The sentence text to append. Must be non-empty. wsHandle: Optional writing system handle. Defaults to project default vernacular WS. 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 """ self._EnsureWriteEnabled() self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo") self._ValidateParam(text, "text") text_str = text.strip() if isinstance(text, str) else str(text).strip() if not text_str: 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: # Check the last character of the current contents last_char_pos = current_length - 1 last_char = para.Contents.Text[last_char_pos] if para.Contents.Text else "" if last_char not in (".", "!", "?"): # Insert ". " as sentence terminator terminator = TsStringUtils.MakeString(". ", ws) bldr.ReplaceTsString(current_length, current_length, terminator) # Recalculate insertion point after terminator insert_at = current_length + 2 else: # Already terminated — just insert a space separator sep = TsStringUtils.MakeString(" ", ws) bldr.ReplaceTsString(current_length, current_length, sep) insert_at = current_length + 1 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 = factory.Create() 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") owner.SegmentsOS.Remove(segment_obj) @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 = seg.Paragraph if para is None: raise FP_ParameterError("Segment has no owning paragraph") 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}" ) if seg1.Owner != seg2.Owner: raise FP_ParameterError("Segments must be in the same paragraph") para = self._GetTypedOwner(seg1) if para is None: raise FP_ParameterError("Segments have no valid owner paragraph") segments_list = list(para.SegmentsOS) idx1 = segments_list.index(seg1) idx2 = segments_list.index(seg2) # 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 --- if seg2 in list(para.SegmentsOS): para.SegmentsOS.Remove(seg2) 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) 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 para.Contents = snapshot return para.SegmentsOS # ========== 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) segments_list = list(para_obj.SegmentsOS) return segment_obj in segments_list @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) if hasattr(segment_obj, "IsLabel"): 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) if self.project._CompareValues(val1, val2): differences[key] = (val1, val2) is_different = len(differences) > 0 return (is_different, differences)