#
# TextOperations.py
#
# Class: TextOperations
# Text operations for FieldWorks Language Explorer projects
# via SIL Language and Culture Model (LCM) API.
#
# Copyright Craig Farrow, 2008 - 2024
#
import clr
clr.AddReference("System")
import System
from SIL.LCModel import (
IText,
ITextFactory,
IStTextFactory,
IStTxtParaFactory,
ITextRepository,
IStTxtPara,
ICmPossibility,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils
from ..FLExProject import (
FP_ParameterError,
)
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable
from ..Shared.string_utils import normalize_match_key
[docs]
class TextOperations(BaseOperations):
"""
Text operations for managing FLEx Text objects.
This class provides methods for creating, reading, updating, and deleting
texts in a FieldWorks Language Explorer project.
Usage:
project = FLExProject()
project.OpenProject("MyProject", writeEnabled=True)
# Access through the Texts property
text_ops = TextOperations(project)
# Or if integrated into FLExProject:
# new_text = project.Texts.Create("Story 1")
"""
def __init__(self, project):
"""
Initialize TextOperations with a FLEx project.
Args:
project: The FLExProject instance to operate on.
"""
super().__init__(project)
def __WSHandle(self, wsHandle):
"""
Internal helper for writing system handles.
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 __GetTextObject(self, text_or_hvo):
"""
Resolve text_or_hvo to IText object.
Args:
text_or_hvo: Either an IText object or its HVO (integer).
Returns:
IText: The text object.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the HVO doesn't refer to a text object.
"""
self._ValidateParam(text_or_hvo, "text_or_hvo")
# Casts by ClassName BEFORE returning (issue #275, generalising
# #269's fix): self.project.Object() returns a bare ICmObject, so
# isinstance(obj, IText) is False even for a genuine text. Strict
# widening over the bare isinstance check.
if isinstance(text_or_hvo, int):
obj = self.project.Object(text_or_hvo)
if getattr(obj, "ClassName", None) == "Text":
try:
return IText(obj)
except Exception:
pass
if isinstance(obj, IText):
return obj
raise FP_ParameterError(f"HVO {text_or_hvo} does not refer to a text object")
if getattr(text_or_hvo, "ClassName", None) == "Text":
try:
return IText(text_or_hvo)
except Exception:
pass
return text_or_hvo
# --- Core CRUD Operations ---
@OperationsMethod
def Create(self, name, genre=None, guid=None, contents_guid=None):
"""
Create a new text in the project.
Creates a new IText object with the specified name and optional genre.
The text will have an empty StText contents object created automatically.
Args:
name (str): The name of the text. Must be unique and non-empty.
Note: leading/trailing whitespace in the value is preserved
verbatim (Q-242A); a value that is entirely whitespace still
raises FP_ParameterError. Uniqueness is checked
whitespace-insensitively (see Exists) -- a name differing
from an existing one only by leading/trailing whitespace is
treated as a duplicate and raises FP_ParameterError, not
silently accepted.
genre (ICmPossibility, optional): Genre classification for the text.
If provided, must be a valid ICmPossibility from the project's
genre list. Defaults to None.
guid (optional): GUID to assign to the new text, as a
``System.Guid`` or string. Use this when REPRODUCING a
text from another project so it keeps its original
identity. None (the default) mints a fresh GUID.
contents_guid (optional): GUID for the text's owned StText
contents object. Same semantics as ``guid``.
Returns:
IText: The newly created text object.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If name is None or empty.
FP_ParameterError: If a text with this name already exists.
Example:
>>> # Create a simple text
>>> text = project.Texts.Create("Genesis")
>>> print(text.Name.BestAnalysisAlternative.Text)
Genesis
>>> # Create a text with genre
>>> genre_list = project.PossibilityLists.FindList("Text Genres")
>>> narrative_genre = project.PossibilityLists.GetItems(genre_list)[0]
>>> text = project.Texts.Create("Story 1", genre=narrative_genre)
See Also:
Delete, Exists, GetAll
"""
self._EnsureWriteEnabled()
self._ValidateStringNotEmpty(name, "text name")
# Check if text with this name already exists
if self.Exists(name):
raise FP_ParameterError(f"A text with the name '{name}' already exists.")
with self._TransactionCM(f"Create text '{name}'"):
# Create the text object
text_factory = self.project.project.ServiceLocator.GetService(ITextFactory)
new_text = self._CreateWithGuid(text_factory, guid, "IText")
# No collection to add to: texts are unowned in LCM 11 and the
# factory has already registered this one with the repository.
# ILangProject.Texts is a derived read-only IList<IText>, so the
# `lp.Texts.Add(new_text)` that used to sit here was a no-op on a
# throwaway list (issue #317). #22 read the disappearance of
# TextsOC as a rename; it was an ownership-model change.
# Set the name
wsHandle = self.project.project.DefaultAnalWs
name_str = TsStringUtils.MakeString(name, wsHandle)
new_text.Name.set_String(wsHandle, name_str)
# Create the contents (StText)
sttext_factory = self.project.project.ServiceLocator.GetService(IStTextFactory)
contents = self._CreateWithGuid(sttext_factory, contents_guid, "IStText")
new_text.ContentsOA = contents
# Set genre if provided
if genre is not None:
self.SetGenre(new_text, genre)
return new_text
@OperationsMethod
def Delete(self, text_or_hvo):
"""
Delete a text from the project.
Removes the text and all its contents (paragraphs, segments, etc.) from
the project database.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> # Delete by object
>>> text = project.Texts.GetAll()[0]
>>> project.Texts.Delete(text)
>>> # Delete by HVO
>>> project.Texts.Delete(text_hvo)
See Also:
Create, Exists, GetAll
"""
self._EnsureWriteEnabled()
text_obj = self.__GetTextObject(text_or_hvo)
# LCM Delete() removes the object from the repository. Texts are
# UNOWNED in LCM 11 -- ILangProject.Texts is a derived read-only
# IList<IText> rebuilt on each access, not an owning collection, so
# the `lp.Texts.Remove(text_obj)` this used to call mutated a
# throwaway list and silently deleted nothing (issue #317).
with self._TransactionCM("Delete text"):
text_obj.Delete()
@OperationsMethod
def Duplicate(self, item_or_hvo, deep=True, *, insert_after=True):
"""
Duplicate a text, creating a new text with the same properties.
This method creates a copy of an existing text. With deep=True (default),
all paragraphs and their segments are recursively duplicated. With deep=False,
only the text shell (name, genre, abbreviation) is duplicated.
Args:
item_or_hvo: Either an IText object or its HVO (integer identifier)
deep (bool): If True (default), recursively duplicate all paragraphs
and segments. If False, only duplicate the text shell (name, genre).
insert_after (bool): Accepted for API uniformity across Operations classes.
Texts are created via Create(), which appends to the project's
texts collection with no positional-insert concept, so this
parameter is ignored.
Returns:
IText: The newly created duplicate text
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If item_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> # Deep duplicate (default: with all paragraphs)
>>> text = list(project.Texts.GetAll())[0]
>>> duplicate = project.Texts.Duplicate(text) # deep=True by default
>>> print(project.Texts.GetParagraphCount(duplicate))
10
>>> # Shallow duplicate (text shell only, no paragraphs)
>>> text = list(project.Texts.GetAll())[0]
>>> duplicate = project.Texts.Duplicate(text, deep=False)
>>> print(project.Texts.GetName(duplicate))
Genesis (copy)
>>> print(project.Texts.GetParagraphCount(duplicate))
0
Warning:
- deep=True for Text can be slow for long texts with many paragraphs
- The duplicate will have a " (copy)" suffix added to the name
- The duplicate will have identical content but a new GUID
- Media files are NOT duplicated (to avoid file duplication)
- Segments will need re-parsing if you want analyses
Notes:
- Duplicated text is added to the project texts collection
- New GUID is auto-generated for the duplicate
- Name is copied with " (copy)" suffix to ensure uniqueness
- Genre is copied (if set)
- Abbreviation is copied (if set)
- With deep=True, all paragraphs are recursively duplicated
See Also:
Create, Delete, project.Paragraphs.Duplicate
"""
self._EnsureWriteEnabled()
text_obj = self.__GetTextObject(item_or_hvo)
# Get source properties
wsHandle = self.__WSHandle(None)
source_name = self.GetName(text_obj, wsHandle)
source_genre = self.GetGenre(text_obj)
# Create unique name by appending " (copy)"
new_name = f"{source_name} (copy)"
# Ensure uniqueness
counter = 1
while self.Exists(new_name):
new_name = f"{source_name} (copy {counter})"
counter += 1
with self._TransactionCM("Duplicate text"):
# Create the new text (sets Name and Genre via Create helper)
new_text = self.Create(new_name, genre=source_genre)
# Copy remaining MultiString properties (all writing systems).
# IText declares no Title (issue #352) -- that guarded copy
# was dead code and is removed. Description IS present live
# (inherited multistring) and stays.
if hasattr(text_obj, "Abbreviation") and text_obj.Abbreviation:
new_text.Abbreviation.CopyAlternatives(text_obj.Abbreviation)
if hasattr(text_obj, "Description") and text_obj.Description:
new_text.Description.CopyAlternatives(text_obj.Description)
if hasattr(text_obj, "Source") and text_obj.Source:
new_text.Source.CopyAlternatives(text_obj.Source)
# Deep duplication: copy paragraph contents
if deep:
paragraphs = self.GetParagraphs(text_obj)
for para in paragraphs:
if para.Contents:
# Copy the full TsString (preserves formatting and all WS runs)
para_factory = self.project.project.ServiceLocator.GetService(IStTxtParaFactory)
new_para = para_factory.Create()
new_text.ContentsOA.ParagraphsOS.Add(new_para)
new_para.Contents = para.Contents
return new_text
# ========== SYNC INTEGRATION METHODS ==========
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get all syncable properties of a text.
Args:
item: The IText object.
Returns:
dict: Dictionary of syncable properties with their values.
Example:
>>> props = project.Texts.GetSyncableProperties(text)
>>> print(props['Description'])
{'en': 'First book of the Bible'}
Notes:
- MultiString properties: Name, Description, Source
- DateTime properties: DateCreated, DateModified
- Reference Collection properties: GenresRC (GUIDs)
- Owned-container properties: media_uris (list of
{"uri": str, "file_guid": str|None} dicts, R4). ``file_guid``
is always None on this LCM: ``ICmMediaURI`` exposes only
``MediaURI`` (no ``MediaFileRA``; live reflection 2026-09-25)
- Does NOT include owned sequences (paragraphs) - those are children
R4 note: MediaFilesOA is on the concrete DomainImpl.Text, NOT on the
IText interface; cast_to_concrete() is required. Access path:
MediaFilesOA -> MediaURIsOC (same path as GetMediaFiles/AddMediaFile).
"""
from ..lcm_casting import cast_to_concrete
props = {}
# MultiString properties.
# Name (IText.Name) is the user-facing title; present on IText
# interface (R8, issue #325).
if hasattr(item, "Name") and item.Name:
props["Name"] = self.project.GetMultiStringDict(item.Name)
if hasattr(item, "Description") and item.Description:
props["Description"] = self.project.GetMultiStringDict(item.Description)
if hasattr(item, "Source") and item.Source:
props["Source"] = self.project.GetMultiStringDict(item.Source)
# DateTime properties
if hasattr(item, "DateCreated") and item.DateCreated:
props["DateCreated"] = str(item.DateCreated)
if hasattr(item, "DateModified") and item.DateModified:
props["DateModified"] = str(item.DateModified)
# Reference Collection properties (return list of GUIDs)
if hasattr(item, "GenresRC") and item.GenresRC:
props["GenresRC"] = [str(g.Guid) for g in item.GenresRC]
# Media URIs (R4): MediaFilesOA is on the concrete impl, not IText
# interface. cast_to_concrete() is required; hasattr guard is still
# needed because MediaFilesOA itself may be None (no container).
# Access path: concrete_text -> MediaFilesOA (None-safe) ->
# MediaURIsOC -> iterate ICmMediaURI elements.
concrete = cast_to_concrete(item)
if concrete is not None and hasattr(concrete, "MediaFilesOA"):
container = concrete.MediaFilesOA
if container is not None and hasattr(container, "MediaURIsOC"):
media_uris = []
for uri_obj in container.MediaURIsOC:
file_guid = None
if getattr(uri_obj, "MediaFileRA", None) is not None:
try:
file_guid = str(uri_obj.MediaFileRA.Guid)
except Exception:
pass
media_uris.append({
"uri": str(uri_obj.MediaURI) if uri_obj.MediaURI else "",
"file_guid": file_guid,
})
if media_uris:
props["media_uris"] = media_uris
return props
@OperationsMethod
def ApplySyncableProperties(self, item, props, ws_map=None, fill_gaps=False):
"""
Apply a syncable-properties dict onto an IText item.
Extends the base implementation to handle the Text-specific fields
that are not plain MultiString or plain string attributes.
Args:
item: Target IText object (must already exist in target project).
props: dict produced by GetSyncableProperties on a source text.
ws_map: Optional source->target writing-system Id mapping.
fill_gaps (bool): When True, only write fields whose current
target value is empty/absent; passed through to BaseOperations.
media_uris is always applied regardless (reconcile-by-URI
semantics; missing entries are added, existing ones kept).
Notes:
- MultiString fields (Name, Description, Source) are handled by
the base class loop.
- media_uris: if the key is absent or the list is empty the
container is left as-is (don't destroy existing media per R4
apply strategy). If populated, reconcile by URI string -- add
missing URIs; do not delete extras without an explicit delete
call (R4). Requires write-enabled project.
- GenresRC, DateCreated, DateModified: not applied here (read-only
or cross-project resolution not yet implemented).
Example:
>>> props = source.Texts.GetSyncableProperties(src_text)
>>> target.Texts.ApplySyncableProperties(tgt_text, props)
"""
import logging as _logging
_log = _logging.getLogger(__name__)
self._EnsureWriteEnabled()
_special_fields = ("media_uris", "GenresRC", "DateCreated", "DateModified")
remaining_props = {}
special_props = {}
for k, v in props.items():
if k in _special_fields:
special_props[k] = v
else:
remaining_props[k] = v
with self._TransactionCM("Apply text sync properties"):
# Apply Name, Description, Source (MultiString) via base class.
super().ApplySyncableProperties(
item, remaining_props, ws_map=ws_map, fill_gaps=fill_gaps
)
# --- media_uris (R4) ---
# Reconcile by URI: add missing entries; leave extras untouched.
# An absent or empty media_uris key means do-nothing (R4 apply
# strategy: do not destroy existing media).
#
# Live LCM facts (issue #325 probe, 2026-09-22):
# - MediaFilesOA requires ICmMediaContainer (ICmMediaContainerFactory),
# NOT ICmFolder (ICmFolderFactory raises type conversion failure).
# - Elements of MediaURIsOC are ICmMediaURI (ICmMediaURIFactory),
# NOT ICmMedia (ICmMediaFactory cannot Add to MediaURIsOC).
# - set_MediaURI NREs on an unowned factory instance; Add to
# MediaURIsOC BEFORE setting MediaURI (same ownership pattern
# as ConstChartMovedText / issue #290).
media_uris_data = special_props.get("media_uris")
if media_uris_data:
from ..lcm_casting import cast_to_concrete
from System import Type as _ClrType
concrete = cast_to_concrete(item)
if concrete is None or not hasattr(concrete, "MediaFilesOA"):
_log.warning(
"[WARN] ApplySyncableProperties: media_uris present but "
"MediaFilesOA not accessible on concrete text -- skipping"
)
else:
sl = self.project.project.ServiceLocator
# Resolve factories by CLR type name -- pythonnet dir() of
# SIL.LCModel omits these interfaces, but GetService finds them.
cont_fac = sl.GetService(
_ClrType.GetType(
"SIL.LCModel.ICmMediaContainerFactory, SIL.LCModel",
True,
)
)
uri_fac = sl.GetService(
_ClrType.GetType(
"SIL.LCModel.ICmMediaURIFactory, SIL.LCModel",
True,
)
)
# Ensure container exists.
if concrete.MediaFilesOA is None:
concrete.MediaFilesOA = cont_fac.Create()
container = concrete.MediaFilesOA
# Build set of existing URIs to avoid duplicates.
existing_uris = set()
if hasattr(container, "MediaURIsOC"):
for uri_obj in container.MediaURIsOC:
if uri_obj.MediaURI:
existing_uris.add(str(uri_obj.MediaURI))
# Add missing entries (own first, then set MediaURI).
for entry in media_uris_data:
uri_str = entry.get("uri", "")
if not uri_str or uri_str in existing_uris:
continue
try:
if not hasattr(container, "MediaURIsOC"):
_log.warning(
"[WARN] ApplySyncableProperties: MediaURIsOC "
"absent on media container -- skipping %r",
uri_str,
)
continue
new_uri = uri_fac.Create()
container.MediaURIsOC.Add(new_uri)
new_uri.MediaURI = uri_str
existing_uris.add(uri_str)
except Exception as exc:
_log.warning(
"[WARN] ApplySyncableProperties: could not add "
"media URI %r -- %s", uri_str, exc
)
@OperationsMethod
def CompareTo(self, item1, item2, ops1=None, ops2=None):
"""
Compare two texts for differences.
Args:
item1: First text object (from project 1)
item2: Second text object (from project 2)
ops1: Optional TextOperations instance for project 1 (defaults to self)
ops2: Optional TextOperations instance for project 2 (defaults to self)
Returns:
tuple: (is_different, differences_dict)
- is_different (bool): True if texts differ, False if identical
- differences_dict (dict): Maps property names to (value1, value2) tuples
Example:
>>> is_diff, diffs = ops1.CompareTo(text1, text2, ops1, ops2)
>>> if is_diff:
... for prop, (val1, val2) in diffs.items():
... print(f"{prop}: {val1} != {val2}")
Notes:
- Compares all syncable properties
- MultiStrings are compared across all writing systems
- Reference collections are compared by GUID
- Empty/null values are treated as equivalent
"""
if ops1 is None:
ops1 = self
if ops2 is None:
ops2 = self
props1 = ops1.GetSyncableProperties(item1)
props2 = ops2.GetSyncableProperties(item2)
differences = {}
# Get all property keys from both items
all_keys = set(props1.keys()) | set(props2.keys())
for key in all_keys:
val1 = props1.get(key)
val2 = props2.get(key)
# Compare values inline: FLExProject has no _CompareValues
# member (calling it raised AttributeError on every compare;
# same fix as MediaOperations.CompareTo).
if val1 != val2:
# Values are different
differences[key] = (val1, val2)
is_different = len(differences) > 0
return (is_different, differences)
@OperationsMethod
def Exists(self, name):
"""
Check if a text with the given name exists in the project.
Performs a case-sensitive comparison of text names in the default
analysis writing system.
Args:
name (str): The name of the text to check.
Note: the comparison strips leading/trailing whitespace on
BOTH the search value and each stored text name (Q-242A) --
a name differing only by whitespace still counts as a
match.
Returns:
bool: True if a text with the given name exists, False otherwise.
Raises:
FP_NullParameterError: If name is None or empty.
Example:
>>> if project.Texts.Exists("Genesis"):
... print("Text already exists")
... else:
... text = project.Texts.Create("Genesis")
See Also:
Create, GetAll, GetName
"""
self._ValidateStringNotEmpty(name, "text name")
# Check all texts for matching name. Both sides of the comparison
# are stripped inline (C4) -- the needle so a padded caller
# argument still matches, and the haystack so a name persisted
# with its original whitespace (see Create/SetName, which no
# longer strip before persisting) is not invisible to this check.
target = normalize_match_key(name, casefold=False).strip()
for text in self.project.ObjectsIn(ITextRepository):
text_name = ITsString(text.Name.BestAnalysisAlternative).Text
if normalize_match_key(text_name, casefold=False).strip() == target:
return True
return False
@wrap_enumerable
@OperationsMethod
def GetAll(self):
"""
Get all texts in the project.
Returns an EnumerableWrapper (subscriptable, len()-able, lazily materialized) that yields IText objects. This is a wrapper around
the existing TextsGetAll method but returns the raw IText objects instead
of (name, content) tuples.
Returns:
EnumerableWrapper[IText]: Each text object in the project.
Example:
>>> # Iterate over all texts
>>> for text in project.Texts.GetAll():
... name = text.Name.BestAnalysisAlternative.Text
... print(f"Text: {name}")
>>> # Get as list
>>> all_texts = list(project.Texts.GetAll())
>>> print(f"Total texts: {len(all_texts)}")
See Also:
Create, Delete, Exists, project.TextsGetAll()
"""
return self.project.ObjectsIn(ITextRepository)
@OperationsMethod
def Find(self, title, wsHandle=None):
"""Find a text by title, returning None when no match exists.
``IText.Name`` is the user-facing title in FieldWorks, so Find
searches that field. Matches a single writing system at a time
(the default analysis WS when ``wsHandle`` is omitted). Returns
the first matching ``IText`` or None; for partial matching,
iterate ``GetAll()`` and filter manually.
"""
self._ValidateParam(title, "title")
if not title or not title.strip():
return None
wsHandle = self.__WSHandle(wsHandle)
target = normalize_match_key(title, casefold=False)
for text in self.GetAll():
current = ITsString(text.Name.get_String(wsHandle)).Text
if normalize_match_key(current, casefold=False) == target:
return text
return None
# GetTitle / SetTitle are user-facing names for the same field that
# LCM exposes as IText.Name. Both spellings appear in the codebase's
# public conventions (and in FieldWorks' UI), so expose them as
# first-class methods rather than as deprecated aliases.
[docs]
def GetTitle(self, text_or_hvo, wsHandle=None):
"""Get the text's title (same field as GetName)."""
return self.GetName(text_or_hvo, wsHandle=wsHandle)
[docs]
def SetTitle(self, text_or_hvo, title, wsHandle=None):
"""Set the text's title (same field as SetName)."""
return self.SetName(text_or_hvo, title, wsHandle=wsHandle)
@OperationsMethod
def GetName(self, text_or_hvo, wsHandle=None):
"""
Get the name of a text.
Retrieves the text name in the specified writing system, or the default
analysis writing system if not specified.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system. Can also be a language tag string.
Returns:
str: The name of the text in the specified writing system. Returns
empty string if no name is set for that writing system.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>>
>>> # Get name in default analysis WS
>>> name = project.Texts.GetName(text)
>>>
>>> # Get name in specific WS
>>> ws_handle = project.WSHandle('en')
>>> name_en = project.Texts.GetName(text, ws_handle)
See Also:
SetName, Exists
"""
text_obj = self.__GetTextObject(text_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
# Get the name string
name_str = ITsString(text_obj.Name.get_String(wsHandle)).Text
return name_str or ""
@OperationsMethod
def SetName(self, text_or_hvo, name, wsHandle=None):
"""
Set the name of a text.
Sets the text name in the specified writing system, or the default
analysis writing system if not specified.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
name (str): The new name for the text. Must be non-empty.
Note: leading/trailing whitespace in the value is preserved
verbatim (Q-242A). A whitespace-only value is NOT rejected
here and is persisted literally (Q-242D, a known remaining
gap -- see Q-242C for the pending harmonisation decision);
this differs from Create(), which does reject it.
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system. Can also be a language tag string.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If text_or_hvo or name is None. An empty
or whitespace-only name is NOT rejected here; it is
persisted literally (Q-242D, a known remaining gap -- see
Q-242C for the pending harmonisation decision).
AttributeError: If name is not a str (from a throwaway .strip()
call retained per C7(b); not a deliberate type check).
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>>
>>> # Set name in default analysis WS
>>> project.Texts.SetName(text, "Updated Story")
>>>
>>> # Set name in specific WS
>>> ws_handle = project.WSHandle('en')
>>> project.Texts.SetName(text, "English Title", ws_handle)
See Also:
GetName, Create
"""
self._EnsureWriteEnabled()
self._ValidateParam(name, "name")
# Throwaway .strip() call: not reassigned to `name`, so the persist
# below writes the caller's original, unstripped bytes (C4). The
# call is still MADE (result discarded) so a non-str payload still
# raises AttributeError here, exactly as before -- per C7(b), this
# site's exception TYPE on non-str input is Q-242C's concern, not
# this feature's, and must not change.
name.strip()
self._ValidateParam(name, "name")
text_obj = self.__GetTextObject(text_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
# Set the name
with self._TransactionCM(f"Set text name '{name}'"):
mkstr = TsStringUtils.MakeString(name, wsHandle)
text_obj.Name.set_String(wsHandle, mkstr)
@OperationsMethod
def GetGenre(self, text_or_hvo):
"""
Get the genre of a text.
Retrieves the first genre assigned to the text. In FLEx, texts can
technically have multiple genres, but typically only one is assigned.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
ICmPossibility or None: The genre object if one is assigned, or None
if no genre is set.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> genre = project.Texts.GetGenre(text)
>>> if genre:
... genre_name = genre.Name.BestAnalysisAlternative.Text
... print(f"Genre: {genre_name}")
... else:
... print("No genre assigned")
See Also:
SetGenre, Create
"""
text_obj = self.__GetTextObject(text_or_hvo)
# Get the first genre (if any)
if text_obj.GenresRC.Count > 0:
return text_obj.GenresRC.FirstOrDefault()
return None
@wrap_enumerable
@OperationsMethod
def GetGenres(self, text_or_hvo):
"""
Get every genre assigned to a text.
Reads the text's ``GenresRC`` reference collection in full, so a
text carrying more than one genre reports all of them. The singular
GetGenre reports only the first and is deliberately unchanged; use
this method whenever the count matters.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
list: List of ICmPossibility genre objects assigned to the text.
Empty list when no genre is assigned (never None).
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> genres = project.Texts.GetGenres(text)
>>> for genre in genres:
... print(genre.Name.BestAnalysisAlternative.Text)
Narrative
>>> if not genres:
... print("No genre assigned")
Notes:
- Returns an empty list when nothing is assigned, so `if not
genres:` is the empty check; there is no None case.
- IText.GenresRC is an ILcmReferenceCollection (verified in
tests/contract/snapshots/liblcm_baseline.json, under IText's
reflected_properties). A reference COLLECTION carries no
positional guarantee the way a reference sequence does, so do
not read meaning into the order of the returned list.
- Genres are ICmPossibility items from the project's "Text
Genres" possibility list (project.PossibilityLists.FindList).
- The list is a snapshot taken at call time; a later SetGenre
does not update a list already returned.
See Also:
GetGenre, SetGenre, Create
"""
text_obj = self.__GetTextObject(text_or_hvo)
return list(text_obj.GenresRC)
@OperationsMethod
def SetGenre(self, text_or_hvo, genre):
"""
Set the genre of a text.
Clears any existing genres and sets the specified genre. If genre is None,
all genres are cleared.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
genre (ICmPossibility or None): The genre to assign. Must be a valid
ICmPossibility from the project's "Text Genres" possibility list
(see project.PossibilityLists.FindList). If None, clears all genres.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid, or if
genre is not a valid ICmPossibility.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>>
>>> # Set genre to first available genre
>>> genre_list = project.PossibilityLists.FindList("Text Genres")
>>> items = project.PossibilityLists.GetItems(genre_list) if genre_list else []
>>> if items:
... project.Texts.SetGenre(text, items[0])
>>>
>>> # Clear genre
>>> project.Texts.SetGenre(text, None)
See Also:
GetGenre, Create
"""
self._EnsureWriteEnabled()
text_obj = self.__GetTextObject(text_or_hvo)
with self._TransactionCM("Set text genre"):
# Clear existing genres
text_obj.GenresRC.Clear()
# Add new genre if provided
if genre is not None:
try:
# Validate it's a possibility
genre_poss = ICmPossibility(genre)
text_obj.GenresRC.Add(genre_poss)
except (TypeError, System.InvalidCastException, AttributeError):
raise FP_ParameterError("genre must be a valid ICmPossibility object")
# --- Advanced Text Content Operations ---
@OperationsMethod
def GetContents(self, text_or_hvo):
"""
Get the StText contents object for a text.
Retrieves the IStText object that contains the actual content (paragraphs,
segments, etc.) of the text. Each IText has a ContentsOA (owned atomic)
property that points to its StText content object.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
IStText or None: The text contents object if set, or None if the text
has no contents.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = project.Texts.Find("Genesis")
>>> contents = project.Texts.GetContents(text)
>>> if contents:
... print(f"Paragraphs: {contents.ParagraphsOS.Count}")
... else:
... print("Text has no contents")
See Also:
GetParagraphs, GetParagraphCount, Create
"""
text_obj = self.__GetTextObject(text_or_hvo)
return text_obj.ContentsOA if text_obj.ContentsOA else None
@OperationsMethod
def GetParagraphs(self, text_or_hvo):
"""
Get all paragraphs in a text.
Retrieves a list of all paragraph objects (IStTxtPara) from the text's
contents. If the text has no contents, returns an empty list.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
list: List of IStTxtPara objects. Returns empty list if the text has
no contents or no paragraphs.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> paras = project.Texts.GetParagraphs(text)
>>> for i, para in enumerate(paras, 1):
... content = para.Contents.Text
... print(f"Paragraph {i}: {content}")
See Also:
GetContents, GetParagraphCount
"""
text_obj = self.__GetTextObject(text_or_hvo)
if text_obj.ContentsOA:
# Cast each paragraph to IStTxtPara to ensure SegmentsOS is accessible
return [IStTxtPara(para) for para in text_obj.ContentsOA.ParagraphsOS]
return []
@OperationsMethod
def GetParagraphCount(self, text_or_hvo):
"""
Get the number of paragraphs in a text.
Counts the paragraphs in the text's contents. This is more efficient
than getting all paragraphs and checking the length if you only need
the count.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
int: The number of paragraphs. Returns 0 if the text has no contents.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> count = project.Texts.GetParagraphCount(text)
>>> print(f"Text has {count} paragraphs")
See Also:
GetParagraphs, GetContents
"""
text_obj = self.__GetTextObject(text_or_hvo)
if text_obj.ContentsOA:
return text_obj.ContentsOA.ParagraphsOS.Count
return 0
@OperationsMethod
def GetMediaFiles(self, text_or_hvo):
"""
Get media files associated with a text.
Retrieves all media files (audio, video, etc.) that are linked to the
text. In FLEx, media files are stored in a CmMediaContainer object
referenced by the text's MediaFilesOA property.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
list: List of ICmMediaURI objects. Returns empty list if no media
container exists or no media URIs are attached.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> media = project.Texts.GetMediaFiles(text)
>>> for uri in media:
... if uri.MediaFileRA:
... path = uri.MediaFileRA.AbsoluteInternalPath
... print(f"Media file: {path}")
See Also:
AddMediaFile
"""
from ..lcm_casting import cast_to_concrete
text_obj = self.__GetTextObject(text_or_hvo)
concrete = cast_to_concrete(text_obj)
if concrete is not None and getattr(concrete, "MediaFilesOA", None):
container = concrete.MediaFilesOA
if container is not None and hasattr(container, "MediaURIsOC"):
return list(container.MediaURIsOC)
return []
@OperationsMethod
def AddMediaFile(self, text_or_hvo, filepath, label=None):
"""
Add a media file to a text.
Copies the file to the project's LinkedFiles directory and creates
a media reference linked to the text. If the text doesn't have a
media container yet, one will be created automatically.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
filepath (str): Path to the external media file to import.
label (str, optional): Descriptive label for the media file.
Returns:
ICmMediaURI: The created media URI with ICmFile properly linked.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If text_or_hvo or filepath is None/empty.
FP_ParameterError: If the text does not exist, is invalid, or file not found.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> media = project.Texts.AddMediaFile(
... text,
... "/home/user/audio/genesis.mp3",
... label="Genesis Recording"
... )
>>> print(f"Media added: {media.Hvo}")
Notes:
- File is copied to project's LinkedFiles/AudioVisual directory
- Unique filename generated if collision occurs (file_1.mp3, etc.)
- Creates ICmMediaURI owned in MediaURIsOC with ICmFile reference
- Uses ICmMediaContainerFactory (not ICmFolderFactory) per R4
See Also:
GetMediaFiles, project.Media.CopyToProject
"""
from ..lcm_casting import cast_to_concrete
from System import Type as _ClrType
self._EnsureWriteEnabled()
self._ValidateParam(filepath, "filepath")
filepath = filepath.strip()
self._ValidateParam(filepath, "filepath")
text_obj = self.__GetTextObject(text_or_hvo)
concrete = cast_to_concrete(text_obj)
if concrete is None or not hasattr(concrete, "MediaFilesOA"):
raise FP_ParameterError(
"Text object does not expose MediaFilesOA on its concrete type"
)
with self._TransactionCM("Add media file"):
sl = self.project.project.ServiceLocator
cont_fac = sl.GetService(
_ClrType.GetType(
"SIL.LCModel.ICmMediaContainerFactory, SIL.LCModel",
True,
)
)
uri_fac = sl.GetService(
_ClrType.GetType(
"SIL.LCModel.ICmMediaURIFactory, SIL.LCModel",
True,
)
)
cm_file = self.project.Media.CopyToProject(
filepath, internal_subdir="AudioVisual", label=label
)
if concrete.MediaFilesOA is None:
concrete.MediaFilesOA = cont_fac.Create()
container = concrete.MediaFilesOA
media_uri = uri_fac.Create()
container.MediaURIsOC.Add(media_uri)
media_uri.MediaFileRA = cm_file
try:
media_uri.MediaURI = cm_file.AbsoluteInternalPath or filepath
except Exception:
media_uri.MediaURI = filepath
return media_uri
@OperationsMethod
def GetAbbreviation(self, text_or_hvo, wsHandle=None):
"""
Get the abbreviation for a text.
Retrieves the text's abbreviation in the specified writing system.
Abbreviations are used as short identifiers for texts in FLEx's UI
and exports.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system. Can also be a language tag string.
Returns:
str: The abbreviation in the specified writing system. Returns empty
string if no abbreviation is set for that writing system.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>>
>>> # Get abbreviation in default analysis WS
>>> abbr = project.Texts.GetAbbreviation(text)
>>> print(f"Abbreviation: {abbr}")
>>>
>>> # Get abbreviation in specific WS
>>> ws_handle = project.WSHandle('en')
>>> abbr_en = project.Texts.GetAbbreviation(text, ws_handle)
See Also:
GetName, SetName
"""
text_obj = self.__GetTextObject(text_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
abbr_str = ITsString(text_obj.Abbreviation.get_String(wsHandle)).Text
return abbr_str or ""
@OperationsMethod
def GetIsTranslated(self, text_or_hvo):
"""
Check if a text's translation is marked as complete.
Retrieves the IsTranslated boolean property indicating whether the
text has been fully translated or not.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
bool: True if the text is marked as translated, False otherwise.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>> is_translated = project.Texts.GetIsTranslated(text)
>>> if is_translated:
... print("Text translation is complete")
... else:
... print("Text translation is incomplete")
Notes:
- This is a boolean property on the IText object
- Used to track translation workflow status
- Independent of the actual translation content
- Can be set manually or programmatically
See Also:
SetIsTranslated
"""
text_obj = self.__GetTextObject(text_or_hvo)
return bool(text_obj.IsTranslated)
@OperationsMethod
def SetIsTranslated(self, text_or_hvo, value):
"""
Mark a text as translated or untranslated.
Sets the IsTranslated boolean property to indicate whether the text
has been fully translated.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
value (bool): True to mark as translated, False to mark as untranslated.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid, or if
value is not a boolean.
Example:
>>> text = list(project.Texts.GetAll())[0]
>>>
>>> # Mark text as translated
>>> project.Texts.SetIsTranslated(text, True)
>>>
>>> # Verify
>>> if project.Texts.GetIsTranslated(text):
... print("Text is now marked as translated")
>>>
>>> # Mark text as untranslated
>>> project.Texts.SetIsTranslated(text, False)
Notes:
- value must be a boolean (True or False)
- Used to track translation workflow status
- Does not affect the actual content
- Useful for managing translation progress
See Also:
GetIsTranslated
"""
self._EnsureWriteEnabled()
self._ValidateParam(text_or_hvo, "text_or_hvo")
if not isinstance(value, bool):
raise FP_ParameterError("value must be a boolean (True or False)")
text_obj = self.__GetTextObject(text_or_hvo)
with self._TransactionCM("Set text is-translated flag"):
text_obj.IsTranslated = value