#
# DiscourseOperations.py
#
# Class: DiscourseOperations
# Discourse and constituent chart management operations for FieldWorks
# Language Explorer projects via SIL Language and Culture Model (LCM) API.
#
# Copyright 2025
#
import clr
clr.AddReference("System")
import System
from SIL.LCModel import (
IDsConstChart, # Fixed: was IConstChart
IDsConstChartFactory, # Fixed: was IConstChartFactory
IDsChart,
IDsDiscourseData, # Fixed: was IDiscourseData
IDsDiscourseDataFactory,
IConstChartRow,
IConstChartRowFactory,
IConstChartWordGroup,
IConstChartWordGroupFactory,
IConstChartMovedTextMarker,
IConstChartMovedTextMarkerFactory,
IConstChartClauseMarker,
IConstChartClauseMarkerFactory,
IConstChartTag,
IText,
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
[docs]
class DiscourseOperations(BaseOperations):
"""
Discourse and constituent chart management operations for FLEx projects.
This class provides methods for creating and managing discourse charts and
constituent charts. Charts provide a visual analysis of text structure, showing
relationships between clauses, participants, and discourse features.
Note:
Charts are complex UI-heavy features. This class focuses on data access
and basic chart structure management. Full chart editing capabilities
typically require the FLEx UI.
Usage::
from flexicon import FLExProject, DiscourseOperations
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
discourse_ops = DiscourseOperations(project)
# Get a text
text = list(project.Texts.GetAll())[0]
# Get all charts for the text
charts = list(discourse_ops.GetAllCharts(text))
print(f"Text has {len(charts)} charts")
# Create a new constituent chart
chart = discourse_ops.CreateChart(text, "Main Chart", "constituent")
chart_name = discourse_ops.GetChartName(chart)
print(f"Created chart: {chart_name}")
# Add rows to the chart
row1 = discourse_ops.AddRow(chart)
row2 = discourse_ops.AddRow(chart)
row_count = discourse_ops.GetRowCount(chart)
print(f"Chart has {row_count} rows")
# Get chart rows
rows = discourse_ops.GetRows(chart)
for i, row in enumerate(rows, 1):
print(f"Row {i}")
project.CloseProject()
"""
def __init__(self, project):
"""
Initialize DiscourseOperations 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 discourse charts."""
return parent.ChartsOS
# --- Helper Methods ---
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 object doesn't exist or isn't a valid text.
"""
self._ValidateParam(text_or_hvo, "text_or_hvo")
# Casts by ClassName BEFORE returning (issue #508, generalising
# #275 / TextOperations.__GetTextObject): self.project.Object()
# returns a bare ICmObject, so isinstance(obj, IText) is False even
# for a genuine text and the pass-through branch must not return an
# uncast view (Defect 1 from #275).
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
def __CastChartView(self, obj):
"""
Cast a chart LCM object to IDsConstChart or IDsChart when possible.
``project.Object(hvo)`` returns a bare ``ICmObject``; ClassName dispatch
is required before pythonnet will expose chart-specific members (#275).
"""
class_name = getattr(obj, "ClassName", None)
if class_name == "DsConstChart":
try:
return IDsConstChart(obj)
except Exception:
pass
if class_name == "DsChart":
try:
return IDsChart(obj)
except Exception:
pass
if isinstance(obj, IDsConstChart):
return obj
try:
return IDsConstChart(obj)
except (
TypeError,
System.InvalidCastException,
KeyError,
AttributeError,
System.Collections.Generic.KeyNotFoundException,
):
pass
try:
return IDsChart(obj)
except (
TypeError,
System.InvalidCastException,
KeyError,
AttributeError,
System.Collections.Generic.KeyNotFoundException,
):
pass
return obj
def __GetChartObject(self, chart_or_hvo):
"""
Resolve chart_or_hvo to chart object (IConstChart or IDsChart).
Args:
chart_or_hvo: Either a chart object or its HVO (integer).
Returns:
Chart object (IConstChart or IDsChart).
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the object doesn't exist or isn't a valid chart.
"""
self._ValidateParam(chart_or_hvo, "chart_or_hvo")
# Casts by ClassName BEFORE returning (issue #510, generalising #275 /
# ConstChartOperations.__ResolveObject): pass-through must not return an
# uncast ``project.Object(hvo)`` view (Defect 1 from #275).
if isinstance(chart_or_hvo, int):
obj = self.project.Object(chart_or_hvo)
class_name = getattr(obj, "ClassName", None)
if class_name not in ("DsConstChart", "DsChart"):
raise FP_ParameterError(f"Invalid chart HVO: {chart_or_hvo}")
return self.__CastChartView(obj)
class_name = getattr(chart_or_hvo, "ClassName", None)
if class_name in ("DsConstChart", "DsChart"):
return self.__CastChartView(chart_or_hvo)
return chart_or_hvo
def __GetRowObject(self, row_or_hvo):
"""
Resolve row_or_hvo to IConstChartRow object.
Args:
row_or_hvo: Either an IConstChartRow object or its HVO (integer).
Returns:
IConstChartRow: The row object.
Raises:
FP_NullParameterError: If row_or_hvo is None.
FP_ParameterError: If the object doesn't exist or isn't a valid row.
"""
self._ValidateParam(row_or_hvo, "row_or_hvo")
# Casts by ClassName BEFORE returning (issue #510, generalising
# ConstChartRowOperations.__ResolveObject / #275 Defect 1).
if isinstance(row_or_hvo, int):
obj = self.project.Object(row_or_hvo)
if getattr(obj, "ClassName", None) == "ConstChartRow":
try:
return IConstChartRow(obj)
except Exception:
pass
if isinstance(obj, IConstChartRow):
return obj
raise FP_ParameterError(f"Invalid row HVO: {row_or_hvo}")
if getattr(row_or_hvo, "ClassName", None) == "ConstChartRow":
try:
return IConstChartRow(row_or_hvo)
except Exception:
pass
return row_or_hvo
# --- Chart Management Operations ---
@wrap_enumerable
@OperationsMethod
def GetAllCharts(self, text_or_hvo):
"""
Get all charts (discourse and constituent) for a text.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
Returns:
EnumerableWrapper[IConstChart | IDsChart]: Each chart associated with the text.
Raises:
FP_NullParameterError: If text_or_hvo is None.
FP_ParameterError: If the text does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> print(f"Text has {len(charts)} charts")
Text has 2 charts
>>>
>>> for chart in charts:
... name = discourse_ops.GetChartName(chart)
... chart_type = discourse_ops.GetChartType(chart)
... print(f"Chart: {name} ({chart_type})")
Notes:
- Returns both constituent charts and discourse charts
- Charts are owned project-level; only charts whose BasedOnRA
references this text's StText contents are returned
- Returns empty generator if no charts exist
- Use list() to convert to a list if needed
See Also:
CreateChart, DeleteChart, GetChartName
"""
text_obj = self.__GetTextObject(text_or_hvo)
# Charts are owned project-level by lp.DiscourseDataOA.ChartsOC --
# never by the text -- and link back to their text through the
# chart's BasedOnRA reference to the text's StText contents
# (live-proven: DsConstChart.BasedOnRA is IStText). Yield the charts
# based on this text's contents.
if not text_obj.ContentsOA:
return
discourse = self.project.lp.DiscourseDataOA
if discourse is None:
return
contents_hvo = text_obj.ContentsOA.Hvo
for chart in discourse.ChartsOC:
# ChartsOC yields a limited view on which BasedOnRA is not
# projected -- cast by ClassName first (same #275 pattern as
# __GetChartObject); without it every chart reads as unlinked.
typed = self.__CastChartView(chart)
based_on = getattr(typed, "BasedOnRA", None)
if based_on is not None and based_on.Hvo == contents_hvo:
yield typed
@OperationsMethod
def __GetOrCreateDiscourseData(self):
"""
Get or create the project-level DsDiscourseData chart container.
Returns:
IDsDiscourseData: The discourse data container.
Notes:
- Charts are owned project-level, never per-text; the container
lives at ``LangProject.DiscourseDataOA`` (same helper as
ConstChartOperations, which owns the other half of this path).
- The ``is None`` guard stays OUTSIDE the transaction so an
already-initialised container is a true no-op.
"""
discourse = self.project.lp.DiscourseDataOA
if discourse is None:
factory = self.project.project.ServiceLocator.GetService(IDsDiscourseDataFactory)
with self._TransactionCM("Initialise discourse data"):
discourse = factory.Create()
self.project.lp.DiscourseDataOA = discourse
return discourse
[docs]
def CreateChart(self, text_or_hvo, name, chart_type="constituent"):
"""
Create a new constituent chart based on a text.
The chart is owned project-level by ``lp.DiscourseDataOA.ChartsOC``
(that is where the LCM keeps charts -- texts carry no chart
collection) and links back to the text through its ``BasedOnRA``
reference to the text's StText contents, which is what
GetAllCharts/GetOwningText read.
Args:
text_or_hvo: Either an IText object or its HVO (integer identifier).
name (str): The name of the chart. Must be non-empty.
Note: leading/trailing whitespace in the value is preserved
verbatim (Q-242A); a value that is entirely whitespace still
raises FP_ParameterError.
chart_type (str): Must be "constituent" (the default). "discourse"
raises FP_ParameterError: the LCM exposes no factory for
discourse (DsChart) creation, so only constituent
(DsConstChart) charts can be created through this API.
Returns:
Chart object (IDsConstChart): The newly created chart.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If text_or_hvo or name is None.
FP_ParameterError: If name is empty, text is invalid, text has no
StText contents, or chart_type is not "constituent".
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>>
>>> # Create a constituent chart (default)
>>> chart = discourse_ops.CreateChart(text, "Main Analysis")
>>> print(discourse_ops.GetChartName(chart))
Main Analysis
Notes:
- Chart name should be descriptive of the analysis being performed
- The text must have a ContentsOA (StText) object
See Also:
DeleteChart, GetAllCharts, SetChartName
"""
self._EnsureWriteEnabled()
self._ValidateStringNotEmpty(name, "chart name")
# Only constituent charts can be created: IDsConstChartFactory is the
# only chart factory the LCM exposes (verified by live reflection --
# no DsChart factory exists under any name).
chart_type_lower = chart_type.lower() if chart_type else "constituent"
if chart_type_lower != "constituent":
raise FP_ParameterError(
f"chart_type must be 'constituent', got '{chart_type}'"
)
text_obj = self.__GetTextObject(text_or_hvo)
# Ensure text has contents -- the chart's BasedOnRA points at it.
if not text_obj.ContentsOA:
raise FP_ParameterError("Text has no StText contents")
discourse = self.__GetOrCreateDiscourseData()
with self._TransactionCM(f"Create chart '{name}'"):
# Create the chart using the factory
factory = self.project.project.ServiceLocator.GetService(IDsConstChartFactory)
chart = factory.Create()
# Own project-level, link to the text.
discourse.ChartsOC.Add(chart)
chart.BasedOnRA = text_obj.ContentsOA
# Set the chart name
wsHandle = self.__WSHandle(None)
name_str = TsStringUtils.MakeString(name, wsHandle)
chart.Name.set_String(wsHandle, name_str)
return chart
@OperationsMethod
def DeleteChart(self, chart_or_hvo):
"""
Delete a chart from its text.
Removes the chart and all its rows, cells, and associated data from the
text's chart collection.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... # Delete the first chart
... discourse_ops.DeleteChart(charts[0])
... print("Chart deleted")
Warning:
- Deletion is permanent and cannot be undone
- All chart rows and cells will be deleted
- All chart analysis data will be lost
Notes:
- The chart is removed from its owning text's collection
- Use with caution on charts with significant analysis work
See Also:
CreateChart, GetAllCharts
"""
self._EnsureWriteEnabled()
chart_obj = self.__GetChartObject(chart_or_hvo)
# Get the owner and remove the chart. The ownership capability check
# stays OUTSIDE the transaction so an unremovable chart raises without
# opening an empty undo task.
owner = self._GetTypedOwner(chart_obj)
if owner and hasattr(owner, "ChartsOC"):
with self._TransactionCM("Delete chart"):
owner.ChartsOC.Remove(chart_obj)
else:
raise FP_ParameterError("Chart has no valid owner or cannot be removed")
@OperationsMethod
def GetChartName(self, chart_or_hvo, wsHandle=None):
"""
Get the name of a chart.
Retrieves the chart's name in the specified writing system, or the default
analysis writing system if not specified.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system.
Returns:
str: The chart name in the specified writing system. Returns empty
string if no name is set.
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... name = discourse_ops.GetChartName(charts[0])
... print(f"Chart name: {name}")
Chart name: Main Analysis
See Also:
SetChartName, CreateChart
"""
chart_obj = self.__GetChartObject(chart_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
# Get the chart name
if hasattr(chart_obj, "Name"):
name_str = ITsString(chart_obj.Name.get_String(wsHandle)).Text
return name_str or ""
return ""
@OperationsMethod
def SetChartName(self, chart_or_hvo, name, wsHandle=None):
"""
Set the name of a chart.
Updates the chart's name in the specified writing system, or the default
analysis writing system if not specified.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
name (str): The new name for the chart. Must be non-empty.
Note: leading/trailing whitespace in the value is preserved
verbatim (Q-242A); a value that is entirely whitespace still
raises FP_ParameterError.
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If chart_or_hvo or name is None.
FP_ParameterError: If name is empty or chart is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... discourse_ops.SetChartName(charts[0], "Updated Analysis")
... print(discourse_ops.GetChartName(charts[0]))
Updated Analysis
See Also:
GetChartName, CreateChart
"""
self._EnsureWriteEnabled()
self._ValidateStringNotEmpty(name, "chart name")
chart_obj = self.__GetChartObject(chart_or_hvo)
wsHandle = self.__WSHandle(wsHandle)
# Set the chart name. The capability check stays OUTSIDE the
# transaction so a chart that cannot be named raises without opening
# an empty undo task.
if hasattr(chart_obj, "Name"):
with self._TransactionCM(f"Set chart name '{name}'"):
name_str = TsStringUtils.MakeString(name, wsHandle)
chart_obj.Name.set_String(wsHandle, name_str)
else:
raise FP_ParameterError("Chart does not support name setting")
@OperationsMethod
def GetChartType(self, chart_or_hvo):
"""
Get the type of a chart (constituent or discourse).
Determines whether the chart is a constituent chart (IConstChart) or
a discourse chart (IDsChart) based on its class type.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Returns:
str: "constituent" for constituent charts, "discourse" for discourse
charts, or "unknown" if the type cannot be determined.
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> for chart in charts:
... name = discourse_ops.GetChartName(chart)
... chart_type = discourse_ops.GetChartType(chart)
... print(f"{name}: {chart_type}")
Main Analysis: constituent
Discourse Chart: discourse
Notes:
- Chart type is determined by the object's class
- Constituent charts are IConstChart instances
- Discourse charts are IDsChart instances
- Most charts in FLEx are constituent charts
See Also:
CreateChart, GetAllCharts
"""
chart_obj = self.__GetChartObject(chart_or_hvo)
# Determine chart type by class
class_name = chart_obj.GetType().Name
if "ConstChart" in class_name:
return "constituent"
elif "DsChart" in class_name:
return "discourse"
else:
# Try to determine by checking for specific interfaces
# Catch TypeError (pythonnet casting) and any .NET exceptions
try:
IDsConstChart(chart_obj)
return "constituent"
except (TypeError, AttributeError, SystemError):
try:
IDsChart(chart_obj)
return "discourse"
except (TypeError, AttributeError, SystemError):
return "unknown"
# --- Chart Row/Structure Operations ---
@wrap_enumerable
@OperationsMethod
def GetRows(self, chart_or_hvo):
"""
Get all rows in a chart.
Retrieves the ordered list of rows (typically representing clauses or
discourse units) from the chart.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Returns:
list: List of IConstChartRow objects. Returns empty list if the chart
has no rows.
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... rows = discourse_ops.GetRows(charts[0])
... print(f"Chart has {len(rows)} rows")
... for i, row in enumerate(rows, 1):
... print(f"Row {i}")
Chart has 5 rows
Row 1
Row 2
Row 3
Row 4
Row 5
Notes:
- Rows are returned in the order they appear in the chart
- Each row typically represents a clause or sentence
- Empty list returned if chart has no rows
- Use GetRowCount() for just the count
See Also:
GetRowCount, AddRow, DeleteRow
"""
chart_obj = self.__GetChartObject(chart_or_hvo)
# Get rows from the chart
if hasattr(chart_obj, "RowsOS"):
return list(chart_obj.RowsOS)
return []
@OperationsMethod
def GetRowCount(self, chart_or_hvo):
"""
Get the number of rows in a chart.
This is more efficient than calling len(GetRows()) when you only need
the count.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Returns:
int: The number of rows in the chart. Returns 0 if the chart has
no rows.
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... count = discourse_ops.GetRowCount(charts[0])
... print(f"Chart has {count} rows")
Chart has 5 rows
Notes:
- More efficient than getting all rows for just the count
- Returns 0 for charts with no rows
- Each row typically represents a clause or discourse unit
See Also:
GetRows, AddRow, DeleteRow
"""
chart_obj = self.__GetChartObject(chart_or_hvo)
# Get row count
if hasattr(chart_obj, "RowsOS"):
return chart_obj.RowsOS.Count
return 0
@OperationsMethod
def AddRow(self, chart_or_hvo):
"""
Add a new row to a chart.
Creates a new row and appends it to the end of the chart's row collection.
The row will be empty initially and can be populated with cells and content.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Returns:
IConstChartRow: The newly created row object.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> chart = discourse_ops.CreateChart(text, "Analysis")
>>>
>>> # Add rows to the chart
>>> row1 = discourse_ops.AddRow(chart)
>>> row2 = discourse_ops.AddRow(chart)
>>> row3 = discourse_ops.AddRow(chart)
>>> print(f"Chart has {discourse_ops.GetRowCount(chart)} rows")
Chart has 3 rows
Notes:
- Rows are appended to the end of the chart
- New rows are initially empty
- Rows typically represent clauses or discourse segments
- Use InsertRow() to insert at a specific position (if implemented)
See Also:
DeleteRow, GetRows, GetRowCount
"""
self._EnsureWriteEnabled()
chart_obj = self.__GetChartObject(chart_or_hvo)
with self._TransactionCM("Add chart row"):
# Create the new row using the factory
factory = self.project.project.ServiceLocator.GetService(IConstChartRowFactory)
row = factory.Create()
# Add to chart's row collection
if hasattr(chart_obj, "RowsOS"):
chart_obj.RowsOS.Add(row)
else:
raise FP_ParameterError("Chart does not support rows")
return row
@OperationsMethod
def DeleteRow(self, row_or_hvo):
"""
Delete a row from its chart.
Removes the row and all its cells and content from the chart's row
collection.
Args:
row_or_hvo: Either an IConstChartRow object or its HVO (integer identifier).
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If row_or_hvo is None.
FP_ParameterError: If the row does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... rows = discourse_ops.GetRows(charts[0])
... if rows:
... # Delete the last row
... discourse_ops.DeleteRow(rows[-1])
... print(f"Chart now has {discourse_ops.GetRowCount(charts[0])} rows")
Warning:
- Deletion is permanent and cannot be undone
- All cells and content in the row will be deleted
- Chart structure may be affected
Notes:
- The row is removed from its owning chart's collection
- All associated data (cells, markers, etc.) is also deleted
See Also:
AddRow, GetRows, GetRowCount
"""
self._EnsureWriteEnabled()
row_obj = self.__GetRowObject(row_or_hvo)
# Get the owner (chart) and remove the row. The ownership capability
# check stays OUTSIDE the transaction so an unremovable row raises
# without opening an empty undo task.
owner = self._GetTypedOwner(row_obj)
if owner and hasattr(owner, "RowsOS"):
with self._TransactionCM("Delete chart row"):
owner.RowsOS.Remove(row_obj)
else:
raise FP_ParameterError("Row has no valid owner or cannot be removed")
# --- Chart Content Operations ---
@OperationsMethod
def GetCells(self, row_or_hvo):
"""
Get all cells in a chart row.
Retrieves the cells (word groups, markers, etc.) that make up a row in
the chart. Cells represent individual units of analysis within the row.
Args:
row_or_hvo: Either an IConstChartRow object or its HVO (integer identifier).
Returns:
list: List of cell objects (IConstChartWordGroup, IConstChartMovedTextMarker,
IConstChartClauseMarker, etc.). Returns empty list if the row has no cells.
Raises:
FP_NullParameterError: If row_or_hvo is None.
FP_ParameterError: If the row does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... rows = discourse_ops.GetRows(charts[0])
... for row in rows:
... cells = discourse_ops.GetCells(row)
... print(f"Row has {len(cells)} cells")
Row has 3 cells
Row has 2 cells
Row has 4 cells
Notes:
- Cells are returned in the order they appear in the row
- Different cell types include word groups, markers, etc.
- Empty list returned if row has no cells
- Chart cells are complex objects with various properties
See Also:
GetCellContent, SetCellContent, GetRows
"""
row_obj = self.__GetRowObject(row_or_hvo)
# Get cells from the row. Cast row_obj first: an object that
# arrived as a bare ICmObject answers False to
# hasattr(row_obj, "CellsOS") even when it is a real chart row,
# which silently returned an empty list. Cells themselves are
# declared over IConstituentChartCellPart, so cast them too --
# this method deliberately returns every cell-part subtype, and
# callers need each one's concrete surface (issue #270).
from ..lcm_casting import cast_to_concrete
row_obj = cast_to_concrete(row_obj)
if hasattr(row_obj, "CellsOS"):
return self._GetTypedElements(row_obj.CellsOS)
return []
@OperationsMethod
def SetCellContent(self, cell, content, wsHandle=None):
"""
Set content for a chart cell.
Updates the text content of a chart cell. This is primarily for cells
that contain annotations or labels.
Note:
Most chart cells reference text segments rather than containing
their own text. This method is for cells with editable content
like notes or labels.
Args:
cell: The cell object to update.
content (str): The content to set.
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system.
Raises:
FP_ReadOnlyError: If project was not opened with writeEnabled=True.
FP_NullParameterError: If cell or content is None.
FP_ParameterError: If the cell is invalid or doesn't support content.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... rows = discourse_ops.GetRows(charts[0])
... if rows:
... cells = discourse_ops.GetCells(rows[0])
... if cells:
... # Set content for a cell (if it supports it)
... discourse_ops.SetCellContent(cells[0], "Subject")
Notes:
- Not all cell types support editable content
- Most cells reference text segments from the source text
- Use this for annotations, labels, or notes on cells
- Content may be available only for specific cell types
See Also:
GetCellContent, GetCells
"""
self._EnsureWriteEnabled()
self._ValidateParam(cell, "cell")
self._ValidateParam(content, "content")
wsHandle = self.__WSHandle(wsHandle)
# Check if cell has a content property that can be set
# Different cell types have different properties
# Both capability checks stay OUTSIDE the transaction so a cell that
# supports neither property raises without opening an empty undo task.
if hasattr(cell, "Label"):
# Some cells have a Label property. Chart-cell types declare
# no Label at all (issue #352), but chart rows carry a bare
# ITsString Label (no set_String) -- handle both shapes.
with self._TransactionCM("Set chart cell content"):
content_str = TsStringUtils.MakeString(content, wsHandle)
if hasattr(cell.Label, "set_String"):
cell.Label.set_String(wsHandle, content_str)
else:
cell.Label = self._MakeTsString(content, wsHandle)
elif hasattr(cell, "Comment"):
# Some cells have a Comment property
with self._TransactionCM("Set chart cell content"):
content_str = TsStringUtils.MakeString(content, wsHandle)
cell.Comment.set_String(wsHandle, content_str)
else:
raise FP_ParameterError("Cell does not support editable content (no Label or Comment property)")
@OperationsMethod
def GetCellContent(self, cell, wsHandle=None):
"""
Get content from a chart cell.
Retrieves the text content of a chart cell. For cells that reference
text segments, this may return the referenced text. For annotation cells,
it returns the annotation content.
Args:
cell: The cell object to read from.
wsHandle (int, optional): Writing system handle. If None, uses the
default analysis writing system.
Returns:
str: The cell content, or empty string if no content is available.
Raises:
FP_NullParameterError: If cell is None.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... rows = discourse_ops.GetRows(charts[0])
... for row in rows:
... cells = discourse_ops.GetCells(row)
... for cell in cells:
... content = discourse_ops.GetCellContent(cell)
... if content:
... print(f"Cell: {content}")
Notes:
- Different cell types store content differently
- Word group cells reference text segments
- Marker cells may have labels or comments
- Returns empty string if no content is available
- Content type depends on cell type
See Also:
SetCellContent, GetCells
"""
self._ValidateParam(cell, "cell")
wsHandle = self.__WSHandle(wsHandle)
# Try different properties based on cell type
content = ""
# Try Label property (for markers, annotations). Chart-cell
# types declare no Label (issue #352); chart rows carry a bare
# ITsString Label, which the get_String read below cannot parse
# -- fall back to a direct ITsString read in that case.
if hasattr(cell, "Label"):
try:
label_str = ITsString(cell.Label.get_String(wsHandle)).Text
if label_str:
content = label_str
except (AttributeError, System.NullReferenceException, TypeError):
try:
label_str = self._ReadTsString(cell.Label)
if label_str:
content = label_str
except (AttributeError, System.NullReferenceException, TypeError):
pass
# Try Comment property
if not content and hasattr(cell, "Comment"):
try:
comment_str = ITsString(cell.Comment.get_String(wsHandle)).Text
if comment_str:
content = comment_str
except (AttributeError, System.NullReferenceException, TypeError) as e:
pass
# For word groups, get the baseline text
if not content and hasattr(cell, "BeginSegment"):
try:
# Word groups reference segments
segment = cell.BeginSegment
if segment and hasattr(segment, "BaselineText"):
baseline = ITsString(segment.BaselineText).Text
if baseline:
content = baseline
except (AttributeError, System.NullReferenceException, TypeError) as e:
pass
return content or ""
# --- Utility Operations ---
@OperationsMethod
def GetOwningText(self, chart_or_hvo):
"""
Get the text that owns a chart.
Retrieves the IText object that contains the chart.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Returns:
IText: The text object that owns the chart.
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or has no owner.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... owner = discourse_ops.GetOwningText(charts[0])
... text_name = owner.Name.BestAnalysisAlternative.Text
... print(f"Chart belongs to text: {text_name}")
Chart belongs to text: Genesis
Notes:
- Charts are owned project-level; the text is reached through
the chart's BasedOnRA reference, not the ownership chain
- Useful for navigation and context
- A chart with no BasedOnRA (or whose StText has no owning text)
raises FP_ParameterError
See Also:
GetAllCharts, CreateChart
"""
chart_obj = self.__GetChartObject(chart_or_hvo)
# Navigate Chart -> StText -> IText (issue #515). Charts are owned
# project-level by lp.DiscourseDataOA, so the owner chain cannot
# reach a text; the link is the chart's BasedOnRA reference to the
# text's StText contents (live-proven: DsConstChart.BasedOnRA is
# IStText). The text is that StText's owner.
st_text = getattr(chart_obj, "BasedOnRA", None)
if st_text is None or st_text.Owner is None:
raise FP_ParameterError("Chart has no valid owning text")
return self.__GetTextObject(st_text.Owner)
@OperationsMethod
def GetGuid(self, chart_or_hvo):
"""
Get the GUID of a chart.
Retrieves the globally unique identifier for the chart.
Args:
chart_or_hvo: Either a chart object or its HVO (integer identifier).
Returns:
System.Guid: The GUID of the chart.
Raises:
FP_NullParameterError: If chart_or_hvo is None.
FP_ParameterError: If the chart does not exist or is invalid.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... guid = discourse_ops.GetGuid(charts[0])
... print(f"Chart GUID: {guid}")
Chart GUID: 12345678-1234-1234-1234-123456789abc
Notes:
- GUIDs are globally unique identifiers
- Persistent across project versions
- Use for external references and tracking
- Same GUID across different copies of the project
See Also:
GetOwningText, GetChartName
"""
chart_obj = self.__GetChartObject(chart_or_hvo)
if hasattr(chart_obj, "Guid"):
return chart_obj.Guid
else:
raise FP_ParameterError("Chart object does not have a GUID")
@OperationsMethod
def Duplicate(self, item_or_hvo, insert_after=False, deep=True):
"""
Duplicate a chart, creating a new copy with a new GUID.
Args:
item_or_hvo: The chart object or HVO to duplicate.
insert_after (bool): Ignored. ChartsOC is an unordered
ILcmOwningCollection with no Insert() method and no concept of
positional ordering. The duplicate is always appended via Add().
deep (bool): If True (default), also duplicate rows (structure only, empty).
If False, only copy chart properties. Note: Row contents and
cells are not duplicated (requires complex cell mapping logic).
Returns:
Chart object: The newly created duplicate with a new GUID.
Raises:
FP_ReadOnlyError: If the project is not opened with write enabled.
FP_NullParameterError: If item_or_hvo is None.
Example:
>>> discourse_ops = DiscourseOperations(project)
>>> text = list(project.Texts.GetAll())[0]
>>> charts = list(discourse_ops.GetAllCharts(text))
>>> if charts:
... # Deep duplicate (creates empty row structure)
... dup = discourse_ops.Duplicate(charts[0]) # deep=True by default
... print(f"Duplicate: {discourse_ops.GetChartName(dup)}")
... row_count = len(list(discourse_ops.GetChartRows(dup)))
... # Shallow duplicate (properties/metadata only, no rows)
... dup_shallow = discourse_ops.Duplicate(charts[0], deep=False)
... print(f"Shallow rows: {len(list(discourse_ops.GetChartRows(dup_shallow)))}") # 0
Notes:
- Factory.Create() automatically generates a new GUID
- MultiString property: Name
- Chart rows duplicated only if deep=True (creates empty row structure)
- Chart cells, word groups, and markers are NOT copied
- Use deep=False if you only need the chart metadata
- Full row/cell duplication requires complex cell content mapping logic
See Also:
CreateChart, DeleteChart, GetGuid
"""
self._EnsureWriteEnabled()
self._ValidateParam(item_or_hvo, "item_or_hvo")
# Get source chart and parent
source = self.__GetChartObject(item_or_hvo)
parent = self._GetTypedOwner(source)
with self._TransactionCM("Duplicate chart"):
# Create new chart using factory (auto-generates new GUID)
factory = self.project.project.ServiceLocator.GetService(IDsConstChartFactory)
duplicate = factory.Create()
# ADD TO PARENT FIRST.
# ChartsOC is an unordered ILcmOwningCollection; insert_after has no
# semantic meaning and is ignored.
if parent is not None and hasattr(parent, "ChartsOC"):
parent.ChartsOC.Add(duplicate)
else:
raise FP_ParameterError("Chart has no valid owning collection")
# Copy MultiString properties (AFTER adding to parent)
if hasattr(source, "Name") and source.Name:
duplicate.Name.CopyAlternatives(source.Name)
# Keep the text link: a duplicate of a text's chart is based on
# the same text (BasedOnRA is the IStText reference GetAllCharts
# and GetOwningText read).
if getattr(source, "BasedOnRA", None) is not None:
duplicate.BasedOnRA = source.BasedOnRA
# Deep copy: duplicate rows
if deep and hasattr(source, "RowsOS") and source.RowsOS.Count > 0:
for row in source.RowsOS:
# Create new row
row_factory = self.project.project.ServiceLocator.GetService(IConstChartRowFactory)
dup_row = row_factory.Create()
duplicate.RowsOS.Add(dup_row)
# Note: Full row/cell duplication is complex and would require
# additional logic to copy cell contents, word groups, markers, etc.
# For now, we create empty rows
return duplicate
# ========== SYNC INTEGRATION METHODS ==========
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get all syncable properties of a discourse chart.
Args:
item: The chart object (IConstChart or IDsChart).
Returns:
dict: Dictionary of syncable properties with their values.
Keys are property names, values are the property values.
Example:
>>> props = discourse_ops.GetSyncableProperties(chart)
>>> print(props['Name'])
{'en': 'Main Chart'}
Notes:
- MultiString properties are returned as dicts with WS keys
- Reference Atomic properties return GUID strings
- Does NOT include owned sequences (rows, cells) - those are children
"""
props = {}
# MultiString properties
if hasattr(item, "Name") and item.Name:
props["Name"] = self.project.GetMultiStringDict(item.Name)
return props
@OperationsMethod
def CompareTo(self, item1, item2, ops1=None, ops2=None):
"""
Compare two discourse charts for differences.
Args:
item1: First chart object (from project 1)
item2: Second chart object (from project 2)
ops1: Optional DiscourseOperations instance for project 1 (defaults to self)
ops2: Optional DiscourseOperations instance for project 2 (defaults to self)
Returns:
tuple: (is_different, differences_dict)
- is_different (bool): True if charts differ, False if identical
- differences_dict (dict): Maps property names to (value1, value2) tuples
for properties that differ
Example:
>>> is_diff, diffs = ops1.CompareTo(chart1, chart2, 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
- 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)