#!/usr/bin/python3
"""Export yWriter project to html. 

Version 2.1.8
Requires Python 3.6+
Copyright (c) 2023 Peter Triesberger
For further information see https://github.com/peter88213/yw2html
Published under the MIT License (https://opensource.org/licenses/mit-license.php)
"""
import os
import argparse
import sys
import gettext
import locale

__all__ = ['Error',
           '_',
           'LOCALE_PATH',
           'CURRENT_LANGUAGE',
           'norm_path',
           'string_to_list',
           'list_to_string',
           ]


class Error(Exception):
    """Base class for exceptions."""
    pass


#--- Initialize localization.
LOCALE_PATH = f'{os.path.dirname(sys.argv[0])}/locale/'
try:
    CURRENT_LANGUAGE = locale.getlocale()[0][:2]
except:
    # Fallback for old Windows versions.
    CURRENT_LANGUAGE = locale.getdefaultlocale()[0][:2]
try:
    t = gettext.translation('pywriter', LOCALE_PATH, languages=[CURRENT_LANGUAGE])
    _ = t.gettext
except:

    def _(message):
        return message


def norm_path(path):
    if path is None:
        path = ''
    return os.path.normpath(path)


def string_to_list(text, divider=';'):
    """Convert a string into a list with unique elements.
    
    Positional arguments:
        text -- string containing divider-separated substrings.
        
    Optional arguments:
        divider -- string that divides the substrings.
    
    Split a string into a list of strings. Retain the order, but discard duplicates.
    Remove leading and trailing spaces, if any.
    Return a list of strings.
    If an error occurs, return an empty list.
    """
    elements = []
    try:
        tempList = text.split(divider)
        for element in tempList:
            element = element.strip()
            if element and not element in elements:
                elements.append(element)
        return elements

    except:
        return []


def list_to_string(elements, divider=';'):
    """Join strings from a list.
    
    Positional arguments:
        elements -- list of elements to be concatenated.
        
    Optional arguments:
        divider -- string that divides the substrings.
    
    Return a string which is the concatenation of the 
    members of the list of strings "elements", separated by 
    a comma plus a space. The space allows word wrap in 
    spreadsheet cells.
    If an error occurs, return an empty string.
    """
    try:
        text = divider.join(elements)
        return text

    except:
        return ''



class Ui:
    """Base class for UI facades, implementing a 'silent mode'.
    
    Public methods:
        ask_yes_no(text) -- return True or False.
        set_info_how(message) -- show how the converter is doing.
        set_info_what(message) -- show what the converter is going to do.
        show_warning(message) -- Stub for displaying a warning message.
        start() -- launch the GUI, if any.
        
    Public instance variables:
        infoWhatText -- buffer for general messages.
        infoHowText -- buffer for error/success messages.
    """

    def __init__(self, title):
        """Initialize text buffers for messaging.
        
        Positional arguments:
            title -- application title.
        """
        self.infoWhatText = ''
        self.infoHowText = ''

    def ask_yes_no(self, text):
        """Return True or False.
        
        Positional arguments:
            text -- question to be asked. 
            
        This is a stub used for "silent mode".
        The application may use a subclass for confirmation requests.    
        """
        return True

    def set_info_how(self, message):
        """Show how the converter is doing.
        
        Positional arguments:
            message -- message to be buffered.
            
        Print the message to stderr, replacing the error marker, if any.
        """
        if message.startswith('!'):
            message = f'FAIL: {message.split("!", maxsplit=1)[1].strip()}'
            sys.stderr.write(message)
        self.infoHowText = message

    def set_info_what(self, message):
        """Show what the converter is going to do.
        
        Positional arguments:
            message -- message to be buffered. 
        """
        self.infoWhatText = message

    def show_warning(self, message):
        """Stub for displaying a warning message.

        Positional arguments:
            message -- message to be displayed. 
        """
        pass

    def start(self):
        """Launch the GUI, if any.
        
        To be overridden by subclasses requiring
        special action to launch the user interaction.
        """
        pass



class UiCmd(Ui):
    """Ui subclass implementing a console interface.
    
    Public methods:
        ask_yes_no(text) -- query yes or no at the console.
        set_info_how(message) -- show how the converter is doing.
        set_info_what(message) -- show what the converter is going to do.
        show_warning(message) -- Display a warning message.
    """

    def __init__(self, title):
        """Print the title.
        
        Positional arguments:
            title -- application title to be displayed at the console.
        
        Extends the superclass constructor.
        """
        super().__init__(title)
        print(title)

    def ask_yes_no(self, text):
        """Query yes or no at the console.
        
        Positional arguments:
            text -- question to be asked at the console. 
            
        Overrides the superclass method.       
        """
        result = input(f'{_("WARNING")}: {text} (y/n)')
        if result.lower() == 'y':
            return True
        else:
            return False

    def set_info_how(self, message):
        """Show how the converter is doing.

        Positional arguments:
            message -- message to be printed at the console. 
            
        Print the message, replacing the error marker, if any.
        Overrides the superclass method.
        """
        if message.startswith('!'):
            message = f'FAIL: {message.split("!", maxsplit=1)[1].strip()}'
        self.infoHowText = message
        print(message)

    def set_info_what(self, message):
        """Show what the converter is going to do.
        
        Positional arguments:
            message -- message to be printed at the console. 
            
        Print the message.
        Overrides the superclass method.
        """
        print(message)

    def show_warning(self, message):
        """Display a warning message."""
        print(f'\nWARNING: {message}\n')


def open_document(document):
    """Open a document with the operating system's standard application."""
    try:
        os.startfile(norm_path(document))
        # Windows
    except:
        try:
            os.system('xdg-open "%s"' % norm_path(document))
            # Linux
        except:
            try:
                os.system('open "%s"' % norm_path(document))
                # Mac
            except:
                pass
import re


class BasicElement:
    """Basic element representation (may be a project note).
    
    Public instance variables:
        title: str -- title (name).
        desc: str -- description.
        kwVar: dict -- custom keyword variables.
    """

    def __init__(self):
        """Initialize instance variables."""
        self.title = None
        # xml: <Title>

        self.desc = None
        # xml: <Desc>

        self.kwVar = {}
        # Optional key/value instance variables for customization.

LANGUAGE_TAG = re.compile(r'\[lang=(.*?)\]')


class Novel(BasicElement):
    """Novel representation.

    This class represents a novel with additional 
    attributes and structural information (a full set or a subset
    of the information included in an yWriter project file).

    Public methods:
        get_languages() -- Determine the languages used in the document.
        check_locale() -- Check the document's locale (language code and country code).

    Public instance variables:
        authorName -- author's name.
        author bio -- information about the author.
        fieldTitle1 -- scene rating field title 1.
        fieldTitle2 -- scene rating field title 2.
        fieldTitle3 -- scene rating field title 3.
        fieldTitle4 -- scene rating field title 4.
        chapters: dict -- (key: ID; value: chapter instance).
        scenes: dict -- (key: ID, value: scene instance).
        srtChapters: list -- the novel's sorted chapter IDs.
        locations: dict -- (key: ID, value: WorldElement instance).
        srtLocations: list -- the novel's sorted location IDs.
        items: dict -- (key: ID, value: WorldElement instance).
        srtItems: list -- the novel's sorted item IDs.
        characters: dict -- (key: ID, value: character instance).
        srtCharacters: list -- the novel's sorted character IDs.
        projectNotes: dict --  (key: ID, value: projectNote instance).
        srtPrjNotes: list -- the novel's sorted project notes.
    """

    def __init__(self):
        """Initialize instance variables.
            
        Extends the superclass constructor.          
        """
        super().__init__()

        self.authorName = None
        # xml: <PROJECT><AuthorName>

        self.authorBio = None
        # xml: <PROJECT><Bio>

        self.fieldTitle1 = None
        # xml: <PROJECT><FieldTitle1>

        self.fieldTitle2 = None
        # xml: <PROJECT><FieldTitle2>

        self.fieldTitle3 = None
        # xml: <PROJECT><FieldTitle3>

        self.fieldTitle4 = None
        # xml: <PROJECT><FieldTitle4>

        self.wordTarget = None
        # xml: <PROJECT><wordTarget>

        self.wordCountStart = None
        # xml: <PROJECT><wordCountStart>

        self.wordTarget = None
        # xml: <PROJECT><wordCountStart>

        self.chapters = {}
        # xml: <CHAPTERS><CHAPTER><ID>
        # key = chapter ID, value = Chapter instance.
        # The order of the elements does not matter (the novel's order of the chapters is defined by srtChapters)

        self.scenes = {}
        # xml: <SCENES><SCENE><ID>
        # key = scene ID, value = Scene instance.
        # The order of the elements does not matter (the novel's order of the scenes is defined by
        # the order of the chapters and the order of the scenes within the chapters)

        self.languages = None
        # List of non-document languages occurring as scene markup.
        # Format: ll-CC, where ll is the language code, and CC is the country code.

        self.srtChapters = []
        # The novel's chapter IDs. The order of its elements corresponds to the novel's order of the chapters.

        self.locations = {}
        # dict
        # xml: <LOCATIONS>
        # key = location ID, value = WorldElement instance.
        # The order of the elements does not matter.

        self.srtLocations = []
        # The novel's location IDs. The order of its elements
        # corresponds to the XML project file.

        self.items = {}
        # xml: <ITEMS>
        # key = item ID, value = WorldElement instance.
        # The order of the elements does not matter.

        self.srtItems = []
        # The novel's item IDs. The order of its elements corresponds to the XML project file.

        self.characters = {}
        # xml: <CHARACTERS>
        # key = character ID, value = Character instance.
        # The order of the elements does not matter.

        self.srtCharacters = []
        # The novel's character IDs. The order of its elements corresponds to the XML project file.

        self.projectNotes = {}
        # xml: <PROJECTNOTES>
        # key = note ID, value = note instance.
        # The order of the elements does not matter.

        self.srtPrjNotes = []
        # The novel's projectNote IDs. The order of its elements corresponds to the XML project file.

        self.languageCode = None
        # Language code acc. to ISO 639-1.

        self.countryCode = None
        # Country code acc. to ISO 3166-2.

    def get_languages(self):
        """Determine the languages used in the document.
        
        Populate the self.languages list with all language codes found in the scene contents.        
        Example:
        - language markup: 'Standard text [lang=en-AU]Australian text[/lang=en-AU].'
        - language code: 'en-AU'
        """

        def languages(text):
            """Return the language codes appearing in text.
            
            Example:
            - language markup: 'Standard text [lang=en-AU]Australian text[/lang=en-AU].'
            - language code: 'en-AU'
            """
            if text:
                m = LANGUAGE_TAG.search(text)
                while m:
                    text = text[m.span()[1]:]
                    yield m.group(1)
                    m = LANGUAGE_TAG.search(text)

        self.languages = []
        for scId in self.scenes:
            text = self.scenes[scId].sceneContent
            if text:
                for language in languages(text):
                    if not language in self.languages:
                        self.languages.append(language)

    def check_locale(self):
        """Check the document's locale (language code and country code).
        
        If the locale is missing, set the system locale.  
        If the locale doesn't look plausible, set "no language".        
        """
        if not self.languageCode:
            # Language isn't set.
            try:
                sysLng, sysCtr = locale.getlocale()[0].split('_')
            except:
                # Fallback for old Windows versions.
                sysLng, sysCtr = locale.getdefaultlocale()[0].split('_')
            self.languageCode = sysLng
            self.countryCode = sysCtr
            return

        try:
            # Plausibility check: code must have two characters.
            if len(self.languageCode) == 2:
                if len(self.countryCode) == 2:
                    return
                    # keep the setting
        except:
            # code isn't a string
            pass
        # Existing language or country field looks not plausible
        self.languageCode = 'zxx'
        self.countryCode = 'none'



class YwCnvUi:
    """Base class for Novel file conversion with user interface.

    Public methods:
        export_from_yw(sourceFile, targetFile) -- Convert from yWriter project to other file format.
        create_yw(sourceFile, targetFile) -- Create target from source.
        import_to_yw(sourceFile, targetFile) -- Convert from any file format to yWriter project.

    Instance variables:
        ui -- Ui (can be overridden e.g. by subclasses).
        newFile: str -- path to the target file in case of success.   
    """

    def __init__(self):
        """Define instance variables."""
        self.ui = Ui('')
        # Per default, 'silent mode' is active.
        self.newFile = None
        # Also indicates successful conversion.

    def export_from_yw(self, source, target):
        """Convert from yWriter project to other file format.

        Positional arguments:
            source -- YwFile subclass instance.
            target -- Any Novel subclass instance.

        Operation:
        1. Send specific information about the conversion to the UI.
        2. Convert source into target.
        3. Pass the message to the UI.
        4. Save the new file pathname.

        Error handling:
        - If the conversion fails, newFile is set to None.
        """
        self.ui.set_info_what(
            _('Input: {0} "{1}"\nOutput: {2} "{3}"').format(source.DESCRIPTION, norm_path(source.filePath), target.DESCRIPTION, norm_path(target.filePath)))
        try:
            self.check(source, target)
            source.novel = Novel()
            source.read()
            target.novel = source.novel
            target.write()
        except Exception as ex:
            message = f'!{str(ex)}'
            self.newFile = None
        else:
            message = f'{_("File written")}: "{norm_path(target.filePath)}".'
            self.newFile = target.filePath
        finally:
            self.ui.set_info_how(message)

    def create_yw7(self, source, target):
        """Create target from source.

        Positional arguments:
            source -- Any Novel subclass instance.
            target -- YwFile subclass instance.

        Operation:
        1. Send specific information about the conversion to the UI.
        2. Convert source into target.
        3. Pass the message to the UI.
        4. Save the new file pathname.

        Error handling:
        - Tf target already exists as a file, the conversion is cancelled,
          an error message is sent to the UI.
        - If the conversion fails, newFile is set to None.
        """
        self.ui.set_info_what(
            _('Create a yWriter project file from {0}\nNew project: "{1}"').format(source.DESCRIPTION, norm_path(target.filePath)))
        if os.path.isfile(target.filePath):
            self.ui.set_info_how(f'!{_("File already exists")}: "{norm_path(target.filePath)}".')
        else:
            try:
                self.check(source, target)
                source.novel = Novel()
                source.read()
                target.novel = source.novel
                target.write()
            except Exception as ex:
                message = f'!{str(ex)}'
                self.newFile = None
            else:
                message = f'{_("File written")}: "{norm_path(target.filePath)}".'
                self.newFile = target.filePath
            finally:
                self.ui.set_info_how(message)

    def import_to_yw(self, source, target):
        """Convert from any file format to yWriter project.

        Positional arguments:
            source -- Any Novel subclass instance.
            target -- YwFile subclass instance.

        Operation:
        1. Send specific information about the conversion to the UI.
        2. Convert source into target.
        3. Pass the message to the UI.
        4. Delete the temporay file, if exists.
        5. Save the new file pathname.

        Error handling:
        - If the conversion fails, newFile is set to None.
        """
        self.ui.set_info_what(
            _('Input: {0} "{1}"\nOutput: {2} "{3}"').format(source.DESCRIPTION, norm_path(source.filePath), target.DESCRIPTION, norm_path(target.filePath)))
        self.newFile = None
        try:
            self.check(source, target)
            target.novel = Novel()
            target.read()
            source.novel = target.novel
            source.read()
            target.novel = source.novel
            target.write()
        except Exception as ex:
            message = f'!{str(ex)}'
        else:
            message = f'{_("File written")}: "{norm_path(target.filePath)}".'
            self.newFile = target.filePath
            if source.scenesSplit:
                self.ui.show_warning(_('New scenes created during conversion.'))
        finally:
            self.ui.set_info_how(message)

    def _confirm_overwrite(self, filePath):
        """Return boolean permission to overwrite the target file.
        
        Positional arguments:
            fileName -- path to the target file.
        
        Overrides the superclass method.
        """
        return self.ui.ask_yes_no(_('Overwrite existing file "{}"?').format(norm_path(filePath)))

    def _open_newFile(self):
        """Open the converted file for editing and exit the converter script."""
        open_document(self.newFile)
        sys.exit(0)

    def check(self, source, target):
        """Error handling:
        
        - Check if source and target are correctly initialized.
        - Ask for permission to overwrite target.
        - Raise the "Error" exception in case of error. 
        """
        if source.filePath is None:
            raise Error(f'{_("File type is not supported")}.')

        if not os.path.isfile(source.filePath):
            raise Error(f'{_("File not found")}: "{norm_path(source.filePath)}".')

        if target.filePath is None:
            raise Error(f'{_("File type is not supported")}.')

        if os.path.isfile(target.filePath) and not self._confirm_overwrite(target.filePath):
            raise Error(f'{_("Action canceled by user")}.')

from abc import ABC, abstractmethod


class FileFactory(ABC):
    """Base class for conversion object factory classes.
    """

    def __init__(self, fileClasses=[]):
        """Write the parameter to a "private" instance variable.

        Optional arguments:
            _fileClasses -- list of classes from which an instance can be returned.
        """
        self._fileClasses = fileClasses

    @abstractmethod
    def make_file_objects(self, sourcePath, **kwargs):
        """Instantiate a source object for conversion from a yWriter project.

        Positional arguments:
            sourcePath: str -- path to the source file to convert.
        """
        pass


class ExportSourceFactory(FileFactory):
    """A factory class that instantiates a yWriter object to read.

    Public methods:
        make_file_objects(self, sourcePath, **kwargs) -- return conversion objects.
    """

    def make_file_objects(self, sourcePath, **kwargs):
        """Instantiate a source object for conversion from a yWriter project.

        Positional arguments:
            sourcePath: str -- path to the source file to convert.

        Return a tuple with two elements:
        - sourceFile: a YwFile subclass instance
        - targetFile: None

        Raise the "Error" exception in case of error. 
        """
        __, fileExtension = os.path.splitext(sourcePath)
        for fileClass in self._fileClasses:
            if fileClass.EXTENSION == fileExtension:
                sourceFile = fileClass(sourcePath, **kwargs)
                return sourceFile, None

        raise Error(f'{_("File type is not supported")}: "{norm_path(sourcePath)}".')


class ExportTargetFactory(FileFactory):
    """A factory class that instantiates a document object to write.

    Public methods:
        make_file_objects(self, sourcePath, **kwargs) -- return conversion objects.
    """

    def make_file_objects(self, sourcePath, **kwargs):
        """Instantiate a target object for conversion from a yWriter project.

        Positional arguments:
            sourcePath: str -- path to the source file to convert.

        Required keyword arguments: 
            suffix: str -- target file name suffix.

        Return a tuple with two elements:
        - sourceFile: None
        - targetFile: a FileExport subclass instance
        
        Raise the "Error" exception in case of error.          
        """
        fileName, __ = os.path.splitext(sourcePath)
        suffix = kwargs['suffix']
        for fileClass in self._fileClasses:
            if fileClass.SUFFIX == suffix:
                if suffix is None:
                    suffix = ''
                targetFile = fileClass(f'{fileName}{suffix}{fileClass.EXTENSION}', **kwargs)
                return None, targetFile

        raise Error(f'{_("Export type is not supported")}: "{suffix}".')


class ImportSourceFactory(FileFactory):
    """A factory class that instantiates a documente object to read.

    Public methods:
        make_file_objects(self, sourcePath, **kwargs) -- return conversion objects.
    """

    def make_file_objects(self, sourcePath, **kwargs):
        """Instantiate a source object for conversion to a yWriter project.       

        Positional arguments:
            sourcePath: str -- path to the source file to convert.

        Return a tuple with two elements:
        - sourceFile: a Novel subclass instance, or None in case of error
        - targetFile: None

        Raise the "Error" exception in case of error. 
        """
        for fileClass in self._fileClasses:
            if fileClass.SUFFIX is not None:
                if sourcePath.endswith(f'{fileClass.SUFFIX }{fileClass.EXTENSION}'):
                    sourceFile = fileClass(sourcePath, **kwargs)
                    return sourceFile, None

        raise Error(f'{_("This document is not meant to be written back")}.')


class ImportTargetFactory(FileFactory):
    """A factory class that instantiates a yWriter object to write.

    Public methods:
        make_file_objects(self, sourcePath, **kwargs) -- return conversion objects.
    """

    def make_file_objects(self, sourcePath, **kwargs):
        """Instantiate a target object for conversion to a yWriter project.

        Positional arguments:
            sourcePath: str -- path to the source file to convert.

        Required keyword arguments: 
            suffix: str -- target file name suffix.

        Return a tuple with two elements:
        - sourceFile: None
        - targetFile: a YwFile subclass instance

        Raise the "Error" exception in case of error. 
        """
        fileName, __ = os.path.splitext(sourcePath)
        sourceSuffix = kwargs['suffix']
        if sourceSuffix:
            # Remove the suffix from the source file name.
            # This should also work if the file name already contains the suffix,
            # e.g. "test_notes_notes.odt".
            e = fileName.split(sourceSuffix)
            if len(e) > 1:
                e.pop()
            ywPathBasis = ''.join(e)
        else:
            ywPathBasis = fileName

        # Look for an existing yWriter project to rewrite.
        for fileClass in self._fileClasses:
            if os.path.isfile(f'{ywPathBasis}{fileClass.EXTENSION}'):
                targetFile = fileClass(f'{ywPathBasis}{fileClass.EXTENSION}', **kwargs)
                return None, targetFile

        raise Error(f'{_("No yWriter project to write")}.')


class YwCnvFf(YwCnvUi):
    """Class for Novel file conversion using factory methods to create target and source classes.

    Public methods:
        run(sourcePath, **kwargs) -- create source and target objects and run conversion.

    Class constants:
        EXPORT_SOURCE_CLASSES -- list of YwFile subclasses from which can be exported.
        EXPORT_TARGET_CLASSES -- list of FileExport subclasses to which export is possible.
        IMPORT_SOURCE_CLASSES -- list of Novel subclasses from which can be imported.
        IMPORT_TARGET_CLASSES -- list of YwFile subclasses to which import is possible.

    All lists are empty and meant to be overridden by subclasses.

    Instance variables:
        exportSourceFactory: ExportSourceFactory.
        exportTargetFactory: ExportTargetFactory.
        importSourceFactory: ImportSourceFactory.
        importTargetFactory: ImportTargetFactory.
        newProjectFactory: FileFactory (to be overridden by subclasses).
    """
    EXPORT_SOURCE_CLASSES = []
    EXPORT_TARGET_CLASSES = []
    IMPORT_SOURCE_CLASSES = []
    IMPORT_TARGET_CLASSES = []

    def __init__(self):
        """Create strategy class instances.
        
        Extends the superclass constructor.
        """
        super().__init__()
        self.exportSourceFactory = ExportSourceFactory(self.EXPORT_SOURCE_CLASSES)
        self.exportTargetFactory = ExportTargetFactory(self.EXPORT_TARGET_CLASSES)
        self.importSourceFactory = ImportSourceFactory(self.IMPORT_SOURCE_CLASSES)
        self.importTargetFactory = ImportTargetFactory(self.IMPORT_TARGET_CLASSES)
        self.newProjectFactory = None

    def run(self, sourcePath, **kwargs):
        """Create source and target objects and run conversion.

        Positional arguments: 
            sourcePath: str -- the source file path.
        
        Required keyword arguments: 
            suffix: str -- target file name suffix.

        This is a template method that calls superclass methods as primitive operations by case.
        """
        self.newFile = None
        if not os.path.isfile(sourcePath):
            self.ui.set_info_how(f'!{_("File not found")}: "{norm_path(sourcePath)}".')
            return

        try:
            source, __ = self.exportSourceFactory.make_file_objects(sourcePath, **kwargs)
        except Error:
            # The source file is not a yWriter project.
            try:
                source, __ = self.importSourceFactory.make_file_objects(sourcePath, **kwargs)
            except Error:
                # A new yWriter project might be required.
                try:
                    source, target = self.newProjectFactory.make_file_objects(sourcePath, **kwargs)
                except Error as ex:
                    self.ui.set_info_how(f'!{str(ex)}')
                else:
                    self.create_yw7(source, target)
            else:
                # Try to update an existing yWriter project.
                kwargs['suffix'] = source.SUFFIX
                try:
                    __, target = self.importTargetFactory.make_file_objects(sourcePath, **kwargs)
                except Error as ex:
                    self.ui.set_info_how(f'!{str(ex)}')
                else:
                    self.import_to_yw(source, target)
        else:
            # The source file is a yWriter project.
            try:
                __, target = self.exportTargetFactory.make_file_objects(sourcePath, **kwargs)
            except Error as ex:
                self.ui.set_info_how(f'!{str(ex)}')
            else:
                self.export_from_yw(source, target)
from html import unescape
from datetime import datetime
import xml.etree.ElementTree as ET


class Chapter(BasicElement):
    """yWriter chapter representation.
    
    Public instance variables:
        chLevel: int -- chapter level (part/chapter).
        chType: int -- chapter type (Normal/Notes/Todo/Unused).
        suppressChapterTitle: bool -- uppress chapter title when exporting.
        isTrash: bool -- True, if the chapter is the project's trash bin.
        suppressChapterBreak: bool -- Suppress chapter break when exporting.
        srtScenes: list of str -- the chapter's sorted scene IDs.        
    """

    def __init__(self):
        """Initialize instance variables.
        
        Extends the superclass constructor.
        """
        super().__init__()

        self.chLevel = None
        # xml: <SectionStart>
        # 0 = chapter level
        # 1 = section level ("this chapter begins a section")

        self.chType = None
        # 0 = Normal
        # 1 = Notes
        # 2 = Todo
        # 3= Unused
        # Applies to projects created by yWriter version 7.0.7.2+.
        #
        # xml: <ChapterType>
        # xml: <Type>
        # xml: <Unused>
        #
        # This is how yWriter 7.1.3.0 reads the chapter type:
        #
        # Type   |<Unused>|<Type>|<ChapterType>|chType
        # -------+--------+------+--------------------
        # Normal | N/A    | N/A  | N/A         | 0
        # Normal | N/A    | 0    | N/A         | 0
        # Notes  | x      | 1    | N/A         | 1
        # Unused | -1     | 0    | N/A         | 3
        # Normal | N/A    | x    | 0           | 0
        # Notes  | x      | x    | 1           | 1
        # Todo   | x      | x    | 2           | 2
        # Unused | -1     | x    | x           | 3
        #
        # This is how yWriter 7.1.3.0 writes the chapter type:
        #
        # Type   |<Unused>|<Type>|<ChapterType>|chType
        #--------+--------+------+-------------+------
        # Normal | N/A    | 0    | 0           | 0
        # Notes  | -1     | 1    | 1           | 1
        # Todo   | -1     | 1    | 2           | 2
        # Unused | -1     | 1    | 0           | 3

        self.suppressChapterTitle = None
        # xml: <Fields><Field_SuppressChapterTitle> 1
        # True: Chapter heading not to be displayed in written document.
        # False: Chapter heading to be displayed in written document.

        self.isTrash = None
        # xml: <Fields><Field_IsTrash> 1
        # True: This chapter is the yw7 project's "trash bin".
        # False: This chapter is not a "trash bin".

        self.suppressChapterBreak = None
        # xml: <Fields><Field_SuppressChapterBreak> 0

        self.srtScenes = []
        # xml: <Scenes><ScID>
        # The chapter's scene IDs. The order of its elements
        # corresponds to the chapter's order of the scenes.

#--- Regular expressions for counting words and characters like in LibreOffice.
# See: https://help.libreoffice.org/latest/en-GB/text/swriter/guide/words_count.html

ADDITIONAL_WORD_LIMITS = re.compile(r'--|—|–')
# this is to be replaced by spaces, thus making dashes and dash replacements word limits

NO_WORD_LIMITS = re.compile(r'\[.+?\]|\/\*.+?\*\/|-|^\>', re.MULTILINE)
# this is to be replaced by empty strings, thus excluding markup and comments from
# word counting, and making hyphens join words

NON_LETTERS = re.compile(r'\[.+?\]|\/\*.+?\*\/|\n|\r')
# this is to be replaced by empty strings, thus excluding markup, comments, and linefeeds
# from letter counting


class Scene(BasicElement):
    """yWriter scene representation.
    
    Public instance variables:
        sceneContent: str -- scene content (property with getter and setter).
        wordCount: int -- word count (derived; updated by the sceneContent setter).
        letterCount: int -- letter count (derived; updated by the sceneContent setter).
        scType: int -- Scene type (Normal/Notes/Todo/Unused).
        doNotExport: bool -- True if the scene is not to be exported to RTF.
        status: int -- scene status (Outline/Draft/1st Edit/2nd Edit/Done).
        notes: str -- scene notes in a single string.
        tags -- list of scene tags. 
        field1: int -- scene ratings field 1.
        field2: int -- scene ratings field 2.
        field3: int -- scene ratings field 3.
        field4: int -- scene ratings field 4.
        appendToPrev: bool -- if True, append the scene without a divider to the previous scene.
        isReactionScene: bool -- if True, the scene is "reaction". Otherwise, it's "action". 
        isSubPlot: bool -- if True, the scene belongs to a sub-plot. Otherwise it's main plot.  
        goal: str -- the main actor's scene goal. 
        conflict: str -- what hinders the main actor to achieve his goal.
        outcome: str -- what comes out at the end of the scene.
        characters -- list of character IDs related to this scene.
        locations -- list of location IDs related to this scene. 
        items -- list of item IDs related to this scene.
        date: str -- specific start date in ISO format (yyyy-mm-dd).
        time: str -- specific start time in ISO format (hh:mm).
        minute: str -- unspecific start time: minutes.
        hour: str -- unspecific start time: hour.
        day: str -- unspecific start time: day.
        lastsMinutes: str -- scene duration: minutes.
        lastsHours: str -- scene duration: hours.
        lastsDays: str -- scene duration: days. 
        image: str --  path to an image related to the scene. 
        scnArcs: str -- Semicolon-separated arc titles.
        scnMode: str -- Mode of discourse (Narration/Dramatic action/Dialogue/Description/Exposition).
    """
    STATUS = [None,
                    'Outline',
                    'Draft',
                    '1st Edit',
                    '2nd Edit',
                    'Done'
                    ]
    # Emulate an enumeration for the scene status
    # Since the items are used to replace text,
    # they may contain spaces. This is why Enum cannot be used here.

    ACTION_MARKER = 'A'
    REACTION_MARKER = 'R'
    NULL_DATE = '0001-01-01'
    NULL_TIME = '00:00:00'

    def __init__(self):
        """Initialize instance variables.
        
        Extends the superclass constructor.
        """
        super().__init__()

        self._sceneContent = None
        # xml: <SceneContent>
        # Scene text with yW7 raw markup.

        self.wordCount = 0
        # xml: <WordCount>
        # To be updated by the sceneContent setter

        self.letterCount = 0
        # xml: <LetterCount>
        # To be updated by the sceneContent setter

        self.scType = None
        # Scene type (Normal/Notes/Todo/Unused).
        #
        # xml: <Unused>
        # xml: <Fields><Field_SceneType>
        #
        # This is how yWriter 7.1.3.0 reads the scene type:
        #
        # Type   |<Unused>|Field_SceneType>|scType
        #--------+--------+----------------+------
        # Notes  | x      | 1              | 1
        # Todo   | x      | 2              | 2
        # Unused | -1     | N/A            | 3
        # Unused | -1     | 0              | 3
        # Normal | N/A    | N/A            | 0
        # Normal | N/A    | 0              | 0
        #
        # This is how yWriter 7.1.3.0 writes the scene type:
        #
        # Type   |<Unused>|Field_SceneType>|scType
        #--------+--------+----------------+------
        # Normal | N/A    | N/A            | 0
        # Notes  | -1     | 1              | 1
        # Todo   | -1     | 2              | 2
        # Unused | -1     | 0              | 3

        self.doNotExport = None
        # xml: <ExportCondSpecific><ExportWhenRTF>

        self.status = None
        # xml: <Status>
        # 1 - Outline
        # 2 - Draft
        # 3 - 1st Edit
        # 4 - 2nd Edit
        # 5 - Done
        # See also the STATUS list for conversion.

        self.notes = None
        # xml: <Notes>

        self.tags = None
        # xml: <Tags>

        self.field1 = None
        # xml: <Field1>

        self.field2 = None
        # xml: <Field2>

        self.field3 = None
        # xml: <Field3>

        self.field4 = None
        # xml: <Field4>

        self.appendToPrev = None
        # xml: <AppendToPrev> -1

        self.isReactionScene = None
        # xml: <ReactionScene> -1

        self.isSubPlot = None
        # xml: <SubPlot> -1

        self.goal = None
        # xml: <Goal>

        self.conflict = None
        # xml: <Conflict>

        self.outcome = None
        # xml: <Outcome>

        self.characters = None
        # xml: <Characters><CharID>

        self.locations = None
        # xml: <Locations><LocID>

        self.items = None
        # xml: <Items><ItemID>

        self.date = None
        # yyyy-mm-dd
        # xml: <SpecificDateMode>-1
        # xml: <SpecificDateTime>1900-06-01 20:38:00

        self.time = None
        # hh:mm:ss
        # xml: <SpecificDateMode>-1
        # xml: <SpecificDateTime>1900-06-01 20:38:00

        self.day = None
        # xml: <Day>

        self.lastsMinutes = None
        # xml: <LastsMinutes>

        self.lastsHours = None
        # xml: <LastsHours>

        self.lastsDays = None
        # xml: <LastsDays>

        self.image = None
        # xml: <ImageFile>

        self.scnArcs = None
        # xml: <Field_SceneArcs>
        # Semicolon-separated arc titles.
        # Example: 'A' for 'A-Storyline'.
        # If the scene is "Todo" type, an assigned single arc
        # should be defined by it.

        self.scnMode = None
        # xml: <Field_SceneMode>
        # Mode of discourse.

    @property
    def sceneContent(self):
        return self._sceneContent

    @sceneContent.setter
    def sceneContent(self, text: str):
        """Set sceneContent updating word count and letter count."""
        self._sceneContent = text
        text = ADDITIONAL_WORD_LIMITS.sub(' ', text)
        text = NO_WORD_LIMITS.sub('', text)
        wordList = text.split()
        self.wordCount = len(wordList)
        text = NON_LETTERS.sub('', self._sceneContent)
        self.letterCount = len(text)


class WorldElement(BasicElement):
    """Story world element representation (may be location or item).
    
    Public instance variables:
        image: str -- image file path.
        tags -- list of tags.
        aka: str -- alternate name.
    """

    def __init__(self):
        """Initialize instance variables.
        
        Extends the superclass constructor.
        """
        super().__init__()

        self.image = None
        # xml: <ImageFile>

        self.tags = None
        # xml: <Tags>

        self.aka = None
        # xml: <AKA>



class Character(WorldElement):
    """yWriter character representation.

    Public instance variables:
        notes: str -- character notes.
        bio: str -- character biography.
        goals: str -- character's goals in the story.
        fullName: str -- full name (the title inherited may be a short name).
        isMajor: bool -- True, if it's a major character.
    """
    MAJOR_MARKER = 'Major'
    MINOR_MARKER = 'Minor'

    def __init__(self):
        """Extends the superclass constructor by adding instance variables."""
        super().__init__()

        self.notes = None
        # xml: <Notes>

        self.bio = None
        # xml: <Bio>

        self.goals = None
        # xml: <Goals>

        self.fullName = None
        # xml: <FullName>

        self.isMajor = None
        # xml: <Major>
from abc import ABC
from urllib.parse import quote


class File(ABC):
    """Abstract yWriter project file representation.

    This class represents a file containing a novel with additional 
    attributes and structural information (a full set or a subset
    of the information included in an yWriter project file).

    Public methods:
        read() -- Parse the file and get the instance variables.
        write() -- Write instance variables to the file.

    Public instance variables:
        projectName: str -- URL-coded file name without suffix and extension. 
        projectPath: str -- URL-coded path to the project directory. 
        scenesSplit: bool -- True, if a scene or chapter is split during merging.
        filePath: str -- path to the file (property with getter and setter). 

    Public class constants:
        PRJ_KWVAR -- List of the names of the project keyword variables.
        CHP_KWVAR -- List of the names of the chapter keyword variables.
        SCN_KWVAR -- List of the names of the scene keyword variables.
        CRT_KWVAR -- List of the names of the character keyword variables.
        LOC_KWVAR -- List of the names of the location keyword variables.
        ITM_KWVAR -- List of the names of the item keyword variables.
        PNT_KWVAR -- List of the names of the project note keyword variables.
    """
    DESCRIPTION = _('File')
    EXTENSION = None
    SUFFIX = None
    # To be extended by subclass methods.

    PRJ_KWVAR = []
    CHP_KWVAR = []
    SCN_KWVAR = []
    CRT_KWVAR = []
    LOC_KWVAR = []
    ITM_KWVAR = []
    PNT_KWVAR = []
    # Keyword variables for custom fields in the .yw7 XML file.

    def __init__(self, filePath, **kwargs):
        """Initialize instance variables.

        Positional arguments:
            filePath: str -- path to the file represented by the File instance.
            
        Optional arguments:
            kwargs -- keyword arguments to be used by subclasses.  
        """
        self.novel = None

        self._filePath = None
        # str
        # Path to the file. The setter only accepts files of a supported type as specified by EXTENSION.

        self.projectName = None
        # str
        # URL-coded file name without suffix and extension.

        self.projectPath = None
        # str
        # URL-coded path to the project directory.

        self.scenesSplit = False
        self.filePath = filePath

    @property
    def filePath(self):
        return self._filePath

    @filePath.setter
    def filePath(self, filePath):
        """Setter for the filePath instance variable.
                
        - Format the path string according to Python's requirements. 
        - Accept only filenames with the right suffix and extension.
        """
        filePath = filePath.replace('\\', '/')
        if self.SUFFIX is not None:
            suffix = self.SUFFIX
        else:
            suffix = ''
        if filePath.lower().endswith(f'{suffix}{self.EXTENSION}'.lower()):
            self._filePath = filePath
            try:
                head, tail = os.path.split(os.path.realpath(filePath))
                # realpath() completes relative paths, but may not work on virtual file systems.
            except:
                head, tail = os.path.split(filePath)
            self.projectPath = quote(head.replace('\\', '/'), '/:')
            self.projectName = quote(tail.replace(f'{suffix}{self.EXTENSION}', ''))

    def read(self):
        """Parse the file and get the instance variables.
        
        Raise the "Error" exception in case of error. 
        This is a stub to be overridden by subclass methods.
        """
        raise NotImplementedError

    def write(self):
        """Write instance variables to the file.
        
        Raise the "Error" exception in case of error. 
        This is a stub to be overridden by subclass methods.
        """
        raise NotImplementedError

    def _convert_from_yw(self, text, quick=False):
        """Return text, converted from yw7 markup to target format.
        
        Positional arguments:
            text -- string to convert.
        
        Optional arguments:
            quick: bool -- if True, apply a conversion mode for one-liners without formatting.
        
        This is a stub to be overridden by subclass methods.
        """
        return text.rstrip()

    def _convert_to_yw(self, text):
        """Return text, converted from source format to yw7 markup.
        
        Positional arguments:
            text -- string to convert.
        
        This is a stub to be overridden by subclass methods.
        """
        return text.rstrip()



def create_id(elements):
    """Return an unused ID for a new element.
    
    Positional arguments:
        elements -- list or dictionary containing all existing IDs
    """
    i = 1
    while str(i) in elements:
        i += 1
    return str(i)



def indent(elem, level=0):
    """xml pretty printer

    Kudos to to Fredrik Lundh. 
    Source: http://effbot.org/zone/element-lib.htm#prettyprint
    """
    i = f'\n{level * "  "}'
    if len(elem):
        if not elem.text or not elem.text.strip():
            elem.text = f'{i}  '
        if not elem.tail or not elem.tail.strip():
            elem.tail = i
        for elem in elem:
            indent(elem, level + 1)
        if not elem.tail or not elem.tail.strip():
            elem.tail = i
    else:
        if level and (not elem.tail or not elem.tail.strip()):
            elem.tail = i


class Yw7File(File):
    """yWriter 7 project file representation.

    Public methods:
        adjust_scene_types() -- Make sure that scenes in non-"Normal" chapters inherit the chapter's type.
        is_locked() -- check whether the yw7 file is locked by yWriter.
        read() -- parse the yWriter xml file and get the instance variables.
        write() -- write instance variables to the yWriter xml file.

    Public instance variables:
        tree -- xml element tree of the yWriter project
        
    Public class constants:
        PRJ_KWVAR -- List of the names of the project keyword variables.
        SCN_KWVAR -- List of the names of the scene keyword variables.
    """
    DESCRIPTION = _('yWriter 7 project')
    EXTENSION = '.yw7'
    _CDATA_TAGS = [
        'Title',
        'AuthorName',
        'Bio',
        'Desc',
        'FieldTitle1',
        'FieldTitle2',
        'FieldTitle3',
        'FieldTitle4',
        'LaTeXHeaderFile',
        'Tags',
        'AKA',
        'ImageFile',
        'FullName',
        'Goals',
        'Notes',
        'RTFFile',
        'SceneContent',
        'Outcome',
        'Goal',
        'Conflict'
        'Field_ChapterHeadingPrefix',
        'Field_ChapterHeadingSuffix',
        'Field_PartHeadingPrefix',
        'Field_PartHeadingSuffix',
        'Field_CustomGoal',
        'Field_CustomConflict',
        'Field_CustomOutcome',
        'Field_CustomChrBio',
        'Field_CustomChrGoals',
        'Field_ArcDefinition',
        'Field_SceneArcs',
        'Field_CustomAR',
        ]
    # Names of xml elements containing CDATA.
    # ElementTree.write omits CDATA tags, so they have to be inserted afterwards.

    PRJ_KWVAR = [
        'Field_LanguageCode',
        'Field_CountryCode',
        ]
    SCN_KWVAR = [
        'Field_SceneArcs',
        'Field_SceneMode',
        ]
    CRT_KWVAR = [
        'Field_Link',
        'Field_BirthDate',
        'Field_DeathDate',
        ]
    LOC_KWVAR = [
        'Field_Link',
        ]
    ITM_KWVAR = [
        'Field_Link',
        ]

    def __init__(self, filePath, **kwargs):
        """Initialize instance variables.
        
        Positional arguments:
            filePath: str -- path to the yw7 file.
            
        Optional arguments:
            kwargs -- keyword arguments (not used here).            
        
        Extends the superclass constructor.
        """
        super().__init__(filePath)
        self.tree = None

    def adjust_scene_types(self):
        """Make sure that scenes in non-"Normal" chapters inherit the chapter's type."""
        for chId in self.novel.srtChapters:
            if self.novel.chapters[chId].chType != 0:
                for scId in self.novel.chapters[chId].srtScenes:
                    self.novel.scenes[scId].scType = self.novel.chapters[chId].chType

    def is_locked(self):
        """Check whether the yw7 file is locked by yWriter.
        
        Return True if a .lock file placed by yWriter exists.
        Otherwise, return False. 
        """
        return os.path.isfile(f'{self.filePath}.lock')

    def read(self):
        """Parse the yWriter xml file and get the instance variables.
        
        Raise the "Error" exception in case of error. 
        Overrides the superclass method.
        """

        for field in self.PRJ_KWVAR:
            self.novel.kwVar[field] = None

        if self.is_locked():
            raise Error(f'{_("yWriter seems to be open. Please close first")}.')
        try:
            try:
                with open(self.filePath, 'r', encoding='utf-8') as f:
                    xmlText = f.read()
            except:
                # yw7 file may be UTF-16 encoded, with a wrong XML header (yWriter for iOS)
                with open(self.filePath, 'r', encoding='utf-16') as f:
                    xmlText = f.read()
        except:
            try:
                self.tree = ET.parse(self.filePath)
            except Exception as ex:
                raise Error(f'{_("Can not process file")} - {str(ex)}')

        xmlText = re.sub('[\x00-\x08|\x0b-\x0c|\x0e-\x1f]', '', xmlText)
        root = ET.fromstring(xmlText)
        xmlText = None
        # saving memory
        self.tree = ET.ElementTree(root)
        self._read_project(root)
        self._read_locations(root)
        self._read_items(root)
        self._read_characters(root)
        self._read_projectvars(root)
        self._read_projectnotes(root)
        self._read_scenes(root)
        self._read_chapters(root)
        self.adjust_scene_types()

        #--- Set custom instance variables.
        for scId in self.novel.scenes:
            self.novel.scenes[scId].scnArcs = self.novel.scenes[scId].kwVar.get('Field_SceneArcs', None)
            scnMode = self.novel.scenes[scId].kwVar.get('Field_SceneMode', None)
            try:
                self.novel.scenes[scId].scnMode = int(scnMode)
            except:
                self.novel.scenes[scId].scnMode = None

    def write(self):
        """Write instance variables to the yWriter xml file.
        
        Open the yWriter xml file located at filePath and replace the instance variables 
        not being None. Create new XML elements if necessary.
        Raise the "Error" exception in case of error. 
        Overrides the superclass method.
        """
        if self.is_locked():
            raise Error(f'{_("yWriter seems to be open. Please close first")}.')

        if self.novel.languages is None:
            self.novel.get_languages()

        #--- Get custom instance variables.
        for scId in self.novel.scenes:
            if self.novel.scenes[scId].scnArcs is not None:
                self.novel.scenes[scId].kwVar['Field_SceneArcs'] = self.novel.scenes[scId].scnArcs
            if self.novel.scenes[scId].scnMode is not None:
                if self.novel.scenes[scId].scnMode == 0:
                    self.novel.scenes[scId].kwVar['Field_SceneMode'] = None
                else:
                    self.novel.scenes[scId].kwVar['Field_SceneMode'] = str(self.novel.scenes[scId].scnMode)
            self.novel.scenes[scId].kwVar['Field_SceneStyle'] = None
        self._build_element_tree()
        self._write_element_tree(self)
        self._postprocess_xml_file(self.filePath)

    def _build_element_tree(self):
        """Modify the yWriter project attributes of an existing xml element tree."""

        def set_element(parent, tag, text, index):
            subelement = parent.find(tag)
            if subelement is None:
                if text is not None:
                    subelement = ET.Element(tag)
                    parent.insert(index, subelement)
                    subelement.text = text
                    index += 1
            elif text is not None:
                subelement.text = text
                index += 1
            return index

        def build_scene_subtree(xmlScene, prjScn):

            def remove_date_time():
                """Delete all scene start data."""
                if xmlScene.find('SpecificDateTime') is not None:
                    xmlScene.remove(xmlScene.find('SpecificDateTime'))

                if xmlScene.find('SpecificDateMode') is not None:
                    xmlScene.remove(xmlScene.find('SpecificDateMode'))

                if xmlScene.find('Day') is not None:
                    xmlScene.remove(xmlScene.find('Day'))

                if xmlScene.find('Hour') is not None:
                    xmlScene.remove(xmlScene.find('Hour'))

                if xmlScene.find('Minute') is not None:
                    xmlScene.remove(xmlScene.find('Minute'))

            i = 1
            i = set_element(xmlScene, 'Title', prjScn.title, i)

            if prjScn.desc is not None:
                try:
                    xmlScene.find('Desc').text = prjScn.desc
                except(AttributeError):
                    if prjScn.desc:
                        ET.SubElement(xmlScene, 'Desc').text = prjScn.desc

            if xmlScene.find('SceneContent') is None:
                ET.SubElement(xmlScene, 'SceneContent').text = prjScn.sceneContent

            if xmlScene.find('WordCount') is None:
                ET.SubElement(xmlScene, 'WordCount').text = str(prjScn.wordCount)

            if xmlScene.find('LetterCount') is None:
                ET.SubElement(xmlScene, 'LetterCount').text = str(prjScn.letterCount)

            #--- Write scene type.
            #
            # This is how yWriter 7.1.3.0 writes the scene type:
            #
            # Type   |<Unused>|Field_SceneType>|scType
            #--------+--------+----------------+------
            # Normal | N/A    | N/A            | 0
            # Notes  | -1     | 1              | 1
            # Todo   | -1     | 2              | 2
            # Unused | -1     | 0              | 3

            scTypeEncoding = (
                (False, None),
                (True, '1'),
                (True, '2'),
                (True, '0'),
                )
            if prjScn.scType is None:
                prjScn.scType = 0
            yUnused, ySceneType = scTypeEncoding[prjScn.scType]

            # <Unused> (remove, if scene is "Normal").
            if yUnused:
                if xmlScene.find('Unused') is None:
                    ET.SubElement(xmlScene, 'Unused').text = '-1'
            elif xmlScene.find('Unused') is not None:
                xmlScene.remove(xmlScene.find('Unused'))

            # <Fields><Field_SceneType> (remove, if scene is "Normal")
            xmlSceneFields = xmlScene.find('Fields')
            if xmlSceneFields is not None:
                fieldScType = xmlSceneFields.find('Field_SceneType')
                if ySceneType is None:
                    if fieldScType is not None:
                        xmlSceneFields.remove(fieldScType)
                else:
                    try:
                        fieldScType.text = ySceneType
                    except(AttributeError):
                        ET.SubElement(xmlSceneFields, 'Field_SceneType').text = ySceneType
            elif ySceneType is not None:
                xmlSceneFields = ET.SubElement(xmlScene, 'Fields')
                ET.SubElement(xmlSceneFields, 'Field_SceneType').text = ySceneType

            #--- Export when RTF.
            if prjScn.doNotExport is not None:
                xmlExportCondSpecific = xmlScene.find('ExportCondSpecific')
                xmlExportWhenRtf = xmlScene.find('ExportWhenRTF')
                if prjScn.doNotExport:
                    if xmlExportCondSpecific is None:
                        xmlExportCondSpecific = ET.SubElement(xmlScene, 'ExportCondSpecific')
                    if xmlExportWhenRtf is not None:
                        xmlScene.remove(xmlExportWhenRtf)
                else:
                    if xmlExportCondSpecific is not None:
                        if xmlExportWhenRtf is None:
                            ET.SubElement(xmlScene, 'ExportWhenRTF').text = '-1'

            #--- Write scene custom fields.
            for field in self.SCN_KWVAR:
                if prjScn.kwVar.get(field, None):
                    if xmlSceneFields is None:
                        xmlSceneFields = ET.SubElement(xmlScene, 'Fields')
                    fieldEntry = self._convert_from_yw(prjScn.kwVar[field])
                    try:
                        xmlSceneFields.find(field).text = fieldEntry
                    except(AttributeError):
                        ET.SubElement(xmlSceneFields, field).text = fieldEntry
                elif xmlSceneFields is not None:
                    try:
                        xmlSceneFields.remove(xmlSceneFields.find(field))
                    except:
                        pass

            if prjScn.status is not None:
                try:
                    xmlScene.find('Status').text = str(prjScn.status)
                except:
                    ET.SubElement(xmlScene, 'Status').text = str(prjScn.status)

            if prjScn.notes is not None:
                try:
                    xmlScene.find('Notes').text = prjScn.notes
                except(AttributeError):
                    if prjScn.notes:
                        ET.SubElement(xmlScene, 'Notes').text = prjScn.notes

            if prjScn.tags is not None:
                try:
                    xmlScene.find('Tags').text = list_to_string(prjScn.tags)
                except(AttributeError):
                    if prjScn.tags:
                        ET.SubElement(xmlScene, 'Tags').text = list_to_string(prjScn.tags)

            if prjScn.field1 is not None:
                try:
                    xmlScene.find('Field1').text = prjScn.field1
                except(AttributeError):
                    if prjScn.field1:
                        ET.SubElement(xmlScene, 'Field1').text = prjScn.field1

            if prjScn.field2 is not None:
                try:
                    xmlScene.find('Field2').text = prjScn.field2
                except(AttributeError):
                    if prjScn.field2:
                        ET.SubElement(xmlScene, 'Field2').text = prjScn.field2

            if prjScn.field3 is not None:
                try:
                    xmlScene.find('Field3').text = prjScn.field3
                except(AttributeError):
                    if prjScn.field3:
                        ET.SubElement(xmlScene, 'Field3').text = prjScn.field3

            if prjScn.field4 is not None:
                try:
                    xmlScene.find('Field4').text = prjScn.field4
                except(AttributeError):
                    if prjScn.field4:
                        ET.SubElement(xmlScene, 'Field4').text = prjScn.field4

            if prjScn.appendToPrev:
                if xmlScene.find('AppendToPrev') is None:
                    ET.SubElement(xmlScene, 'AppendToPrev').text = '-1'
            elif xmlScene.find('AppendToPrev') is not None:
                xmlScene.remove(xmlScene.find('AppendToPrev'))

            #--- Write scene start.
            if (prjScn.date is not None) and (prjScn.time is not None):
                separator = ' '
                dateTime = f'{prjScn.date}{separator}{prjScn.time}'

                # Remove scene start data from XML, if date and time are empty strings.
                if dateTime == separator:
                    remove_date_time()

                elif xmlScene.find('SpecificDateTime') is not None:
                    if dateTime.count(':') < 2:
                        dateTime = f'{dateTime}:00'
                    xmlScene.find('SpecificDateTime').text = dateTime
                else:
                    ET.SubElement(xmlScene, 'SpecificDateTime').text = dateTime
                    ET.SubElement(xmlScene, 'SpecificDateMode').text = '-1'

                    if xmlScene.find('Day') is not None:
                        xmlScene.remove(xmlScene.find('Day'))

                    if xmlScene.find('Hour') is not None:
                        xmlScene.remove(xmlScene.find('Hour'))

                    if xmlScene.find('Minute') is not None:
                        xmlScene.remove(xmlScene.find('Minute'))

            elif (prjScn.day is not None) or (prjScn.time is not None):

                # Remove scene start data from XML, if day and time are empty strings.
                if not prjScn.day and not prjScn.time:
                    remove_date_time()

                else:
                    if xmlScene.find('SpecificDateTime') is not None:
                        xmlScene.remove(xmlScene.find('SpecificDateTime'))

                    if xmlScene.find('SpecificDateMode') is not None:
                        xmlScene.remove(xmlScene.find('SpecificDateMode'))
                    if prjScn.day is not None:
                        try:
                            xmlScene.find('Day').text = prjScn.day
                        except(AttributeError):
                            ET.SubElement(xmlScene, 'Day').text = prjScn.day
                    if prjScn.time is not None:
                        hours, minutes, __ = prjScn.time.split(':')
                        try:
                            xmlScene.find('Hour').text = hours
                        except(AttributeError):
                            ET.SubElement(xmlScene, 'Hour').text = hours
                        try:
                            xmlScene.find('Minute').text = minutes
                        except(AttributeError):
                            ET.SubElement(xmlScene, 'Minute').text = minutes

            #--- Write scene duration.
            if prjScn.lastsDays is not None:
                try:
                    xmlScene.find('LastsDays').text = prjScn.lastsDays
                except(AttributeError):
                    if prjScn.lastsDays:
                        ET.SubElement(xmlScene, 'LastsDays').text = prjScn.lastsDays

            if prjScn.lastsHours is not None:
                try:
                    xmlScene.find('LastsHours').text = prjScn.lastsHours
                except(AttributeError):
                    if prjScn.lastsHours:
                        ET.SubElement(xmlScene, 'LastsHours').text = prjScn.lastsHours

            if prjScn.lastsMinutes is not None:
                try:
                    xmlScene.find('LastsMinutes').text = prjScn.lastsMinutes
                except(AttributeError):
                    if prjScn.lastsMinutes:
                        ET.SubElement(xmlScene, 'LastsMinutes').text = prjScn.lastsMinutes

            # Plot related information
            if prjScn.isReactionScene:
                if xmlScene.find('ReactionScene') is None:
                    ET.SubElement(xmlScene, 'ReactionScene').text = '-1'
            elif xmlScene.find('ReactionScene') is not None:
                xmlScene.remove(xmlScene.find('ReactionScene'))

            if prjScn.isSubPlot:
                if xmlScene.find('SubPlot') is None:
                    ET.SubElement(xmlScene, 'SubPlot').text = '-1'
            elif xmlScene.find('SubPlot') is not None:
                xmlScene.remove(xmlScene.find('SubPlot'))

            if prjScn.goal is not None:
                try:
                    xmlScene.find('Goal').text = prjScn.goal
                except(AttributeError):
                    if prjScn.goal:
                        ET.SubElement(xmlScene, 'Goal').text = prjScn.goal

            if prjScn.conflict is not None:
                try:
                    xmlScene.find('Conflict').text = prjScn.conflict
                except(AttributeError):
                    if prjScn.conflict:
                        ET.SubElement(xmlScene, 'Conflict').text = prjScn.conflict

            if prjScn.outcome is not None:
                try:
                    xmlScene.find('Outcome').text = prjScn.outcome
                except(AttributeError):
                    if prjScn.outcome:
                        ET.SubElement(xmlScene, 'Outcome').text = prjScn.outcome

            if prjScn.image is not None:
                try:
                    xmlScene.find('ImageFile').text = prjScn.image
                except(AttributeError):
                    if prjScn.image:
                        ET.SubElement(xmlScene, 'ImageFile').text = prjScn.image

            #--- Characters/Locations/Items
            try:
                xmlScene.remove(xmlScene.find('Characters'))
            except:
                pass
            if prjScn.characters:
                xmlCharacters = ET.SubElement(xmlScene, 'Characters')
                for crId in prjScn.characters:
                    ET.SubElement(xmlCharacters, 'CharID').text = crId

            try:
                xmlScene.remove(xmlScene.find('Locations'))
            except:
                pass
            if prjScn.locations:
                xmlLocations = ET.SubElement(xmlScene, 'Locations')
                for lcId in prjScn.locations:
                    ET.SubElement(xmlLocations, 'LocID').text = lcId

            try:
                xmlScene.remove(xmlScene.find('Items'))
            except:
                pass
            if prjScn.items:
                xmlItems = ET.SubElement(xmlScene, 'Items')
                for ItId in prjScn.items:
                    ET.SubElement(xmlItems, 'ItmID').text = ItId

        def build_chapter_subtree(xmlChapter, prjChp):
            # This is how yWriter 7.1.3.0 writes the chapter type:
            #
            # Type   |<Unused>|<Type>|<ChapterType>|chType
            #--------+--------+------+-------------+------
            # Normal | N/A    | 0    | 0           | 0
            # Notes  | -1     | 1    | 1           | 1
            # Todo   | -1     | 1    | 2           | 2
            # Unused | -1     | 1    | 0           | 3

            chTypeEncoding = (
                (False, '0', '0'),
                (True, '1', '1'),
                (True, '1', '2'),
                (True, '1', '0'),
                )
            if prjChp.chType is None:
                prjChp.chType = 0
            yUnused, yType, yChapterType = chTypeEncoding[prjChp.chType]

            i = 1
            i = set_element(xmlChapter, 'Title', prjChp.title, i)
            i = set_element(xmlChapter, 'Desc', prjChp.desc, i)

            if yUnused:
                if xmlChapter.find('Unused') is None:
                    elem = ET.Element('Unused')
                    elem.text = '-1'
                    xmlChapter.insert(i, elem)
            elif xmlChapter.find('Unused') is not None:
                xmlChapter.remove(xmlChapter.find('Unused'))
            if xmlChapter.find('Unused') is not None:
                i += 1
            try:
                xmlChapter.remove(xmlChapter.find('SortOrder'))
            except:
                pass

            #--- Write chapter fields.
            xmlChapterFields = xmlChapter.find('Fields')
            if prjChp.suppressChapterTitle:
                if xmlChapterFields is None:
                    xmlChapterFields = ET.Element('Fields')
                    xmlChapter.insert(i, xmlChapterFields)
                try:
                    xmlChapterFields.find('Field_SuppressChapterTitle').text = '1'
                except(AttributeError):
                    ET.SubElement(xmlChapterFields, 'Field_SuppressChapterTitle').text = '1'
            elif xmlChapterFields is not None:
                if xmlChapterFields.find('Field_SuppressChapterTitle') is not None:
                    xmlChapterFields.find('Field_SuppressChapterTitle').text = '0'

            if prjChp.suppressChapterBreak:
                if xmlChapterFields is None:
                    xmlChapterFields = ET.Element('Fields')
                    xmlChapter.insert(i, xmlChapterFields)
                try:
                    xmlChapterFields.find('Field_SuppressChapterBreak').text = '1'
                except(AttributeError):
                    ET.SubElement(xmlChapterFields, 'Field_SuppressChapterBreak').text = '1'
            elif xmlChapterFields is not None:
                if xmlChapterFields.find('Field_SuppressChapterBreak') is not None:
                    xmlChapterFields.find('Field_SuppressChapterBreak').text = '0'

            if prjChp.isTrash:
                if xmlChapterFields is None:
                    xmlChapterFields = ET.Element('Fields')
                    xmlChapter.insert(i, xmlChapterFields)
                try:
                    xmlChapterFields.find('Field_IsTrash').text = '1'
                except(AttributeError):
                    ET.SubElement(xmlChapterFields, 'Field_IsTrash').text = '1'

            elif xmlChapterFields is not None:
                if xmlChapterFields.find('Field_IsTrash') is not None:
                    xmlChapterFields.remove(xmlChapterFields.find('Field_IsTrash'))

            #--- Write chapter custom fields.
            for field in self.CHP_KWVAR:
                if prjChp.kwVar.get(field, None):
                    if xmlChapterFields is None:
                        xmlChapterFields = ET.Element('Fields')
                        xmlChapter.insert(i, xmlChapterFields)
                    fieldEntry = self._convert_from_yw(prjChp.kwVar[field])
                    try:
                        xmlChapterFields.find(field).text = fieldEntry
                    except(AttributeError):
                        ET.SubElement(xmlChapterFields, field).text = fieldEntry
                elif xmlChapterFields is not None:
                    try:
                        xmlChapterFields.remove(xmlChapterFields.find(field))
                    except:
                        pass
            if xmlChapter.find('Fields') is not None:
                i += 1

            if xmlChapter.find('SectionStart') is not None:
                if prjChp.chLevel == 0:
                    xmlChapter.remove(xmlChapter.find('SectionStart'))
            elif prjChp.chLevel == 1:
                elem = ET.Element('SectionStart')
                elem.text = '-1'
                xmlChapter.insert(i, elem)
            if xmlChapter.find('SectionStart') is not None:
                i += 1

            i = set_element(xmlChapter, 'Type', yType, i)
            i = set_element(xmlChapter, 'ChapterType', yChapterType, i)

            #--- Rebuild the chapter's scene list.
            xmlScnList = xmlChapter.find('Scenes')
            if xmlScnList is not None:
                xmlChapter.remove(xmlScnList)

            # Rebuild the Scenes section in a modified sort order.
            if prjChp.srtScenes:
                xmlScnList = ET.SubElement(xmlChapter, 'Scenes')
                for scId in prjChp.srtScenes:
                    ET.SubElement(xmlScnList, 'ScID').text = scId

        def build_location_subtree(xmlLoc, prjLoc):
            if prjLoc.title is not None:
                ET.SubElement(xmlLoc, 'Title').text = prjLoc.title

            if prjLoc.image is not None:
                ET.SubElement(xmlLoc, 'ImageFile').text = prjLoc.image

            if prjLoc.desc is not None:
                ET.SubElement(xmlLoc, 'Desc').text = prjLoc.desc

            if prjLoc.aka is not None:
                ET.SubElement(xmlLoc, 'AKA').text = prjLoc.aka

            if prjLoc.tags is not None:
                ET.SubElement(xmlLoc, 'Tags').text = list_to_string(prjLoc.tags)

            #--- Write location custom fields.
            xmlLocationFields = xmlLoc.find('Fields')
            for field in self.LOC_KWVAR:
                if prjLoc.kwVar.get(field, None):
                    if xmlLocationFields is None:
                        xmlLocationFields = ET.SubElement(xmlLoc, 'Fields')
                    fieldEntry = self._convert_from_yw(prjLoc.kwVar[field])
                    try:
                        xmlLocationFields.find(field).text = fieldEntry
                    except(AttributeError):
                        ET.SubElement(xmlLocationFields, field).text = fieldEntry
                elif xmlLocationFields is not None:
                    try:
                        xmlLocationFields.remove(xmlLocationFields.find(field))
                    except:
                        pass
            try:
                xmlLoc.remove(xmlLoc.find('SortOrder'))
            except:
                pass

        def build_prjNote_subtree(xmlProjectnote, projectNote):
            if projectNote.title is not None:
                ET.SubElement(xmlProjectnote, 'Title').text = projectNote.title

            if projectNote.desc is not None:
                ET.SubElement(xmlProjectnote, 'Desc').text = projectNote.desc

        def add_projectvariable(title, desc, tags):
            # Note:
            # prjVars, xmlProjectvars are caller's variables
            pvId = create_id(prjVars)
            prjVars.append(pvId)
            # side effect
            xmlProjectvar = ET.SubElement(xmlProjectvars, 'PROJECTVAR')
            ET.SubElement(xmlProjectvar, 'ID').text = pvId
            ET.SubElement(xmlProjectvar, 'Title').text = title
            ET.SubElement(xmlProjectvar, 'Desc').text = desc
            ET.SubElement(xmlProjectvar, 'Tags').text = tags

        def build_item_subtree(xmlItm, prjItm):
            if prjItm.title is not None:
                ET.SubElement(xmlItm, 'Title').text = prjItm.title

            if prjItm.image is not None:
                ET.SubElement(xmlItm, 'ImageFile').text = prjItm.image

            if prjItm.desc is not None:
                ET.SubElement(xmlItm, 'Desc').text = prjItm.desc

            if prjItm.aka is not None:
                ET.SubElement(xmlItm, 'AKA').text = prjItm.aka

            if prjItm.tags is not None:
                ET.SubElement(xmlItm, 'Tags').text = list_to_string(prjItm.tags)

            #--- Write item custom fields.
            xmlItemFields = xmlItm.find('Fields')
            for field in self.ITM_KWVAR:
                if prjItm.kwVar.get(field, None):
                    if xmlItemFields is None:
                        xmlItemFields = ET.SubElement(xmlItm, 'Fields')
                    fieldEntry = self._convert_from_yw(prjItm.kwVar[field])
                    try:
                        xmlItemFields.find(field).text = fieldEntry
                    except(AttributeError):
                        ET.SubElement(xmlItemFields, field).text = fieldEntry
                elif xmlItemFields is not None:
                    try:
                        xmlItemFields.remove(xmlItemFields.find(field))
                    except:
                        pass
            try:
                xmlItm.remove(xmlItm.find('SortOrder'))
            except:
                pass

        def build_character_subtree(xmlCrt, prjCrt):
            if prjCrt.title is not None:
                ET.SubElement(xmlCrt, 'Title').text = prjCrt.title

            if prjCrt.desc is not None:
                ET.SubElement(xmlCrt, 'Desc').text = prjCrt.desc

            if prjCrt.image is not None:
                ET.SubElement(xmlCrt, 'ImageFile').text = prjCrt.image

            if prjCrt.notes is not None:
                ET.SubElement(xmlCrt, 'Notes').text = prjCrt.notes

            if prjCrt.aka is not None:
                ET.SubElement(xmlCrt, 'AKA').text = prjCrt.aka

            if prjCrt.tags is not None:
                ET.SubElement(xmlCrt, 'Tags').text = list_to_string(prjCrt.tags)

            if prjCrt.bio is not None:
                ET.SubElement(xmlCrt, 'Bio').text = prjCrt.bio

            if prjCrt.goals is not None:
                ET.SubElement(xmlCrt, 'Goals').text = prjCrt.goals

            if prjCrt.fullName is not None:
                ET.SubElement(xmlCrt, 'FullName').text = prjCrt.fullName

            if prjCrt.isMajor:
                ET.SubElement(xmlCrt, 'Major').text = '-1'

            #--- Write character custom fields.
            xmlCharacterFields = xmlCrt.find('Fields')
            for field in self.CRT_KWVAR:
                if prjCrt.kwVar.get(field, None):
                    if xmlCharacterFields is None:
                        xmlCharacterFields = ET.SubElement(xmlCrt, 'Fields')
                    fieldEntry = self._convert_from_yw(prjCrt.kwVar[field])
                    try:
                        xmlCharacterFields.find(field).text = fieldEntry
                    except(AttributeError):
                        ET.SubElement(xmlCharacterFields, field).text = fieldEntry
                elif xmlCharacterFields is not None:
                    try:
                        xmlCharacterFields.remove(xmlCharacterFields.find(field))
                    except:
                        pass
            try:
                xmlCrt.remove(xmlCrt.find('SortOrder'))
            except:
                pass

        def build_project_subtree(xmlProject):
            VER = '7'
            try:
                xmlProject.find('Ver').text = VER
            except(AttributeError):
                ET.SubElement(xmlProject, 'Ver').text = VER

            if self.novel.title is not None:
                try:
                    xmlProject.find('Title').text = self.novel.title
                except(AttributeError):
                    ET.SubElement(xmlProject, 'Title').text = self.novel.title

            if self.novel.desc is not None:
                try:
                    xmlProject.find('Desc').text = self.novel.desc
                except(AttributeError):
                    ET.SubElement(xmlProject, 'Desc').text = self.novel.desc

            if self.novel.authorName is not None:
                try:
                    xmlProject.find('AuthorName').text = self.novel.authorName
                except(AttributeError):
                    ET.SubElement(xmlProject, 'AuthorName').text = self.novel.authorName

            if self.novel.authorBio is not None:
                try:
                    xmlProject.find('Bio').text = self.novel.authorBio
                except(AttributeError):
                    ET.SubElement(xmlProject, 'Bio').text = self.novel.authorBio

            if self.novel.fieldTitle1 is not None:
                try:
                    xmlProject.find('FieldTitle1').text = self.novel.fieldTitle1
                except(AttributeError):
                    ET.SubElement(xmlProject, 'FieldTitle1').text = self.novel.fieldTitle1

            if self.novel.fieldTitle2 is not None:
                try:
                    xmlProject.find('FieldTitle2').text = self.novel.fieldTitle2
                except(AttributeError):
                    ET.SubElement(xmlProject, 'FieldTitle2').text = self.novel.fieldTitle2

            if self.novel.fieldTitle3 is not None:
                try:
                    xmlProject.find('FieldTitle3').text = self.novel.fieldTitle3
                except(AttributeError):
                    ET.SubElement(xmlProject, 'FieldTitle3').text = self.novel.fieldTitle3

            if self.novel.fieldTitle4 is not None:
                try:
                    xmlProject.find('FieldTitle4').text = self.novel.fieldTitle4
                except(AttributeError):
                    ET.SubElement(xmlProject, 'FieldTitle4').text = self.novel.fieldTitle4

            #--- Write word target data.
            if self.novel.wordCountStart is not None:
                try:
                    xmlProject.find('WordCountStart').text = str(self.novel.wordCountStart)
                except(AttributeError):
                    ET.SubElement(xmlProject, 'WordCountStart').text = str(self.novel.wordCountStart)

            if self.novel.wordTarget is not None:
                try:
                    xmlProject.find('WordTarget').text = str(self.novel.wordTarget)
                except(AttributeError):
                    ET.SubElement(xmlProject, 'WordTarget').text = str(self.novel.wordTarget)

            #--- Write project custom fields.

            # This is for projects written with v7.6 - v7.10:
            self.novel.kwVar['Field_LanguageCode'] = None
            self.novel.kwVar['Field_CountryCode'] = None

            xmlProjectFields = xmlProject.find('Fields')
            for field in self.PRJ_KWVAR:
                setting = self.novel.kwVar.get(field, None)
                if setting:
                    if xmlProjectFields is None:
                        xmlProjectFields = ET.SubElement(xmlProject, 'Fields')
                    fieldEntry = self._convert_from_yw(setting)
                    try:
                        xmlProjectFields.find(field).text = fieldEntry
                    except(AttributeError):
                        ET.SubElement(xmlProjectFields, field).text = fieldEntry
                else:
                    try:
                        xmlProjectFields.remove(xmlProjectFields.find(field))
                    except:
                        pass
            try:
                xmlProject.remove(xmlProject.find('SavedWith'))
            except:
                pass
            try:
                xmlProject.remove(xmlProject.find('SavedOn'))
            except:
                pass

        TAG = 'YWRITER7'
        xmlNewScenes = {}
        xmlNewChapters = {}
        try:
            # Try processing an existing tree.
            root = self.tree.getroot()
            xmlProject = root.find('PROJECT')
            xmlLocations = root.find('LOCATIONS')
            xmlItems = root.find('ITEMS')
            xmlCharacters = root.find('CHARACTERS')
            xmlProjectnotes = root.find('PROJECTNOTES')
            xmlScenes = root.find('SCENES')
            xmlChapters = root.find('CHAPTERS')
        except(AttributeError):
            # Build a new tree.
            root = ET.Element(TAG)
            xmlProject = ET.SubElement(root, 'PROJECT')
            xmlLocations = ET.SubElement(root, 'LOCATIONS')
            xmlItems = ET.SubElement(root, 'ITEMS')
            xmlCharacters = ET.SubElement(root, 'CHARACTERS')
            xmlProjectnotes = ET.SubElement(root, 'PROJECTNOTES')
            xmlScenes = ET.SubElement(root, 'SCENES')
            xmlChapters = ET.SubElement(root, 'CHAPTERS')

        #--- Process project attributes.

        build_project_subtree(xmlProject)

        #--- Process Locations.

        # Remove LOCATION entries in order to rewrite
        # the LOCATIONS section in a modified sort order.
        for xmlLoc in xmlLocations.findall('LOCATION'):
            xmlLocations.remove(xmlLoc)

        # Add the new XML location subtrees to the project tree.
        for lcId in self.novel.srtLocations:
            xmlLoc = ET.SubElement(xmlLocations, 'LOCATION')
            ET.SubElement(xmlLoc, 'ID').text = lcId
            build_location_subtree(xmlLoc, self.novel.locations[lcId])

        #--- Process Items.

        # Remove ITEM entries in order to rewrite
        # the ITEMS section in a modified sort order.
        for xmlItm in xmlItems.findall('ITEM'):
            xmlItems.remove(xmlItm)

        # Add the new XML item subtrees to the project tree.
        for itId in self.novel.srtItems:
            xmlItm = ET.SubElement(xmlItems, 'ITEM')
            ET.SubElement(xmlItm, 'ID').text = itId
            build_item_subtree(xmlItm, self.novel.items[itId])

        #--- Process Characters.

        # Remove CHARACTER entries in order to rewrite
        # the CHARACTERS section in a modified sort order.
        for xmlCrt in xmlCharacters.findall('CHARACTER'):
            xmlCharacters.remove(xmlCrt)

        # Add the new XML character subtrees to the project tree.
        for crId in self.novel.srtCharacters:
            xmlCrt = ET.SubElement(xmlCharacters, 'CHARACTER')
            ET.SubElement(xmlCrt, 'ID').text = crId
            build_character_subtree(xmlCrt, self.novel.characters[crId])

        #--- Process project notes.

        # Remove PROJECTNOTE entries in order to rewrite
        # the PROJECTNOTES section in a modified sort order.
        if xmlProjectnotes is not None:
            for xmlProjectnote in xmlProjectnotes.findall('PROJECTNOTE'):
                xmlProjectnotes.remove(xmlProjectnote)
            if not self.novel.srtPrjNotes:
                root.remove(xmlProjectnotes)
        elif self.novel.srtPrjNotes:
            xmlProjectnotes = ET.SubElement(root, 'PROJECTNOTES')
        if self.novel.srtPrjNotes:
            # Add the new XML prjNote subtrees to the project tree.
            for pnId in self.novel.srtPrjNotes:
                xmlProjectnote = ET.SubElement(xmlProjectnotes, 'PROJECTNOTE')
                ET.SubElement(xmlProjectnote, 'ID').text = pnId
                build_prjNote_subtree(xmlProjectnote, self.novel.projectNotes[pnId])

        #--- Process project variables.
        xmlProjectvars = root.find('PROJECTVARS')
        if self.novel.languages or self.novel.languageCode or self.novel.countryCode:
            self.novel.check_locale()
            if xmlProjectvars is None:
                xmlProjectvars = ET.SubElement(root, 'PROJECTVARS')
            prjVars = []
            # list of all project variable IDs
            languages = self.novel.languages.copy()
            hasLanguageCode = False
            hasCountryCode = False
            for xmlProjectvar in xmlProjectvars.findall('PROJECTVAR'):
                prjVars.append(xmlProjectvar.find('ID').text)
                title = xmlProjectvar.find('Title').text

                # Collect language codes.
                if title.startswith('lang='):
                    try:
                        __, langCode = title.split('=')
                        languages.remove(langCode)
                    except:
                        pass

                # Get the document's locale.
                elif title == 'Language':
                    xmlProjectvar.find('Desc').text = self.novel.languageCode
                    hasLanguageCode = True

                elif title == 'Country':
                    xmlProjectvar.find('Desc').text = self.novel.countryCode
                    hasCountryCode = True

            # Define project variables for the missing locale.
            if not hasLanguageCode:
                add_projectvariable('Language',
                                    self.novel.languageCode,
                                    '0')

            if not hasCountryCode:
                add_projectvariable('Country',
                                    self.novel.countryCode,
                                    '0')

            # Define project variables for the missing language code tags.
            for langCode in languages:
                add_projectvariable(f'lang={langCode}',
                                    f'<HTM <SPAN LANG="{langCode}"> /HTM>',
                                    '0')
                add_projectvariable(f'/lang={langCode}',
                                    f'<HTM </SPAN> /HTM>',
                                    '0')
                # adding new IDs to the prjVars list

        #--- Process scenes.

        # Save the original XML scene subtrees
        # and remove them from the project tree.
        for xmlScene in xmlScenes.findall('SCENE'):
            scId = xmlScene.find('ID').text
            xmlNewScenes[scId] = xmlScene
            xmlScenes.remove(xmlScene)

        # Add the new XML scene subtrees to the project tree.
        for scId in self.novel.scenes:
            if not scId in xmlNewScenes:
                xmlNewScenes[scId] = ET.Element('SCENE')
                ET.SubElement(xmlNewScenes[scId], 'ID').text = scId
            build_scene_subtree(xmlNewScenes[scId], self.novel.scenes[scId])
            xmlScenes.append(xmlNewScenes[scId])

        #--- Process chapters.

        # Save the original XML chapter subtree
        # and remove it from the project tree.
        for xmlChapter in xmlChapters.findall('CHAPTER'):
            chId = xmlChapter.find('ID').text
            xmlNewChapters[chId] = xmlChapter
            xmlChapters.remove(xmlChapter)

        # Add the new XML chapter subtrees to the project tree.
        for chId in self.novel.srtChapters:
            if not chId in xmlNewChapters:
                xmlNewChapters[chId] = ET.Element('CHAPTER')
                ET.SubElement(xmlNewChapters[chId], 'ID').text = chId
            build_chapter_subtree(xmlNewChapters[chId], self.novel.chapters[chId])
            xmlChapters.append(xmlNewChapters[chId])

        # Modify the scene contents of an existing xml element tree.
        for xmlScene in root.find('SCENES'):
            scId = xmlScene.find('ID').text
            if self.novel.scenes[scId].sceneContent is not None:
                xmlScene.find('SceneContent').text = self.novel.scenes[scId].sceneContent
            try:
                xmlScene.remove(xmlScene.find('WordCount'))
            except:
                pass
            try:
                xmlScene.remove(xmlScene.find('LetterCount'))
            except:
                pass
            try:
                xmlScene.remove(xmlScene.find('RTFFile'))
            except:
                pass
            try:
                xmlScene.remove(xmlScene.find('BelongsToChID'))
            except:
                pass

        indent(root)
        self.tree = ET.ElementTree(root)

    def _convert_from_yw(self, text, quick=False):
        """Return text without markup, converted to target format.
        
        Positional arguments:
            text -- string to convert.
        
        Optional arguments:
            quick: bool -- if True, apply a conversion mode for one-liners without formatting.
        
        Overrides the superclass method.
        """
        if text:
            # Apply XML predefined entities.
            XML_REPLACEMENTS = [
                ('&', '&amp;'),
                ('>', '&gt;'),
                ('<', '&lt;'),
                ("'", '&apos;'),
                ('"', '&quot;'),
                ]
            for yw, xm in XML_REPLACEMENTS:
                text = text.replace(yw, xm)
        else:
            text = ''
        return text

    def _postprocess_xml_file(self, filePath):
        """Postprocess an xml file created by ElementTree.
        
        Positional argument:
            filePath: str -- path to xml file.
        
        Read the xml file, put a header on top, insert the missing CDATA tags,
        and replace xml entities by plain text (unescape). Overwrite the .yw7 xml file.
        Raise the "Error" exception in case of error. 
        
        Note: The path is given as an argument rather than using self.filePath. 
        So this routine can be used for yWriter-generated xml files other than .yw7 as well. 
        """
        with open(filePath, 'r', encoding='utf-8') as f:
            text = f.read()
        lines = text.split('\n')
        newlines = ['<?xml version="1.0" encoding="utf-8"?>']
        for line in lines:
            for tag in self._CDATA_TAGS:
                line = re.sub(fr'\<{tag}\>', f'<{tag}><![CDATA[', line)
                line = re.sub(fr'\<\/{tag}\>', f']]></{tag}>', line)
            newlines.append(line)
        text = '\n'.join(newlines)
        text = text.replace('[CDATA[ \n', '[CDATA[')
        text = text.replace('\n]]', ']]')
        if not self.novel.chapters:
            text = text.replace('<CHAPTERS />', '<CHAPTERS></CHAPTERS>')
            # otherwise, yWriter fails to parse the file if there are no chapters.
        text = unescape(text)
        try:
            with open(filePath, 'w', encoding='utf-8') as f:
                f.write(text)
        except:
            raise Error(f'{_("Cannot write file")}: "{norm_path(filePath)}".')

    def _read_project(self, root):
        """Read attributes at project level from the xml element tree."""
        xmlProject = root.find('PROJECT')

        if xmlProject.find('Title') is not None:
            self.novel.title = xmlProject.find('Title').text

        if xmlProject.find('AuthorName') is not None:
            self.novel.authorName = xmlProject.find('AuthorName').text

        if xmlProject.find('Bio') is not None:
            self.novel.authorBio = xmlProject.find('Bio').text

        if xmlProject.find('Desc') is not None:
            self.novel.desc = xmlProject.find('Desc').text

        if xmlProject.find('FieldTitle1') is not None:
            self.novel.fieldTitle1 = xmlProject.find('FieldTitle1').text

        if xmlProject.find('FieldTitle2') is not None:
            self.novel.fieldTitle2 = xmlProject.find('FieldTitle2').text

        if xmlProject.find('FieldTitle3') is not None:
            self.novel.fieldTitle3 = xmlProject.find('FieldTitle3').text

        if xmlProject.find('FieldTitle4') is not None:
            self.novel.fieldTitle4 = xmlProject.find('FieldTitle4').text

        #--- Read word target data.
        if xmlProject.find('WordCountStart') is not None:
            try:
                self.novel.wordCountStart = int(xmlProject.find('WordCountStart').text)
            except:
                self.novel.wordCountStart = 0
        if xmlProject.find('WordTarget') is not None:
            try:
                self.novel.wordTarget = int(xmlProject.find('WordTarget').text)
            except:
                self.novel.wordTarget = 0

        #--- Initialize custom keyword variables.
        for fieldName in self.PRJ_KWVAR:
            self.novel.kwVar[fieldName] = None

        #--- Read project custom fields.
        for xmlProjectFields in xmlProject.findall('Fields'):
            for fieldName in self.PRJ_KWVAR:
                field = xmlProjectFields.find(fieldName)
                if field is not None:
                    self.novel.kwVar[fieldName] = field.text

        # This is for projects written with v7.6 - v7.10:
        if self.novel.kwVar['Field_LanguageCode']:
            self.novel.languageCode = self.novel.kwVar['Field_LanguageCode']
        if self.novel.kwVar['Field_CountryCode']:
            self.novel.countryCode = self.novel.kwVar['Field_CountryCode']

    def _read_locations(self, root):
        """Read locations from the xml element tree."""
        self.novel.srtLocations = []
        # This is necessary for re-reading.
        for xmlLocation in root.find('LOCATIONS'):
            lcId = xmlLocation.find('ID').text
            self.novel.srtLocations.append(lcId)
            self.novel.locations[lcId] = WorldElement()

            if xmlLocation.find('Title') is not None:
                self.novel.locations[lcId].title = xmlLocation.find('Title').text

            if xmlLocation.find('ImageFile') is not None:
                self.novel.locations[lcId].image = xmlLocation.find('ImageFile').text

            if xmlLocation.find('Desc') is not None:
                self.novel.locations[lcId].desc = xmlLocation.find('Desc').text

            if xmlLocation.find('AKA') is not None:
                self.novel.locations[lcId].aka = xmlLocation.find('AKA').text

            if xmlLocation.find('Tags') is not None:
                if xmlLocation.find('Tags').text is not None:
                    tags = string_to_list(xmlLocation.find('Tags').text)
                    self.novel.locations[lcId].tags = self._strip_spaces(tags)

            #--- Initialize custom keyword variables.
            for fieldName in self.LOC_KWVAR:
                self.novel.locations[lcId].kwVar[fieldName] = None

            #--- Read location custom fields.
            for xmlLocationFields in xmlLocation.findall('Fields'):
                for fieldName in self.LOC_KWVAR:
                    field = xmlLocationFields.find(fieldName)
                    if field is not None:
                        self.novel.locations[lcId].kwVar[fieldName] = field.text

    def _read_items(self, root):
        """Read items from the xml element tree."""
        self.novel.srtItems = []
        # This is necessary for re-reading.
        for xmlItem in root.find('ITEMS'):
            itId = xmlItem.find('ID').text
            self.novel.srtItems.append(itId)
            self.novel.items[itId] = WorldElement()

            if xmlItem.find('Title') is not None:
                self.novel.items[itId].title = xmlItem.find('Title').text

            if xmlItem.find('ImageFile') is not None:
                self.novel.items[itId].image = xmlItem.find('ImageFile').text

            if xmlItem.find('Desc') is not None:
                self.novel.items[itId].desc = xmlItem.find('Desc').text

            if xmlItem.find('AKA') is not None:
                self.novel.items[itId].aka = xmlItem.find('AKA').text

            if xmlItem.find('Tags') is not None:
                if xmlItem.find('Tags').text is not None:
                    tags = string_to_list(xmlItem.find('Tags').text)
                    self.novel.items[itId].tags = self._strip_spaces(tags)

            #--- Initialize custom keyword variables.
            for fieldName in self.ITM_KWVAR:
                self.novel.items[itId].kwVar[fieldName] = None

            #--- Read item custom fields.
            for xmlItemFields in xmlItem.findall('Fields'):
                for fieldName in self.ITM_KWVAR:
                    field = xmlItemFields.find(fieldName)
                    if field is not None:
                        self.novel.items[itId].kwVar[fieldName] = field.text

    def _read_characters(self, root):
        """Read characters from the xml element tree."""
        self.novel.srtCharacters = []
        # This is necessary for re-reading.
        for xmlCharacter in root.find('CHARACTERS'):
            crId = xmlCharacter.find('ID').text
            self.novel.srtCharacters.append(crId)
            self.novel.characters[crId] = Character()

            if xmlCharacter.find('Title') is not None:
                self.novel.characters[crId].title = xmlCharacter.find('Title').text

            if xmlCharacter.find('ImageFile') is not None:
                self.novel.characters[crId].image = xmlCharacter.find('ImageFile').text

            if xmlCharacter.find('Desc') is not None:
                self.novel.characters[crId].desc = xmlCharacter.find('Desc').text

            if xmlCharacter.find('AKA') is not None:
                self.novel.characters[crId].aka = xmlCharacter.find('AKA').text

            if xmlCharacter.find('Tags') is not None:
                if xmlCharacter.find('Tags').text is not None:
                    tags = string_to_list(xmlCharacter.find('Tags').text)
                    self.novel.characters[crId].tags = self._strip_spaces(tags)

            if xmlCharacter.find('Notes') is not None:
                self.novel.characters[crId].notes = xmlCharacter.find('Notes').text

            if xmlCharacter.find('Bio') is not None:
                self.novel.characters[crId].bio = xmlCharacter.find('Bio').text

            if xmlCharacter.find('Goals') is not None:
                self.novel.characters[crId].goals = xmlCharacter.find('Goals').text

            if xmlCharacter.find('FullName') is not None:
                self.novel.characters[crId].fullName = xmlCharacter.find('FullName').text

            if xmlCharacter.find('Major') is not None:
                self.novel.characters[crId].isMajor = True
            else:
                self.novel.characters[crId].isMajor = False

            #--- Initialize custom keyword variables.
            for fieldName in self.CRT_KWVAR:
                self.novel.characters[crId].kwVar[fieldName] = None

            #--- Read character custom fields.
            for xmlCharacterFields in xmlCharacter.findall('Fields'):
                for fieldName in self.CRT_KWVAR:
                    field = xmlCharacterFields.find(fieldName)
                    if field is not None:
                        self.novel.characters[crId].kwVar[fieldName] = field.text

    def _read_projectnotes(self, root):
        """Read project notes from the xml element tree."""
        self.novel.srtPrjNotes = []
        # This is necessary for re-reading.

        try:
            for xmlProjectnote in root.find('PROJECTNOTES'):
                if xmlProjectnote.find('ID') is not None:
                    pnId = xmlProjectnote.find('ID').text
                    self.novel.srtPrjNotes.append(pnId)
                    self.novel.projectNotes[pnId] = BasicElement()
                    if xmlProjectnote.find('Title') is not None:
                        self.novel.projectNotes[pnId].title = xmlProjectnote.find('Title').text
                    if xmlProjectnote.find('Desc') is not None:
                        self.novel.projectNotes[pnId].desc = xmlProjectnote.find('Desc').text

                #--- Initialize project note custom fields.
                for fieldName in self.PNT_KWVAR:
                    self.novel.projectNotes[pnId].kwVar[fieldName] = None

                #--- Read project note custom fields.
                for pnFields in xmlProjectnote.findall('Fields'):
                    field = pnFields.find(fieldName)
                    if field is not None:
                        self.novel.projectNotes[pnId].kwVar[fieldName] = field.text
        except:
            pass

    def _read_projectvars(self, root):
        """Read relevant project variables from the xml element tree."""
        try:
            for xmlProjectvar in root.find('PROJECTVARS'):
                if xmlProjectvar.find('Title') is not None:
                    title = xmlProjectvar.find('Title').text
                    if title == 'Language':
                        if xmlProjectvar.find('Desc') is not None:
                            self.novel.languageCode = xmlProjectvar.find('Desc').text

                    elif title == 'Country':
                        if xmlProjectvar.find('Desc') is not None:
                            self.novel.countryCode = xmlProjectvar.find('Desc').text

                    elif title.startswith('lang='):
                        try:
                            __, langCode = title.split('=')
                            if self.novel.languages is None:
                                self.novel.languages = []
                            self.novel.languages.append(langCode)
                        except:
                            pass
        except:
            pass

    def _read_scenes(self, root):
        """ Read attributes at scene level from the xml element tree."""
        for xmlScene in root.find('SCENES'):
            scId = xmlScene.find('ID').text
            self.novel.scenes[scId] = Scene()

            if xmlScene.find('Title') is not None:
                self.novel.scenes[scId].title = xmlScene.find('Title').text

            if xmlScene.find('Desc') is not None:
                self.novel.scenes[scId].desc = xmlScene.find('Desc').text

            if xmlScene.find('SceneContent') is not None:
                sceneContent = xmlScene.find('SceneContent').text
                if sceneContent is not None:
                    self.novel.scenes[scId].sceneContent = sceneContent

            #--- Read scene type.

            # This is how yWriter 7.1.3.0 reads the scene type:
            #
            # Type   |<Unused>|Field_SceneType>|scType
            #--------+--------+----------------+------
            # Notes  | x      | 1              | 1
            # Todo   | x      | 2              | 2
            # Unused | -1     | N/A            | 3
            # Unused | -1     | 0              | 3
            # Normal | N/A    | N/A            | 0
            # Normal | N/A    | 0              | 0

            self.novel.scenes[scId].scType = 0

            #--- Initialize custom keyword variables.
            for fieldName in self.SCN_KWVAR:
                self.novel.scenes[scId].kwVar[fieldName] = None

            for xmlSceneFields in xmlScene.findall('Fields'):
                #--- Read scene custom fields.
                for fieldName in self.SCN_KWVAR:
                    field = xmlSceneFields.find(fieldName)
                    if field is not None:
                        self.novel.scenes[scId].kwVar[fieldName] = field.text

                # Read scene type, if any.
                if xmlSceneFields.find('Field_SceneType') is not None:
                    if xmlSceneFields.find('Field_SceneType').text == '1':
                        self.novel.scenes[scId].scType = 1
                    elif xmlSceneFields.find('Field_SceneType').text == '2':
                        self.novel.scenes[scId].scType = 2
            if xmlScene.find('Unused') is not None:
                if self.novel.scenes[scId].scType == 0:
                    self.novel.scenes[scId].scType = 3

            # Export when RTF.
            if xmlScene.find('ExportCondSpecific') is None:
                self.novel.scenes[scId].doNotExport = False
            elif xmlScene.find('ExportWhenRTF') is not None:
                self.novel.scenes[scId].doNotExport = False
            else:
                self.novel.scenes[scId].doNotExport = True

            if xmlScene.find('Status') is not None:
                self.novel.scenes[scId].status = int(xmlScene.find('Status').text)

            if xmlScene.find('Notes') is not None:
                self.novel.scenes[scId].notes = xmlScene.find('Notes').text

            if xmlScene.find('Tags') is not None:
                if xmlScene.find('Tags').text is not None:
                    tags = string_to_list(xmlScene.find('Tags').text)
                    self.novel.scenes[scId].tags = self._strip_spaces(tags)

            if xmlScene.find('Field1') is not None:
                self.novel.scenes[scId].field1 = xmlScene.find('Field1').text

            if xmlScene.find('Field2') is not None:
                self.novel.scenes[scId].field2 = xmlScene.find('Field2').text

            if xmlScene.find('Field3') is not None:
                self.novel.scenes[scId].field3 = xmlScene.find('Field3').text

            if xmlScene.find('Field4') is not None:
                self.novel.scenes[scId].field4 = xmlScene.find('Field4').text

            if xmlScene.find('AppendToPrev') is not None:
                self.novel.scenes[scId].appendToPrev = True
            else:
                self.novel.scenes[scId].appendToPrev = False

            #--- Scene start.
            if xmlScene.find('SpecificDateTime') is not None:
                dateTimeStr = xmlScene.find('SpecificDateTime').text

                # Check SpecificDateTime for ISO compliance.
                try:
                    dateTime = datetime.fromisoformat(dateTimeStr)
                except:
                    self.novel.scenes[scId].date = ''
                    self.novel.scenes[scId].time = ''
                else:
                    startDateTime = dateTime.isoformat().split('T')
                    self.novel.scenes[scId].date = startDateTime[0]
                    self.novel.scenes[scId].time = startDateTime[1]
            else:
                if xmlScene.find('Day') is not None:
                    day = xmlScene.find('Day').text

                    # Check if Day represents an integer.
                    try:
                        int(day)
                    except ValueError:
                        day = ''
                    self.novel.scenes[scId].day = day

                hasUnspecificTime = False
                if xmlScene.find('Hour') is not None:
                    hour = xmlScene.find('Hour').text.zfill(2)
                    hasUnspecificTime = True
                else:
                    hour = '00'
                if xmlScene.find('Minute') is not None:
                    minute = xmlScene.find('Minute').text.zfill(2)
                    hasUnspecificTime = True
                else:
                    minute = '00'
                if hasUnspecificTime:
                    self.novel.scenes[scId].time = f'{hour}:{minute}:00'

            #--- Scene duration.
            if xmlScene.find('LastsDays') is not None:
                self.novel.scenes[scId].lastsDays = xmlScene.find('LastsDays').text

            if xmlScene.find('LastsHours') is not None:
                self.novel.scenes[scId].lastsHours = xmlScene.find('LastsHours').text

            if xmlScene.find('LastsMinutes') is not None:
                self.novel.scenes[scId].lastsMinutes = xmlScene.find('LastsMinutes').text

            if xmlScene.find('ReactionScene') is not None:
                self.novel.scenes[scId].isReactionScene = True
            else:
                self.novel.scenes[scId].isReactionScene = False

            if xmlScene.find('SubPlot') is not None:
                self.novel.scenes[scId].isSubPlot = True
            else:
                self.novel.scenes[scId].isSubPlot = False

            if xmlScene.find('Goal') is not None:
                self.novel.scenes[scId].goal = xmlScene.find('Goal').text

            if xmlScene.find('Conflict') is not None:
                self.novel.scenes[scId].conflict = xmlScene.find('Conflict').text

            if xmlScene.find('Outcome') is not None:
                self.novel.scenes[scId].outcome = xmlScene.find('Outcome').text

            if xmlScene.find('ImageFile') is not None:
                self.novel.scenes[scId].image = xmlScene.find('ImageFile').text

            if xmlScene.find('Characters') is not None:
                for characters in xmlScene.find('Characters').iter('CharID'):
                    crId = characters.text
                    if crId in self.novel.srtCharacters:
                        if self.novel.scenes[scId].characters is None:
                            self.novel.scenes[scId].characters = []
                        self.novel.scenes[scId].characters.append(crId)

            if xmlScene.find('Locations') is not None:
                for locations in xmlScene.find('Locations').iter('LocID'):
                    lcId = locations.text
                    if lcId in self.novel.srtLocations:
                        if self.novel.scenes[scId].locations is None:
                            self.novel.scenes[scId].locations = []
                        self.novel.scenes[scId].locations.append(lcId)

            if xmlScene.find('Items') is not None:
                for items in xmlScene.find('Items').iter('ItemID'):
                    itId = items.text
                    if itId in self.novel.srtItems:
                        if self.novel.scenes[scId].items is None:
                            self.novel.scenes[scId].items = []
                        self.novel.scenes[scId].items.append(itId)

    def _read_chapters(self, root):
        """Read attributes at chapter level from the xml element tree."""
        self.novel.srtChapters = []
        # This is necessary for re-reading.
        for xmlChapter in root.find('CHAPTERS'):
            chId = xmlChapter.find('ID').text
            self.novel.chapters[chId] = Chapter()
            self.novel.srtChapters.append(chId)

            if xmlChapter.find('Title') is not None:
                self.novel.chapters[chId].title = xmlChapter.find('Title').text

            if xmlChapter.find('Desc') is not None:
                self.novel.chapters[chId].desc = xmlChapter.find('Desc').text

            if xmlChapter.find('SectionStart') is not None:
                self.novel.chapters[chId].chLevel = 1
            else:
                self.novel.chapters[chId].chLevel = 0

            # This is how yWriter 7.1.3.0 reads the chapter type:
            #
            # Type   |<Unused>|<Type>|<ChapterType>|chType
            # -------+--------+------+--------------------
            # Normal | N/A    | N/A  | N/A         | 0
            # Normal | N/A    | 0    | N/A         | 0
            # Notes  | x      | 1    | N/A         | 1
            # Unused | -1     | 0    | N/A         | 3
            # Normal | N/A    | x    | 0           | 0
            # Notes  | x      | x    | 1           | 1
            # Todo   | x      | x    | 2           | 2
            # Unused | -1     | x    | x           | 3

            self.novel.chapters[chId].chType = 0
            if xmlChapter.find('Unused') is not None:
                yUnused = True
            else:
                yUnused = False
            if xmlChapter.find('ChapterType') is not None:
                # The file may be created with yWriter version 7.0.7.2+
                yChapterType = xmlChapter.find('ChapterType').text
                if yChapterType == '2':
                    self.novel.chapters[chId].chType = 2
                elif yChapterType == '1':
                    self.novel.chapters[chId].chType = 1
                elif yUnused:
                    self.novel.chapters[chId].chType = 3
            else:
                # The file may be created with a yWriter version prior to 7.0.7.2
                if xmlChapter.find('Type') is not None:
                    yType = xmlChapter.find('Type').text
                    if yType == '1':
                        self.novel.chapters[chId].chType = 1
                    elif yUnused:
                        self.novel.chapters[chId].chType = 3

            self.novel.chapters[chId].suppressChapterTitle = False
            if self.novel.chapters[chId].title is not None:
                if self.novel.chapters[chId].title.startswith('@'):
                    self.novel.chapters[chId].suppressChapterTitle = True

            #--- Initialize custom keyword variables.
            for fieldName in self.CHP_KWVAR:
                self.novel.chapters[chId].kwVar[fieldName] = None

            #--- Read chapter fields.
            for xmlChapterFields in xmlChapter.findall('Fields'):
                if xmlChapterFields.find('Field_SuppressChapterTitle') is not None:
                    if xmlChapterFields.find('Field_SuppressChapterTitle').text == '1':
                        self.novel.chapters[chId].suppressChapterTitle = True
                self.novel.chapters[chId].isTrash = False
                if xmlChapterFields.find('Field_IsTrash') is not None:
                    if xmlChapterFields.find('Field_IsTrash').text == '1':
                        self.novel.chapters[chId].isTrash = True
                self.novel.chapters[chId].suppressChapterBreak = False
                if xmlChapterFields.find('Field_SuppressChapterBreak') is not None:
                    if xmlChapterFields.find('Field_SuppressChapterBreak').text == '1':
                        self.novel.chapters[chId].suppressChapterBreak = True

                #--- Read chapter custom fields.
                for fieldName in self.CHP_KWVAR:
                    field = xmlChapterFields.find(fieldName)
                    if field is not None:
                        self.novel.chapters[chId].kwVar[fieldName] = field.text

            #--- Read chapter's scene list.
            self.novel.chapters[chId].srtScenes = []
            if xmlChapter.find('Scenes') is not None:
                for scn in xmlChapter.find('Scenes').findall('ScID'):
                    scId = scn.text
                    if scId in self.novel.scenes:
                        self.novel.chapters[chId].srtScenes.append(scId)

    def _strip_spaces(self, lines):
        """Local helper method.

        Positional argument:
            lines -- list of strings

        Return lines with leading and trailing spaces removed.
        """
        stripped = []
        for line in lines:
            stripped.append(line.strip())
        return stripped

    def _write_element_tree(self, ywProject):
        """Write back the xml element tree to a .yw7 xml file located at filePath.
        
        Raise the "Error" exception in case of error. 
        """
        backedUp = False
        if os.path.isfile(ywProject.filePath):
            try:
                os.replace(ywProject.filePath, f'{ywProject.filePath}.bak')
            except:
                raise Error(f'{_("Cannot overwrite file")}: "{norm_path(ywProject.filePath)}".')
            else:
                backedUp = True
        try:
            ywProject.tree.write(ywProject.filePath, xml_declaration=False, encoding='utf-8')
        except:
            if backedUp:
                os.replace(f'{ywProject.filePath}.bak', ywProject.filePath)
            raise Error(f'{_("Cannot write file")}: "{norm_path(ywProject.filePath)}".')




def read_html_file(filePath):
    """Open a html file being encoded utf-8 or ANSI.
    
    Return the file content in a single string. None in case of error.
    Raise the "Error" exception in case of error. 
    """
    try:
        with open(filePath, 'r', encoding='utf-8') as f:
            content = f.read()
    except:
        # HTML files exported by a word processor may be ANSI encoded.
        try:
            with open(filePath, 'r') as f:
                content = (f.read())
        except(FileNotFoundError):
            raise Error(f'{_("File not found")}: "{norm_path(filePath)}".')

    return content
from string import Template


class Filter:
    """Filter an entity (chapter/scene/character/location/item) by filter criteria.
    
    Public methods:
        accept(source, eId) -- check whether an entity matches the filter criteria.
    
    Strategy class, implementing filtering criteria for template-based export.
    This is a stub with no filter criteria specified.
    """

    def accept(self, source, eId):
        """Check whether an entity matches the filter criteria.
        
        Positional arguments:
            source -- Novel instance holding the entity to check.
            eId -- ID of the entity to check.       
        
        Return True if the entity is not to be filtered out.
        This is a stub to be overridden by subclass methods implementing filters.
        """
        return True


class FileExport(File):
    """Abstract yWriter project file exporter representation.
    
    Public methods:
        write() -- write instance variables to the export file.
    
    This class is generic and contains no conversion algorithm and no templates.
    """
    SUFFIX = ''
    _fileHeader = ''
    _partTemplate = ''
    _chapterTemplate = ''
    _notesPartTemplate = ''
    _todoPartTemplate = ''
    _notesChapterTemplate = ''
    _todoChapterTemplate = ''
    _unusedChapterTemplate = ''
    _notExportedChapterTemplate = ''
    _sceneTemplate = ''
    _firstSceneTemplate = ''
    _appendedSceneTemplate = ''
    _notesSceneTemplate = ''
    _todoSceneTemplate = ''
    _unusedSceneTemplate = ''
    _notExportedSceneTemplate = ''
    _sceneDivider = ''
    _chapterEndTemplate = ''
    _unusedChapterEndTemplate = ''
    _notExportedChapterEndTemplate = ''
    _notesChapterEndTemplate = ''
    _todoChapterEndTemplate = ''
    _characterSectionHeading = ''
    _characterTemplate = ''
    _locationSectionHeading = ''
    _locationTemplate = ''
    _itemSectionHeading = ''
    _itemTemplate = ''
    _fileFooter = ''
    _projectNoteTemplate = ''

    _DIVIDER = ', '

    def __init__(self, filePath, **kwargs):
        """Initialize filter strategy class instances.
        
        Positional arguments:
            filePath: str -- path to the file represented by the File instance.
            
        Optional arguments:
            kwargs -- keyword arguments to be used by subclasses.            

        Extends the superclass constructor.
        """
        super().__init__(filePath, **kwargs)
        self._sceneFilter = Filter()
        self._chapterFilter = Filter()
        self._characterFilter = Filter()
        self._locationFilter = Filter()
        self._itemFilter = Filter()

    def write(self):
        """Write instance variables to the export file.
        
        Create a template-based output file. 
        Return a message in case of success.
        Raise the "Error" exception in case of error. 
        """
        text = self._get_text()
        backedUp = False
        if os.path.isfile(self.filePath):
            try:
                os.replace(self.filePath, f'{self.filePath}.bak')
            except:
                raise Error(f'{_("Cannot overwrite file")}: "{norm_path(self.filePath)}".')
            else:
                backedUp = True
        try:
            with open(self.filePath, 'w', encoding='utf-8') as f:
                f.write(text)
        except:
            if backedUp:
                os.replace(f'{self.filePath}.bak', self.filePath)
            raise Error(f'{_("Cannot write file")}: "{norm_path(self.filePath)}".')

    def _get_fileHeaderMapping(self):
        """Return a mapping dictionary for the project section.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        projectTemplateMapping = dict(
            Title=self._convert_from_yw(self.novel.title, True),
            Desc=self._convert_from_yw(self.novel.desc),
            AuthorName=self._convert_from_yw(self.novel.authorName, True),
            AuthorBio=self._convert_from_yw(self.novel.authorBio, True),
            FieldTitle1=self._convert_from_yw(self.novel.fieldTitle1, True),
            FieldTitle2=self._convert_from_yw(self.novel.fieldTitle2, True),
            FieldTitle3=self._convert_from_yw(self.novel.fieldTitle3, True),
            FieldTitle4=self._convert_from_yw(self.novel.fieldTitle4, True),
            Language=self.novel.languageCode,
            Country=self.novel.countryCode,
        )
        return projectTemplateMapping

    def _convert_from_yw(self, text, quick=False):
        """Return text, converted from yw7 markup to target format.
        
        Positional arguments:
            text -- string to convert.
        
        Optional arguments:
            quick: bool -- if True, apply a conversion mode for one-liners without formatting.
        
        Overrides the superclass method.
        """
        if text is None:
            text = ''
        return(text)

    def _get_chapterMapping(self, chId, chapterNumber):
        """Return a mapping dictionary for a chapter section.
        
        Positional arguments:
            chId: str -- chapter ID.
            chapterNumber: int -- chapter number.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        if chapterNumber == 0:
            chapterNumber = ''

        chapterMapping = dict(
            ID=chId,
            ChapterNumber=chapterNumber,
            Title=self._convert_from_yw(self.novel.chapters[chId].title, True),
            Desc=self._convert_from_yw(self.novel.chapters[chId].desc),
            ProjectName=self._convert_from_yw(self.projectName, True),
            ProjectPath=self.projectPath,
            Language=self.novel.languageCode,
            Country=self.novel.countryCode,
        )
        return chapterMapping

    def _get_chapters(self):
        """Process the chapters and nested scenes.
        
        Iterate through the sorted chapter list and apply the templates, 
        substituting placeholders according to the chapter mapping dictionary.
        For each chapter call the processing of its included scenes.
        Skip chapters not accepted by the chapter filter.
        Return a list of strings.
        This is a template method that can be extended or overridden by subclasses.
        """
        lines = []
        chapterNumber = 0
        sceneNumber = 0
        wordsTotal = 0
        lettersTotal = 0
        for chId in self.novel.srtChapters:
            dispNumber = 0
            if not self._chapterFilter.accept(self, chId):
                continue

            # The order counts; be aware that "Todo" and "Notes" chapters are
            # always unused.
            # Has the chapter only scenes not to be exported?
            sceneCount = 0
            notExportCount = 0
            doNotExport = False
            template = None
            for scId in self.novel.chapters[chId].srtScenes:
                sceneCount += 1
                if self.novel.scenes[scId].doNotExport:
                    notExportCount += 1
            if sceneCount > 0 and notExportCount == sceneCount:
                doNotExport = True
            if self.novel.chapters[chId].chType == 2:
                # Chapter is "Todo" type.
                if self.novel.chapters[chId].chLevel == 1:
                    # Chapter is "Todo Part" type.
                    if self._todoPartTemplate:
                        template = Template(self._todoPartTemplate)
                elif self._todoChapterTemplate:
                    template = Template(self._todoChapterTemplate)
            elif self.novel.chapters[chId].chType == 1:
                # Chapter is "Notes" type.
                if self.novel.chapters[chId].chLevel == 1:
                    # Chapter is "Notes Part" type.
                    if self._notesPartTemplate:
                        template = Template(self._notesPartTemplate)
                elif self._notesChapterTemplate:
                    template = Template(self._notesChapterTemplate)
            elif self.novel.chapters[chId].chType == 3:
                # Chapter is "unused" type.
                if self._unusedChapterTemplate:
                    template = Template(self._unusedChapterTemplate)
            elif doNotExport:
                if self._notExportedChapterTemplate:
                    template = Template(self._notExportedChapterTemplate)
            elif self.novel.chapters[chId].chLevel == 1 and self._partTemplate:
                template = Template(self._partTemplate)
            else:
                template = Template(self._chapterTemplate)
                chapterNumber += 1
                dispNumber = chapterNumber
            if template is not None:
                lines.append(template.safe_substitute(self._get_chapterMapping(chId, dispNumber)))

            #--- Process scenes.
            sceneLines, sceneNumber, wordsTotal, lettersTotal = self._get_scenes(
                chId, sceneNumber, wordsTotal, lettersTotal, doNotExport)
            lines.extend(sceneLines)

            #--- Process chapter ending.
            template = None
            if self.novel.chapters[chId].chType == 2:
                if self._todoChapterEndTemplate:
                    template = Template(self._todoChapterEndTemplate)
            elif self.novel.chapters[chId].chType == 1:
                if self._notesChapterEndTemplate:
                    template = Template(self._notesChapterEndTemplate)
            elif self.novel.chapters[chId].chType == 3:
                if self._unusedChapterEndTemplate:
                    template = Template(self._unusedChapterEndTemplate)
            elif doNotExport:
                if self._notExportedChapterEndTemplate:
                    template = Template(self._notExportedChapterEndTemplate)
            elif self._chapterEndTemplate:
                template = Template(self._chapterEndTemplate)
            if template is not None:
                lines.append(template.safe_substitute(self._get_chapterMapping(chId, dispNumber)))
        return lines

    def _get_characterMapping(self, crId):
        """Return a mapping dictionary for a character section.
        
        Positional arguments:
            crId: str -- character ID.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        if self.novel.characters[crId].tags is not None:
            tags = list_to_string(self.novel.characters[crId].tags, divider=self._DIVIDER)
        else:
            tags = ''
        if self.novel.characters[crId].isMajor:
            characterStatus = Character.MAJOR_MARKER
        else:
            characterStatus = Character.MINOR_MARKER

        characterMapping = dict(
            ID=crId,
            Title=self._convert_from_yw(self.novel.characters[crId].title, True),
            Desc=self._convert_from_yw(self.novel.characters[crId].desc),
            Tags=self._convert_from_yw(tags),
            Image=self.novel.characters[crId].image,
            AKA=self._convert_from_yw(self.novel.characters[crId].aka, True),
            Notes=self._convert_from_yw(self.novel.characters[crId].notes),
            Bio=self._convert_from_yw(self.novel.characters[crId].bio),
            Goals=self._convert_from_yw(self.novel.characters[crId].goals),
            FullName=self._convert_from_yw(self.novel.characters[crId].fullName, True),
            Status=characterStatus,
            ProjectName=self._convert_from_yw(self.projectName),
            ProjectPath=self.projectPath,
        )
        return characterMapping

    def _get_characters(self):
        """Process the characters.
        
        Iterate through the sorted character list and apply the template, 
        substituting placeholders according to the character mapping dictionary.
        Skip characters not accepted by the character filter.
        Return a list of strings.
        This is a template method that can be extended or overridden by subclasses.
        """
        if self._characterSectionHeading:
            lines = [self._characterSectionHeading]
        else:
            lines = []
        template = Template(self._characterTemplate)
        for crId in self.novel.srtCharacters:
            if self._characterFilter.accept(self, crId):
                lines.append(template.safe_substitute(self._get_characterMapping(crId)))
        return lines

    def _get_fileHeader(self):
        """Process the file header.
        
        Apply the file header template, substituting placeholders 
        according to the file header mapping dictionary.
        Return a list of strings.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        lines = []
        template = Template(self._fileHeader)
        lines.append(template.safe_substitute(self._get_fileHeaderMapping()))
        return lines

    def _get_itemMapping(self, itId):
        """Return a mapping dictionary for an item section.
        
        Positional arguments:
            itId: str -- item ID.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        if self.novel.items[itId].tags is not None:
            tags = list_to_string(self.novel.items[itId].tags, divider=self._DIVIDER)
        else:
            tags = ''

        itemMapping = dict(
            ID=itId,
            Title=self._convert_from_yw(self.novel.items[itId].title, True),
            Desc=self._convert_from_yw(self.novel.items[itId].desc),
            Tags=self._convert_from_yw(tags, True),
            Image=self.novel.items[itId].image,
            AKA=self._convert_from_yw(self.novel.items[itId].aka, True),
            ProjectName=self._convert_from_yw(self.projectName, True),
            ProjectPath=self.projectPath,
        )
        return itemMapping

    def _get_items(self):
        """Process the items. 
        
        Iterate through the sorted item list and apply the template, 
        substituting placeholders according to the item mapping dictionary.
        Skip items not accepted by the item filter.
        Return a list of strings.
        This is a template method that can be extended or overridden by subclasses.
        """
        if self._itemSectionHeading:
            lines = [self._itemSectionHeading]
        else:
            lines = []
        template = Template(self._itemTemplate)
        for itId in self.novel.srtItems:
            if self._itemFilter.accept(self, itId):
                lines.append(template.safe_substitute(self._get_itemMapping(itId)))
        return lines

    def _get_locationMapping(self, lcId):
        """Return a mapping dictionary for a location section.
        
        Positional arguments:
            lcId: str -- location ID.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        if self.novel.locations[lcId].tags is not None:
            tags = list_to_string(self.novel.locations[lcId].tags, divider=self._DIVIDER)
        else:
            tags = ''

        locationMapping = dict(
            ID=lcId,
            Title=self._convert_from_yw(self.novel.locations[lcId].title, True),
            Desc=self._convert_from_yw(self.novel.locations[lcId].desc),
            Tags=self._convert_from_yw(tags, True),
            Image=self.novel.locations[lcId].image,
            AKA=self._convert_from_yw(self.novel.locations[lcId].aka, True),
            ProjectName=self._convert_from_yw(self.projectName, True),
            ProjectPath=self.projectPath,
        )
        return locationMapping

    def _get_locations(self):
        """Process the locations.
        
        Iterate through the sorted location list and apply the template, 
        substituting placeholders according to the location mapping dictionary.
        Skip locations not accepted by the location filter.
        Return a list of strings.
        This is a template method that can be extended or overridden by subclasses.
        """
        if self._locationSectionHeading:
            lines = [self._locationSectionHeading]
        else:
            lines = []
        template = Template(self._locationTemplate)
        for lcId in self.novel.srtLocations:
            if self._locationFilter.accept(self, lcId):
                lines.append(template.safe_substitute(self._get_locationMapping(lcId)))
        return lines

    def _get_sceneMapping(self, scId, sceneNumber, wordsTotal, lettersTotal):
        """Return a mapping dictionary for a scene section.
        
        Positional arguments:
            scId: str -- scene ID.
            sceneNumber: int -- scene number to be displayed.
            wordsTotal: int -- accumulated wordcount.
            lettersTotal: int -- accumulated lettercount.
        
        This is a template method that can be extended or overridden by subclasses.
        """

        #--- Create a comma separated tag list.
        if sceneNumber == 0:
            sceneNumber = ''
        if self.novel.scenes[scId].tags is not None:
            tags = list_to_string(self.novel.scenes[scId].tags, divider=self._DIVIDER)
        else:
            tags = ''

        #--- Create a comma separated character list.
        try:
            # Note: Due to a bug, yWriter scenes might hold invalid
            # viepoint characters
            sChList = []
            for crId in self.novel.scenes[scId].characters:
                sChList.append(self.novel.characters[crId].title)
            sceneChars = list_to_string(sChList, divider=self._DIVIDER)
            viewpointChar = sChList[0]
        except:
            sceneChars = ''
            viewpointChar = ''

        #--- Create a comma separated location list.
        if self.novel.scenes[scId].locations is not None:
            sLcList = []
            for lcId in self.novel.scenes[scId].locations:
                sLcList.append(self.novel.locations[lcId].title)
            sceneLocs = list_to_string(sLcList, divider=self._DIVIDER)
        else:
            sceneLocs = ''

        #--- Create a comma separated item list.
        if self.novel.scenes[scId].items is not None:
            sItList = []
            for itId in self.novel.scenes[scId].items:
                sItList.append(self.novel.items[itId].title)
            sceneItems = list_to_string(sItList, divider=self._DIVIDER)
        else:
            sceneItems = ''

        #--- Create A/R marker string.
        if self.novel.scenes[scId].isReactionScene:
            reactionScene = Scene.REACTION_MARKER
        else:
            reactionScene = Scene.ACTION_MARKER

        #--- Date or day.
        if self.novel.scenes[scId].date is not None and self.novel.scenes[scId].date != Scene.NULL_DATE:
            scDay = ''
            scDate = self.novel.scenes[scId].date
            cmbDate = self.novel.scenes[scId].date
        else:
            scDate = ''
            if self.novel.scenes[scId].day is not None:
                scDay = self.novel.scenes[scId].day
                cmbDate = f'Day {self.novel.scenes[scId].day}'
            else:
                scDay = ''
                cmbDate = ''

        #--- Time.
        if self.novel.scenes[scId].time is not None:
            scTime = self.novel.scenes[scId].time.rsplit(':', 1)[0]
            # remove seconds
        else:
            scTime = ''

        #--- Create a combined duration information.
        if self.novel.scenes[scId].lastsDays is not None and self.novel.scenes[scId].lastsDays != '0':
            lastsDays = self.novel.scenes[scId].lastsDays
            days = f'{self.novel.scenes[scId].lastsDays}d '
        else:
            lastsDays = ''
            days = ''
        if self.novel.scenes[scId].lastsHours is not None and self.novel.scenes[scId].lastsHours != '0':
            lastsHours = self.novel.scenes[scId].lastsHours
            hours = f'{self.novel.scenes[scId].lastsHours}h '
        else:
            lastsHours = ''
            hours = ''
        if self.novel.scenes[scId].lastsMinutes is not None and self.novel.scenes[scId].lastsMinutes != '0':
            lastsMinutes = self.novel.scenes[scId].lastsMinutes
            minutes = f'{self.novel.scenes[scId].lastsMinutes}min'
        else:
            lastsMinutes = ''
            minutes = ''
        duration = f'{days}{hours}{minutes}'

        sceneMapping = dict(
            ID=scId,
            SceneNumber=sceneNumber,
            Title=self._convert_from_yw(self.novel.scenes[scId].title, True),
            Desc=self._convert_from_yw(self.novel.scenes[scId].desc),
            WordCount=str(self.novel.scenes[scId].wordCount),
            WordsTotal=wordsTotal,
            LetterCount=str(self.novel.scenes[scId].letterCount),
            LettersTotal=lettersTotal,
            Status=Scene.STATUS[self.novel.scenes[scId].status],
            SceneContent=self._convert_from_yw(self.novel.scenes[scId].sceneContent),
            FieldTitle1=self._convert_from_yw(self.novel.fieldTitle1, True),
            FieldTitle2=self._convert_from_yw(self.novel.fieldTitle2, True),
            FieldTitle3=self._convert_from_yw(self.novel.fieldTitle3, True),
            FieldTitle4=self._convert_from_yw(self.novel.fieldTitle4, True),
            Field1=self.novel.scenes[scId].field1,
            Field2=self.novel.scenes[scId].field2,
            Field3=self.novel.scenes[scId].field3,
            Field4=self.novel.scenes[scId].field4,
            Date=scDate,
            Time=scTime,
            Day=scDay,
            ScDate=cmbDate,
            LastsDays=lastsDays,
            LastsHours=lastsHours,
            LastsMinutes=lastsMinutes,
            Duration=duration,
            ReactionScene=reactionScene,
            Goal=self._convert_from_yw(self.novel.scenes[scId].goal),
            Conflict=self._convert_from_yw(self.novel.scenes[scId].conflict),
            Outcome=self._convert_from_yw(self.novel.scenes[scId].outcome),
            Tags=self._convert_from_yw(tags, True),
            Image=self.novel.scenes[scId].image,
            Characters=sceneChars,
            Viewpoint=viewpointChar,
            Locations=sceneLocs,
            Items=sceneItems,
            Notes=self._convert_from_yw(self.novel.scenes[scId].notes),
            ProjectName=self._convert_from_yw(self.projectName, True),
            ProjectPath=self.projectPath,
            Language=self.novel.languageCode,
            Country=self.novel.countryCode,
        )
        return sceneMapping

    def _get_scenes(self, chId, sceneNumber, wordsTotal, lettersTotal, doNotExport):
        """Process the scenes.
        
        Positional arguments:
            chId: str -- chapter ID.
            sceneNumber: int -- number of previously processed scenes.
            wordsTotal: int -- accumulated wordcount of the previous scenes.
            lettersTotal: int -- accumulated lettercount of the previous scenes.
            doNotExport: bool -- scene belongs to a chapter that is not to be exported.
        
        Iterate through a sorted scene list and apply the templates, 
        substituting placeholders according to the scene mapping dictionary.
        Skip scenes not accepted by the scene filter.
        
        Return a tuple:
            lines: list of strings -- the lines of the processed scene.
            sceneNumber: int -- number of all processed scenes.
            wordsTotal: int -- accumulated wordcount of all processed scenes.
            lettersTotal: int -- accumulated lettercount of all processed scenes.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        lines = []
        firstSceneInChapter = True
        for scId in self.novel.chapters[chId].srtScenes:
            dispNumber = 0
            if not self._sceneFilter.accept(self, scId):
                continue

            sceneContent = self.novel.scenes[scId].sceneContent
            if sceneContent is None:
                sceneContent = ''

            # The order counts; be aware that "Todo" and "Notes" scenes are
            # always unused.
            if self.novel.scenes[scId].scType == 2:
                if self._todoSceneTemplate:
                    template = Template(self._todoSceneTemplate)
                else:
                    continue

            elif self.novel.scenes[scId].scType == 1:
                # Scene is "Notes" type.
                if self._notesSceneTemplate:
                    template = Template(self._notesSceneTemplate)
                else:
                    continue

            elif self.novel.scenes[scId].scType == 3 or self.novel.chapters[chId].chType == 3:
                if self._unusedSceneTemplate:
                    template = Template(self._unusedSceneTemplate)
                else:
                    continue

            elif self.novel.scenes[scId].doNotExport or doNotExport:
                if self._notExportedSceneTemplate:
                    template = Template(self._notExportedSceneTemplate)
                else:
                    continue

            elif sceneContent.startswith('<HTML>'):
                continue

            elif sceneContent.startswith('<TEX>'):
                continue

            else:
                sceneNumber += 1
                dispNumber = sceneNumber
                wordsTotal += self.novel.scenes[scId].wordCount
                lettersTotal += self.novel.scenes[scId].letterCount
                template = Template(self._sceneTemplate)
                if not firstSceneInChapter and self.novel.scenes[scId].appendToPrev and self._appendedSceneTemplate:
                    template = Template(self._appendedSceneTemplate)
            if not (firstSceneInChapter or self.novel.scenes[scId].appendToPrev):
                lines.append(self._sceneDivider)
            if firstSceneInChapter and self._firstSceneTemplate:
                template = Template(self._firstSceneTemplate)
            lines.append(template.safe_substitute(self._get_sceneMapping(
                        scId, dispNumber, wordsTotal, lettersTotal)))
            firstSceneInChapter = False
        return lines, sceneNumber, wordsTotal, lettersTotal

    def _get_prjNoteMapping(self, pnId):
        """Return a mapping dictionary for a project note.
        
        Positional arguments:
            pnId: str -- project note ID.
        
        This is a template method that can be extended or overridden by subclasses.
        """
        itemMapping = dict(
            ID=pnId,
            Title=self._convert_from_yw(self.novel.projectNotes[pnId].title, True),
            Desc=self._convert_from_yw(self.novel.projectNotes[pnId].desc, True),
            ProjectName=self._convert_from_yw(self.projectName, True),
            ProjectPath=self.projectPath,
        )
        return itemMapping

    def _get_projectNotes(self):
        """Process the project notes. 
        
        Iterate through the sorted project note list and apply the template, 
        substituting placeholders according to the item mapping dictionary.
        Skip items not accepted by the item filter.
        Return a list of strings.
        This is a template method that can be extended or overridden by subclasses.
        """
        lines = []
        template = Template(self._projectNoteTemplate)
        for pnId in self.novel.srtPrjNotes:
            map = self._get_prjNoteMapping(pnId)
            lines.append(template.safe_substitute(map))
        return lines

    def _get_text(self):
        """Call all processing methods.
        
        Return a string to be written to the output file.
        This is a template method that can be extended or overridden by subclasses.
        """
        lines = self._get_fileHeader()
        lines.extend(self._get_chapters())
        lines.extend(self._get_characters())
        lines.extend(self._get_locations())
        lines.extend(self._get_items())
        lines.extend(self._get_projectNotes())
        lines.append(self._fileFooter)
        return ''.join(lines)

    def _remove_inline_code(self, text):
        """Remove inline raw code from text and return the result."""
        if text:
            text = text.replace('<RTFBRK>', '')
            YW_SPECIAL_CODES = ('HTM', 'TEX', 'RTF', 'epub', 'mobi', 'rtfimg')
            for specialCode in YW_SPECIAL_CODES:
                text = re.sub(fr'\<{specialCode} .+?\/{specialCode}\>', '', text)
        else:
            text = ''
        return text


class HtmlExport(FileExport):
    """HTML file representation.
    
    Provide basic HTML templates for exporting chapters and scenes.
    """
    DESCRIPTION = 'HTML export'
    EXTENSION = '.html'
    SCENE_DIVIDER = '* * *'

    _fileHeader = '''<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>

<style type="text/css">
h1, h2, h3, h4, p {font: 1em monospace; margin: 3em; line-height: 1.5em}
h1, h2, h3, h4 {text-align: center}
h1 {letter-spacing: 0.5em; font-style: italic}
h1, h2 {font-weight: bold}
h3 {font-style: italic}
p {margin-top:0; margin-bottom:0}
p+p {margin-top:0; margin-bottom:0; text-indent: 1em}
p.title {text-align:center; font-weight:normal; text-transform: uppercase}
p.author {text-align:center; font-weight:normal}
p.scenedivider {text-align:center; margin: 1.5em; line-height: 1.5em}
strong {font-weight:normal; text-transform: uppercase}
</style>

<title>$Title</title>
</head>

<body>
<p class=title><strong>$Title</strong></p>
<p class=author>by</p>
<p class=author>$AuthorName</p>

'''

    _partTemplate = '''<h1><a name="ChID:$ID" />$Title</h1>
'''

    _chapterTemplate = '''<h2><a name="ChID:$ID" />$Title</h2>
'''

    _sceneTemplate = '''<a name="ScID:$ID" /><!-- ${Title} -->
<p>$SceneContent</p>
'''

    _sceneDivider = f'<p class="scenedivider">{SCENE_DIVIDER}</p>'

    _fileFooter = '''</body>
</html>
'''

    def _convert_from_yw(self, text, quick=False):
        """Return text, converted from yw7 markup to HTML markup.
        
        Positional arguments:
            text -- string to convert.
        
        Optional arguments:
            quick: bool -- if True, apply a conversion mode for one-liners without formatting.
        
        Overrides the superclass method.
        """
        if quick:
            # Just clean up a one-liner without sophisticated formatting.
            if text is None:
                return ''
            else:
                return text

        if text:
            text = self._remove_inline_code(text)

            # Apply html formatting.
            HTML_REPLACEMENTS = [
                ('\n', '</p>\n<p>'),
                ('[i]', '<em>'),
                ('[/i]', '</em>'),
                ('[b]', '<strong>'),
                ('[/b]', '</strong>'),
                ('<p></p>', '<p><br /></p>'),
                ('/*', '<!--'),
                ('*/', '-->'),
            ]
            for yw, htm in HTML_REPLACEMENTS:
                text = text.replace(yw, htm)

            # Remove highlighting, alignment,
            # strikethrough, and underline tags.
            text = re.sub(r'\[\/*[h|c|r|s|u]\d*\]', '', text)
        else:
            text = ''
        return(text)


class HtmlTemplatefileExport(HtmlExport):
    """Export content or metadata from a yWriter project to a HTML file.
    
    Public methods:
        write() -- write instance variables to the export file.
    
    Read the templates from external HTML flies.
    """

    # Reset default templates.
    _fileHeader = ''
    _partTemplate = ''
    _chapterTemplate = ''
    _sceneTemplate = ''
    _sceneDivider = ''
    _fileFooter = ''

    # Define template files.
    _HTML_HEADER = 'html_header'
    _CHARACTER_TEMPLATE = 'character_template'
    _LOCATION_TEMPLATE = 'location_template'
    _ITEM_TEMPLATE = 'item_template'
    _HTML_FOOTER = 'html_footer'
    _PART_TEMPLATE = 'part_template'
    _CHAPTER_TEMPLATE = 'chapter_template'
    _CHAPTER_END_TEMPLATE = 'chapter_end_template'
    _UNUSED_CHAPTER_TEMPLATE = 'unused_chapter_template'
    _UNUSED_CHAPTER_END_TEMPLATE = 'unused_chapter_end_template'
    _NOTES_CHAPTER_TEMPLATE = 'notes_chapter_template'
    _NOTES_CHAPTER_END_TEMPLATE = 'notes_chapter_end_template'
    _TODO_CHAPTER_TEMPLATE = 'todo_chapter_template'
    _TODO_CHAPTER_END_TEMPLATE = 'todo_chapter_end_template'
    _SCENE_TEMPLATE = 'scene_template'
    _FIRST_SCENE_TEMPLATE = 'first_scene_template'
    _UNUSED_SCENE_TEMPLATE = 'unused_scene_template'
    _NOTES_SCENE_TEMPLATE = 'notes_scene_template'
    _TODO_SCENE_TEMPLATE = 'todo_scene_template'
    _SCENE_DIVIDER = 'scene_divider'
    _TEMPLATE_CHAPTER_TITLE = 'html templates'

    def __init__(self, filePath, **kwargs):
        """Read templates from files, if any.

        Positional arguments:
            filePath: str -- path to the file represented by the Novel instance.
            
        Required keyword arguments:
            template_path: str -- template directory path.
        
        Extends the superclass constructor.
        """

        def load_template(filePath):
            try:
                return read_html_file(filePath)
            except:
                return None

        super().__init__(filePath)
        templatePath = kwargs['template_path']

        # Project level.
        content = load_template(f'{templatePath}/{self._HTML_HEADER}{self.EXTENSION}')
        if content is not None:
            self._fileHeader = content
        content = load_template(f'{templatePath}/{self._CHARACTER_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._characterTemplate = content
        content = load_template(f'{templatePath}/{self._LOCATION_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._locationTemplate = content
        content = load_template(f'{templatePath}/{self._ITEM_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._itemTemplate = content
        content = load_template(f'{templatePath}/{self._HTML_FOOTER}{self.EXTENSION}')
        if content is not None:
            self._fileFooter = content

        # Chapter level.
        content = load_template(f'{templatePath}/{self._PART_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._partTemplate = content
        content = load_template(f'{templatePath}/{self._CHAPTER_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._chapterTemplate = content
        content = load_template(f'{templatePath}/{self._CHAPTER_END_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._chapterEndTemplate = content
        content = load_template(f'{templatePath}/{self._UNUSED_CHAPTER_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._unusedChapterTemplate = content
        content = load_template(f'{templatePath}/{self._UNUSED_CHAPTER_END_TEMPLATE}{self.EXTENSION}')

        if content is not None:
            self._unusedChapterEndTemplate = content
        content = load_template(f'{templatePath}/{self._NOTES_CHAPTER_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._notesChapterTemplate = content
        content = load_template(f'{templatePath}/{self._NOTES_CHAPTER_END_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._notesChapterEndTemplate = content
        content = load_template(f'{templatePath}/{self._TODO_CHAPTER_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._todoChapterTemplate = content
        content = load_template(f'{templatePath}/{self._TODO_CHAPTER_END_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._todoChapterEndTemplate = content

        # Scene level.
        content = load_template(f'{templatePath}/{self._SCENE_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._sceneTemplate = content
        content = load_template(f'{templatePath}/{self._FIRST_SCENE_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._firstSceneTemplate = content
        content = load_template(f'{templatePath}/{self._UNUSED_SCENE_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._unusedSceneTemplate = content
        content = load_template(f'{templatePath}/{self._NOTES_SCENE_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._notesSceneTemplate = content
        content = load_template(f'{templatePath}/{self._TODO_SCENE_TEMPLATE}{self.EXTENSION}')
        if content is not None:
            self._todoSceneTemplate = content
        content = load_template(f'{templatePath}/{self._SCENE_DIVIDER}{self.EXTENSION}')
        if content is not None:
            self._sceneDivider = content

    def _get_chapterMapping(self, chId, chapterNumber):
        """Return a mapping dictionary for a chapter section. 

        Positional arguments:
            chId: str -- chapter ID.
            chapterNumber: int -- chapter number.

        Extends the superclass method.
        """
        ROMAN = [
            (1000, "M"),
            (900, "CM"),
            (500, "D"),
            (400, "CD"),
            (100, "C"),
            (90, "XC"),
            (50, "L"),
            (40, "XL"),
            (10, "X"),
            (9, "IX"),
            (5, "V"),
            (4, "IV"),
            (1, "I"),
        ]

        def number_to_roman(n):
            """Return n as a Roman number.
            
            Credit goes to the user 'Aristide' on stack overflow.
            https://stackoverflow.com/a/47713392
            """
            result = []
            for (arabic, roman) in ROMAN:
                (factor, n) = divmod(n, arabic)
                result.append(roman * factor)
                if n == 0:
                    break

            return "".join(result)

        TENS = {30: 'thirty', 40: 'forty', 50: 'fifty',
                60: 'sixty', 70: 'seventy', 80: 'eighty', 90: 'ninety'}
        ZERO_TO_TWENTY = (
            'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
            'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'
        )

        def number_to_english(n):
            """Return n as a number written out in English.

            Credit goes to the user 'Hunter_71' on stack overflow.
            https://stackoverflow.com/a/51849443
            """
            if any(not x.isdigit() for x in str(n)):
                return ''

            if n <= 20:
                return ZERO_TO_TWENTY[n]

            elif n < 100 and n % 10 == 0:
                return TENS[n]

            elif n < 100:
                return f'{number_to_english(n - (n % 10))} {number_to_english(n % 10)}'

            elif n < 1000 and n % 100 == 0:
                return f'{number_to_english(n / 100)} hundred'

            elif n < 1000:
                return f'{number_to_english(n / 100)} hundred {number_to_english(n % 100)}'

            elif n < 1000000:
                return f'{number_to_english(n / 1000)} thousand {number_to_english(n % 1000)}'

            return ''

        chapterMapping = super()._get_chapterMapping(chId, chapterNumber)
        if chapterNumber:
            chapterMapping['ChNumberEnglish'] = number_to_english(chapterNumber).capitalize()
            chapterMapping['ChNumberRoman'] = number_to_roman(chapterNumber)
        else:
            chapterMapping['ChNumberEnglish'] = ''
            chapterMapping['ChNumberRoman'] = ''
        if self.novel.chapters[chId].suppressChapterTitle:
            chapterMapping['Title'] = ''
        return chapterMapping

    def write(self):
        """Read templates from the source file, if any.

        Extends the superclass constructor.
        """

        def get_template(scId, title):
            """Retrieve a template from a yWriter scene.
            
            Return scene content if title matches. Otherwise return None.
            """
            if self.novel.scenes[scId].title == title:
                content = self.novel.scenes[scId].sceneContent
            else:
                content = None
            return content

        # Find template chapter.
        for chId in self.novel.chapters:
            if self.novel.chapters[chId].chType != 3:
                continue

            if self.novel.chapters[chId].title != self._TEMPLATE_CHAPTER_TITLE:
                continue

            for scId in self.novel.chapters[chId].srtScenes:

                # Project level.
                content = get_template(scId, self._HTML_HEADER)
                if content is not None:
                    self._fileHeader = content
                content = get_template(scId, self._CHARACTER_TEMPLATE)
                if content is not None:
                    self._characterTemplate = content
                content = get_template(scId, self._LOCATION_TEMPLATE)
                if content is not None:
                    self._locationTemplate = content
                content = get_template(scId, self._ITEM_TEMPLATE)
                if content is not None:
                    self._itemTemplate = content
                content = get_template(scId, self._HTML_FOOTER)
                if content is not None:
                    self._fileFooter = content

                # Chapter level.

                content = get_template(scId, self._PART_TEMPLATE)
                if content is not None:
                    self._partTemplate = content
                content = get_template(scId, self._CHAPTER_TEMPLATE)
                if content is not None:
                    self._chapterTemplate = content
                content = get_template(scId, self._CHAPTER_END_TEMPLATE)
                if content is not None:
                    self._chapterEndTemplate = content
                content = get_template(scId, self._UNUSED_CHAPTER_TEMPLATE)
                if content is not None:
                    self._unusedChapterTemplate = content
                content = get_template(scId, self._UNUSED_CHAPTER_END_TEMPLATE)
                if content is not None:
                    self._unusedChapterEndTemplate = content
                content = get_template(scId, self._NOTES_CHAPTER_TEMPLATE)
                if content is not None:
                    self._notesChapterTemplate = content
                content = get_template(scId, self._NOTES_CHAPTER_END_TEMPLATE)
                if content is not None:
                    self._notesChapterEndTemplate = content
                content = get_template(scId, self._TODO_CHAPTER_TEMPLATE)
                if content is not None:
                    self._todoChapterTemplate = content
                content = get_template(scId, self._TODO_CHAPTER_END_TEMPLATE)
                if content is not None:
                    self._todoChapterEndTemplate = content

                # Scene level.
                content = get_template(scId, self._SCENE_TEMPLATE)
                if content is not None:
                    self._sceneTemplate = content
                content = get_template(scId, self._FIRST_SCENE_TEMPLATE)
                if content is not None:
                    self._firstSceneTemplate = content
                content = get_template(scId, self._UNUSED_SCENE_TEMPLATE)
                if content is not None:
                    self._unusedSceneTemplate = content
                content = get_template(scId, self._NOTES_SCENE_TEMPLATE)
                if content is not None:
                    self._notesSceneTemplate = content
                content = get_template(scId, self._TODO_SCENE_TEMPLATE)
                if content is not None:
                    self._todoSceneTemplate = content
                content = get_template(scId, self._SCENE_DIVIDER)
                if content is not None:
                    self._sceneDivider = content
        super().write()


class ExportAnyTargetFactory(FileFactory):
    """A factory class that instantiates an export target file object."""

    def make_file_objects(self, sourcePath, **kwargs):
        """Instantiate a target object for conversion to any format.

        Positional arguments:
            sourcePath: str -- path to the source file to convert.

        Optional arguments:
            suffix: str -- an indicator for the target file type.

        Required keyword arguments: 
            suffix: str -- target file name suffix.

        Return a tuple with two elements:
        - sourceFile: None
        - targetFile: a FileExport subclass instance

        Raise the "Error" exception in case of error.          
        """
        fileName, __ = os.path.splitext(sourcePath)
        suffix = kwargs['suffix']
        for fileClass in self._fileClasses:
            if suffix is None:
                suffix = ''
            fileClass.SUFFIX = suffix
            targetFile = fileClass(f'{fileName}{suffix}{fileClass.EXTENSION}', **kwargs)
            return None, targetFile

        raise Error(f'File type of "{norm_path(sourcePath)}" not supported.')

class HtmlExporter(YwCnvFf):
    """A converter class for html export.

    Class constants:
        EXPORT_SOURCE_CLASSES -- List of YwFile subclasses from which can be exported.
        EXPORT_TARGET_CLASSES -- List of FileExport subclasses to which export is possible.
    """
    EXPORT_SOURCE_CLASSES = [Yw7File]
    EXPORT_TARGET_CLASSES = [HtmlTemplatefileExport]

    def __init__(self):
        """Extends the superclass constructor.

        Delegate the exportTargetFactory to a project
        specific class that accepts all suffixes.
        Extends the superclass constructor.
        """
        super().__init__()
        self.exportTargetFactory = ExportAnyTargetFactory(self.EXPORT_TARGET_CLASSES)




def run(sourcePath, templatePath, suffix, silentMode=True):
    if silentMode:
        ui = Ui('')
    else:
        ui = UiCmd('Export html from yWriter')
    converter = HtmlExporter()
    converter.ui = ui
    kwargs = {'suffix': suffix, 'template_path': templatePath}
    converter.run(sourcePath, **kwargs)
    ui.start()


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description='Export yWriter project to html.',
        epilog='If no template directory is set, templates are searched for in the yWriter project directory. If no templates are found, the output file will be empty.')
    parser.add_argument('sourcePath', metavar='Project',
                        help='yWriter project file')
    parser.add_argument('-t', dest='templatePath', metavar='template-dir',
                        help='path to the directory containing the templates')
    parser.add_argument('-s', dest='suffix', metavar='suffix',
                        help='suffix to the output file name (optional)')
    parser.add_argument('--silent',
                        action="store_true",
                        help='suppress error messages and the request to confirm overwriting')
    args = parser.parse_args()
    if args.templatePath:
        templatePath = args.templatePath
    else:
        templatePath = os.path.dirname(args.sourcePath)
    if args.templatePath is not None:
        templatePath = args.templatePath
    else:
        templatePath = os.path.dirname(args.sourcePath)
    if not templatePath:
        templatePath = '.'
    if args.suffix is not None:
        suffix = args.suffix
    else:
        suffix = ''
    run(args.sourcePath, templatePath, suffix, args.silent)
