#
# ScrDraftOperations.py
#
# Class: ScrDraftOperations
# Scripture draft/version operations for FieldWorks Language Explorer
# projects via SIL Language and Culture Model (LCM) API.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2025
#
import warnings
# Import BaseOperations parent class
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable
from ..Shared.string_utils import normalize_match_key
# Import FLEx LCM types
from SIL.LCModel import (
IScrDraft,
IScrDraftFactory,
IScripture,
)
# Import flexlibs exceptions
from ..FLExProject import (
FP_ParameterError,
)
# Draft-type labels with no LCM ScrDraftType member. Accepted as a silent
# no-op before 4.10.0; now warn and create a saved version. Removed in v5.0.0.
_DEPRECATED_DRAFT_TYPE_LABELS = frozenset({"consultant_check", "back_translation"})
[docs]
class ScrDraftOperations(BaseOperations):
"""
This class provides operations for managing Scripture drafts/versions in a
FieldWorks project.
Scripture drafts are saved versions of the Scripture text, allowing tracking
of different translation drafts, consultant checks, or archived versions.
This class should be accessed via FLExProject.ScrDrafts property.
Usage::
from flexicon import FLExProject
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
# Get all drafts
for draft in project.ScrDrafts.GetAll():
desc = project.ScrDrafts.GetDescription(draft)
print(f"Draft: {desc}")
# Create a new draft
draft = project.ScrDrafts.Create("First Draft - January 2025", "saved_version")
# Find draft by description
draft = project.ScrDrafts.Find("First Draft")
# Get books in draft
books = project.ScrDrafts.GetBooks(draft)
project.CloseProject()
"""
def __init__(self, project):
"""
Initialize ScrDraftOperations with a FLExProject instance.
Args:
project: The FLExProject instance to operate on.
"""
super().__init__(project)
# --- Core CRUD Operations ---
@wrap_enumerable
@OperationsMethod
def GetAll(self):
"""
Get all Scripture drafts in the project.
This method returns an EnumerableWrapper (subscriptable, len()-able, lazily materialized) over all IScrDraft objects in the
project, allowing iteration over all saved Scripture versions.
Returns:
EnumerableWrapper[IScrDraft]: Each Scripture draft object in the project
Example:
>>> for draft in project.ScrDrafts.GetAll():
... desc = project.ScrDrafts.GetDescription(draft)
... print(f"Draft: {desc}")
Draft: First Draft - January 2025
Draft: Consultant Check - February 2025
Draft: Final Version - March 2025
Notes:
- Returns an EnumerableWrapper (subscriptable, len()-able) for memory efficiency; the underlying LCM enumerator is only materialized into a list on first len()/index/iteration access
- Drafts are returned in database order
- Use GetDescription() to get the draft description
See Also:
Find, Create, GetDescription
"""
scripture = self.__GetScripture()
if not scripture:
return iter([])
return iter(scripture.ArchivedDraftsOC)
@OperationsMethod
def Create(self, description, type="saved_version"):
"""
Create a new Scripture draft/version.
Args:
description (str): Description of the draft (e.g., "First Draft - Jan 2025")
type (str, optional): Draft type. Defaults to "saved_version".
Types: "saved_version", "imported_version" -- the two
members of the LCM ``ScrDraftType`` enum.
"consultant_check" and "back_translation" are deprecated
(no LCM member; they create a saved version and warn).
Returns:
IScrDraft: The newly created draft object
Raises:
FP_ReadOnlyError: If project is not opened with write enabled
FP_NullParameterError: If description is None
FP_ParameterError: If description is empty or Scripture not enabled
Example:
>>> # Create a saved version
>>> draft = project.ScrDrafts.Create("First Draft - January 2025")
>>> # Record an imported version
>>> imported = project.ScrDrafts.Create(
... "Imported from Paratext - February 2025",
... "imported_version"
... )
Notes:
- Draft is added to Scripture.ArchivedDraftsOC
- Draft GUID is auto-generated
- Description should be descriptive and unique
- Type selects the LCM ScrDraftType (saved or imported
version)
See Also:
Delete, Find, GetDescription
"""
self._EnsureWriteEnabled()
self._ValidateParam(description, "description")
if not description or not description.strip():
raise FP_ParameterError("Description cannot be empty")
scripture = self.__GetScripture()
if not scripture:
raise FP_ParameterError("Project does not have Scripture enabled")
draft_type = self.__CoerceDraftType(type)
with self._TransactionCM("Create draft"):
# IScrDraftFactory exposes no no-arg Create() -- only
# Create(description[, ...]) (live-proven TypeError; issue
# #352 follow-up). The factory parents the new draft into
# ArchivedDraftsOC itself, so only Add when it did not
# (compare by HVO: proxies have no stable identity).
factory = self.project.project.ServiceLocator.GetService(IScrDraftFactory)
try:
new_draft = factory.Create(description, draft_type)
except TypeError:
new_draft = factory.Create(description)
new_draft.Type = draft_type
if new_draft.Hvo not in {d.Hvo for d in scripture.ArchivedDraftsOC}:
scripture.ArchivedDraftsOC.Add(new_draft)
# Description is a scalar String, not a multistring
# (live-proven: plain str; issue #352) -- assign directly,
# same as SemanticDomain OcmCodes. The factory already set
# it from the description argument; re-assert it so a later
# refactor of the factory call cannot silently drop it.
new_draft.Description = description
return new_draft
@OperationsMethod
def Delete(self, draft_or_hvo):
"""
Delete a Scripture draft from the FLEx project.
Args:
draft_or_hvo: Either an IScrDraft object or its HVO (database ID)
Raises:
FP_ReadOnlyError: If project is not opened with write enabled
FP_NullParameterError: If draft_or_hvo is None
FP_ParameterError: If draft doesn't exist
Example:
>>> draft = project.ScrDrafts.Find("Old Draft")
>>> if draft:
... project.ScrDrafts.Delete(draft)
>>> # Delete by HVO
>>> project.ScrDrafts.Delete(12345)
Warning:
- This is a destructive operation
- All books and content in the draft will be deleted
- Cannot be undone
Notes:
- Deletion cascades to all owned objects
- Does not affect the current Scripture text
See Also:
Create
"""
self._EnsureWriteEnabled()
self._ValidateParam(draft_or_hvo, "draft_or_hvo")
# Resolve to draft object
draft = self.__ResolveObject(draft_or_hvo)
with self._TransactionCM("Delete scripture draft"):
# Delete the draft (LCM handles removal from repository)
draft.Delete()
@OperationsMethod
def Find(self, description):
"""
Find a Scripture draft by its description.
Args:
description (str): The draft description to search for (case-insensitive)
Returns:
IScrDraft or None: The draft object if found, None otherwise
Raises:
FP_NullParameterError: If description is None
Example:
>>> draft = project.ScrDrafts.Find("First Draft")
>>> if draft:
... desc = project.ScrDrafts.GetDescription(draft)
... print(f"Found: {desc}")
Found: First Draft - January 2025
>>> # Case-insensitive search
>>> draft = project.ScrDrafts.Find("first draft")
Notes:
- Search is case-insensitive
- Returns first match only
- Returns None if not found
- Partial match searches entire description
See Also:
GetAll, GetDescription
"""
self._ValidateParam(description, "description")
if not description or not description.strip():
return None
scripture = self.__GetScripture()
if not scripture:
return None
target = normalize_match_key(description, casefold=True)
# Search through all drafts. Description is a scalar String
# (issue #352), so there is no writing-system dimension.
for draft in scripture.ArchivedDraftsOC:
draft_desc = draft.Description or ""
if target and target in normalize_match_key(draft_desc, casefold=True):
return draft
return None
# --- Draft Properties ---
@OperationsMethod
def GetDescription(self, draft_or_hvo):
"""
Get the description of a Scripture draft.
Args:
draft_or_hvo: Either an IScrDraft object or its HVO
Returns:
str: The draft description (empty string if not set)
Raises:
FP_NullParameterError: If draft_or_hvo is None
Example:
>>> draft = project.ScrDrafts.Find("First Draft")
>>> desc = project.ScrDrafts.GetDescription(draft)
>>> print(desc)
First Draft - January 2025
Notes:
- Returns empty string if description not set
- Description is a scalar String (no writing-system
variants; issue #352)
See Also:
SetDescription, Create
"""
self._ValidateParam(draft_or_hvo, "draft_or_hvo")
draft = self.__ResolveObject(draft_or_hvo)
return draft.Description or ""
@OperationsMethod
def SetDescription(self, draft_or_hvo, text):
"""
Set the description of a Scripture draft.
Args:
draft_or_hvo: Either an IScrDraft object or its HVO
text (str): The new draft description
Raises:
FP_ReadOnlyError: If project is not opened with write enabled
FP_NullParameterError: If draft_or_hvo or text is None
Example:
>>> draft = project.ScrDrafts.Find("First Draft")
>>> project.ScrDrafts.SetDescription(
... draft,
... "First Draft - Revised January 2025"
... )
Notes:
- Description is a scalar String (no writing-system
variants; issue #352)
- Empty description is allowed but not recommended
See Also:
GetDescription, Create
"""
self._EnsureWriteEnabled()
self._ValidateParam(draft_or_hvo, "draft_or_hvo")
self._ValidateParam(text, "text")
draft = self.__ResolveObject(draft_or_hvo)
with self._TransactionCM("Set draft description"):
draft.Description = text
@OperationsMethod
def GetBooks(self, draft_or_hvo):
"""
Get all books in a Scripture draft.
Args:
draft_or_hvo: Either an IScrDraft object or its HVO
Returns:
list: List of IScrBook objects (empty list if none)
Raises:
FP_NullParameterError: If draft_or_hvo is None
Example:
>>> draft = project.ScrDrafts.Find("First Draft")
>>> books = project.ScrDrafts.GetBooks(draft)
>>> for book in books:
... title = project.ScrBooks.GetTitle(book)
... print(f"Book: {title}")
Notes:
- Returns empty list if draft has no books
- Books are in database order (not canonical order)
- Books in drafts are separate from current Scripture books
See Also:
GetDescription
"""
self._ValidateParam(draft_or_hvo, "draft_or_hvo")
draft = self.__ResolveObject(draft_or_hvo)
return list(draft.BooksOS)
# --- Private Helper Methods ---
def __ResolveObject(self, draft_or_hvo):
"""
Resolve HVO or object to IScrDraft.
Args:
draft_or_hvo: Either an IScrDraft object or an HVO (int)
Returns:
IScrDraft: The resolved draft object
Raises:
FP_ParameterError: If HVO doesn't refer to a Scripture draft
"""
if isinstance(draft_or_hvo, int):
obj = self.project.Object(draft_or_hvo)
if getattr(obj, "ClassName", None) == "ScrDraft":
try:
return IScrDraft(obj)
except Exception:
pass
if isinstance(obj, IScrDraft):
return obj
raise FP_ParameterError("HVO does not refer to a Scripture draft")
if getattr(draft_or_hvo, "ClassName", None) == "ScrDraft":
try:
return IScrDraft(draft_or_hvo)
except Exception:
pass
return draft_or_hvo
def __GetScripture(self):
"""
Get the Scripture object from the project.
Returns:
IScripture or None: The Scripture object if available
"""
if not hasattr(self.project, "lp") or not self.project.lp:
return None
return self.project.lp.TranslatedScriptureOA
def __CoerceDraftType(self, type_label):
"""
Map a user-facing draft-type label to ScrDraftType.
The LCM ``ScrDraftType`` enum has exactly two members,
``SavedVersion`` and ``ImportedVersion`` (live reflection,
2026-09-25). The labels ``"consultant_check"`` and
``"back_translation"`` were accepted before 4.10.0 as a silent
no-op that left the draft a saved version; they still create a
saved version, now with a ``DeprecationWarning``, and are removed
in v5.0.0. Members are read lazily, per label, so a member absent
from the installed LCM can never break an unrelated label.
Raises:
FP_ParameterError: If the label is unknown or empty after strip.
"""
if type_label is None:
raise FP_ParameterError("Draft type cannot be None")
key = str(type_label).strip().lower()
if not key:
raise FP_ParameterError("Draft type cannot be empty")
from SIL.LCModel import ScrDraftType
by_label = {
"saved_version": "SavedVersion",
"imported_version": "ImportedVersion",
}
if key in _DEPRECATED_DRAFT_TYPE_LABELS:
warnings.warn(
f"ScrDrafts.Create type {key!r} has no LCM ScrDraftType "
f"member (the enum is SavedVersion / ImportedVersion only); "
f"it creates a saved version, as it always has. The label "
f"is deprecated and will be removed in v5.0.0 -- pass "
f"'saved_version' instead.",
DeprecationWarning,
stacklevel=3,
)
key = "saved_version"
try:
member = by_label[key]
except KeyError:
allowed = ", ".join(sorted(by_label))
raise FP_ParameterError(
f"Unknown draft type {type_label!r}; expected one of: {allowed}"
)
return getattr(ScrDraftType, member)