<jupyter_start><jupyter_text>nerHelper Application for Reading & Updating XML with NEROnce names authority is ready use following encoding to link to external documents:https://tei-c.org/release/doc/tei-p5-doc/en/html/ref-listPrefixDef.html```xml            Private URIs using the bibl prefix can be         expanded to form URIs which retrieve the relevant         bibliographical reference from www.example.com.    ```<jupyter_code>import warnings, re, glob, datetime, csv, sys, os, base64, io, spacy
import pandas as pd
import numpy as np
from lxml import etree

import dash, dash_table
import dash_core_components as dcc
from dash.dependencies import Input, Output, State
from dash.exceptions import PreventUpdate
import dash_html_components as html
from jupyter_dash import JupyterDash

# Import spaCy language model.
nlp = spacy.load('en_core_web_sm')

# Ignore simple warnings.
warnings.simplefilter('ignore', DeprecationWarning)

# Declare directory location to shorten filepaths later.
abs_dir = "/Users/quinn.wi/Documents/GitHub/dsg-mhs/"<jupyter_output><empty_output><jupyter_text>Declare Functions<jupyter_code>%%time

label_dict = {'PERSON':'persName',
                  'LOC':'placeName', # Non-GPE locations, mountain ranges, bodies of water.
                  'GPE':'placeName', # Countries, cities, states.
                  'FAC':'placeName', # Buildings, airports, highways, bridges, etc.
                  'ORG':'orgName', # Companies, agencies, institutions, etc.
                  'NORP':'name', # Nationalities or religious or political groups.
                  'EVENT':'name', # Named hurricanes, battles, wars, sports events, etc.
                  'WORK_OF_ART':'name', # Titles of books, songs, etc.
                  'LAW':'name', # Named documents made into laws.
                  'DATE':'date' # Absolute or relative dates or periods.
                 }

"""
XML Parsing Function: Get Namespaces
"""
def get_namespace(root):
    namespace = re.match(r"{(.*)}", str(root.tag))
    ns = {"ns":namespace.group(1)}
    return ns


"""
XML Parsing Function: Convert to String
"""
def get_text(elem):
    text_list = []
    text = ''.join(etree.tostring(elem, encoding='unicode', method='text', with_tail=False))
    text_list.append(re.sub(r'\s+', ' ', text))
    return ' '.join(text_list)

        
"""
XML Parsing Function: Get Encoded Content
"""    
def get_encoding(elem):
    encoding = etree.tostring(elem, pretty_print = True).decode('utf-8')
    encoding = re.sub('\s+', ' ', encoding) # remove additional whitespace
    return encoding


  

"""
NER Function
"""
# spaCy
def get_spacy_entities(text, subset_ner):
    sp_entities_l = []
    doc = nlp(text)
    for ent in doc.ents:
        if ent.label_ in subset_ner.keys():
            sp_entities_l.append((str(ent), ent.label_))
        else:
            pass
    return sp_entities_l




"""
XML & NER: Retrieve Contents
"""
def get_contents(ancestor, xpath_as_string, namespace, subset_ner):
    
    textContent = get_text(ancestor) # Get plain text.
    encodedContent = get_encoding(ancestor) # Get encoded content.
    sp_entities_l = get_spacy_entities(textContent, subset_ner) # Get named entities from plain text.

    return (sp_entities_l, encodedContent)



"""
XML & Regex: Up Conversion

Function replaces all spaces between beginning and end tags with underscores.
Then, function wraps each token (determined by whitespace) with word tags (<w>...</w>)
"""
def up_convert_encoding(column):
#     Regularize spacing & store data as new variable ('converted_encoding').
    converted_encoding = re.sub('\s+', ' ', column, re.MULTILINE)
    
#     Create regex that replaces spaces with underscores if spaces occur within tags.
#     This regex treats tags as a single token later.
    tag_regex = re.compile('<(.*?)>')

#     Accumulate underscores through iteration
    for match in re.findall(tag_regex, column):
        replace_space = re.sub('\s', '_', match)
        converted_encoding = re.sub(match, replace_space, converted_encoding)
    
#     Up-Converstion
#     Tokenize encoding and text, appending <w> tags, and re-join.
    converted_encoding = converted_encoding.split(' ')
    for idx, item in enumerate(converted_encoding):
        item = '<w>' + item + '</w>'
        converted_encoding[idx] = item
    converted_encoding = ' '.join(converted_encoding)
    
    return converted_encoding


"""
XML Parsing Function: Intersperse Entity with Likely TEI Information for Capacious Regex
"""
def intersperse(lst, item):
    result = [item] * (len(lst) * 2 - 0)
    result[0::2] = lst
    return result


"""
XML Function: Build KWIC of Found Entities in Up Converted Encoding
"""
def get_kwic_encoding(entity, encoding, banned_list, kwic_range):
#     Up convert arguments.
    converted_encoding = up_convert_encoding(encoding)
    converted_entity = up_convert_encoding(entity)

#     Intersperse & 'up convert' by hand entity.
    expanded_entity = [c for c in entity]
    expanded_regex = '[' + "|".join(['(<.*?>)']) + ']*'

    expanded_regex = r''.join(intersperse(expanded_entity, expanded_regex))
    expanded_entity = re.sub('\s', '</w> <w>', expanded_regex)
    
#     <w>(?:(?!<w>).)*
#     'Tempered greedy token solution', <w> cannot appear after a <w>, unless within expanded_entity
#     entity_regex = re.compile('(<w>(?:(?!<w>).)*' + expanded_entity + '.*?</w>)')
    entity_regex = re.compile('([^\s]*' + expanded_entity + '[^\s]*)')
    
    
    # Use regex match as final conv. entity.
    try:
        kwic_dict = {entity: []}
        for m in entity_regex.finditer(converted_encoding):
            
            if any(item in m.group() for item in banned_list):
                pass
            
            else:
#                 Gather context:
#                 Start of match (m.start()) minus kwic_range through end of match plus kwic_range.
                context = converted_encoding[ m.start() - kwic_range : m.end() + kwic_range]
                kwic_dict[entity].append(context)
        
        
#         For each item in entity list, create new regex and expand until reaches preceeding </w> and trailing <w>.
        for n, i in enumerate(kwic_dict[entity]):
            complete_kwic = re.search(f'([^\s]*{i}[^\s]*)', converted_encoding).group()
            kwic_dict[entity][n] = complete_kwic
        
#         Return values only
        return kwic_dict[entity]
            
    except AttributeError:
        return np.nan


"""
XML: & NER: Create Dataframe of Entities
"""
def make_dataframe(descendant, df, ns, subset_ner, filename, descendant_order):
    entities, previous_encoding = get_contents(descendant, './/ns:.', ns, subset_ner)
    
    df = df.append({
        'file':re.sub('.*/(.*.xml)', '\\1', filename),
        'descendant_order': descendant_order,
        'previous_encoding': previous_encoding,
        'entities':entities,
    },
        ignore_index = True)
    
    return df



"""
Parse Contents: XML Structure (ouput-data-upload)
"""
def parse_contents(contents, filename, date, ner_values):
    ner_values = ner_values.split(',')
    content_type, content_string = contents.split(',')
    decoded = base64.b64decode(content_string).decode('utf-8')
    
    # Label dictionary.
    label_dict = {'PERSON':'persRef',
                  'LOC':'placeName', # Non-GPE locations, mountain ranges, bodies of water.
                  'GPE':'placeName', # Countries, cities, states.
                  'FAC':'placeName', # Buildings, airports, highways, bridges, etc.
                  'ORG':'orgName', # Companies, agencies, institutions, etc.
                  'NORP':'name', # Nationalities or religious or political groups.
                  'EVENT':'name', # Named hurricanes, battles, wars, sports events, etc.
                  'WORK_OF_ART':'name', # Titles of books, songs, etc.
                  'LAW':'name', # Named documents made into laws.
                  'DATE':'date' # Absolute or relative dates or periods.
                 }
    
    #### Subset label_dict with input values from Checklist *****
    subset_ner = {k: label_dict[k] for k in ner_values}
    
#     Run XML Parser + NER here.
    try:
#         Assume that the user uploaded a CSV file
        if 'csv' in filename:
            df = pd.read_csv(
                io.StringIO(decoded)
            )
            
#         Assume that the user uploaded an XML file
        elif 'xml' in filename:
            xml_file = decoded.encode('utf-8')
            
            df = pd.DataFrame(columns = ['file', 'previous_encoding', 'entities'])
            
            root = etree.fromstring(xml_file)
            ns = get_namespace(root)
            
#             Search through elements for entities.
            desc_order = 0
            for child in root.findall('.//ns:body', ns): # Change this line to specify where to look for entities.
                
                for descendant in child:
                    desc_order = desc_order + 1
                    df = make_dataframe(descendant, df, ns, subset_ner, filename, desc_order)
            
#             Join data
            df = df \
                .explode('entities') \
                .dropna()

            df[['entity', 'label']] = pd.DataFrame(df['entities'].tolist(), index = df.index)
        
            # Add additional columns for user input.
            df['uniq_id'] = ''
            
#             Replace 'previous_encoding' with a KWIC version containing entity.
            df['previous_encoding'] = df.apply(lambda row: get_kwic_encoding(row['entity'],
                                                                             row['previous_encoding'], 
                                                                             banned_list,
                                                                             30),
                                               axis = 1)

            
    except Exception as e:
        return html.Div([
            f'There was an error processing this file: {e}.'
    ])
    
#     Explode lists within more than one item.
    df = df.explode('previous_encoding').dropna().drop_duplicates()
    
#     Return HTML with outputs.
    return filename, date, df




"""
XML: Remove word tags and clean up
"""
def xml_cleanup(encoding):
#     Clean up any additional whitespace and remove word tags.
    encoding = re.sub('\s+', ' ', encoding, re.MULTILINE)
    encoding = re.sub('(<[/]?w>)', '', encoding)

    encoding = re.sub('_', ' ', encoding) # Remove any remaining underscores in tags.
    encoding = re.sub('“', '"', encoding) # Change quotation marks to correct unicode.
    encoding = re.sub('”', '"', encoding)
    
    return encoding


"""
Reading Pane: Highlight Found Entity
"""
def highlighter(previous_encoding, entity):
#     Remove all tags.
    highlighted_text = re.sub('(<.*?>)', '', previous_encoding) 
    
    entity_match = re.search(f'(.*)({entity})(.*)', highlighted_text)
    
    highlighted_text = html.P([entity_match.group(1), html.Mark(entity_match.group(2)), entity_match.group(3)])
    
    return highlighted_text

"""
XML Parsing Function: Suggest New Encoding with Hand Edits

Similar to make_ner_suggestions(), this function folds in revision using regular expressions.
The outcome is the previous encoding with additional encoded information determined by user input.

Expected Columns:
    previous_encoding
    entities
    uniq_id
"""
def revise_with_selections(label_dict, label, uniq_id, entity, previous_encoding):
    
    label = label_dict[label]
    
#     <w>(?:(?!<w>).)*
#     'Tempered greedy token solution', <w> cannot appear after a <w>, unless it's within expanded_entity.
    expanded_entity = [c for c in entity]
    expanded_regex = '[' + "|".join(['(<.*?>)']) + ']*'
    expanded_regex = r''.join(intersperse(expanded_entity, expanded_regex))
    expanded_entity = re.sub('\s', '</w> <w>', expanded_regex)
    
#     [^\s]*(</w>)?
#     Match anything except for whitespace until first </w> appears.
#     expanded_entity = f'(<w>(?:(?!<w>).)*{expanded_entity}[^\s]*(</w>)?)'
    expanded_entity = f'([^\s]*{expanded_entity}[^\s]*)'
    
    matched_entity = re.search(expanded_entity, previous_encoding).group()
    
#     If there is a unique id to add & hand edits...
    if uniq_id != '':
        revised_encoding = re.sub(f'{matched_entity}',
                                  f'<{label} type="nerHelper-added">{matched_entity}</{label}>',
                                  previous_encoding)        
        revised_encoding = xml_cleanup(revised_encoding)

        return revised_encoding
    
    elif uniq_id == '':
        revised_encoding = re.sub(f'{matched_entity}',
                                  f'<{label} type="nerHelper-added">{matched_entity}</{label}>',
                                  previous_encoding)
        revised_encoding = xml_cleanup(revised_encoding)
        
        return revised_encoding

    else:
        pass


"""
XML & NER: Update/Inherit Accepted Changes
Expects a dataframe (from a .csv) with these columns:
    file
    abridged_xpath
    descendant_order
    previous_encoding
    entities
    new_encoding
    uniq_id
"""
def commit_revisions(label_dict, dataframe):
    
    dataframe = dataframe.fillna('').reset_index()
    
    for index, row in dataframe.iterrows():
        revised_by_hand = revise_with_selections(label_dict, row['label'], row['uniq_id'],
                                                 row['entity'], row['previous_encoding'])
        dataframe.loc[index, 'new_encoding'] = revised_by_hand
    
#     Clean up previous_encoding (remove word tags)
    dataframe['previous_encoding'] = dataframe.apply(lambda row: xml_cleanup(row['previous_encoding']), axis = 1)
    
    return dataframe




"""
XML & NER: Write New XML File with Accepted Revisions
Expects:
    XML File with Original Encoding
    CSV File with Accepted Changes
    Label Dictionary
"""
def revise_xml(xml_contents, csv_df):
#     Label dictionary.
    label_dict = {'PERSON':'persRef',
                  'LOC':'placeName', # Non-GPE locations, mountain ranges, bodies of water.
                  'GPE':'placeName', # Countries, cities, states.
                  'FAC':'placeName', # Buildings, airports, highways, bridges, etc.
                  'ORG':'orgName', # Companies, agencies, institutions, etc.
                  'NORP':'name', # Nationalities or religious or political groups.
                  'EVENT':'name', # Named hurricanes, battles, wars, sports events, etc.
                  'WORK_OF_ART':'name', # Titles of books, songs, etc.
                  'LAW':'name', # Named documents made into laws.
                  'DATE':'date' # Absolute or relative dates or periods.
                 }
    
    xml_content_type, xml_content_string = xml_contents.split(',')
    xml_decoded = base64.b64decode(xml_content_string).decode('utf-8')
    xml_file = xml_decoded.encode('utf-8')
    
    root = etree.fromstring(xml_file)
    ns = get_namespace(root)    
    
#     Convert XML structure to string for regex processing.
    tree_as_string = etree.tostring(root, pretty_print = True).decode('utf-8')
    tree_as_string = re.sub('\s+', ' ', tree_as_string) # remove additional whitespace
    
    new_data = commit_revisions(label_dict, csv_df)
    
#     Write accepted code into XML tree.
    for index, row in new_data.iterrows():
        tree_as_string = re.sub(f'(.*)({row.previous_encoding})(.*)',
                                f'\\1{row.new_encoding}\\3',
                                tree_as_string)
        
#     Check well-formedness (will fail if not well-formed)
    doc = etree.fromstring(tree_as_string)
    et = etree.ElementTree(doc)
    
#     Convert to string.
    et = etree.tostring(et, encoding='unicode', method='xml')
    return et


"""
XML: Write Schema Information before Root
Input: 
    - Revised XML document (return variable from revise_xml())
    - XML File with Original Encoding
"""
def write_schema_information(xml_contents, final_revisions):
    xml_content_type, xml_content_string = xml_contents.split(',')
    xml_decoded = base64.b64decode(xml_content_string).decode('utf-8')
    
    xml_file = xml_decoded.encode('utf-8').decode('utf-8')
    xml_file = re.sub('\s+', ' ', xml_file)
    
    schema_match = re.search('(<?.*)(<TEI.*)', xml_file)
    schema_match = schema_match.group(1)
    
    completed_document = schema_match + final_revisions

    return completed_document<jupyter_output>CPU times: user 7 µs, sys: 1 µs, total: 8 µs
Wall time: 8.82 µs<jupyter_text>APP<jupyter_code>%%time

# External JavaScript files
external_scripts = [
    'https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js',
    {'src':'https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js'}
]

app = JupyterDash(__name__, 
                  external_scripts = external_scripts)

app.config.suppress_callback_exceptions = True


# Preset variables.
ner_labels = ['PERSON','LOC','GPE','FAC','ORG','NORP','EVENT','WORK_OF_ART','LAW','DATE']

# Banned List (list of elements that already encode entities)
banned_list = ['persRef', 'date']

# Layout.
app.layout = html.Div([
    
#     Title
    html.Div(
        className="app-header",
        children = [
            html.Div('nerHelper Application', className = "app-header--title")
        ]),

#     Add or substract labels to list for NER to find. Complete list of NER labels: https://spacy.io/api/annotation
    html.H2('Select Entities to Search For'),
    
#     Add legend & checklist for ner_labels.
    html.Table([
        html.Thead([
            html.Tr([
                html.Th('Label'),
                html.Th('Definition'),
            ]),
        ]),
        html.Tbody([
            html.Tr([
                html.Td('PERSON'),
                html.Td('A person\'s name (proper noun)' ),
            ]),
            html.Tr([
                html.Td('LOC'),
                html.Td('Non-GPE locations, mountain ranges, bodies of water.' ),
            ]),
            html.Tr([
                html.Td('GPE'),
                html.Td('Countries, cities, states.' ),
            ]),
            html.Tr([
                html.Td('FAC'),
                html.Td('Buildings, airports, highways, bridges, etc.' ),
            ]),
            html.Tr([
                html.Td('ORG'),
                html.Td('Companies, agencies, institutions, etc.' ),
            ]),
            html.Tr([
                html.Td('NORP'),
                html.Td('Nationalities or religious or political groups.' ),
            ]),
            html.Tr([
                html.Td('EVENT'),
                html.Td('Named hurricanes, battles, wars, sports events, etc.' ),
            ]),
            html.Tr([
                html.Td('WORK_OF_ART'),
                html.Td('Titles of books, songs, etc.' ),
            ]),
            html.Tr([
                html.Td('LAW'),
                html.Td('Named documents made into laws.' ),
            ]),
            html.Tr([
                html.Td('DATE'),
                html.Td('Absolute or relative dates or periods.' ),
            ]),
        ]),
    ]),
    
    dcc.Checklist(
        id = 'ner-checklist',
        options = [{
            'label': i,
            'value': i
        } for i in ner_labels],
        value = ['PERSON', 'LOC', 'GPE']
    ),
    
    
#     Upload Data Area.
    html.H2('Upload File'),
    dcc.Upload(
        id = 'upload-data',
        children = html.Div([
            'Drag and Drop or ', html.A('Select File')
        ]),
        style={
            'width': '95%',
            'height': '60px',
            'lineHeight': '60px',
            'borderWidth': '1px',
            'borderStyle': 'dashed',
            'borderRadius': '5px',
            'textAlign': 'center',
            'margin': '10px'
        },
        multiple=True # Allow multiple files to be uploaded
    ),
    
#     Store uploaded data.
    dcc.Store(id = 'data-upload-store'),
    
#     Display pane for file information.
    html.Div(id = 'file-information'),
    
#     Display pane for data as table.
    dash_table.DataTable(id = 'data-table-container',
                         row_selectable="single",
                         selected_rows = [0],
                         editable = True,
                         page_size=1,
                        ),
    
#     Display pane for reading data from selected row & revision options.
    html.Div(id = 'reading-container'),
    html.Div(id = 'revision-radio-container'),
    html.Div(id = 'revision-text-container'),
    html.Div(id = 'revision-button-container'),
    
#     Store revised data.
    dcc.Store(id = 'revisions-store'),
    
    
#     Div to hold button that will write and download XML file.
    html.Div(id = 'write-button-container'),
    
    html.Div(id = 'download-button-container')

])



####################################################################################################################
#####                             ##################################################################################
#####     Callbacks               ##################################################################################
#####                             ##################################################################################
####################################################################################################################



# Upload data & create table.
@app.callback([Output('file-information', 'children'),
               Output('data-upload-store', 'data')],
              [Input('upload-data', 'contents'),
               Input('ner-checklist', 'value')],
              [State('upload-data', 'filename'),
               State('upload-data', 'last_modified')])
def upload_data(list_of_contents, ner_values, list_of_names, list_of_dates):
    if list_of_contents is None:
        raise PreventUpdate
    
#     Parse uploaded contents.
    children = [
        parse_contents(c, n, d, ner) for c, n, d, ner in
        zip(list_of_contents, list_of_names, list_of_dates, ner_values)
    ]
    data = children[0][2]
    
#     Extract file information.
    file_information = html.Div([html.P(f'File name: {children[0][0]}'),
                                 html.P(f'Last Modified: {datetime.datetime.fromtimestamp(children[0][1])}')])    
    
    return file_information, data.to_dict('rows')



# Generate table with data from store.
@app.callback([Output('data-table-container', 'data'),
               Output('data-table-container', 'columns')],
              Input('data-upload-store', 'data'))
def populate_data_table(data):

    df = pd.DataFrame(data)[['file', 'entity', 'label']]
    cols = [{'name':i, 'id': i} for i in df.columns]

    return df.to_dict('rows'), cols



# Create reading pane & revision options once row from table is selected.
@app.callback([Output('reading-container', 'children'),
               Output('revision-radio-container', 'children'),
               Output('revision-text-container', 'children'),
               Output('revision-button-container', 'children')],
              [Input('data-upload-store', 'data'),
               Input('data-table-container', 'selected_rows')])
def create_reading_and_revisions_pane(data, selected_rows):
    if data is None:
        raise PreventUpdate
        
    reading_df = pd.DataFrame(data).iloc[selected_rows]

#     Access previous and new encoding and squeeze() them to return only scalar (the text).
#     Use highlighter() to re-construct previous_encoding with html.Mark() around found entity.
    highlighted_text = highlighter(reading_df['previous_encoding'].squeeze(),
                                   reading_df['entity'].squeeze())
    
    reading_pane = html.Div([
        html.H2('Found Entity'),
        html.Div(highlighted_text),
        html.H2('Revisions Options'),
        html.P('Please confirm the correct label that describes the entity.'),
        html.P('If you\'ve selected "PERSON," you must also hand-type an xml:id below. The xml:id should match an entity in the names authority database.'),
    ])
    
#     Choose correct entity label with radio buttons.
    revision_radio = dcc.RadioItems(
            id = 'radioInput',
            options = [{'label':'PERSON', 'value':'PERSON'},
                       {'label':'LOC', 'value':'LOC'},
                       {'label':'GPE', 'value':'GPE'},
                       {'label':'FAC', 'value':'FAC'},
                       {'label':'ORG', 'value':'ORG'},
                       {'label':'NORP', 'value':'NORP'},
                       {'label':'EVENT', 'value':'EVENT'},
                       {'label':'WORK_OF_ART', 'value':'WORK_OF_ART'},
                       {'label':'LAW', 'value':'LAW'},
                       {'label':'DATE', 'value':'DATE'},
                       {'label':'No Changes', 'value':''}
            ]
        ),
    
#     Create text area for manual changes.
    revision_text = dcc.Input(id = 'textInput', type = 'text', 
                              placeholder = 'Type a Unique ID here.', value = '', debounce = True)
        
#     Create button for committing changes.
    revision_button = html.Button('Confirm Changes?', id = 'confirm-button', n_clicks = 0),
    
    return reading_pane, revision_radio, revision_text, revision_button


# Once a revisions is accepted, write row with instructions to dataframe.
@app.callback(Output('revisions-store', 'data'),
              [Input('revision-button-container', 'n_clicks'),
               Input('data-upload-store', 'data'),
               Input('data-table-container', 'selected_rows'),
               Input('revision-radio-container', 'children'), 
               Input('revision-text-container', 'children')],
              State('revisions-store', 'data'))
def commit_revisions_to_dataframe(n_clicks, data, selected_rows,
                                  radio_children, text_children, revisions):
    
#     Only run if the n_click 'id' is triggered by the revision-button-container.
    changed_id = [p['prop_id'] for p in dash.callback_context.triggered][0]
        
    if changed_id != 'revision-button-container.n_clicks':
        raise PreventUpdate
    
    revised_row = pd.DataFrame(data).iloc[selected_rows]
        
#     Check if radio_ and text_children each have a value by seeing if a 'value' key is nested in 'props'.
    if 'value' in radio_children[0]['props']:
        radio_value = radio_children[0]['props']['value']
    else:
        radio_value = ''
    
    if 'value' in text_children['props']:
        text_value = text_children['props']['value']
    else:
        text_value = ''
    
    revised_row['uniq_id'] = text_value
    revised_row['label'] = radio_value
    
    if revisions is None:
        revisions = pd.DataFrame(revised_row)
    else:
        revisions = pd.DataFrame(revisions)
        revisions = revisions.append(revised_row, ignore_index = True)
    
    return revisions.to_dict('rows')

    

# After last revision (or whenever one change completed), provide button to commit changes to XML.
@app.callback(Output('write-button-container', 'children'),
              Input('revisions-store', 'data'))
def provide_button_to_download_revisions(data):
    if data is None:
        raise PreventUpdate
    
    return html.Button('Finished? Download Revised XML.', id = 'write-xml-button')


# Run functions to revise XML and download new document.
@app.callback(Output('download-button-container', 'children'),
              [Input('write-button-container', 'n_clicks'),
               Input('upload-data', 'contents'),
               Input('revisions-store', 'data')],
              State('upload-data', 'filename'))
def provide_download_link(n_clicks, contents, revisions, filename):
    write_id = [p['prop_id'] for p in dash.callback_context.triggered][0]
    
    if write_id != 'write-button-container.n_clicks':
        raise PreventUpdate
    
    xml_contents = contents[0]
    revisions = pd.DataFrame(revisions)
    
    final_revisions = revise_xml(xml_contents, revisions)
    
    completed_file = write_schema_information(xml_contents, final_revisions)
    
    path = f"revised-{filename[0]}"
    with open(path, "w") as file:
        file.write(completed_file)

    return f'{filename[0]} downloaded! Please review the XML document for well-formedness.'

if __name__ == "__main__":
#     app.run_server(mode = 'inline', debug = True) # mode = 'inline' for JupyterDash
    app.run_server(debug = True)<jupyter_output>Dash app running on http://127.0.0.1:8050/
CPU times: user 26.2 ms, sys: 11.1 ms, total: 37.3 ms
Wall time: 49.2 ms
{'fraction_non_alphanumeric': 0.14316506744541788, 'fraction_numerical': 0.0036851620080656376, 'mean_word_length': 2.374589394650399, 'pattern_counts': {'":': 2, '<': 49, '<?xml version=': 0, '>': 47, 'https://': 4, 'lorem ipsum': 0, 'www.': 1, 'xml': 40}, 'pii_count': 1, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 28, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '3', 'dir': 'jupyter-structured-clean-dedup', 'id': '542566', 'license': 'MIT', 'n_tokens_mistral': 8512, 'n_tokens_neox': 8117, 'n_words': 2054, 'path': 'Jupyter_Notebooks/Interfaces/NER_Application/App_nerHelper-Generic.ipynb', 'repo_name': 'NEU-Libraries/dsg-mhs', 'size': '28764'}