Source code for flexicon.code.Notebook.NoteOperations

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

# Import FLEx LCM types
from SIL.LCModel import (
    IScrScriptureNote,
    IScrScriptureNoteFactory,
    ICmAgent,
    ICmBaseAnnotation,
    ICmBaseAnnotationFactory,
    ICmBaseAnnotationRepository,
    ICmAnnotationDefn,
    ICmPossibility,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils
from System import DateTime
from ..Shared.string_utils import best_analysis_text

# Import flexlibs exceptions
from ..FLExProject import (
    FP_ParameterError,
)
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable


[docs] class NoteOperations(BaseOperations): """ This class provides operations for managing notes and comments in a FieldWorks project. Notes in FLEx are annotations that can be attached to various objects including lexical entries, senses, texts, paragraphs, and more. Notes support threading (replies), categorization, metadata tracking, and multi-lingual content. This class should be accessed via FLExProject.Note property. Usage:: from flexicon import FLExProject project = FLExProject() project.OpenProject("my project", writeEnabled=True) # Get a lexical entry entry = project.LexEntry.Find("run") # Get all notes for the entry for note in project.Note.GetAll(entry): content = project.Note.GetContent(note) date = project.Note.GetDateCreated(note) print(f"Note: {content} (created {date})") # Create a new note note = project.Note.Create(entry, "Review etymology", "en") # Set note type project.Note.SetNoteType(note, "To Do") # Add a reply reply = project.Note.AddReply(note, "Checked - looks correct", "en") project.CloseProject() """ def __init__(self, project): """ Initialize NoteOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ super().__init__(project) def __GetDiscussionParent(self, note): """Return the parent note for a threaded reply, if any (issue #323).""" if not hasattr(note, "BeginObjectRA") or note.BeginObjectRA is None: return None try: ICmBaseAnnotation(note.BeginObjectRA) return note.BeginObjectRA except Exception: return None def __AttachReply(self, parent_note, reply, insert_after=False, after_source=None): """ Attach ``reply`` under ``parent_note`` using the LCM surface that exists. ``ICmBaseAnnotation`` has no ``RepliesOS``; general discussion replies live in ``LangProject.AnnotationsOC`` with ``BeginObjectRA`` pointing at the parent note. ``IScrScriptureNote`` uses ``ResponsesOS``. """ # Reached only from inside a caller's bracket (Duplicate / AddReply), # so this transaction joins that one (nesting-aware per B1). Stated # anyway so the site is grep-auditable per D5. with self._TransactionCM("Attach note reply"): try: scr_parent = IScrScriptureNote(parent_note) if insert_after and after_source is not None: idx = scr_parent.ResponsesOS.IndexOf(after_source) scr_parent.ResponsesOS.Insert(idx + 1, reply) else: scr_parent.ResponsesOS.Add(reply) return except Exception: pass self.project.lp.AnnotationsOC.Add(reply) reply.BeginObjectRA = parent_note def __IterDirectReplies(self, note): """Yield direct reply notes for ``note`` (issue #323).""" try: scr = IScrScriptureNote(note) for reply in scr.ResponsesOS: yield reply return except Exception: pass # The repository is its own service; GetService(ICmBaseAnnotation) # raises ActivationException (no instance registered for an object # interface). Live-proven 2026-09-25. repos = self.project.project.ServiceLocator.GetService( ICmBaseAnnotationRepository ) note_hvo = note.Hvo for ann in repos.AllInstances(): if ann.Hvo == note_hvo: continue if hasattr(ann, "BeginObjectRA") and ann.BeginObjectRA is not None: if ann.BeginObjectRA.Hvo == note_hvo: yield ann # --- Core CRUD Operations --- @wrap_enumerable @OperationsMethod def GetAll(self, owner_object): """ Get all notes attached to an object. Args: owner_object: The object whose notes to retrieve. Can be an ILexEntry, ILexSense, IText, IStPara, or any annotatable object. Returns: EnumerableWrapper[ICmBaseAnnotation]: Each note/annotation attached to the object. Raises: FP_NullParameterError: If owner_object is None. Example: >>> entry = project.LexEntry.Find("run") >>> for note in project.Note.GetAll(entry): ... content = project.Note.GetContent(note) ... print(f"Note: {content}") Note: Check etymology source Note: Add example sentences Notes: - Returns notes in creation order - Returns empty generator if object has no notes - Works with any object that supports annotations - Includes both top-level notes and replies See Also: Create, Delete, GetReplies """ self._ValidateParam(owner_object, "owner_object") # Get the annotation repository anno_repos = self.project.project.ServiceLocator.GetService(ICmBaseAnnotationRepository) # Find all annotations for this object for annotation in anno_repos.AllInstances(): # Check if this annotation references our owner object if hasattr(annotation, "BeginObjectRA"): if annotation.BeginObjectRA == owner_object: yield annotation # Also check for direct ownership. .Owner is on every # ICmObject; no hasattr guard needed. (issue #133 sweep) if annotation.Owner == owner_object: yield annotation @OperationsMethod def Create(self, owner_object, content, wsHandle=None): """ Create a new note attached to an object. Args: owner_object: The object to attach the note to (entry, sense, etc.). content (str): The text content of the note. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: ICmBaseAnnotation: The newly created note object. Raises: FP_ReadOnlyError: If project is not opened with write enabled. FP_NullParameterError: If owner_object or content is None. FP_ParameterError: If content is empty. Example: >>> entry = project.LexEntry.Find("run") >>> note = project.Note.Create(entry, "Check etymology", "en") >>> print(project.Note.GetContent(note)) Check etymology >>> # Create with specific writing system >>> sense = entry.SensesOS[0] >>> note = project.Note.Create(sense, "À vérifier", ... project.WSHandle('fr')) Notes: - Note is immediately added to the database - DateCreated is set automatically to current time - Use SetNoteType() to assign a category - Use AddReply() to create threaded discussions - GUID is auto-generated See Also: Delete, SetContent, SetNoteType, AddReply """ self._EnsureWriteEnabled() self._ValidateParam(owner_object, "owner_object") self._ValidateParam(content, "content") if not content or not content.strip(): raise FP_ParameterError("Note content cannot be empty") wsHandle = self.__WSHandle(wsHandle) # Create the annotation using the factory with self._TransactionCM("Create note"): factory = self.project.project.ServiceLocator.GetService(ICmBaseAnnotationFactory) note = factory.Create() # Add to the annotations collection (must be done before setting # properties -- an unowned CmObject has no Services yet, so # note.Comment.set_String() below throws # System.NullReferenceException at CmObject.get_Services() # until the note is owned by *something*). # # In this LCM version, AnnotationsOC lives ONLY on LangProject # (the project root) -- no domain object (ILexEntry, ILexSense, # IText, IStTxtPara, ...) exposes it directly (verified live). # Notes reference their subject via BeginObjectRA instead of # being owned children of it, so fall back to the project-level # collection whenever the owner_object itself has none. if hasattr(owner_object, "AnnotationsOC"): owner_object.AnnotationsOC.Add(note) else: self.project.lp.AnnotationsOC.Add(note) # Set the content mkstr = TsStringUtils.MakeString(content, wsHandle) note.Comment.set_String(wsHandle, mkstr) # Set the reference to the annotated object if hasattr(note, "BeginObjectRA"): note.BeginObjectRA = owner_object # Set creation date note.DateCreated = DateTime.Now return note @OperationsMethod def Delete(self, note): """ Delete a note. Args: note: The ICmBaseAnnotation (note) object to delete. Raises: FP_ReadOnlyError: If project is not opened with write enabled. FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("obsolete") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... project.Note.Delete(notes[0]) Warning: - This is a destructive operation - All replies to the note will also be deleted - Cannot be undone Notes: - Deletion cascades to all replies - Owner references are automatically cleaned up - Consider archiving important notes before deletion See Also: Create, GetReplies """ self._EnsureWriteEnabled() self._ValidateParam(note, "note") # Remove from owner's collection. note.Owner is typed as # ICmObject and does not expose AnnotationsOC / ResponsesOS; cast # to the concrete owner so the typed collection is reachable. with self._TransactionCM("Delete note"): owner = self._GetTypedOwner(note) if owner is not None: if hasattr(owner, "AnnotationsOC") and note in owner.AnnotationsOC: owner.AnnotationsOC.Remove(note) elif hasattr(owner, "ResponsesOS") and note in owner.ResponsesOS: owner.ResponsesOS.Remove(note) # Removing from an owning collection (AnnotationsOC/RepliesOS) # already deletes the underlying CmObject -- calling # note.Delete() again afterward throws # System.NullReferenceException at # CmObject.ICmObjectInternal.DeleteObject() because the object # is already gone. Only call Delete() explicitly when the note # is still a valid object (e.g. it had no owning collection to # remove it from). if hasattr(note, "Delete") and getattr(note, "IsValidObject", True): note.Delete() @OperationsMethod def Duplicate(self, item_or_hvo, insert_after=True, deep=True): """ Duplicate a note, creating a new copy with a new GUID. Args: item_or_hvo: The ICmBaseAnnotation (note) object or HVO to duplicate. insert_after (bool): If True (default), insert after the source note. If False, insert at end of owner's annotation list. deep (bool): If True (default), also duplicate owned objects (replies). If False, only copy simple properties and references. Returns: ICmBaseAnnotation: The newly created duplicate note 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: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... # Shallow duplicate (no replies) ... dup = project.Note.Duplicate(notes[0]) ... print(f"Original: {project.Note.GetGuid(notes[0])}") ... print(f"Duplicate: {project.Note.GetGuid(dup)}") Original: 12345678-1234-1234-1234-123456789abc Duplicate: 87654321-4321-4321-4321-cba987654321 ... ... # Deep duplicate (includes all replies) ... deep_dup = project.Note.Duplicate(notes[0], deep=True) ... print(f"Replies: {len(list(project.Note.GetReplies(deep_dup)))}") Notes: - Factory.Create() automatically generates a new GUID - insert_after=True preserves the original note's position - Simple properties copied: Comment, Source (author), DateCreated, DateModified - Reference properties copied: AnnotationTypeRA, BeginObjectRA - Owned objects (deep=True): RepliesOS (threaded discussion) - DateCreated and DateModified are copied from source, not set to current time See Also: Create, Delete, GetGuid, GetReplies """ self._EnsureWriteEnabled() self._ValidateParam(item_or_hvo, "item_or_hvo") # Get source note and parent. source.Owner is typed as ICmObject # and does not expose AnnotationsOC / RepliesOS; cast to the # concrete owner so the typed collection is reachable (the # previous hasattr checks silently no-opped, leaving the # duplicate orphaned). source = item_or_hvo if not isinstance(item_or_hvo, int) else self.project.Object(item_or_hvo) discussion_parent = self.__GetDiscussionParent(source) owner = self._GetTypedOwner(source) if discussion_parent is None and owner is None: raise FP_ParameterError("Note has no owning record or parent note") # Create new note using factory (auto-generates new GUID) with self._TransactionCM("Duplicate note"): factory = self.project.project.ServiceLocator.GetService(ICmBaseAnnotationFactory) duplicate = factory.Create() if discussion_parent is not None: self.__AttachReply( discussion_parent, duplicate, insert_after=insert_after, after_source=source, ) elif owner is not None and hasattr(owner, "ResponsesOS"): if insert_after: idx = owner.ResponsesOS.IndexOf(source) owner.ResponsesOS.Insert(idx + 1, duplicate) else: owner.ResponsesOS.Add(duplicate) elif owner is not None and hasattr(owner, "AnnotationsOC"): # AnnotationsOC is unordered (OC); insert_after is a no-op, add at end owner.AnnotationsOC.Add(duplicate) else: self.project.lp.AnnotationsOC.Add(duplicate) # Copy simple MultiString properties duplicate.Comment.CopyAlternatives(source.Comment) # Copy Reference Atomic (RA) properties # "Source" was renamed to SourceRA in this LCM version -- it is # a reference to the annotation's author/source object, not a # MultiString, so it has no CopyAlternatives (confirmed live: # AttributeError with pythonnet's own "Did you mean: 'SourceRA'?" # hint). if hasattr(source, "SourceRA"): duplicate.SourceRA = source.SourceRA if hasattr(source, "AnnotationTypeRA"): duplicate.AnnotationTypeRA = source.AnnotationTypeRA if hasattr(source, "BeginObjectRA"): duplicate.BeginObjectRA = source.BeginObjectRA # Copy datetime properties if hasattr(source, "DateCreated"): duplicate.DateCreated = source.DateCreated if hasattr(source, "DateModified"): duplicate.DateModified = source.DateModified # Handle owned objects if deep=True if deep: for reply in self.__IterDirectReplies(source): self._DuplicateReplyInto(reply, duplicate, deep=True) return duplicate def _DuplicateReplyInto(self, source_reply, parent_note, deep=True): """Duplicate a reply note under ``parent_note`` (issue #323).""" # Every caller reaches this helper from inside Duplicate's own # "Duplicate note" bracket, so these mutations are already covered at # runtime and this bracket merely joins that transaction (nesting-aware # per B1). It is stated anyway so the site is grep-auditable per D5 and # no future caller can reach it unbracketed. with self._TransactionCM("Duplicate note reply"): factory = self.project.project.ServiceLocator.GetService(ICmBaseAnnotationFactory) dup_reply = factory.Create() self.__AttachReply(parent_note, dup_reply) # Copy properties. "Source" was renamed to SourceRA in this LCM # version (see note in Duplicate() above) -- it is a reference, # not a MultiString. dup_reply.Comment.CopyAlternatives(source_reply.Comment) if hasattr(source_reply, "SourceRA"): dup_reply.SourceRA = source_reply.SourceRA if hasattr(source_reply, "AnnotationTypeRA"): dup_reply.AnnotationTypeRA = source_reply.AnnotationTypeRA if hasattr(source_reply, "BeginObjectRA"): dup_reply.BeginObjectRA = source_reply.BeginObjectRA if deep: for nested_reply in self.__IterDirectReplies(source_reply): self._DuplicateReplyInto(nested_reply, dup_reply, deep=True) # ========== SYNC INTEGRATION METHODS ========== @OperationsMethod def GetSyncableProperties(self, item): """ Get syncable properties for cross-project synchronization. Returns all syncable properties of a note including MultiString fields and reference properties. Args: item: The ICmBaseAnnotation (note) object Returns: dict: Dictionary of syncable properties Example: >>> props = project.Note.GetSyncableProperties(note) >>> print(props) {'Comment': 'Check this', 'Source': 'John', 'AnnotationType': '...'} """ self._ValidateParam(item, "item") note = item wsHandle = self.__WSHandle(None) props = {} # MultiString properties if hasattr(note, "Comment"): props["Comment"] = ITsString(note.Comment.get_String(wsHandle)).Text or "" # Source is a reference (SourceRA) to the authoring agent, not a # MultiString (live-proven: no Source member; issue #352). Sync # the agent GUID like the other RA properties below. if hasattr(note, "SourceRA") and note.SourceRA: props["Source"] = str(note.SourceRA.Guid) else: props["Source"] = None # Reference Atomic (RA) property - return GUID as string if hasattr(note, "AnnotationTypeRA") and note.AnnotationTypeRA: props["AnnotationType"] = str(note.AnnotationTypeRA.Guid) else: props["AnnotationType"] = None if hasattr(note, "BeginObjectRA") and note.BeginObjectRA: props["BeginObject"] = str(note.BeginObjectRA.Guid) else: props["BeginObject"] = None return props @OperationsMethod def CompareTo(self, item1, item2, ops1=None, ops2=None): """ Compare two notes and return detailed differences. Args: item1: First note (from source project) item2: Second note (from target project) ops1: Operations instance for item1's project (defaults to self) ops2: Operations instance for item2's project (defaults to self) Returns: tuple: (is_different, differences_dict) where differences_dict contains 'properties' dict with changed property details Example: >>> is_diff, diffs = ops1.CompareTo(note1, note2, ops1, ops2) >>> if is_diff: ... for prop, details in diffs['properties'].items(): ... print(f"{prop}: {details['source']} -> {details['target']}") """ if ops1 is None: ops1 = self if ops2 is None: ops2 = self is_different = False differences = {"properties": {}} # Get syncable properties from both items props1 = ops1.GetSyncableProperties(item1) props2 = ops2.GetSyncableProperties(item2) # Compare each property for key in set(props1.keys()) | set(props2.keys()): val1 = props1.get(key) val2 = props2.get(key) if val1 != val2: is_different = True differences["properties"][key] = {"source": val1, "target": val2, "type": "modified"} return is_different, differences # --- Content Operations --- @OperationsMethod def GetContent(self, note, wsHandle=None): """ Get the text content of a note. Args: note: The ICmBaseAnnotation (note) object. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: str: The note content, or empty string if not set. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... content = project.Note.GetContent(notes[0]) ... print(content) Check etymology source >>> # Get in specific writing system >>> content_fr = project.Note.GetContent(notes[0], ... project.WSHandle('fr')) Notes: - Returns empty string if content not set in specified WS - Notes can have content in multiple writing systems - Analysis writing system is typically used for notes See Also: SetContent, Create """ self._ValidateParam(note, "note") wsHandle = self.__WSHandle(wsHandle) if hasattr(note, "Comment"): text = ITsString(note.Comment.get_String(wsHandle)).Text return text or "" return "" @OperationsMethod def SetContent(self, note, text, wsHandle=None): """ Set the text content of a note. Args: note: The ICmBaseAnnotation (note) object. text (str): The new note content. wsHandle: Optional writing system handle. Defaults to analysis WS. Raises: FP_ReadOnlyError: If project is not opened with write enabled. FP_NullParameterError: If note or text is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... project.Note.SetContent(notes[0], "Etymology verified") ... print(project.Note.GetContent(notes[0])) Etymology verified Notes: - Empty string is allowed (clears the content) - DateModified is updated automatically - Use different writing systems for multilingual notes See Also: GetContent, Create """ self._EnsureWriteEnabled() self._ValidateParam(note, "note") self._ValidateParam(text, "text") wsHandle = self.__WSHandle(wsHandle) with self._TransactionCM("Set note content"): if hasattr(note, "Comment"): mkstr = TsStringUtils.MakeString(text, wsHandle) note.Comment.set_String(wsHandle, mkstr) # Update modification date note.DateModified = DateTime.Now # --- Note Type Operations --- @OperationsMethod def GetNoteType(self, note): """ Get the note type/category. Args: note: The ICmBaseAnnotation (note) object. Returns: ICmAnnotationDefn: The annotation definition (note type), or None. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... note_type = project.Note.GetNoteType(notes[0]) ... if note_type: ... # Get the name of the note type ... type_name = note_type.Name.BestAnalysisAlternative.Text ... print(f"Note type: {type_name}") Note type: To Do Notes: - Returns None if no note type is set - Note types are defined in the annotation definitions - Common types: "To Do", "Question", "Resolved", etc. See Also: SetNoteType """ self._ValidateParam(note, "note") if hasattr(note, "AnnotationTypeRA"): return note.AnnotationTypeRA return None @OperationsMethod def SetNoteType(self, note, note_type): """ Set the note type/category. Args: note: The ICmBaseAnnotation (note) object. note_type: Either a string name or ICmAnnotationDefn object. Raises: FP_ReadOnlyError: If project is not opened with write enabled. FP_NullParameterError: If note is None. FP_ParameterError: If note_type string not found. Example: >>> entry = project.LexEntry.Find("run") >>> note = project.Note.Create(entry, "Check this later") >>> project.Note.SetNoteType(note, "To Do") >>> # Using annotation definition object >>> anno_defn = project.Note.GetNoteType(existing_note) >>> if anno_defn: ... project.Note.SetNoteType(new_note, anno_defn) Notes: - If string is provided, searches for matching annotation definition - Search is case-sensitive - Common types: "To Do", "Question", "Resolved" - Note type affects display and filtering in FLEx See Also: GetNoteType """ self._EnsureWriteEnabled() self._ValidateParam(note, "note") if not hasattr(note, "AnnotationTypeRA"): raise FP_ParameterError("Note does not support type assignment") # If string, find the annotation definition if isinstance(note_type, str): anno_defn = self.__FindAnnotationDefn(note_type) if not anno_defn: raise FP_ParameterError(f"Note type '{note_type}' not found") note_type = anno_defn with self._TransactionCM("Set note type"): note.AnnotationTypeRA = note_type # --- Metadata Operations --- @OperationsMethod def GetDateCreated(self, note): """ Get the creation date of a note. Args: note: The ICmBaseAnnotation (note) object. Returns: System.DateTime: The creation date/time, or None if not set. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... date = project.Note.GetDateCreated(notes[0]) ... print(f"Created: {date}") Created: 11/23/2025 10:30:45 AM >>> # Sort notes by creation date >>> sorted_notes = sorted(notes, ... key=lambda n: project.Note.GetDateCreated(n) or DateTime.MinValue) Notes: - DateCreated is set automatically when note is created - Returns System.DateTime object - Can be None for notes without creation date - Use for sorting and filtering notes by age See Also: GetDateModified, Create """ self._ValidateParam(note, "note") if hasattr(note, "DateCreated"): return note.DateCreated return None @OperationsMethod def GetDateModified(self, note): """ Get the last modification date of a note. Args: note: The ICmBaseAnnotation (note) object. Returns: System.DateTime: The modification date/time, or None if not set. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... modified = project.Note.GetDateModified(notes[0]) ... if modified: ... print(f"Last modified: {modified}") Last modified: 11/23/2025 2:15:30 PM >>> # Find recently modified notes >>> from System import DateTime >>> one_week_ago = DateTime.Now.AddDays(-7) >>> recent = [n for n in notes ... if project.Note.GetDateModified(n) and ... project.Note.GetDateModified(n) > one_week_ago] Notes: - DateModified is updated when content or properties change - Returns System.DateTime object - May be None if note has never been modified - Useful for tracking note activity See Also: GetDateCreated, SetContent """ self._ValidateParam(note, "note") if hasattr(note, "DateModified"): return note.DateModified return None @OperationsMethod def GetAuthor(self, note): """ Get the author of a note. Args: note: The ICmBaseAnnotation (note) object. Returns: str: The authoring agent's name, or empty string if not set. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... author = project.Note.GetAuthor(notes[0]) ... if author: ... print(f"Author: {author}") Author: John Smith >>> # Filter notes by author >>> john_notes = [n for n in notes ... if project.Note.GetAuthor(n) == "John Smith"] Notes: - Returns empty string if author not set - Author is the SourceRA agent reference (there is no Source multistring; live-proven, issue #352) - Use for filtering and attribution See Also: SetAuthor """ self._ValidateParam(note, "note") source = note.SourceRA if hasattr(note, "SourceRA") else None if source is None: return "" return best_analysis_text(source.Name) or "" @OperationsMethod def SetAuthor(self, note, author): """ Set the author of a note. Args: note: The ICmBaseAnnotation (note) object. author: An ICmAgent object, an agent name string, or ""/None to clear. A name string reuses the existing agent with that name or creates one (find-or-create via Agents). Raises: FP_ReadOnlyError: If project is not opened with write enabled. FP_NullParameterError: If note is None. FP_ParameterError: If author is neither an agent nor a string. Example: >>> entry = project.LexEntry.Find("run") >>> note = project.Note.Create(entry, "Review needed") >>> project.Note.SetAuthor(note, "John Smith") >>> print(project.Note.GetAuthor(note)) John Smith Notes: - Author is the SourceRA agent reference (there is no Source multistring; live-proven, issue #352) - Empty string or None clears the author - Use consistent naming for better filtering See Also: GetAuthor """ self._EnsureWriteEnabled() self._ValidateParam(note, "note") # Type validation stays outside the bracket so a bad argument raises # before any undo task opens. agent = None author_name = None if isinstance(author, str): author_name = author.strip() or None elif author is not None: try: agent = ICmAgent(author) except Exception: raise FP_ParameterError( "author must be an ICmAgent object, an agent name " "string, or empty/None to clear" ) # Find-or-create shares the bracket, so a new agent and the author # link land as one undo step. with self._TransactionCM("Set note author"): if author_name is not None: agent = self.project.Agents.Find(author) if agent is None: agent = self.project.Agents.Create(author) note.SourceRA = agent # --- Discussion/Threading Operations --- @wrap_enumerable @OperationsMethod def GetReplies(self, note): """ Get all reply notes (threaded discussion) for a note. Args: note: The ICmBaseAnnotation (note) object. Yields: ICmBaseAnnotation: Each reply note. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> notes = list(project.Note.GetAll(entry)) >>> if notes: ... parent = notes[0] ... print(f"Parent: {project.Note.GetContent(parent)}") ... for reply in project.Note.GetReplies(parent): ... content = project.Note.GetContent(reply) ... author = project.Note.GetAuthor(reply) ... print(f" Reply by {author}: {content}") Parent: Check etymology Reply by Jane: Looks correct to me Reply by John: Agreed, verified in source Notes: - Replies are child annotations of the parent note - Returns empty generator if note has no replies - Replies can themselves have replies (nested threading) - Use recursively for full conversation trees See Also: AddReply, Create """ self._ValidateParam(note, "note") for reply in self.__IterDirectReplies(note): yield reply @OperationsMethod def AddReply(self, parent_note, content, wsHandle=None): """ Add a reply to an existing note (threaded discussion). Args: parent_note: The ICmBaseAnnotation (note) to reply to. content (str): The reply text content. wsHandle: Optional writing system handle. Defaults to analysis WS. Returns: ICmBaseAnnotation: The newly created reply note. Raises: FP_ReadOnlyError: If project is not opened with write enabled. FP_NullParameterError: If parent_note or content is None. FP_ParameterError: If content is empty. Example: >>> entry = project.LexEntry.Find("run") >>> note = project.Note.Create(entry, "Is this etymology correct?") >>> project.Note.SetAuthor(note, "John") >>> >>> # Add a reply >>> reply = project.Note.AddReply(note, "Yes, verified in source") >>> project.Note.SetAuthor(reply, "Jane") >>> >>> # Add a nested reply >>> nested = project.Note.AddReply(reply, "Thanks for checking!") >>> project.Note.SetAuthor(nested, "John") Notes: - Reply is created as a child of the parent note - DateCreated is set automatically - Replies support all note operations (type, metadata, etc.) - Can create multi-level threaded discussions See Also: GetReplies, Create, SetAuthor """ self._EnsureWriteEnabled() self._ValidateParam(parent_note, "parent_note") self._ValidateParam(content, "content") if not content or not content.strip(): raise FP_ParameterError("Reply content cannot be empty") wsHandle = self.__WSHandle(wsHandle) # Create the reply annotation using the factory with self._TransactionCM("Add reply"): factory = self.project.project.ServiceLocator.GetService(ICmBaseAnnotationFactory) reply = factory.Create() self.__AttachReply(parent_note, reply) # Set the content mkstr = TsStringUtils.MakeString(content, wsHandle) reply.Comment.set_String(wsHandle, mkstr) # Set creation date reply.DateCreated = DateTime.Now return reply # --- Utility Operations --- @OperationsMethod def GetOwner(self, note): """ Get the owner object that the note is attached to. Args: note: The ICmBaseAnnotation (note) object. Returns: object: The owner object (ILexEntry, ILexSense, etc.), or None. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> note = project.Note.Create(entry, "Check this") >>> owner = project.Note.GetOwner(note) >>> print(owner == entry) True >>> # Get headword of entry that owns the note >>> if hasattr(owner, 'LexemeFormOA'): ... headword = project.LexEntry.GetHeadword(owner) ... print(f"Note on entry: {headword}") Note on entry: run Notes: - Returns the direct owner object - For replies, returns the parent note, not the original object - Use BeginObjectRA for the annotated object reference See Also: Create, GetAll """ self._ValidateParam(note, "note") # .Owner is on every ICmObject; no hasattr guard needed. The # previous BeginObjectRA fallback was unreachable. (issue #133 # sweep -- BeginObjectRA is exposed as the annotated-object # reference via separate accessors.) # # Route through _GetTypedOwner so the caller gets the owner's # CONCRETE interface. A raw .Owner is a bare ICmObject: none of # ILexEntry/ILexSense/ILangProject's typed properties are # reachable on it and it fails isinstance against its own # interface, so it cannot be handed back to another flexicon # method. (Pattern A sweep gap, issue #270.) return self._GetTypedOwner(note) @OperationsMethod def GetGuid(self, note): """ Get the GUID of a note. Args: note: The ICmBaseAnnotation (note) object. Returns: System.Guid: The GUID of the note. Raises: FP_NullParameterError: If note is None. Example: >>> entry = project.LexEntry.Find("run") >>> note = project.Note.Create(entry, "Important note") >>> guid = project.Note.GetGuid(note) >>> print(f"Note GUID: {guid}") Note 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: GetOwner, Create """ self._ValidateParam(note, "note") return note.Guid # --- Private Helper Methods --- def __WSHandle(self, wsHandle): """ Get writing system handle, defaulting to analysis WS for notes. Args: wsHandle: Optional writing system handle. Returns: int: The writing system handle. """ if wsHandle is None: return self.project.project.DefaultAnalWs return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs) def __FindAnnotationDefn(self, name): """ Find an annotation definition by name. Args: name (str): The name of the annotation definition to find. Returns: ICmAnnotationDefn: The annotation definition, or None if not found. """ # Get annotation definition list anno_defn_list = self.project.project.LangProject.AnnotationDefsOA if not anno_defn_list: return None ws = self.project.project.DefaultAnalWs # Search through possibilities for defn in anno_defn_list.PossibilitiesOS: defn_name = ITsString(defn.Name.get_String(ws)).Text if defn_name == name: return defn return None