Source code for flexicon.code.Notebook.annotation

#
#   annotation.py
#
#   Class: Annotation
#          Wrapper for annotation objects providing unified interface
#          access with definition-based type categorization.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2025
#

"""
Wrapper class for annotation objects with definition-based polymorphism.

This module provides Annotation, a wrapper class that transparently handles
ICmBaseAnnotation objects. Unlike class-based polymorphism (where different
concrete C# classes represent different types), annotations use definition-based
polymorphism: the type is determined by the AnnotationDefn property, not the
ClassName.

Problem:
    Annotations in FLEx are polymorphic but not via class inheritance. Instead:
    - All annotations are ICmBaseAnnotation (single base class)
    - Type is determined by AnnotationDefn property (like a category/tag)
    - Projects can define custom annotation types (ScrScriptureNote, ConsultantNote, etc.)
    - Users must check AnnotationDefn and filter by definition name manually

Solution:
    Annotation wrapper provides:
    - annotation_type property (name of the annotation definition)
    - Convenience checks (is_scripture, is_translator, is_consultant, etc.)
    - Metadata properties (author, content, date_created, date_modified)
    - Smart type detection without exposing AnnotationDefn complexity
    - Unified interface for working with any annotation type

Example::

    from flexlibs2.code.Notebook.annotation import Annotation

    # Wrap an annotation from GetAll()
    anno = noteOps.GetAll(entry)[0]  # Typed as ICmBaseAnnotation
    wrapped = Annotation(anno)

    # Access annotation type
    print(f"Type: {wrapped.annotation_type}")

    # Access metadata
    print(f"Author: {wrapped.author}")
    print(f"Content: {wrapped.content}")
    print(f"Created: {wrapped.date_created}")

    # Type checking (works for any project-defined type)
    if wrapped.annotation_type == "To Do":
        print("This is a to-do annotation")

    # Convenience checks for common types
    if wrapped.is_scripture:
        print("This is a Scripture annotation")
"""

from ..Shared.wrapper_base import LCMObjectWrapper

try:
    from SIL.LCModel.Core.KernelInterfaces import ITsString
except ImportError:
    # Mock for testing
    class ITsString:
        def __init__(self, text=""):
            self.Text = text


[docs] class Annotation(LCMObjectWrapper): """ Wrapper for annotation objects providing unified interface access. Handles ICmBaseAnnotation objects with definition-based type detection. The type is determined by the AnnotationDefn property (like a tag/category), not by the C# class name. Attributes: _obj: The base interface object (ICmBaseAnnotation) _concrete: The concrete type object (always ICmBaseAnnotation in this case, but inherited from LCMObjectWrapper for consistency) Example:: anno = noteOps.GetAll(entry)[0] wrapped = Annotation(anno) print(wrapped.annotation_type) print(wrapped.author) if wrapped.is_scripture: print("Scripture annotation") """ def __init__(self, lcm_annotation): """ Initialize Annotation wrapper with an annotation object. Args: lcm_annotation: An ICmBaseAnnotation object. Typically from NoteOperations.GetAll(). Example:: anno = noteOps.GetAll(entry)[0] wrapped = Annotation(anno) """ super().__init__(lcm_annotation) # ========== Type Detection (Definition-Based) ========== @property def annotation_type(self) -> str: """ Get the annotation type as a string. Returns the name of the AnnotationDefn. This is the primary way to identify the type of annotation, as types are defined by the definition, not by the C# class. Returns: str: The annotation type name (e.g., "To Do", "Question", "Resolved", "ScrScriptureNote", "ConsultantNote"), or empty string if no type set. Example:: print(f"Type: {wrapped.annotation_type}") # Type: To Do if wrapped.annotation_type == "Question": print("This is a question annotation") Notes: - Type is determined by AnnotationDefn.Name, not ClassName - Custom types can be added by projects - Empty if no AnnotationDefn is set - Use for type-based filtering and categorization """ try: if hasattr(self._obj, "AnnotationTypeRA") and self._obj.AnnotationTypeRA: anno_defn = self._obj.AnnotationTypeRA if hasattr(anno_defn, "Name"): default_ws = self._get_default_ws() name_multistring = anno_defn.Name name_text = ITsString(name_multistring.get_String(default_ws)).Text return name_text or "" return "" except Exception: return "" @property def annotation_defn(self): """ Get the annotation definition (type definition) object. Returns the AnnotationDefn object that defines the type of this annotation. For advanced users who need to work with the definition directly. Returns: ICmAnnotationDefn or None: The annotation definition object, or None if no type is set. Example:: if wrapped.annotation_defn: defn_name = wrapped.annotation_type can_create = wrapped.annotation_defn.UserCanCreate Notes: - For most use cases, use annotation_type property instead - AnnotationDefn contains metadata about the type - Returns None if no AnnotationTypeRA is set """ try: if hasattr(self._obj, "AnnotationTypeRA"): return self._obj.AnnotationTypeRA return None except Exception: return None # ========== Convenience Type Checks ========== @property def is_scripture(self): """ Check if this is a Scripture annotation. Returns True if the annotation type name contains 'Scripture' or 'Scr'. This is a convenience check for common Scripture annotation types. Returns: bool: True if this appears to be a Scripture annotation. Example:: if wrapped.is_scripture: print("Scripture annotation") Notes: - Case-insensitive check of annotation type name - Returns True for types like "ScrScriptureNote", "Scripture Comment", etc. - Use for quick type filtering """ anno_type = self.annotation_type.lower() return "scripture" in anno_type or "scr" in anno_type @property def is_translator(self): """ Check if this is a translator note. Returns True if the annotation type name contains 'Translator' or 'Translator Note'. This is a convenience check for translator annotations. Returns: bool: True if this appears to be a translator annotation. Example:: if wrapped.is_translator: print("Translator note") Notes: - Case-insensitive check of annotation type name - Returns True for types like "TranslatorNote", "Translator Comment", etc. """ anno_type = self.annotation_type.lower() return "translator" in anno_type @property def is_consultant(self): """ Check if this is a consultant note. Returns True if the annotation type name contains 'Consultant' or 'Consultant Note'. This is a convenience check for consultant annotations. Returns: bool: True if this appears to be a consultant annotation. Example:: if wrapped.is_consultant: print("Consultant note") Notes: - Case-insensitive check of annotation type name - Returns True for types like "ConsultantNote", "Consultant Comment", etc. """ anno_type = self.annotation_type.lower() return "consultant" in anno_type @property def is_todo(self): """ Check if this is a "To Do" annotation. Returns True if the annotation type is "To Do" (case-insensitive). Returns: bool: True if this is a to-do annotation. Example:: if wrapped.is_todo: print("This needs to be done") Notes: - Case-insensitive check - Exact match (modulo case) of "To Do" """ anno_type = self.annotation_type.lower() return anno_type == "to do" @property def is_question(self): """ Check if this is a question annotation. Returns True if the annotation type name contains 'Question' or 'Query'. Returns: bool: True if this appears to be a question annotation. Example:: if wrapped.is_question: print("This needs clarification") Notes: - Case-insensitive check - Returns True for types like "Question", "Query", etc. """ anno_type = self.annotation_type.lower() return "question" in anno_type or "query" in anno_type # ========== Content Properties ========== @property def content(self) -> str: """ Get the text content of the annotation. Returns the Comment property text from the annotation. Returns: str: The annotation content, or empty string if not set. Example:: print(f"Content: {wrapped.content}") # Content: Check etymology source Notes: - Content is stored in Comment property - Returns in default analysis writing system - Empty string if no content set """ try: if hasattr(self._obj, "Comment"): default_ws = self._get_default_ws() comment_text = ITsString(self._obj.Comment.get_String(default_ws)).Text return comment_text or "" return "" except Exception: return "" @property def author(self) -> str: """ Get the author of the annotation. Returns the Source property text, which typically contains the author name. Returns: str: The author name, or empty string if not set. Example:: print(f"Author: {wrapped.author}") # Author: John Smith Notes: - Author is stored in Source property - Returns in default analysis writing system - Empty string if no author set """ try: if hasattr(self._obj, "Source"): default_ws = self._get_default_ws() source_text = ITsString(self._obj.Source.get_String(default_ws)).Text return source_text or "" return "" except Exception: return "" # ========== Metadata Properties ========== @property def date_created(self): """ Get the creation date of the annotation. Returns: System.DateTime or None: The creation date/time, or None if not set. Example:: if wrapped.date_created: print(f"Created: {wrapped.date_created}") # Created: 11/23/2025 10:30:45 AM Notes: - DateCreated is set automatically when annotation is created - Returns System.DateTime object - May be None for annotations without creation date """ try: if hasattr(self._obj, "DateCreated"): return self._obj.DateCreated return None except Exception: return None @property def date_modified(self): """ Get the last modification date of the annotation. Returns: System.DateTime or None: The modification date/time, or None if not set. Example:: if wrapped.date_modified: print(f"Modified: {wrapped.date_modified}") # Modified: 11/23/2025 2:15:30 PM Notes: - DateModified is updated when annotation is edited - Returns System.DateTime object - May be None for annotations never modified """ try: if hasattr(self._obj, "DateModified"): return self._obj.DateModified return None except Exception: return None # ========== Object References ========== @property def owner(self): """ Get the owner object that this annotation is attached to. Returns the object that owns this annotation (ILexEntry, ILexSense, etc.). Returns: object or None: The owner object, or None if not set. Example:: if wrapped.owner: print(f"Owner type: {wrapped.owner.ClassName}") Notes: - For top-level annotations, returns the annotated object - For reply annotations, returns the parent annotation - Use BeginObjectRA for the original annotated object """ try: if hasattr(self._obj, "Owner"): return self._obj.Owner if hasattr(self._obj, "BeginObjectRA"): return self._obj.BeginObjectRA return None except Exception: return None @property def guid(self): """ Get the GUID of the annotation. Returns: System.Guid: The GUID of the annotation. Example:: guid = wrapped.guid print(f"GUID: {guid}") # GUID: 12345678-1234-1234-1234-123456789abc Notes: - GUIDs are globally unique identifiers - Persistent across project versions - Use for external references and tracking """ try: if hasattr(self._obj, "Guid"): return self._obj.Guid return None except Exception: return None # ========== Threading Support ========== @property def replies(self): """ Get all reply annotations (threaded discussion) for this annotation. Returns: list: List of ICmBaseAnnotation reply objects, or empty list if none. Example:: if wrapped.replies: for reply in wrapped.replies: reply_wrapper = Annotation(reply) print(f"Reply: {reply_wrapper.content}") Notes: - Replies are child annotations of this annotation - Returns empty list if no replies - Replies can themselves have replies (nested threading) """ try: if hasattr(self._obj, "RepliesOS"): return list(self._obj.RepliesOS) return [] except Exception: return [] @property def has_replies(self): """ Check if this annotation has any replies. Returns: bool: True if this annotation has replies. Example:: if wrapped.has_replies: print(f"Has {len(wrapped.replies)} replies") Notes: - Convenience check for checking if RepliesOS is non-empty - Returns False if no RepliesOS property or no replies """ try: if hasattr(self._obj, "RepliesOS"): return self._obj.RepliesOS.Count > 0 return False except Exception: return False # ========== Private Helper Methods ========== def _get_default_ws(self): """ Get the default analysis writing system handle. Returns: int: The writing system handle for default analysis WS. Notes: - Used internally for text property access - Reads from LcmCache.DefaultAnalWs (the authoritative WS handle) """ # self._obj.Cache is the LcmCache; Cache.DefaultAnalWs is the # established accessor (same pattern used in phonological_rule.py, # affix_template.py, compound_rule.py after d2a20fa). # The previous code read OwnerOfClass as a bare attribute (returning # the bound method object), then tried .project.DefaultAnalWs on it; # bare-except swallowed the AttributeError and returned -1 every time. return self._obj.Cache.DefaultAnalWs def __repr__(self): """String representation showing annotation type and content preview.""" anno_type = self.annotation_type or "Untyped" content_preview = (self.content[:30] + "...") if self.content else "[empty]" return f"Annotation({anno_type}: {content_preview})" def __str__(self): """Human-readable description.""" if self.annotation_type: return f"{self.annotation_type}: {self.content}" return f"Annotation: {self.content}"