Source code for flexicon.code.TextsWords.DiscourseOperations

#
#   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)