Source code for flexicon.code.Shared.FilterOperations

#
#   FilterOperations.py
#
#   Class: FilterOperations
#          Saved filter and query management operations for FieldWorks
#          Language Explorer projects via SIL Language and Culture Model (LCM) API.
#
#   Platform: Python.NET
#             FieldWorks Version 9+
#
#   Copyright 2025
#

import logging

logger = logging.getLogger(__name__)

import clr

clr.AddReference("System")
import System
from System import Guid

import json
import os

# Import FLEx LCM types
from SIL.LCModel import (
    ICmFilter,
    ICmFilterFactory,
    IFsClosedFeature,
    ILexEntry,
    IWfiWordform,
    IText,
    ICmObjectRepository,
)

from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils

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

# Import LCM casting utilities for pythonnet interface casting
from ..lcm_casting import get_pos_from_msa

# Import string utilities
from .string_utils import normalize_text, normalize_match_key

# Import BaseOperations decorators
from ..BaseOperations import OperationsMethod, wrap_enumerable

# --- Filter Type Constants ---


[docs] class FilterTypes: """ Filter type constants for different object classes. """ LEXENTRY = "LexEntry" # Lexical entry filters WORDFORM = "Wordform" # Wordform filters TEXT = "Text" # Text filters SENSE = "Sense" # Sense filters ALLOMORPH = "Allomorph" # Allomorph filters CUSTOM = "Custom" # Custom/generic filters
# --- FilterOperations Class ---
[docs] class FilterOperations: """ This class provides operations for managing saved filters and queries in a FieldWorks project. Filters allow you to define reusable criteria for selecting and filtering different types of objects (entries, wordforms, texts, etc.). Each filter has a name, type, and criteria definition that can be applied to collections of objects. This class should be accessed via FLExProject.Filter property. Usage:: from flexlibs2 import FLExProject project = FLExProject() project.OpenProject("my project", writeEnabled=True) # Create a new filter filter_obj = project.Filter.Create( "Verbs", FilterTypes.LEXENTRY, {"pos": "verb"} ) # Get all filters for f in project.Filter.GetAll(): name = project.Filter.GetName(f) print(name) # Find a filter by name filter_obj = project.Filter.Find("Verbs") # Apply filter to entries entries = list(project.LexEntry.GetAll()) matching = project.Filter.ApplyFilter(filter_obj, entries) # Export filter definition project.Filter.ExportFilter(filter_obj, "/path/to/filter.json") project.CloseProject() Notes: - Filters are stored as custom data structures in the project - Filter criteria are stored as JSON-serializable dictionaries - Different filter types support different criteria - Filters can be exported and imported for reuse across projects """ def __init__(self, project): """ Initialize FilterOperations with a FLExProject instance. Args: project: The FLExProject instance to operate on. """ self.project = project self._filter_cache = {} # Cache for filter objects # --- Core Filter Management --- @wrap_enumerable @OperationsMethod def GetAll(self): """ Get all saved filters in the project. This method returns all filter objects stored in the project, regardless of their type. Yields: dict: Each filter object with keys: guid, name, filter_type, criteria Example: >>> for filter_obj in project.Filter.GetAll(): ... name = filter_obj['name'] ... filter_type = filter_obj['filter_type'] ... print(f"{name} ({filter_type})") Verbs (LexEntry) Nouns (LexEntry) Correct Wordforms (Wordform) Notes: - Returns an iterator for memory efficiency - Filters are loaded from project custom settings - Each filter is returned as a dictionary - Use Find() to get a specific filter by name See Also: Find, Create, GetFiltersByType """ filters = self._LoadFiltersFromProject() for filter_guid, filter_data in filters.items(): yield filter_data @OperationsMethod def Create(self, name, filter_type, criteria): """ Create a new saved filter in the project. Args: name (str): The name of the filter filter_type (str): The type of filter (use FilterTypes constants) criteria (dict): The filter criteria as a dictionary Returns: dict: The newly created filter object Raises: FP_ReadOnlyError: If project is not opened with write enabled FP_NullParameterError: If name, filter_type, or criteria is None FP_ParameterError: If name is empty or filter already exists Example: >>> # Create a lexical entry filter >>> verb_filter = project.Filter.Create( ... "Verbs", ... FilterTypes.LEXENTRY, ... {"pos": "verb", "status": "approved"} ... ) >>> # Create a wordform filter >>> correct_wf = project.Filter.Create( ... "Correct Wordforms", ... FilterTypes.WORDFORM, ... {"spelling_status": 2} # 2 = CORRECT ... ) >>> # Create a text filter >>> genre_filter = project.Filter.Create( ... "Narratives", ... FilterTypes.TEXT, ... {"genre": "narrative"} ... ) Notes: - Filter name must be unique - Criteria should be a JSON-serializable dictionary - Filter is immediately saved to the project - A unique GUID is automatically assigned - Common criteria keys depend on filter type: - LexEntry: pos, morph_type, date_created, etc. - Wordform: spelling_status, form_pattern, etc. - Text: genre, title_pattern, etc. See Also: Delete, Find, GetCriteria, SetCriteria """ self._EnsureWriteEnabled() self._ValidateParam(name, "name") if not name or not name.strip(): raise FP_ParameterError("Filter name cannot be empty") # Check if filter already exists if self.Find(name) is not None: raise FP_ParameterError(f"Filter with name '{name}' already exists") # Validate filter type valid_types = [ FilterTypes.LEXENTRY, FilterTypes.WORDFORM, FilterTypes.TEXT, FilterTypes.SENSE, FilterTypes.ALLOMORPH, FilterTypes.CUSTOM, ] if filter_type not in valid_types: raise FP_ParameterError(f"Invalid filter type: {filter_type}. " f"Use one of: {', '.join(valid_types)}") # Create filter object with unique GUID filter_guid = str(Guid.NewGuid()) filter_obj = { "guid": filter_guid, "name": name.strip(), "filter_type": filter_type, "criteria": criteria, "date_created": System.DateTime.Now.ToString(), "date_modified": System.DateTime.Now.ToString(), } # Save to project filters = self._LoadFiltersFromProject() filters[filter_guid] = filter_obj self._SaveFiltersToProject(filters) # Update cache self._filter_cache[filter_guid] = filter_obj return filter_obj @OperationsMethod def Delete(self, filter_obj): """ Delete a saved filter from the project. Args: filter_obj (dict): The filter object to delete Raises: FP_ReadOnlyError: If project is not opened with write enabled FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter doesn't exist Example: >>> filter_obj = project.Filter.Find("Old Filter") >>> if filter_obj: ... project.Filter.Delete(filter_obj) Warning: - This is a destructive operation - Cannot be undone - Filter is permanently removed from the project Notes: - Filter is removed from project custom settings - All filter metadata is deleted - Does not affect objects that were previously filtered See Also: Create, Find """ self._EnsureWriteEnabled() self._ValidateParam(filter_obj, "filter_obj") filter_guid = filter_obj.get("guid") if not filter_guid: raise FP_ParameterError("Invalid filter object: missing GUID") # Load filters and remove filters = self._LoadFiltersFromProject() if filter_guid not in filters: raise FP_ParameterError("Filter not found in project") del filters[filter_guid] self._SaveFiltersToProject(filters) # Clear from cache if filter_guid in self._filter_cache: del self._filter_cache[filter_guid] @OperationsMethod def Find(self, name): """ Find a saved filter by name. Args: name (str): The name of the filter to find Returns: dict or None: The filter object if found, None otherwise Raises: FP_NullParameterError: If name is None Example: >>> verb_filter = project.Filter.Find("Verbs") >>> if verb_filter: ... criteria = project.Filter.GetCriteria(verb_filter) ... print(f"Filter criteria: {criteria}") Filter criteria: {'pos': 'verb'} >>> # Check if filter exists >>> if project.Filter.Find("Nouns") is None: ... print("Nouns filter not found") Notes: - Search is case-sensitive - Returns None if filter not found (doesn't raise exception) - Returns first match if multiple filters have same name - Filter names should be unique See Also: Exists, GetAll, Create """ self._ValidateParam(name, "name") if not name or not name.strip(): return None # NFD-normalize both sides: FLEx stores Unicode in NFD, Python # source is typically NFC. A user looking up "Verbes Compose" with # NFC e-acute against NFD-stored data would silently miss without # this. (issue #125) target = normalize_match_key(name.strip(), casefold=False) filters = self._LoadFiltersFromProject() for filter_data in filters.values(): if normalize_match_key(filter_data["name"], casefold=False) == target: return filter_data return None @OperationsMethod def Exists(self, name): """ Check if a filter with the given name exists. Args: name (str): The filter name to check Returns: bool: True if a filter exists with this name, False otherwise Raises: FP_NullParameterError: If name is None Example: >>> if not project.Filter.Exists("Verbs"): ... verb_filter = project.Filter.Create( ... "Verbs", ... FilterTypes.LEXENTRY, ... {"pos": "verb"} ... ) Notes: - Search is case-sensitive - Returns False for empty or whitespace-only names - Use Find() to get the actual filter object See Also: Find, Create """ self._ValidateParam(name, "name") if not name or not name.strip(): return False return self.Find(name) is not None # --- Filter Properties --- @OperationsMethod def GetName(self, filter_obj): """ Get the name of a filter. Args: filter_obj (dict): The filter object Returns: str: The filter name Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> for filter_obj in project.Filter.GetAll(): ... name = project.Filter.GetName(filter_obj) ... print(name) Verbs Nouns Adjectives Notes: - Name is always a non-empty string - Names should be unique but this is not enforced See Also: SetName, Find """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "name" not in filter_obj: raise FP_ParameterError("Invalid filter object") return filter_obj["name"] @OperationsMethod def SetName(self, filter_obj, name): """ Set the name of a filter. Args: filter_obj (dict): The filter object name (str): The new name for the filter Raises: FP_ReadOnlyError: If project is not opened with write enabled FP_NullParameterError: If filter_obj or name is None FP_ParameterError: If name is empty or filter is invalid Example: >>> filter_obj = project.Filter.Find("Verbs") >>> project.Filter.SetName(filter_obj, "All Verbs") >>> print(project.Filter.GetName(filter_obj)) All Verbs Notes: - Name must not be empty - Changing name does not check for duplicates - Filter is immediately saved to project - Updates modification date See Also: GetName, Create """ self._EnsureWriteEnabled() self._ValidateParam(filter_obj, "filter_obj") if not name or not name.strip(): raise FP_ParameterError("Filter name cannot be empty") if not isinstance(filter_obj, dict) or "guid" not in filter_obj: raise FP_ParameterError("Invalid filter object") # Update filter filter_obj["name"] = name.strip() filter_obj["date_modified"] = System.DateTime.Now.ToString() # Save to project filters = self._LoadFiltersFromProject() filter_guid = filter_obj["guid"] filters[filter_guid] = filter_obj self._SaveFiltersToProject(filters) @OperationsMethod def GetCriteria(self, filter_obj): """ Get the filter criteria definition. The criteria is a dictionary that defines the filter conditions. The structure depends on the filter type. Args: filter_obj (dict): The filter object Returns: dict: The filter criteria Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> verb_filter = project.Filter.Find("Verbs") >>> criteria = project.Filter.GetCriteria(verb_filter) >>> print(criteria) {'pos': 'verb', 'status': 'approved'} >>> # Check specific criterion >>> if criteria.get('pos') == 'verb': ... print("This is a verb filter") Notes: - Criteria is always a dictionary - Keys and values depend on filter type - Common LexEntry criteria: pos, morph_type, etc. - Common Wordform criteria: spelling_status, etc. - Returns original criteria object (modifications affect filter) See Also: SetCriteria, GetFilterType """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "criteria" not in filter_obj: raise FP_ParameterError("Invalid filter object") return filter_obj["criteria"] @OperationsMethod def SetCriteria(self, filter_obj, criteria): """ Set the filter criteria definition. Args: filter_obj (dict): The filter object criteria (dict): The new filter criteria Raises: FP_ReadOnlyError: If project is not opened with write enabled FP_NullParameterError: If filter_obj or criteria is None FP_ParameterError: If filter_obj is invalid Example: >>> verb_filter = project.Filter.Find("Verbs") >>> # Update criteria to include transitivity >>> new_criteria = { ... 'pos': 'verb', ... 'status': 'approved', ... 'transitivity': 'transitive' ... } >>> project.Filter.SetCriteria(verb_filter, new_criteria) >>> # Replace criteria completely >>> project.Filter.SetCriteria(verb_filter, {'pos': 'noun'}) Notes: - Criteria must be a JSON-serializable dictionary - Completely replaces existing criteria - Filter is immediately saved to project - Updates modification date See Also: GetCriteria, Create """ self._EnsureWriteEnabled() self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "guid" not in filter_obj: raise FP_ParameterError("Invalid filter object") # Update filter filter_obj["criteria"] = criteria filter_obj["date_modified"] = System.DateTime.Now.ToString() # Save to project filters = self._LoadFiltersFromProject() filter_guid = filter_obj["guid"] filters[filter_guid] = filter_obj self._SaveFiltersToProject(filters) @OperationsMethod def GetFilterType(self, filter_obj): """ Get the filter type (entry/text/wordform/etc.). Args: filter_obj (dict): The filter object Returns: str: The filter type (from FilterTypes constants) Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> filter_obj = project.Filter.Find("Verbs") >>> filter_type = project.Filter.GetFilterType(filter_obj) >>> print(filter_type) LexEntry >>> # Check filter type before applying >>> if filter_type == FilterTypes.LEXENTRY: ... entries = list(project.LexEntry.GetAll()) ... matching = project.Filter.ApplyFilter(filter_obj, entries) Notes: - Filter type determines what objects the filter can process - Common types: LexEntry, Wordform, Text, Sense, Allomorph - Filter type is set at creation and cannot be changed - Use GetFiltersByType() to get all filters of a specific type See Also: Create, GetFiltersByType, ApplyFilter """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "filter_type" not in filter_obj: raise FP_ParameterError("Invalid filter object") return filter_obj["filter_type"] @OperationsMethod def GetGuid(self, filter_obj): """ Get the GUID of a filter. Args: filter_obj (dict): The filter object Returns: str: The filter's GUID as a string Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> filter_obj = project.Filter.Find("Verbs") >>> guid = project.Filter.GetGuid(filter_obj) >>> print(guid) a1b2c3d4-e5f6-7890-abcd-ef1234567890 Notes: - GUIDs are unique identifiers for filters - GUIDs are assigned automatically at creation - Useful for referencing filters programmatically - GUIDs persist across project sessions See Also: Find, Create """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "guid" not in filter_obj: raise FP_ParameterError("Invalid filter object") return filter_obj["guid"] # --- Filter Application --- @OperationsMethod def ApplyFilter(self, filter_obj, object_collection): """ Apply a filter to a collection of objects. This method evaluates each object in the collection against the filter criteria and returns only those that match. Args: filter_obj (dict): The filter object to apply object_collection: Iterable collection of objects to filter Returns: list: Objects from the collection that match the filter criteria Raises: FP_NullParameterError: If filter_obj or object_collection is None FP_ParameterError: If filter_obj is invalid Example: >>> # Filter lexical entries >>> verb_filter = project.Filter.Find("Verbs") >>> all_entries = list(project.LexEntry.GetAll()) >>> verbs = project.Filter.ApplyFilter(verb_filter, all_entries) >>> print(f"Found {len(verbs)} verbs") >>> # Filter wordforms >>> correct_filter = project.Filter.Find("Correct Wordforms") >>> all_wordforms = list(project.Wordforms.GetAll()) >>> correct_wfs = project.Filter.ApplyFilter(correct_filter, all_wordforms) >>> # Chain filters >>> filtered1 = project.Filter.ApplyFilter(filter1, all_entries) >>> filtered2 = project.Filter.ApplyFilter(filter2, filtered1) Notes: - Filter type should match the object collection type - Returns a new list (doesn't modify input collection) - Empty collection returns empty list - Objects that don't match criteria are excluded - Filtering is done in Python (not database-level) See Also: GetMatchCount, GetCriteria, GetFilterType """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict): raise FP_ParameterError("Invalid filter object") criteria = self.GetCriteria(filter_obj) filter_type = self.GetFilterType(filter_obj) matching_objects = [] for obj in object_collection: if self._ObjectMatchesCriteria(obj, criteria, filter_type): matching_objects.append(obj) return matching_objects @OperationsMethod def GetMatchCount(self, filter_obj, object_collection=None): """ Get the count of objects matching a filter. Args: filter_obj (dict): The filter object object_collection: Optional collection to count in. If None, uses all objects of the appropriate type from the project. Returns: int: Number of objects matching the filter criteria Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> verb_filter = project.Filter.Find("Verbs") >>> # Count all verbs in project >>> count = project.Filter.GetMatchCount(verb_filter) >>> print(f"Found {count} verbs") >>> # Count verbs in specific collection >>> recent_entries = get_recent_entries() >>> recent_verb_count = project.Filter.GetMatchCount( ... verb_filter, ... recent_entries ... ) Notes: - More efficient than len(ApplyFilter(...)) - If no collection provided, searches entire project - Returns 0 if no matches found - Useful for filter validation and reporting See Also: ApplyFilter, GetCriteria """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict): raise FP_ParameterError("Invalid filter object") # If no collection provided, get all objects of the filter type if object_collection is None: filter_type = self.GetFilterType(filter_obj) object_collection = self._GetAllObjectsOfType(filter_type) matching = self.ApplyFilter(filter_obj, object_collection) return len(matching) # --- Filter Import/Export --- @OperationsMethod def ExportFilter(self, filter_obj, file_path): """ Export a filter definition to a JSON file. Args: filter_obj (dict): The filter object to export file_path (str): Path to the output file Raises: FP_NullParameterError: If filter_obj or file_path is None FP_ParameterError: If filter_obj is invalid or export fails Example: >>> filter_obj = project.Filter.Find("Verbs") >>> project.Filter.ExportFilter(filter_obj, "/path/to/verbs.json") >>> # Export all filters >>> for f in project.Filter.GetAll(): ... name = project.Filter.GetName(f) ... file_name = f"{name.replace(' ', '_')}.json" ... project.Filter.ExportFilter(f, file_name) Notes: - Filter is exported as JSON format - File is overwritten if it exists - GUID is preserved for re-import - Exported filters can be shared across projects - File path can be absolute or relative See Also: ImportFilter, GetCriteria """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict): raise FP_ParameterError("Invalid filter object") try: with open(file_path, "w", encoding="utf-8") as f: json.dump(filter_obj, f, indent=2, ensure_ascii=False) except Exception as e: raise FP_ParameterError(f"Failed to export filter: {e}") @OperationsMethod def ImportFilter(self, file_path, rename_if_exists=False): """ Import a filter definition from a JSON file. Args: file_path (str): Path to the filter file to import rename_if_exists (bool): If True, rename the filter if name already exists. If False, raise error on name conflict. Returns: dict: The imported filter object Raises: FP_ReadOnlyError: If project is not opened with write enabled FP_NullParameterError: If file_path is None FP_ParameterError: If file doesn't exist, is invalid, or name conflicts Example: >>> # Import a filter >>> imported = project.Filter.ImportFilter("/path/to/verbs.json") >>> print(f"Imported: {project.Filter.GetName(imported)}") >>> # Import with auto-rename on conflict >>> imported = project.Filter.ImportFilter( ... "/path/to/filter.json", ... rename_if_exists=True ... ) Notes: - Filter file must be valid JSON - A new GUID is assigned on import - Import preserves name, type, and criteria - If rename_if_exists is True, appends number to name (e.g., "Verbs 2") - Import date is set to current time See Also: ExportFilter, Create """ self._EnsureWriteEnabled() self._ValidateParam(file_path, "file_path") if not os.path.exists(file_path): raise FP_ParameterError(f"Filter file not found: {file_path}") try: with open(file_path, "r", encoding="utf-8") as f: filter_data = json.load(f) except Exception as e: raise FP_ParameterError(f"Failed to import filter: {e}") # Validate filter data if not isinstance(filter_data, dict): raise FP_ParameterError("Invalid filter file: not a dictionary") required_keys = ["name", "filter_type", "criteria"] for key in required_keys: if key not in filter_data: raise FP_ParameterError(f"Invalid filter file: missing '{key}'") # Handle name conflicts original_name = filter_data["name"] import_name = original_name if self.Exists(import_name): if rename_if_exists: # Find a unique name counter = 2 while self.Exists(f"{original_name} {counter}"): counter += 1 import_name = f"{original_name} {counter}" else: raise FP_ParameterError( f"Filter with name '{original_name}' already exists. " f"Use rename_if_exists=True to auto-rename." ) # Create the filter (this assigns new GUID and dates) return self.Create(import_name, filter_data["filter_type"], filter_data["criteria"]) # --- Utility Methods --- @OperationsMethod def GetFiltersByType(self, filter_type): """ Get all filters of a specific type. Args: filter_type (str): The filter type to retrieve (from FilterTypes) Yields: dict: Each filter object of the specified type Raises: FP_NullParameterError: If filter_type is None Example: >>> # Get all lexical entry filters >>> for f in project.Filter.GetFiltersByType(FilterTypes.LEXENTRY): ... name = project.Filter.GetName(f) ... print(name) Verbs Nouns Adjectives >>> # Count wordform filters >>> wf_filters = list(project.Filter.GetFiltersByType(FilterTypes.WORDFORM)) >>> print(f"Found {len(wf_filters)} wordform filters") Notes: - Returns an iterator for memory efficiency - Returns empty iterator if no filters of that type exist - Filter types are defined in FilterTypes class See Also: GetAll, GetFilterType, FilterTypes """ self._ValidateParam(filter_type, "filter_type") for filter_obj in self.GetAll(): if filter_obj["filter_type"] == filter_type: yield filter_obj @OperationsMethod def GetDateCreated(self, filter_obj): """ Get the creation date of a filter. Args: filter_obj (dict): The filter object Returns: str: The date and time the filter was created (ISO format string) Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> filter_obj = project.Filter.Find("Verbs") >>> created = project.Filter.GetDateCreated(filter_obj) >>> print(f"Created: {created}") Created: 2025-01-15 14:30:22 Notes: - Date is stored as string in ISO format - Automatically set when filter is created - Cannot be modified See Also: GetDateModified, Create """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "date_created" not in filter_obj: raise FP_ParameterError("Invalid filter object") return filter_obj.get("date_created", "") @OperationsMethod def GetDateModified(self, filter_obj): """ Get the last modification date of a filter. Args: filter_obj (dict): The filter object Returns: str: The date and time the filter was last modified (ISO format string) Raises: FP_NullParameterError: If filter_obj is None FP_ParameterError: If filter_obj is invalid Example: >>> filter_obj = project.Filter.Find("Verbs") >>> modified = project.Filter.GetDateModified(filter_obj) >>> print(f"Last modified: {modified}") Last modified: 2025-01-20 09:15:43 Notes: - Date is stored as string in ISO format - Automatically updated when filter is modified - Updates on name, criteria, or any property change See Also: GetDateCreated, SetName, SetCriteria """ self._ValidateParam(filter_obj, "filter_obj") if not isinstance(filter_obj, dict) or "date_modified" not in filter_obj: raise FP_ParameterError("Invalid filter object") return filter_obj.get("date_modified", "") # --- Private Helper Methods --- def _LoadFiltersFromProject(self): """ Load all filters from project custom settings. Returns: dict: Dictionary of filters keyed by GUID """ # Try to load from project settings # Since FLEx doesn't have a built-in filter storage API, # we store filters as JSON in a custom project property try: if hasattr(self.project, "GetProjectProperty"): filters_json = self.project.GetProjectProperty("flexlibs_filters") if filters_json: return json.loads(filters_json) except (json.JSONDecodeError, KeyError, AttributeError) as e: pass # Return empty dict if no filters stored return {} def _SaveFiltersToProject(self, filters): """ Save all filters to project custom settings. Args: filters (dict): Dictionary of filters keyed by GUID """ # Save to project settings as JSON filters_json = json.dumps(filters, indent=2, ensure_ascii=False) if hasattr(self.project, "SetProjectProperty"): self.project.SetProjectProperty("flexlibs_filters", filters_json) else: # Fallback: store in memory cache only # (will be lost when project closes) logger.warning("Project does not support custom properties. " "Filters will only persist in memory.") def _GetAllObjectsOfType(self, filter_type): """ Get all objects of a specific type from the project. Args: filter_type (str): The filter type Returns: list: All objects of that type """ if filter_type == FilterTypes.LEXENTRY: return list(self.project.LexEntry.GetAll()) elif filter_type == FilterTypes.WORDFORM: return list(self.project.Wordforms.GetAll()) elif filter_type == FilterTypes.TEXT: return list(self.project.Texts.GetAll()) else: # For other types, return empty list logger.warning(f"GetAllObjectsOfType not implemented for {filter_type}") return [] def _ObjectMatchesCriteria(self, obj, criteria, filter_type): """ Check if an object matches filter criteria. Args: obj: The object to check criteria (dict): The filter criteria filter_type (str): The filter type Returns: bool: True if object matches all criteria, False otherwise """ try: if filter_type == FilterTypes.LEXENTRY: return self._MatchLexEntryCriteria(obj, criteria) elif filter_type == FilterTypes.WORDFORM: return self._MatchWordformCriteria(obj, criteria) elif filter_type == FilterTypes.TEXT: return self._MatchTextCriteria(obj, criteria) else: # For custom or unknown types, always return True return True except Exception as e: logger.error(f"Error matching criteria: {e}") return False def _MatchLexEntryCriteria(self, entry, criteria): """ Check if a lexical entry matches criteria. Args: entry: ILexEntry object criteria (dict): Filter criteria Returns: bool: True if matches all criteria """ # Example criteria matching for lexical entries # This can be extended based on actual requirements # Check POS (part of speech) if "pos" in criteria: pos_name = criteria["pos"].lower() # Get entry's POS and compare # Use get_pos_from_msa() to handle pythonnet interface casting try: senses = list(entry.SensesOS) if senses: for sense in senses: if sense.MorphoSyntaxAnalysisRA: pos = get_pos_from_msa(sense.MorphoSyntaxAnalysisRA) if pos: pos_text = normalize_text(ITsString(pos.Name.BestAnalysisAlternative).Text) if pos_text and pos_text.lower() == pos_name: return True return False except (TypeError, AttributeError, KeyError) as e: return False # Check morph type if "morph_type" in criteria: morph_type_name = criteria["morph_type"].lower() try: if entry.LexemeFormOA and entry.LexemeFormOA.MorphTypeRA: mt = entry.LexemeFormOA.MorphTypeRA mt_text = normalize_text(ITsString(mt.Name.BestAnalysisAlternative).Text) if mt_text and mt_text.lower() != morph_type_name: return False except (TypeError, AttributeError, KeyError) as e: return False # Check form pattern (regex) if "form_pattern" in criteria: import re pattern = criteria["form_pattern"] try: form = normalize_text(ITsString(entry.LexemeFormOA.Form.BestVernacularAlternative).Text) if form and not re.search(pattern, form): return False except (TypeError, AttributeError, KeyError) as e: return False # If no criteria specified or all matched, return True return True def _MatchWordformCriteria(self, wordform, criteria): """ Check if a wordform matches criteria. Args: wordform: IWfiWordform object criteria (dict): Filter criteria Returns: bool: True if matches all criteria """ # Check spelling status if "spelling_status" in criteria: expected_status = criteria["spelling_status"] if wordform.SpellingStatus != expected_status: return False # Check form pattern (regex) if "form_pattern" in criteria: import re pattern = criteria["form_pattern"] try: form = ITsString(wordform.Form.BestVernacularAlternative).Text if form and not re.search(pattern, form): return False except (TypeError, AttributeError, KeyError) as e: return False return True def _MatchTextCriteria(self, text, criteria): """ Check if a text matches criteria. Args: text: IText object criteria (dict): Filter criteria Returns: bool: True if matches all criteria """ # Check genre if "genre" in criteria: genre_name = criteria["genre"].lower() try: if text.GenresRC and text.GenresRC.Count > 0: for genre in text.GenresRC: genre_text = ITsString(genre.Name.BestAnalysisAlternative).Text if genre_text and genre_text.lower() == genre_name: return True return False except (TypeError, AttributeError, KeyError) as e: return False # Check title pattern (regex) if "title_pattern" in criteria: import re pattern = criteria["title_pattern"] try: title = ITsString(text.Title.BestAnalysisAlternative).Text if title and not re.search(pattern, title): return False except (TypeError, AttributeError, KeyError) as e: return False return True @OperationsMethod def Duplicate(self, item_or_hvo, insert_after=True): """ Duplicate a filter, creating a new copy with a new GUID. Args: item_or_hvo: The filter dict object to duplicate (not HVO-based). Returns: dict: The newly created duplicate filter 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: >>> verb_filter = project.Filter.Find("Verbs") >>> if verb_filter: ... dup = project.Filter.Duplicate(verb_filter) ... project.Filter.SetName(dup, "Verbs Copy") ... print(f"Duplicate: {project.Filter.GetName(dup)}") Notes: - Creates a new filter with new GUID - All properties copied: name, filter_type, criteria - Name is copied as-is (may need renaming to avoid confusion) - Filters are stored as JSON, not LCM objects See Also: Create, Delete, GetGuid """ self._EnsureWriteEnabled() self._ValidateParam(item_or_hvo, "item_or_hvo") if not isinstance(item_or_hvo, dict): raise FP_ParameterError("Filter object must be a dictionary") # Get source filter source = item_or_hvo # Create new filter with new GUID from System import Guid import System filter_guid = str(Guid.NewGuid()) duplicate = { "guid": filter_guid, "name": source["name"], # Copy name as-is "filter_type": source["filter_type"], "criteria": dict(source["criteria"]), # Deep copy criteria dict "date_created": System.DateTime.Now.ToString(), "date_modified": System.DateTime.Now.ToString(), } # Save to project filters = self._LoadFiltersFromProject() filters[filter_guid] = duplicate self._SaveFiltersToProject(filters) # Update cache self._filter_cache[filter_guid] = duplicate return duplicate # ========== SYNC INTEGRATION METHODS ========== @OperationsMethod def GetSyncableProperties(self, item): """ Get all syncable properties of a filter. Args: item: The filter dict object. Returns: dict: Dictionary of syncable properties with their values. Example: >>> props = project.Filter.GetSyncableProperties(filter_obj) >>> print(props['name']) 'Verbs' >>> print(props['filter_type']) 'LexEntry' Notes: - Filters are dict-based, not LCM objects - Returns name, filter_type, and criteria - Does NOT include guid, date_created, date_modified (metadata) """ props = {} # String properties if "name" in item: props["name"] = item["name"] if "filter_type" in item: props["filter_type"] = item["filter_type"] # Dict property if "criteria" in item: props["criteria"] = item["criteria"] return props @OperationsMethod def CompareTo(self, item1, item2, ops1=None, ops2=None): """ Compare two filters for differences. Args: item1: First filter dict object (from project 1) item2: Second filter dict object (from project 2) ops1: Optional FilterOperations instance for project 1 (defaults to self) ops2: Optional FilterOperations instance for project 2 (defaults to self) Returns: tuple: (is_different, differences_dict) - is_different (bool): True if filters differ, False if identical - differences_dict (dict): Maps property names to (value1, value2) tuples Example: >>> is_diff, diffs = ops1.CompareTo(filter1, filter2, ops1, ops2) >>> if is_diff: ... for prop, (val1, val2) in diffs.items(): ... print(f"{prop}: {val1} != {val2}") Notes: - Compares name, filter_type, and criteria - 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 (handle dicts specially for criteria) if key == "criteria": # Deep comparison for criteria dict if val1 != val2: differences[key] = (val1, val2) else: # Simple comparison for strings if val1 != val2: differences[key] = (val1, val2) is_different = len(differences) > 0 return (is_different, differences)