From 144adf64c4971444df83e573e38de8d8a9a1cbe3 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:41:48 -0500 Subject: [PATCH] Complete PDF Sort and Merge Flask Application - Full-featured web application for PDF processing - Microsoft Entra (Azure AD) authentication with PKCE - Parallel PDF sorting and merging capabilities - Job management system with real-time progress tracking - Modern web UI with responsive design - Comprehensive error handling and logging - Ubuntu installation script for easy deployment - Environment configuration for secure credential management --- .env | 24 + .env.example | 23 + .gitignore | 41 ++ AZURE_AD_SETUP.md | 93 ++++ Find_Missing_Addresses.sh | 82 ++++ README.md | 210 ++++++++- Removing_Files.sh | 68 +++ Run.sh | 94 ++++ Sort_and_Merge_Files.sh | 62 +++ app.py | 935 ++++++++++++++++++++++++++++++++++++++ jobs.json | 30 ++ requirements.txt | 6 + static/trbc-logo-full.png | Bin 0 -> 71976 bytes templates/index.html | 312 +++++++++++++ templates/job.html | 227 +++++++++ templates/jobs.html | 242 ++++++++++ templates/results.html | 219 +++++++++ ubuntu-install.sh | 237 ++++++++++ 18 files changed, 2903 insertions(+), 2 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AZURE_AD_SETUP.md create mode 100755 Find_Missing_Addresses.sh create mode 100755 Removing_Files.sh create mode 100644 Run.sh create mode 100755 Sort_and_Merge_Files.sh create mode 100644 app.py create mode 100644 jobs.json create mode 100644 requirements.txt create mode 100644 static/trbc-logo-full.png create mode 100644 templates/index.html create mode 100644 templates/job.html create mode 100644 templates/jobs.html create mode 100644 templates/results.html create mode 100644 ubuntu-install.sh diff --git a/.env b/.env new file mode 100644 index 0000000..c5dbdc8 --- /dev/null +++ b/.env @@ -0,0 +1,24 @@ +# Microsoft Entra (Azure AD) Configuration +# Get these values from your Azure AD app registration in the Azure portal + +# Azure AD Tenant ID (Directory ID) +AZURE_TENANT_ID=98367e69-bd4c-47c6-90ef-683461397b64 + +# Azure AD Client ID (Application ID) +AZURE_CLIENT_ID=5b2137d0-b143-4f1a-85b0-d740d62faebc + +# Azure AD Client Secret +AZURE_CLIENT_SECRET=Ql88Q~1wo-_L8vg-csgL4GFhLR_A8klPvphHUcyx + +# Azure AD Authority URL (usually https://login.microsoftonline.com/{tenant_id}) +AZURE_AUTHORITY=https://login.microsoftonline.com/98367e69-bd4c-47c6-90ef-683461397b64 + +# Redirect URI (must match what's configured in Azure AD app registration) +# For local development, use: http://localhost:8080/auth/callback +AZURE_REDIRECT_URI=http://localhost:5000/auth/callback + +# Scopes to request (openid, profile, email are common) +AZURE_SCOPES=openid profile email + +# Flask Configuration +SECRET_KEY=your-secret-key-here-change-this-in-production \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b1f18b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Microsoft Entra (Azure AD) Configuration +# Copy this file to .env and fill in your actual values + +# Azure AD Tenant ID (Directory ID) +AZURE_TENANT_ID= + +# Azure AD Client ID (Application ID) +AZURE_CLIENT_ID= + +# Azure AD Client Secret +AZURE_CLIENT_SECRET= + +# Azure AD Authority URL (usually https://login.microsoftonline.com/{tenant_id}) +AZURE_AUTHORITY= + +# Redirect URI (must match what's configured in Azure AD app registration) +AZURE_REDIRECT_URI=http://localhost:5000/auth/callback + +# Scopes to request (openid, profile, email are common) +AZURE_SCOPES=openid profile email + +# Flask Configuration +SECRET_KEY=your-secret-key-here-change-this-in-production \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62d7cbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Application specific +results/ +status.txt +jobs.json \ No newline at end of file diff --git a/AZURE_AD_SETUP.md b/AZURE_AD_SETUP.md new file mode 100644 index 0000000..5d25b8f --- /dev/null +++ b/AZURE_AD_SETUP.md @@ -0,0 +1,93 @@ +# Microsoft Entra Authentication Setup Guide + +This application uses Microsoft Entra (Azure Active Directory) for authentication. Follow these steps to configure it: + +## 1. Create an Azure AD App Registration + +1. Go to the [Azure Portal](https://portal.azure.com) +2. Navigate to **Azure Active Directory** > **App registrations** +3. Click **New registration** +4. Enter a name for your app (e.g., "PDF Processor") +5. For **Supported account types**, select "Accounts in this organizational directory only" +6. For **Redirect URI**, select "Web" and enter: `http://localhost:5000/auth/callback` +7. Click **Register** + +## 2. Configure Authentication + +1. In your app registration, go to **Authentication** +2. Under **Redirect URIs**, ensure `http://localhost:5000/auth/callback` is listed +3. Under **Implicit grant and hybrid flows**, check: + - [x] Access tokens + - [x] ID tokens +4. Click **Save** + +## 3. Get Application Credentials + +1. Go to **Overview** in your app registration +2. Copy the **Application (client) ID** - this is your `AZURE_CLIENT_ID` +3. Copy the **Directory (tenant) ID** - this is your `AZURE_TENANT_ID` + +## 4. Create a Client Secret + +1. Go to **Certificates & secrets** +2. Click **New client secret** +3. Enter a description and select an expiration +4. Click **Add** +5. **Important**: Copy the secret value immediately - this is your `AZURE_CLIENT_SECRET` + +## 5. Configure API Permissions + +1. Go to **API permissions** +2. Click **Add a permission** +3. Select **Microsoft Graph** +4. Select **Delegated permissions** +5. Add these permissions: + - `openid` + - `profile` + - `email` +6. Click **Add permissions** +7. Click **Grant admin consent** for your organization + +## 6. Configure Environment Variables + +Create a `.env` file in the project root with your Azure AD settings: + +```env +# Azure AD Configuration +AZURE_TENANT_ID=your-tenant-id-here +AZURE_CLIENT_ID=your-client-id-here +AZURE_CLIENT_SECRET=your-client-secret-here +AZURE_AUTHORITY=https://login.microsoftonline.com/your-tenant-id-here +AZURE_REDIRECT_URI=http://localhost:5000/auth/callback +AZURE_SCOPES=openid profile email +``` + +## 7. Update Redirect URI for Production + +When deploying to production, update the redirect URI in both: +- Azure AD app registration +- Your `.env` file (change localhost to your production domain) + +**Note**: For local development, the app runs on port 5000, so use `http://localhost:5000/auth/callback` as the redirect URI. + +## 8. Test the Authentication + +1. Start the application: `python app.py` +2. Visit `http://localhost:5000` +3. Click "Login with Microsoft" +4. You should be redirected to Microsoft login +5. After authentication, you'll be redirected back to the app + +## Troubleshooting + +- **"invalid_client" error**: Check your client ID and secret +- **"invalid_request" error**: Verify the redirect URI matches exactly +- **Permission denied**: Ensure you've granted admin consent for the API permissions +- **Token errors**: Check that your tenant ID is correct + +## Security Notes + +- Never commit the `.env` file to version control +- Use strong client secrets and rotate them regularly +- In production, use HTTPS for all authentication flows +- Consider implementing token refresh logic for long-running sessions \ No newline at end of file diff --git a/Find_Missing_Addresses.sh b/Find_Missing_Addresses.sh new file mode 100755 index 0000000..f898f90 --- /dev/null +++ b/Find_Missing_Addresses.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +#Get the folder with the pdf documents in it. +#echo "" +#echo "This will sort out the addresses in all the Statement PDF's into 2 folders so that you can then run the Merge file." +#echo "To get started please answer 2 questions." +#while : +#do +# echo -e "Where is the directory with ALL the pdf statments?" +# read PDFDIR +# if [ -d $PDFDIR ] +# then +# break +# else +# echo "Directory not found." +# fi +#done +#Get the folder for the output file. +#echo "" +#while : +#do +# echo -e "What output directory do you want for the sorted pdf's? This will sort into 2 folders, Missing_Addresses and Address" +# read OUTPUTFOLDER +# if [ -d $OUTPUTFOLDER ] +# then +# break +# else +# echo "Directory not found." +# fi +#done + +#Create the csv and headers for the Addresses. It is tab separated. +mkdir $OUTPUTFOLDER/Addresses +touch $OUTPUTFOLDER/Addresses/Addresses.csv +echo -e "File,User,Street,City,State,Zip" > $OUTPUTFOLDER/Addresses/Addresses.csv + +#Create csv with all the files with missing addresses +mkdir $OUTPUTFOLDER/Missing_Addresses +touch $OUTPUTFOLDER/Missing_Addresses/Missing_Addresses.csv + +echo -e "File\tUser" > $OUTPUTFOLDER/Missing_Addresses/Missing_Addresses.csv + +cd $PDFDIR +#FOLDER=$(ls | grep *.pdf) +#echo $FOLDER +#*.pdf represensts All files in the current directory with a pdf extention. +for f in $PDFDIR/* +do + echo "Processing file $f." + #pdftotext converst the file to text and places it in the file name temp + pdftotext "$f" - > TEMP + #assigns the lines with relevant values to their variable + USER=$(sed -n '12p' TEMP) + ADDRESS1=$(sed -n '13p' TEMP) + ADDRESS2=$(sed -n '14p' TEMP) + echo -e "User is $USER" + echo -e "Address is $ADDRESS1 $ADDRESS2" + if [ -z "$ADDRESS1" ] + then + #Move the file with the Missing addresses to the Missing Address folder and make an note in its csv. + echo "Copying file to the folder called Missing_Addresses" + cp "$f" "$OUTPUTFOLDER"/Missing_Addresses/ + echo -e "$f\t$USER" >> "$OUTPUTFOLDER"/Missing_Addresses/Missing_Addresses.csv + #Adds the row of variables to the csv. >> appends to the file. \t represents tabs + else + # Split Addresses + CITY=$(echo $ADDRESS2 | cut -d, -f1) + ADDRESS2=$(echo $ADDRESS2 | sed -e "s/$CITY, //") + + STATE=$(echo $ADDRESS2 | cut -d" " -f1) + ZIP=$(echo $ADDRESS2 | cut -d" " -f2) + + echo -e "$f,$USER,$ADDRESS1,$CITY,$STATE,$ZIP" >> $OUTPUTFOLDER/Addresses/Addresses.csv + cp "$f" $OUTPUTFOLDER/Addresses/ + echo "Copying file to the folder called Addresses" + fi +done +echo "All Done!" You can find all the Files in $OUTPUTFOLDER!" +echo " " +echo " " + +exit diff --git a/README.md b/README.md index fcc5726..426f94a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,209 @@ -# EOY PDF Sortandmerge +# End of Year Statements PDF Processor -Project to sort, filter, and merge pdf files. \ No newline at end of file +A web application for sorting and merging PDF statements by address and page count, optimized for processing large batches of documents. + +## Features + +- Upload individual PDFs, ZIP archives, or entire folders +- Automatic address extraction and sorting +- Parallel processing for maximum speed +- Real-time progress tracking +- Web-based interface with progress bars and status updates +- Asynchronous processing to prevent timeouts +- **Microsoft Entra (Azure AD) authentication for secure access** + +## Authentication + +This application requires Microsoft Entra (Azure Active Directory) authentication. Before running the application, you must: + +1. Set up an Azure AD app registration +2. Configure authentication settings in `.env` +3. Grant necessary API permissions + +See [AZURE_AD_SETUP.md](AZURE_AD_SETUP.md) for detailed setup instructions. + +## Server Recommendations + +### VMware Virtual Machine Specifications + +For optimal performance with large PDF batches (1000+ files), we recommend the following VM configuration: + +#### Minimum Requirements +- **vCPUs**: 4 cores +- **RAM**: 8 GB +- **Storage**: 50 GB SSD +- **OS**: Ubuntu Server 22.04 LTS (or CentOS 8+) + +#### Recommended for Production +- **vCPUs**: 8-16 cores (more cores = faster processing) +- **RAM**: 16-32 GB (PDFs load into memory) +- **Storage**: 100 GB NVMe SSD +- **Network**: 1 Gbps connection + +#### High-Performance Setup (for 5000+ PDFs) +- **vCPUs**: 16-32 cores +- **RAM**: 64 GB +- **Storage**: 200 GB NVMe SSD +- **OS**: Ubuntu Server 22.04 LTS + +### VMware Configuration Tips +1. Enable CPU hot-add and memory hot-plug for future upgrades +2. Use VMXNET3 network adapter for better performance +3. Allocate storage from a high-performance datastore +4. Enable VMware Tools for optimal integration + +## Installation Instructions + +### 1. VM Setup +1. Create a new VM in VMware with the specifications above +2. Install Ubuntu Server 22.04 LTS +3. Update the system: + ```bash + sudo apt update && sudo apt upgrade -y + ``` + +### 2. Install Python and Dependencies +```bash +# Install Python 3.10+ +sudo apt install python3 python3-pip python3-venv -y + +# Create application directory +sudo mkdir -p /opt/pdf-processor +sudo chown $USER:$USER /opt/pdf-processor +cd /opt/pdf-processor + +# Clone or copy the application files +# (Assuming you have the files ready) + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt +``` + +### 3. Install Production Server (Gunicorn) +```bash +pip install gunicorn + +# Create systemd service +sudo nano /etc/systemd/system/pdf-processor.service +``` + +Add the following content: +```ini +[Unit] +Description=PDF Processor Web App +After=network.target + +[Service] +User=your-username +Group=your-group +WorkingDirectory=/opt/pdf-processor +Environment="PATH=/opt/pdf-processor/venv/bin" +ExecStart=/opt/pdf-processor/venv/bin/gunicorn -w 4 -b 0.0.0.0:8000 app:app +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Enable and start service +sudo systemctl enable pdf-processor +sudo systemctl start pdf-processor + +# Check status +sudo systemctl status pdf-processor +``` + +### 4. Configure Nginx (Optional, for Production) +```bash +sudo apt install nginx -y + +# Create nginx config +sudo nano /etc/nginx/sites-available/pdf-processor +``` + +Add: +```nginx +server { + listen 80; + server_name your-server-ip; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/pdf-processor /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl restart nginx +``` + +### 5. Access the Application +- Open your web browser and navigate to `http://your-vm-ip:8000` (or port 80 if using Nginx) +- Upload your PDF files and monitor the processing progress + +## Performance Tuning + +### Gunicorn Workers +Adjust the number of workers based on your CPU cores: +```bash +gunicorn -w $(nproc) -b 0.0.0.0:8000 app:app +``` + +### Python Optimization +The app automatically uses all available CPU cores for parallel PDF processing. For very large batches, consider: + +1. Increasing system file limits: + ```bash + sudo nano /etc/security/limits.conf + # Add: * soft nofile 65536 + # Add: * hard nofile 65536 + ``` + +2. Tuning kernel parameters for better I/O: + ```bash + sudo nano /etc/sysctl.conf + # Add: vm.dirty_ratio = 10 + # Add: vm.dirty_background_ratio = 5 + sudo sysctl -p + ``` + +## Troubleshooting + +### Common Issues +1. **413 Request Entity Too Large**: The upload limit is set to 1000TB, but check client-side limits +2. **Processing Stuck**: Check server logs with `sudo journalctl -u pdf-processor -f` +3. **Memory Issues**: Monitor with `htop` and increase RAM if needed +4. **Slow Processing**: Ensure all CPU cores are being utilized + +### Monitoring +```bash +# Check CPU usage during processing +htop + +# Monitor application logs +sudo journalctl -u pdf-processor -f + +# Check disk I/O +iotop +``` + +## Security Considerations +- Run behind a reverse proxy (Nginx) in production +- Use HTTPS with SSL certificates +- Restrict access with firewall rules +- Regularly update dependencies +- Monitor for resource usage + +## Support +For issues or questions, check the application logs and ensure your VM meets the minimum requirements. \ No newline at end of file diff --git a/Removing_Files.sh b/Removing_Files.sh new file mode 100755 index 0000000..c1c44bb --- /dev/null +++ b/Removing_Files.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +#Get the folder with the pdf documents in it. +while : +do + echo -e "What is the directory with the pdfs?" + #read PDFDIR + PDFDIR="/home/btmccrea/Downloads/pdfs" + if [ -d $PDFDIR ] + then + break + else + echo -e "Directory not found." + fi +done +#Get the folder for the output file. +while : +do + echo -e "What is the output directory for the pdf's with missing addresses?" + #read OUTPUTFOLDER + OUTPUTFOLDER="/home/btmccrea/Downloads/output/Final" + if [ -d $OUTPUTFOLDER ] + then + break + else + echo -e "Directory not found." + fi +done +#Get the folder with the csv +while : +do + echo -e "What path and file with?" + #read CSVFILE + if [ -f $CSVFILE ] + then + break + else + echo -e "File not found." + fi +done + +#import list from csv +echo "Processing csv." +while IFS=$'\t' read -r FILE +do + echo "FILE $FILE" +# echo "user $USER" +# echo "match $MATCH" +# echo "${array[0]}" +# if [[ "$MATCH" == "Match-Mailable" ]] +# then +# +# echo -e "Processing file $FILE"lY + cp $PDFDIR/"$FILE" $OUTPUTFOLDER +# else +# echo "no match" +# fi +done < /home/btmccrea/Downloads/Smarty_Streets.csv +#FILE=$(cat /home/btmccrea/Downloads/Smarty_Streets.csv) +#echo $FILE +#IFS=$'\t' +#for f in Smarty_Streets.csv +#do +# echo "doing file $f" +# cp $PDFDIR/"$f" $OUTPUTFOLDER +#done + +#cd to and then remove files diff --git a/Run.sh b/Run.sh new file mode 100644 index 0000000..d1133da --- /dev/null +++ b/Run.sh @@ -0,0 +1,94 @@ + +echo "Welcome! It must be the start of a new year! In order to get started with sorting and merging the PDF documents I need to know a little more information." +echo +echo +read -p 'Where is the directory that has the PDF statments with address? ' PDFDIR +#read PDFDIR +#Get the folder for the output file. +echo +read -p 'What output directory do you want for the sorted PDFs? ' OUTPUTFOLDER +#read OUTPUTFOLDER +echo +echo "The PDFs statments are in $PDFDIR?" +echo "The output folder will be $OUTPUTFOLDER." +echo +read -p "Press Enter to continue" $OUTPUTFOLDER/Addresses/Addresses.csv + +#Create csv with all the files with missing addresses +mkdir $OUTPUTFOLDER/Missing_Addresses +touch $OUTPUTFOLDER/Missing_Addresses/Missing_Addresses.csv + +echo -e "File\tUser" > $OUTPUTFOLDER/Missing_Addresses/Missing_Addresses.csv + +cd $PDFDIR +#FOLDER=$(ls | grep *.pdf) +#echo $FOLDER +#*.pdf represensts All files in the current directory with a pdf extention. +for f in $PDFDIR/* +do + echo "Processing file $f." + #pdftotext converst the file to text and places it in the file name temp + pdftotext "$f" - > TEMP + #assigns the lines with relevant values to their variable + USER=$(sed -n '12p' TEMP) + ADDRESS1=$(sed -n '13p' TEMP) + ADDRESS2=$(sed -n '14p' TEMP) + echo -e "User is $USER" + echo -e "Address is $ADDRESS1 $ADDRESS2" + if [ -z "$ADDRESS1" ] + then + #Move the file with the Missing addresses to the Missing Address folder and make an note in its csv. + echo "Copying file to the folder called Missing_Addresses" + cp "$f" "$OUTPUTFOLDER"/Missing_Addresses/ + echo -e "$f\t$USER" >> "$OUTPUTFOLDER"/Missing_Addresses/Missing_Addresses.csv + #Adds the row of variables to the csv. >> appends to the file. \t represents tabs + else + # Split Addresses + CITY=$(echo $ADDRESS2 | cut -d, -f1) + ADDRESS2=$(echo $ADDRESS2 | sed -e "s/$CITY, //") + + STATE=$(echo $ADDRESS2 | cut -d" " -f1) + ZIP=$(echo $ADDRESS2 | cut -d" " -f2) + + echo -e "$f,$USER,$ADDRESS1,$CITY,$STATE,$ZIP" >> $OUTPUTFOLDER/Addresses/Addresses.csv + cp "$f" $OUTPUTFOLDER/Addresses/ + echo "Copying file to the folder called Addresses" + fi +done +echo "All done sorting! You can find all the Files in $OUTPUTFOLDER!" +echo +echo +read -r -p "Do you want to continue? [Y/n]" response +response=${response,,} # tolower +if [[ $response =~ ^(y| ) ]] || [[ -z $response ]]; then + cd $PDFDIR + for FILENAME in $PDFDIR/* + do + echo $FILENAME +#The next Line assigns the number of pages to the value LOCATION to be used for were the file goes + LOCATION=$(pdfinfo "$FILENAME" | grep Pages: | awk '{print $2}') +#Test if the file exists and if it does move it to Temp.pdf to be merged back to its original location. + if test -f $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf + then + mv $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf $OUTPUTFOLDER/temp.pdf + pdfunite "$FILENAME" $OUTPUTFOLDER/temp.pdf $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf + else + #IF the file doesn't exist create the file with the merge. + pdfunite "$FILENAME" $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf + fi +done +echo +echo +echo "All done, you can find the PDF statments at $OUTPUTFOLDER." +echo +echo +exit \ No newline at end of file diff --git a/Sort_and_Merge_Files.sh b/Sort_and_Merge_Files.sh new file mode 100755 index 0000000..52e25e1 --- /dev/null +++ b/Sort_and_Merge_Files.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +#get directory for pdf files +#get output directory for files + + +#Get the folder with the pdf documents in it. +#echo "This will take the PDF statements that have been sorted and will merge them into PDF files based on the numbers of pages in the statement." +#echo "" +#echo "Before we begin we need a little information." +#echo "" +#while : +#do +# echo "Where is the directory that has the PDF statments with address?" +# read PDFDIR +# if [ -d $PDFDIR ] +# then +# break +# else +# echo "Directory not found." +# fi +#done + +#Get the folder for the output file. +#while : +#do +# echo "" +# echo "What output directory do you want for the sorted PDF's?" +# read OUTPUTFOLDER +# #OUTPUTFOLDER="/home/btmccrea/Downloads/output" +# if [ -d $OUTPUTFOLDER ] +# then +# break +# else +# echo "Directory not found." +# fi +#done + +cd $PDFDIR +for FILENAME in $PDFDIR/* +do + echo $FILENAME +#The next Line assigns the number of pages to the value LOCATION to be used for were the file goes + LOCATION=$(pdfinfo "$FILENAME" | grep Pages: | awk '{print $2}') +#Test if the file exists and if it does move it to Temp.pdf to be merged back to its original location. + if test -f $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf + then + mv $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf $OUTPUTFOLDER/temp.pdf + pdfunite "$FILENAME" $OUTPUTFOLDER/temp.pdf $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf + else + #IF the file doesn't exist create the file with the merge. + pdfunite "$FILENAME" $OUTPUTFOLDER/Statement-Pages-$LOCATION.pdf + fi + +done + +echo "" +echo "" +echo "All done, you can find the PDF statments at $OUTPUTFOLDER. +echo "" +echo "" +exit \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..ab66e30 --- /dev/null +++ b/app.py @@ -0,0 +1,935 @@ +from flask import Flask, request, render_template, send_file, flash, redirect, url_for, jsonify, session +import os +import tempfile +import shutil +import fitz +from werkzeug.utils import secure_filename +import zipfile +import time +import threading +from multiprocessing import Pool, cpu_count +import json +from datetime import datetime +from dotenv import load_dotenv +from requests_oauthlib import OAuth2Session +from functools import wraps +import secrets +import base64 +import hashlib + +app = Flask(__name__) +app.config['UPLOAD_FOLDER'] = tempfile.mkdtemp() +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'secret') +app.config['MAX_CONTENT_LENGTH'] = 1000 * 1024 * 1024 * 1024 # 1000TB + +# Load environment variables +load_dotenv() + +# Microsoft Entra (Azure AD) Configuration +AZURE_TENANT_ID = os.getenv('AZURE_TENANT_ID') +AZURE_CLIENT_ID = os.getenv('AZURE_CLIENT_ID') +AZURE_CLIENT_SECRET = os.getenv('AZURE_CLIENT_SECRET') +AZURE_AUTHORITY = os.getenv('AZURE_AUTHORITY', f'https://login.microsoftonline.com/{AZURE_TENANT_ID}') +AZURE_REDIRECT_URI = os.getenv('AZURE_REDIRECT_URI', 'http://localhost:5000/auth/callback') +AZURE_SCOPES = os.getenv('AZURE_SCOPES', 'openid profile email').split() + +# Validate Azure AD configuration +azure_config_valid = all([ + AZURE_TENANT_ID, + AZURE_CLIENT_ID, + AZURE_CLIENT_SECRET +]) + +if not azure_config_valid: + print("Warning: Azure AD configuration is incomplete. Authentication will not work.") + print("Please check your .env file and ensure all AZURE_* variables are set.") + +# OAuth2 configuration +AUTHORIZATION_BASE_URL = f'{AZURE_AUTHORITY}/oauth2/v2.0/authorize' +TOKEN_URL = f'{AZURE_AUTHORITY}/oauth2/v2.0/token' + +# Authentication helper functions +def get_oauth_client(state=None): + """Create OAuth2 client for Azure AD.""" + if not azure_config_valid: + raise ValueError("Azure AD configuration is incomplete. Please check your .env file.") + return OAuth2Session( + client_id=AZURE_CLIENT_ID, + scope=AZURE_SCOPES, + state=state, + redirect_uri=AZURE_REDIRECT_URI + ) + +def generate_pkce(): + """Generate PKCE code verifier and challenge.""" + code_verifier = secrets.token_urlsafe(32) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).decode().rstrip('=') + + return code_verifier, code_challenge + +def login_required(f): + """Decorator to require authentication for routes.""" + @wraps(f) + def decorated_function(*args, **kwargs): + print(f"DEBUG: login_required called for {f.__name__}") + print(f"DEBUG: azure_config_valid = {azure_config_valid}") + print(f"DEBUG: session keys = {list(session.keys())}") + + if not azure_config_valid: + # If Azure AD is not configured, show error + print("DEBUG: Azure AD not configured") + return "Authentication not configured. Please check your .env file.", 500 + if 'oauth_token' not in session: + print("DEBUG: No oauth_token in session, redirecting to login") + return redirect(url_for('login')) + print("DEBUG: Authentication passed") + return f(*args, **kwargs) + return decorated_function + +def get_user_info(): + """Get user information from session.""" + if 'user_info' in session: + return session['user_info'] + return None + +# Authentication routes +@app.route('/login') +def login(): + """Initiate Azure AD login flow.""" + if 'oauth_token' in session: + return redirect(url_for('index')) + + # Generate PKCE manually + code_verifier, code_challenge = generate_pkce() + session['code_verifier'] = code_verifier + + # Build authorization URL manually with PKCE + from urllib.parse import urlencode + auth_params = { + 'client_id': AZURE_CLIENT_ID, + 'response_type': 'code', + 'redirect_uri': AZURE_REDIRECT_URI, + 'scope': ' '.join(AZURE_SCOPES), + 'state': secrets.token_urlsafe(16), # Generate state + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + session['oauth_state'] = auth_params['state'] + authorization_url = f"{AUTHORIZATION_BASE_URL}?{urlencode(auth_params)}" + + print(f"DEBUG: Authorization URL: {authorization_url}") + return redirect(authorization_url) + +@app.route('/auth/callback') +def auth_callback(): + """Handle Azure AD OAuth2 callback.""" + print(f"DEBUG: Callback URL: {request.url}") + print(f"DEBUG: Session keys: {list(session.keys())}") + + if 'oauth_state' not in session: + return redirect(url_for('login')) + + # Parse the authorization response + from urllib.parse import parse_qs, urlparse + parsed_url = urlparse(request.url) + query_params = parse_qs(parsed_url.query) + + print(f"DEBUG: Query params: {query_params}") + + if 'error' in query_params: + print(f"DEBUG: Error from Azure AD: {query_params['error'][0]}") + return f"Authentication error: {query_params['error'][0]}", 400 + + if 'code' not in query_params or 'state' not in query_params: + return "Invalid authorization response", 400 + + code = query_params['code'][0] + state = query_params['state'][0] + + if state != session['oauth_state']: + return "State mismatch", 400 + + # Use OAuth2Session for token exchange to maintain PKCE consistency + oauth_session = OAuth2Session( + client_id=AZURE_CLIENT_ID, + scope=AZURE_SCOPES, + redirect_uri=AZURE_REDIRECT_URI + ) + + # Set the same PKCE that was used for authorization + if 'code_verifier' in session and hasattr(oauth_session, '_client'): + oauth_session._client.code_verifier = session['code_verifier'] + + # Set the authorization response for the session + redirect_uri_with_params = f"{AZURE_REDIRECT_URI}?code={code}&state={state}" + print(f"DEBUG: Redirect URI with params: {redirect_uri_with_params}") + + # Exchange code for token using direct HTTP request with PKCE + import requests + + token_data = { + 'client_id': AZURE_CLIENT_ID, + 'client_secret': AZURE_CLIENT_SECRET, # Include client_secret for confidential client + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': AZURE_REDIRECT_URI, + 'scope': ' '.join(AZURE_SCOPES) + } + + # Include code_verifier for PKCE + if 'code_verifier' in session: + token_data['code_verifier'] = session['code_verifier'] + print(f"DEBUG: Including code_verifier in token request: {session['code_verifier'][:10]}...") + + print(f"DEBUG: Token request data keys: {list(token_data.keys())}") + + token_response = requests.post(TOKEN_URL, data=token_data) + print(f"DEBUG: Token response status: {token_response.status_code}") + print(f"DEBUG: Token response: {token_response.text[:200]}...") + + if token_response.status_code != 200: + return f"Token exchange failed: {token_response.text}", 400 + + token = token_response.json() + print(f"DEBUG: Token received successfully") + session['oauth_token'] = token + + # Get user info + user_info = oauth_session.get('https://graph.microsoft.com/v1.0/me').json() + session['user_info'] = user_info + + # Clean up + session.pop('oauth_state', None) + session.pop('code_verifier', None) + + return redirect(url_for('index')) + +@app.route('/logout') +def logout(): + """Logout user and clear session.""" + session.clear() + return redirect(url_for('index')) + +# Job storage +JOBS_FILE = 'jobs.json' +RESULTS_DIR = 'results' +os.makedirs(RESULTS_DIR, exist_ok=True) + +processing_complete = False +current_output_dir = None + +def cleanup_temp_files(): + """Clean up temporary files and directories.""" + import shutil + import glob + + # Clean up any leftover temp directories + temp_base = tempfile.gettempdir() + temp_dirs = glob.glob(os.path.join(temp_base, 'tmp*')) + for temp_dir in temp_dirs: + try: + # Only delete if it's a directory and empty or contains our temp files + if os.path.isdir(temp_dir): + # Check if it contains our processing files + contents = os.listdir(temp_dir) + if any('Addresses' in item or 'Missing_Addresses' in item or 'Originals' in item for item in contents): + shutil.rmtree(temp_dir) + print(f"Cleaned up temp directory: {temp_dir}") + except Exception as e: + print(f"Could not clean up {temp_dir}: {e}") + + # Reset global state + global processing_complete, current_output_dir + processing_complete = False + current_output_dir = None + + # Clear status file + if os.path.exists('status.txt'): + try: + os.remove('status.txt') + except: + pass + +def load_jobs(): + """Load jobs from JSON file.""" + if os.path.exists(JOBS_FILE): + try: + with open(JOBS_FILE, 'r') as f: + jobs = json.load(f) + # Convert timestamp strings back to datetime objects + for job in jobs.values(): + if 'timestamp' in job and isinstance(job['timestamp'], str): + try: + # Handle the format: "2025-11-05 09:10:23.730974" + job['timestamp'] = datetime.strptime(job['timestamp'], '%Y-%m-%d %H:%M:%S.%f') + except (ValueError, TypeError): + # If conversion fails, keep as string + pass + return jobs + except: + return {} + return {} + +def save_jobs(jobs): + """Save jobs to JSON file.""" + with open(JOBS_FILE, 'w') as f: + json.dump(jobs, f, indent=2, default=str) + +@app.route('/', methods=['GET', 'POST']) +@login_required +def index(): + global processing_complete, current_output_dir + if request.method == 'POST': + files = request.files.getlist('files') + if not files or files[0].filename == '': + flash('No files uploaded') + return redirect(request.url) + + # Clean up any previous processing + cleanup_temp_files() + + # Create temp dirs + input_dir = tempfile.mkdtemp() + output_dir = tempfile.mkdtemp() + current_output_dir = output_dir + + for file in files: + if file and file.filename: + filename = secure_filename(file.filename) + if filename.endswith('.zip'): + # Extract zip + zip_temp = tempfile.mkdtemp() + zip_path = os.path.join(zip_temp, filename) + file.save(zip_path) + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(input_dir) + shutil.rmtree(zip_temp) + print(f"Extracted ZIP: {filename}") + elif filename.endswith('.pdf'): + file.save(os.path.join(input_dir, filename)) + print(f"Saved PDF: {filename}") + # For folder upload, files are saved with secure_filename, but since webkitdirectory, filename includes path, but secure_filename flattens it + else: + # Assume folder upload + file.save(os.path.join(input_dir, filename)) + print(f"Saved file from folder: {filename}") + + # Create job immediately + job_start_time = datetime.now() + job_id = job_start_time.strftime('%Y%m%d_%H%M%S') + jobs = load_jobs() + jobs[job_id] = { + 'id': job_id, + 'timestamp': job_start_time, + 'status': 'processing', + 'message': 'Starting processing...', + 'file_count': len([f for f in files if f.filename]), # Estimate, will be updated + 'progress': 0 + } + save_jobs(jobs) + + # Start processing in background + processing_complete = False + thread = threading.Thread(target=process_pdfs_async, args=(input_dir, output_dir, job_id)) + thread.start() + + # Redirect to job status page + return redirect(url_for('job_status', job_id=job_id)) + + return render_template('index.html', user_info=get_user_info()) + +@app.route('/status') +def status(): + print("Status requested") + try: + with open('status.txt', 'r') as f: + content = f.read() + if "Processing complete" in content: + return jsonify({"status": "complete", "message": content}) + elif "Error" in content: + return jsonify({"status": "error", "message": content}) + else: + return jsonify({"status": "processing", "message": content}) + except FileNotFoundError: + return jsonify({"status": "error", "message": "Processing not complete or file not found"}) + +@app.route('/results') +@login_required +def results(): + jobs = load_jobs() + # Sort jobs by timestamp, newest first + sorted_jobs = sorted(jobs.values(), key=lambda x: x['timestamp'], reverse=True) + return render_template('results.html', jobs=sorted_jobs, user_info=get_user_info()) + +@app.route('/jobs') +@login_required +def jobs_page(): + jobs = load_jobs() + # Sort jobs by timestamp, newest first + sorted_jobs = sorted(jobs.values(), key=lambda x: x['timestamp'], reverse=True) + return render_template('jobs.html', jobs=sorted_jobs, user_info=get_user_info()) + +@app.route('/job/') +@login_required +def job_status(job_id): + jobs = load_jobs() + if job_id not in jobs: + flash('Job not found') + return redirect(url_for('index')) + + job = jobs[job_id] + return render_template('job.html', job=job, user_info=get_user_info()) + +@app.route('/api/job//status') +def get_job_status(job_id): + jobs = load_jobs() + if job_id not in jobs: + return jsonify({"error": "Job not found"}), 404 + + job = jobs[job_id] + return jsonify({ + "id": job["id"], + "status": job["status"], + "message": job.get("message", ""), + "progress": job.get("progress", 0), + "file_count": job.get("file_count", 0) + }) + +def process_pdfs(pdfdir, outputfolder): + global processing_complete + status_file = 'status.txt' + with open(status_file, 'w') as status_f: + status_f.write("Step 1/4: Finding PDF files...\n") + print("Step 1/4: Finding PDF files...") + time.sleep(0.1) + + addresses_dir = os.path.join(outputfolder, 'Addresses') + missing_dir = os.path.join(outputfolder, 'Missing_Addresses') + os.makedirs(addresses_dir) + os.makedirs(missing_dir) + + with open(os.path.join(addresses_dir, 'Addresses.csv'), 'w') as addr_csv: + addr_csv.write("File,User,Street,City,State,Zip\n") + + with open(os.path.join(missing_dir, 'Missing_Addresses.csv'), 'w') as miss_csv: + miss_csv.write("File\tUser\n") + + pdf_files = [] + for root, dirs, files in os.walk(pdfdir): + for f in files: + if f.endswith('.pdf'): + pdf_files.append(os.path.join(root, f)) + + msg = f"Step 1/4: Found {len(pdf_files)} PDF files\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.1) + + # Update job status + jobs = load_jobs() + jobs[job_id]['message'] = f"Step 1/4: Found {len(pdf_files)} PDF files" + jobs[job_id]['file_count'] = len(pdf_files) + jobs[job_id]['progress'] = 10 + save_jobs(jobs) + + msg = "Step 2/4: Processing files...\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print("Step 2/4: Processing files...") + + # Update job status + jobs[job_id]['message'] = "Step 2/4: Processing files..." + jobs[job_id]['progress'] = 20 + save_jobs(jobs) + + time.sleep(0.1) + + # Process files in parallel + with Pool(processes=cpu_count()) as pool: + results = pool.map(process_single_pdf, pdf_files) + + for result in results: + f = result['filename'] + if result['type'] == 'missing': + # Missing address + shutil.copy(result['filepath'], os.path.join(missing_dir, f)) + with open(os.path.join(missing_dir, 'Missing_Addresses.csv'), 'a') as miss_csv: + miss_csv.write(f"{f}\t{result['user']}\n") + msg = f" -> Moved to Missing_Addresses\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + elif result['type'] == 'address': + # Valid address + shutil.copy(result['filepath'], os.path.join(addresses_dir, f)) + with open(os.path.join(addresses_dir, 'Addresses.csv'), 'a') as addr_csv: + addr_csv.write(f"{f},{result['user']},{result['address']},{result['city']},{result['state']},{result['zip']}\n") + msg = f" -> Moved to Addresses (User: {result['user']}, Address: {result['address']}, {result['city']})\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + elif result['type'] == 'error': + msg = f" -> Error processing {f}: {result['error']}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.05) + + msg = "Step 2/4: Sorting complete. Starting merging...\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs[job_id]['message'] = "Step 2/4: Sorting complete. Starting merging..." + jobs[job_id]['progress'] = 80 + save_jobs(jobs) + + time.sleep(0.1) + + # Merge by page count + page_groups = {} + for filepath in pdf_files: + f = os.path.basename(filepath) + doc = fitz.open(filepath) + pages = len(doc) + doc.close() + if pages not in page_groups: + page_groups[pages] = [] + page_groups[pages].append(filepath) + + msg = f"Step 3/4: Found {len(page_groups)} different page counts to merge\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.1) + + for pages, files in page_groups.items(): + msg = f"Merging {len(files)} files with {pages} pages each\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.05) + try: + merger = fitz.open() + for file in files: + doc = fitz.open(file) + merger.insert_pdf(doc) + doc.close() + output_path = os.path.join(outputfolder, f'Statement-Pages-{pages}.pdf') + merger.save(output_path) + merger.close() + msg = f" -> Created {output_path}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + except Exception as e: + msg = f" -> Error merging files with {pages} pages: {str(e)}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.05) + + msg = f"Processing complete! Processed {len(pdf_files)} files total\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs[job_id]['message'] = f"Processing complete! Processed {len(pdf_files)} files total" + jobs[job_id]['progress'] = 90 + save_jobs(jobs) + + time.sleep(0.1) + + # Step 4/4: Creating ZIP file + msg = "Step 4/4: Creating ZIP file...\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs[job_id]['message'] = "Step 4/4: Creating ZIP file..." + jobs[job_id]['progress'] = 95 + save_jobs(jobs) + + time.sleep(0.1) + + # Create zip with unique name + zip_filename = f'{job_id}.zip' + zip_path = os.path.join(RESULTS_DIR, zip_filename) + + try: + # Create zip with only merged PDF documents (no CSV files) + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Add merged statement PDFs from root output folder + for file in os.listdir(outputfolder): + if file.endswith('.pdf') and file.startswith('Statement-Pages-'): + file_path = os.path.join(outputfolder, file) + zipf.write(file_path, file) + + # Save job metadata + jobs = load_jobs() + jobs[job_id]['status'] = 'completed' + jobs[job_id]['zip_path'] = zip_path + jobs[job_id]['zip_filename'] = zip_filename + jobs[job_id]['message'] = f'Processing complete! ZIP created: {zip_filename}' + jobs[job_id]['progress'] = 100 + save_jobs(jobs) + + msg = f"ZIP created: {zip_filename}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + except Exception as e: + msg = f"Error creating ZIP: {str(e)}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + # Still save job as failed + jobs = load_jobs() + jobs[job_id]['status'] = 'failed' + jobs[job_id]['error'] = str(e) + jobs[job_id]['message'] = f'Error creating ZIP: {str(e)}' + save_jobs(jobs) + + processing_complete = True + global current_output_dir + current_output_dir = outputfolder + +def process_pdfs_async(pdfdir, outputfolder, job_id): + global processing_complete + try: + jobs = load_jobs() + if job_id not in jobs: + return + + status_file = 'status.txt' + with open(status_file, 'w') as status_f: + status_f.write("Step 1/4: Finding PDF files...\n") + print("Step 1/4: Finding PDF files...") + + # Update job status + jobs[job_id]['message'] = "Step 1/4: Finding PDF files..." + jobs[job_id]['progress'] = 5 + save_jobs(jobs) + + time.sleep(0.1) + + addresses_dir = os.path.join(outputfolder, 'Addresses') + missing_dir = os.path.join(outputfolder, 'Missing_Addresses') + os.makedirs(addresses_dir) + os.makedirs(missing_dir) + + pdf_files = [] + for root, dirs, files in os.walk(pdfdir): + for f in files: + if f.endswith('.pdf'): + pdf_files.append(os.path.join(root, f)) + + msg = f"Step 1/4: Found {len(pdf_files)} PDF files\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs = load_jobs() + jobs[job_id]['message'] = f"Step 1/4: Found {len(pdf_files)} PDF files" + jobs[job_id]['file_count'] = len(pdf_files) + jobs[job_id]['progress'] = 10 + save_jobs(jobs) + + time.sleep(0.1) + + msg = "Step 2/4: Processing files...\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print("Step 2/4: Processing files...") + + # Update job status + jobs[job_id]['message'] = "Step 2/4: Processing files..." + jobs[job_id]['progress'] = 20 + save_jobs(jobs) + + time.sleep(0.1) + + # Parallel processing + num_processes = min(cpu_count(), len(pdf_files)) + with Pool(processes=num_processes) as pool: + results = pool.map(process_single_pdf, pdf_files) + + # Process results sequentially + with open(os.path.join(addresses_dir, 'Addresses.csv'), 'w') as addr_csv: + addr_csv.write("File,User,Street,City,State,Zip\n") + + with open(os.path.join(missing_dir, 'Missing_Addresses.csv'), 'w') as miss_csv: + miss_csv.write("File\tUser\n") + + page_groups = {} + for i, result in enumerate(results, 1): + progress_percent = 20 + int((i / len(results)) * 60) # 20-80% range for processing + + msg = f"Processing result {i}/{len(results)}: {result.get('filename', 'unknown')}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status periodically + if i % 100 == 0 or i == len(results): # Update every 100 files or at the end + jobs[job_id]['message'] = f"Processing result {i}/{len(results)}: {result.get('filename', 'unknown')}" + jobs[job_id]['progress'] = progress_percent + save_jobs(jobs) + + if result['type'] == 'address': + with open(os.path.join(addresses_dir, 'Addresses.csv'), 'a') as addr_csv: + addr_csv.write(f"{result['filename']},{result['user']},{result['address']},{result['city']},{result['state']},{result['zip']}\n") + shutil.copy(result['filepath'], os.path.join(addresses_dir, result['filename'])) + msg = f" -> Moved to Addresses\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + elif result['type'] == 'missing': + with open(os.path.join(missing_dir, 'Missing_Addresses.csv'), 'a') as miss_csv: + miss_csv.write(f"{result['filename']}\t{result['user']}\n") + shutil.copy(result['filepath'], os.path.join(missing_dir, result['filename'])) + msg = f" -> Moved to Missing_Addresses\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + elif result['type'] == 'error': + msg = f" -> Error: {result['error']}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Group for merging + if 'pages' in result: + pages = result['pages'] + if pages not in page_groups: + page_groups[pages] = [] + page_groups[pages].append(result['filepath']) + + time.sleep(0.01) # Small delay for status updates + + msg = "Step 2/4: Sorting complete. Starting merging...\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs[job_id]['message'] = "Step 2/4: Sorting complete. Starting merging..." + jobs[job_id]['progress'] = 80 + save_jobs(jobs) + + time.sleep(0.1) + + # Merge by page count + msg = f"Step 3/4: Found {len(page_groups)} different page counts to merge\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.1) + + for pages, files in page_groups.items(): + msg = f"Merging {len(files)} files with {pages} pages each\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.05) + try: + merger = fitz.open() + for file in files: + doc = fitz.open(file) + merger.insert_pdf(doc) + doc.close() + output_path = os.path.join(outputfolder, f'Statement-Pages-{pages}.pdf') + merger.save(output_path) + merger.close() + msg = f" -> Created {output_path}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + except Exception as e: + msg = f" -> Error merging files with {pages} pages: {str(e)}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + time.sleep(0.05) + + msg = f"Processing complete! Processed {len(pdf_files)} files total\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs[job_id]['message'] = f"Processing complete! Processed {len(pdf_files)} files total" + jobs[job_id]['progress'] = 90 + save_jobs(jobs) + + time.sleep(0.1) + + # Step 4/4: Creating ZIP file + msg = "Step 4/4: Creating ZIP file...\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + # Update job status + jobs[job_id]['message'] = "Step 4/4: Creating ZIP file..." + jobs[job_id]['progress'] = 95 + save_jobs(jobs) + + time.sleep(0.1) + + # Create zip with unique name + zip_filename = f'{job_id}.zip' + zip_path = os.path.join(RESULTS_DIR, zip_filename) + + try: + # Create zip with only merged PDF documents (no CSV files) + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Add merged statement PDFs from root output folder + for file in os.listdir(outputfolder): + if file.endswith('.pdf') and file.startswith('Statement-Pages-'): + file_path = os.path.join(outputfolder, file) + zipf.write(file_path, file) + + # Save job metadata + jobs = load_jobs() + jobs[job_id]['status'] = 'completed' + jobs[job_id]['zip_path'] = zip_path + jobs[job_id]['zip_filename'] = zip_filename + jobs[job_id]['message'] = f'Processing complete! ZIP created: {zip_filename}' + jobs[job_id]['progress'] = 100 + save_jobs(jobs) + + msg = f"ZIP created: {zip_filename}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + + except Exception as e: + msg = f"Error creating ZIP: {str(e)}\n" + with open(status_file, 'a') as status_f: + status_f.write(msg) + print(msg.strip()) + # Still save job as failed + jobs = load_jobs() + jobs[job_id]['status'] = 'failed' + jobs[job_id]['error'] = str(e) + jobs[job_id]['message'] = f'Error creating ZIP: {str(e)}' + save_jobs(jobs) + + except Exception as e: + msg = f"Processing error: {e}" + with open('status.txt', 'w') as f: + f.write(msg) + # Mark job as failed + jobs = load_jobs() + if job_id in jobs: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['error'] = str(e) + jobs[job_id]['message'] = msg + save_jobs(jobs) + processing_complete = True # Still mark as complete to allow retry + +@app.route('/download/') +@login_required +def download_job(job_id): + jobs = load_jobs() + if job_id in jobs and jobs[job_id]['status'] == 'completed': + zip_path = jobs[job_id]['zip_path'] + if os.path.exists(zip_path): + return send_file(zip_path, as_attachment=True, download_name=jobs[job_id]['zip_filename']) + return jsonify({"error": "Job not found or not completed"}), 404 + +@app.route('/download') +def download(): + # Legacy route - redirect to results page + return redirect(url_for('results')) + +@app.route('/delete/', methods=['POST']) +@login_required +def delete_job(job_id): + jobs = load_jobs() + if job_id in jobs: + job = jobs[job_id] + # Delete ZIP file if it exists + if 'zip_path' in job and os.path.exists(job['zip_path']): + try: + os.remove(job['zip_path']) + except: + pass # Ignore errors + # Remove from jobs + del jobs[job_id] + save_jobs(jobs) + flash(f'Job {job_id} deleted successfully') + else: + flash('Job not found') + return redirect(url_for('results')) + +@app.route('/clear', methods=['POST']) +def clear_processing(): + try: + cleanup_temp_files() + return jsonify({"message": "Temporary files cleared and processing state reset."}) + except Exception as e: + return jsonify({"message": f"Error clearing files: {str(e)}"}), 500 + +@app.route('/clear_and_upload') +def clear_and_upload(): + cleanup_temp_files() + flash('Processing state cleared. Ready to upload new files.') + return redirect(url_for('index')) + +def process_single_pdf(filepath): + """Process a single PDF file and return the result.""" + try: + f = os.path.basename(filepath) + doc = fitz.open(filepath) + text = "" + for page in doc: + text += page.get_text() + pages = len(doc) + doc.close() + + lines = text.split('\n') + if len(lines) < 15: + user = "" + addr1 = "" + addr2 = "" + else: + user = lines[11].strip() + addr1 = lines[12].strip() + addr2 = lines[13].strip() + + if not addr1: + return {'type': 'missing', 'filename': f, 'user': user, 'filepath': filepath, 'pages': pages} + else: + if ',' in addr2: + city, rest = addr2.split(',', 1) + city = city.strip() + rest_parts = rest.strip().split() + state = rest_parts[0] if rest_parts else '' + zip_code = ' '.join(rest_parts[1:]) if len(rest_parts) > 1 else '' + else: + city = addr2 + state = '' + zip_code = '' + return {'type': 'address', 'filename': f, 'user': user, 'address': addr1, 'city': city, 'state': state, 'zip': zip_code, 'filepath': filepath, 'pages': pages} + except Exception as e: + return {'type': 'error', 'filename': os.path.basename(filepath), 'error': str(e), 'filepath': filepath} + +if __name__ == '__main__': + try: + from waitress import serve + print("Starting with Waitress server (supports large uploads)...") + serve(app, host='0.0.0.0', port=5000, max_request_body_size=1000*1024*1024*1024) # 1TB + except ImportError: + print("Waitress not available, using Flask dev server...") + app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/jobs.json b/jobs.json new file mode 100644 index 0000000..af75d2a --- /dev/null +++ b/jobs.json @@ -0,0 +1,30 @@ +{ + "84a67896-ea93-4355-9020-ca26bad91316": { + "id": "84a67896-ea93-4355-9020-ca26bad91316", + "timestamp": "2025-11-05 09:10:23.730974", + "file_count": 5559, + "zip_path": "results\\job_84a67896-ea93-4355-9020-ca26bad91316.zip", + "zip_filename": "job_84a67896-ea93-4355-9020-ca26bad91316.zip", + "status": "completed" + }, + "20251105_093008": { + "id": "20251105_093008", + "timestamp": "2025-11-05 09:30:08.978343", + "status": "completed", + "message": "Processing complete! ZIP created: 20251105_093008.zip", + "file_count": 5559, + "progress": 100, + "zip_path": "results\\20251105_093008.zip", + "zip_filename": "20251105_093008.zip" + }, + "20251105_095440": { + "id": "20251105_095440", + "timestamp": "2025-11-05 09:54:40.248747", + "status": "completed", + "message": "Processing complete! ZIP created: 20251105_095440.zip", + "file_count": 5559, + "progress": 100, + "zip_path": "results\\20251105_095440.zip", + "zip_filename": "20251105_095440.zip" + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..897ba73 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +PyMuPDF==1.23.7 +Werkzeug==3.0.1 +waitress==3.0.0 +requests-oauthlib==2.0.0 +python-dotenv==1.2.1 \ No newline at end of file diff --git a/static/trbc-logo-full.png b/static/trbc-logo-full.png new file mode 100644 index 0000000000000000000000000000000000000000..b242db4bae5f9e91adbd505935466100ee6c6f0b GIT binary patch literal 71976 zcmbTdbzGD0_XoT&84?4PPDPZEmTm-e?I@fcE!0)UFUtyx$nJyuA&4ZVITp4K(NP;9;kspL~sy@pyx6a1Om(T z%A!GFI?Uq-_g{KUtWUewGQB#(ZD-ID-@L(jWsvcYT@^7{?UDRSN}p6lAl>DFkMi=@ zp4{34Wn8-QfDmDbd`qCFc(3JH{lUE}S-!76+~Z0Aa&nS9UG=(T{O)41kog+}f9dN% z-k8mveouLb-|auEteEXx?w9ZMWfc_{2|=g4+bq8_!Y>d17s z`nm`SohLV{_f1}C4X4qc4NjNX5AKW9OZJn*c`wu9lskHPo{S=~)Ff5n6v?sV-X17V zh8hSrB`2zs*)e?KhUYf1ki4X3Z*EDFJBV$Pq6?w%tp4X82ttY1C zyC0&nJ%YzaJ%WY!Ocp_e>+^iVq|8(fk|Z8nc)gjbE*ND55@f@{BzT3PP=wMRAp8Oy z(s)905^-I^De4zkv|QfFQ8P{8^tuegkpODB?JW6w!TTNh4#dqK^&vCby*hSlue67Q z!%v%hIQgF=S2*b&af2g=}HEAWcKcV!- zwQ0}inqc&L{C&-@sZfpbbp0R0C-oUz6%~o~4-wu5O)MWG2@Hgqy)khGgO5_02Go$L zOOYeLa^~#%S42WA#Eqz3B?x&@6!Lx}Lm>a%@oL6ZpqL)Swby-?k-} z3Qe7zy25PUY(GlbihGqPW)`5iNyl)uuTJ|0^O(}=nN_U%_Lz|8F;w+WsEhWZljM>? zf&1;4ByQP+NaLnJ#6ezXZO^%H>@O4%^Yug5HH?(EJ&k8|=c3a>UBVAqEB>iIMQQXM z9HUsrujTu#)pyAuCxmI`gM8m>vrEzSb4!-3)b}xONh=!+M0>2xGT@Um9tg9<4X=~n zqQ)mO%Zb!D4VpKDy;e>YLr%dnHiU8>M>jPLX(??}JBJJ6-wQB1)_=~f9d19^^;j=m z4Ab`&QNdzJgYRAaQR5NkTc=XBSyQMn^~k-eCgX^9w!5m_j?Kl-b$wX#KsYJRuTD2S zc!CYBCECl8GVL%^{Q~&{vA4dSTi3YPIf2v1668~D78+sn$2<5w@=Z!jx!6{@qNzE; z6C)mb4vjr3ARTe#7pP8kIY2MEH!9A>V!9M#MqLj!sMkd@TRpRiCPNIPUMuW9o+D>w zINqn)+G!oBJCD6QNNIMA2^B<0X0MiC@z33uLGOILzcC}%QK`fnCr}Vp>?h){zd+7t zQO}85j0oM9o%TN|p&Lwl0A@^zj2!2F7&_m;A?!VidoOa(GGX5Oz>)}2QZheD zr6X8r_2&x`zZkl-Otd{3w`8G@VJ?Pe@|D~o5x1+ravFIvi?17nct$MW17m0%yPi>P zyP;nxg@EJX)$v`7xXNE8pq6oB2wQaaGV9{}eh?YV+hFoZ)Z2QbwYpMBenYckM_%s=@=A zfRBW=i7kOzsja=oA6k09!Y5a*^bUnR#(8x*lKVi}^^X zC&w(Nm>emvdz$%imwCCQT5g#oB~20B6dy`R#`g!0Aows!`0N5y{{7?p52WIOF#8Xl z@C$&|s7k`hSJOD0xKW0vvu$%H^?mSiK0JyF)50UJt93}_z(*?n$kD~UAKhZ8b2@zj zCEc@ev=7upe5LZjBI1f0pCc`~P!YO$L;NQQcVnn5EI@zqbmHw$b^?Q z@h!GwEyJd988N;r7T*_6Ktwnw3Bk>6-3zxU&oKZEgwx zm#MZ2=kj=f4cf>QDt>?}jX(-5Rbdn1RR5eS`3gFwVrqh0S-O$?nf?m(8~!&f0JNHb zOOSY`@sdC;(vp8UNID!wy|ommjY@|P>L7#ZHH8`A`Jst*_2c)5&T4K9QXiCUoS%RD zSRldKXkMCBU>Kt37zfpQ=-(%x-@(F~qr6;fcOrYlg;MW*ssMIx(NBk+Z?<3N{@8=i zH25H)`hui#Qt@4z)aIB=OG4x+)s`h~r2$gkk@ihc%y$5{R7Wd6ukjg~F$ON!nn+J3 z#MTh-rHT(O5^N3#@V?Oa%BM^?OI^e*ay%^$0esvSYt4kYMN-KfqLdMm&@RAEDsJT- zp}UezT6vCDvR*-&%KO>%t>Qx7MHt7))j5u2h$Jd2)vK^6YokoS=$`cZAJzjew^V9Q z&*s?}#mxw5A*GJx5RK25Fn3D>tdA`_yV*r)fVzf=by_F3%2vMzVVi;pGFA$r0;>#(moT2`m$8^W2Bo1rTLe9 zS|mbW;S35aSTJ$dLZ7E-OHue}!wFd8cL>Uh6e%XlT$zN`SPuN%zh~>O2(q@J928p2 z&twL4kb@AaEibM+M$x14XdVkGiOo#71T*Q*bEIx|9$a}k zf~pK&F&a;uLztZ7%A@~ee1j{@TeAAV>8_*LfCQN2kdccAb!(G|+-vQJ5$bJ7?4Y*v z5?O)6!EiN+*0RWqJ~5R;_9jvNi%!izXpQK^_ksIuN@?W#H;&0<&Av*fUyqU}nNLQm z=^~@nj@{lpZxQmWuWy__>08XsG~N7+EZ}7k;~gL|;G+rDdouB|?Ua!*Y9amHi^M&w z+tiow4@X60$Dwdai09L{25L3QJqAhb*w-*pF;#Q^1IZ=5T-y}w&AM+M!_wc%;Avcw zhkm!lyZI>AHE+*GztmoVeW;Z7FhcPwa@&g(A3c#Y?f+09fiB)Mbiae4^k8|qxOk2z z-Ia~$)iah{n(jOHotJigM#8l&O%wHNJd~P*`_y)OWl)m`UiFlr%YQZ6-~Gw5eP!1- zhrk|Z&p!CiP(x+iFZ;O%x&9>T{5D4hmb@tU&!>^7MlNUW@jT->KF#LN^07tRheTeO z9>2QNo3%GjOJUbDY+0U8Vl+}Bh4_9>J!L*Dn=T-oaBT}0JCW&G)-yay4mg7#vP%jO zWZ6V|P^(_!p~}9&k?{g-abt|@osQE*tXhW=hHNI{FjoX{L z+=(WXZoy$USVZAM?o$z%lPiFmJNN5 z;|CjH>6Ld~xe3XvZ&C?Ve*6nGcw8Y&wg4mA@wkK9quJ7kKE#Qgr|i6 z4H1?68`67?#~}wVQV-XQxk>&;9I0b!MM_%XNBpMvn$BTMYb$;@K z`q$EzfuKYwSFaJ<2kgRCMRgm>K&2bagMu{=B~vtuEG|X0uAXDJ06MoK=aPj8yI!p9 zN&IZ-9@RfT%OGrh4+u7xTb_=|!SfD?`#1G)(ZQp4G9o>;rm@v zdhd0C08sUa@0xrd2bBB6?`5|4?uf(`cbC5W3or;KUIIHb8@*-^1XoUWP&(MW9*l?| z+l%WkSVsEAlah!+SfkbJldY_76I`jZ`dwgjqfec7?EM3q)%YXrMFbMoJF$5qdVTBA9;y1ZiXCsPgTqS+W zbIKAMe0KVZEw9fwhxO;^15L&x%VQqh-Pgy?I(66%sF;%igeP~O$JQZpsC$A~_#HPS zeEiwd!D6?!Xfvs_uYf)^ZIC7TWW`%GJh`}2ipkTn=F8kJW0sfanFCGJttDEfFDIOn zG4A%+(!x3|nH9QU{|K%ou_lceEY1%QbT*z2|H%OpkAfWz?OGtWNy;kbiYlj((8fJz zdn=T$=iQzzGIa2VlloOn~(t#I$f>}VPE)T3Q~;(_T4 z^wd3R3yVxs=LMQ2A-4}le!1E(+A%d&Js8EFo9!d+mWnyqcPO(6167V zyU&8?t7&tjQ(S_CXl*Sa;iB3FF~uDtG1HOd z&B4}H^>^9)cG30E7Mx~S=a#lKmC?UHQ%%1cvg=}(7=pX^Ri;>Cv2{!qNEX2DYQM57 zSRfmgF~rJ@Ilc4%GlPO{O|8^_A`EtKE1<`wMZ$eT;1z#Gj zkq08SZbZ7oXZ!%$KZ5i$XL3d}`7?(6(zj}Kl%7Z0KH|6(-vd+M;e4Vwl5{KEOpZk{ zA$c{C!9kUwaNANuJE)17&VR5Xz-Jg$DqPLEc1)G*UG8{&D~)-)-{zodR!n>e;oz|R zYZ6Xi*!nR6>LUpon<(Y$dkIH4uixi%lx+(R^Li#0cMnJh-4Zp$Ppn2$gJOnyvPsd- zaRIL5V;+4}8`TfgA?Ln%t8~@DkxDO@#{Y;0yI(c^^X2IzPl0J*y=L2ZSlvK#Q{4_K zptIh0edUKXBmchEkVY@suK@vybmeAG{HibKFfaPeF7SA{;%cW)+<{CZw>!D+c{?)M zy4(LC8C}~0K8v+QQpnD9EkCO%9pIGow!gFMecxn3Pul8(CSQ!=kD~jK+DM7f%Bs|4 z=M?G6H_y|(?yz`g&-A9czB0491CzB&>8Wf?>oGRiE{t9@C+!-EUH{f+q#N(;Yc{E% zUW(RK5kc8Sh(=lkI<5SUY5XGRzIL3eH5{B%Q%S%f}$X54|)laX1{ zy2$a-mm|TPBH1kNH6}8!-6!5m&l6_J%T&6xsH~4(EHW61yvNoT;Cz-GjTL* z8~pS47`o-{VnyI!ftE2xw&@HkW#^@`4ioG_x{oby{J?8Uxp2ux?-J)!c_t z9`_EhFO}6cE|*Tw*;KijMalKJ#JSDXbib=+IuX(sYaij5PtX*ZDICv^yprtdxw|dC zd-I5IO|YNAJ~QGVNS__P3tQIkm@Y&K%Qf}aRlITp~ zaff-g?!aEjlM^Nu%o)+%FVUrAtn9wt8M;sy`NTBU#}1uIveq8KQ{vS2JX1swFlJq? zsFCSQHVyA@<~(~Hx={l&WY&!G`)BmKstkWryge8D7rG-n&z5ZwH6t#1$v$>(VnZaW zGsrb{kEA8!H6~7;%Sh=wo-}bD*G5!Mgsax1f7Y<|lV$uYE&Jha{8eJ*kl$E}yK-T? ze6!!vcE2qN-mMDXk9u!&INb6)ATdwUUvg58><5G0*<3>_Uq$IAktbqeHtpN?itb0t zyLD6k-+$y|U0poSLV@5jG7PsBemuCVbCN3cf!Ks7B26Nr#~nRWa*pK_MonN740d5|UOi)_2!?U<+!a8YXgO)t5}fXi_9L4l13s=}x3$6Z)8sA3*f6L)H}z zVV@CfQkU*Vub-XcX+if3h{lM85R0?Z7+imf<@Lsu(w)!Qy>u(69Xv!w+%lT>PAgxk znm)15^&}e{fHO*T4tyfNR+w_h#~0o!*<_;ZzkaxX^Og}qA&{Ur1}?v{(Z5pqAz09* z4qPy3;u9uegX|tra>X_}EBj$vz6hfpreO=m9)(+P)da$r-XD+_xwIBf0jgeEh+B^P2hRMjTfJWzZ(8LP1jw_P6__Kv(1E-Ye%)TUt&K>7mt zBZI&_2oy#qYy@PW7nvoXA~FU6AXky1e~KRf@>o9zOScvt41k3Pj1F?G*YNu+kcG4G zle@8aaJdh8cLvpa&2t};4GsiSSy*NBzYXFMh9TWEv)d zt1o?1wQ;WjV7YP-eiEu{D*V<^Og!)B42Ekvm?ljsV-xo(b1XCaKFreLyW68~#4N8S z;z#xg&i-dozFg=+2e)5tOT`&Z<;T%<@VUR(zWPyz>#`3&%|go)-qs9v&m;bT>pZeo z41XRaNG6}vKo?x!ANJwK(ep&Svde~76Fri5N7HiJ0o65%pyvE2txCcE5A^i&{o*(S z>874UK5Kg|*nz{!x^|b9kGowz*TkJ0R~_&BmZ2M!1d~d<5=X7_Xw&hk;<5ic%ZVLy z*>}exMs1yLDV%lNGt|Bj?<5d5>Vt#5HGUmoM?`R`tbC?E7J$9_-~oT8`9$+9`@)I; znhFK)Jz6f5SO&~vfxOrF@IxRy(S=ok)YJm$FeNdM)=ukf)Q%&ZXeYSG(BCz{McW&a zeNmwh=7yz-Sl0XhEoYY0fH)0g^yy%>`q2&_8CD>Xh0&?x7+#bmc-sQWuXu_RVQ2JZrRNpq)&AKRtuEN6leQ@ORD$6k=QsDaYK%M*(i0}KKKaef!| z(L;Hd2gAiiz)cbNFPH?%6vYeq0GFN#yU5uuxtC-^YX$!tN_Ft5a*YL`;Z2YGI7~Pw0pe(&7Yp)>R zp@tU?&~ErGhON@4Tx!5hs;U@hTmZDTWr>Ot@DVP$kwHveF6VQ6?c%W1O|#Hkts?!_ zX(@(^1E?OKC};n8=;rXXJMIrD9QuspVau_wA>bl|@aTQAkQAVDo$8?$e&(L$q_{(P z57Ki$JB$0oZaKtqu}Y~ZGXCaFP6E^|K#T2Q%zeNIaGd_B>hleWu;$G94KmT8oYRoF z@7!%64h__R6eT9N1;^Bc>Qs|&AJ<5;2bUCNAL51k_@m~IRGn!$3;*LCLZC=AoBgc_ z+2r)d@+IGWLP=4;$>Q``^P)4?<4VN@?4aC^$P+{L1e}>=>D)60s=zTWck?Q+J30Nc z`}l8n^NsrAm8bj3_p0X;Qm=RL}ia8df7X8HO`y4 zq+pvwDF0*3qC5!{a2e^)%wLXDUO+P-qAj^DbG)+-S-swj-bct(g~uw2TD&ebbc3zEudWyMQ^ zAF5TFeKBP`JxX=nlRUWvT2rXC?~H?@R5)9qH};`1OmrnNlsfDav9Jh1Usz?|jx!W? zxfoT$qsw5-(3s!lJXt%73wyztY0`DFN0%lwh7|XtiB%lK&OX?E=)Dly+2JD8|4KEJ zIZ0G~p)3%BexoG?IKwo&t^5?|23jJ$7z^_Sm<&h3cg?ud%c?&jCiRl+jB+Av@( z4}(KF!xjNh5kPIcq+P^NVs{Gzyqg+emO2a$W0onF9y;X}(i(hXsMx_rrq2u^1_RY# zdlg9JD4deFJrpM1;So^=?BHp6%=i0&xs#4+X{ldSxwr;xb~@O-A70WAM-1;clMpgE z>_MkuT<*78Aoa=F!wVPsI~yWaGPtae2*x7up}r*<#05$vJpLu75>6b^pXzUIg|sg{Pd1jkt}t$= zI5Cu=Yd9~85+K}5p&PvVMwW&Lo77;;3P5O<5+S-VquF_i~dl@))YJ_W-jg-_rc0*4e5};q?j;5Ii zf{8pW9touneiUn_L>JamXGTnf}dPE zmTWxZ=!XZ27!6`4WGPW~hW%6U8WOwfH0M4^vjqiJ;HghYHfH5tySp$EBihZ|v$v7n zV+NX;;YxkX`V57Nn%cN0jlXv-ksFMK+^UoP({O&3tv-WXZ1v;a9SWiA?q=!W>$d6O zS!U;YgTgPH!?UM?gs`zpaLuxC1Weq4lCRae@v$iCm3$(`0a7m;0GRSQg#6f`TGm;H z$d2FX=*pfdvnRxns6mYL`>0AW^SX(xsB=+_a~+#zy6CQU8>7RQ9Qm@7lUb|83OPnQWEt>~`E5P_k4Jk`;($R! zfb*|L1KI0LE0+5z!`72Ta(-OHz>(IVq58@Wt>CTw8_R_ikt4-f{7==Dh>0X(#}_5V|CD4?;vQcefO+iKZ<&QUF@~JmET>Bx*F4b zG43p&{dCslF47HD5*s&8dFH|<#EG6kBK^c$gVxz$gtxF3)S6K}W9COn+N35^8>)?#Fc4Iw7ZQl;id zOUW<*7_z~dyeg=|wBOKmZ7U>}y_Qw^sl4zwyhgYn>WrX%wp?)3J_+5O6DeKxvoZFpZC*OflDXJ>8T(j%j+D0x zk!Xpl*=9-?;FIj5Q1uCFbDPke(O_3{AEt91>N6b_)OG~!xJ|t z=duAaSt9!p2E2Kr3MzrQF!9xsCvwYOfqdBp7TzhUYfpTcO+{N9PChA%&9Lbz3te#O zhLw6VI029La*E$cd}p?A)MvS|eiAG!X0*tvjK;zu^=vnvK+aV@u4Y}(f5W(KB`7=C z0w|76Qlz2H6q?*hS_ixVV&J*U`Z#ZwJ+^`EY8zTKVzBNNft0;u(xzOa3cs>o2(0q3 zMIt*jH;J~ll(P}>AvOGy(9qnPhN0&n`Z-3-HRGZuLQ692wYJheAK%R%gu8YnNW=_` zZ`a1N>zQs|E0A{it{Lx|b=DgjQT0TPhp0^%F~w~!(*ZPwto2ks2hno`S=ZBgr1*Lb zz;`F$+#z2%dVB4`UAk%#{uNX4;q>-q1r;9a!bi2mY3bjN(i}g1>b(Or(uG4BVmy^? zl6$Lq&`f=!+;LALsKX4lgA!sK9R)MKwA#?-rIHEH?U2`ar<|RVzTPI3f&-e48&wx!xWO)0|otUQK=vH<; z77@29Y`*aPkN(J57du&xqg5u+Deh$N=%S@b-*5MZ6Ml*bbv|k}X{C0O>5N%&=luG7 zP!#<$wmS}*JOizb8d?c~rB6?{wO3*eq`xPgzvY1p2L;|T-WQwh zloyp=ynn_`OTcU1j-sA56q%D46`d7)fsoqpDG;~(pegP6#?I(t{ zuhiv%+)TU)g-^|t9&bG3OUf@El_*TpoH21mh!XLNbqlw+h@yV;b*W}meUHg~XhsM; zzPzEVMUv$d$-81sF?qN7sHRgxYZcO+w4U{|Za7`J!pVbwGrruavDjISL8jjEt>|M0 z%k7mPF%debKa&-jwP?ynS($`Pwq1L@@?AQm+*>?Sk|}{uVLyE{juPeFIAL9JQ)A>3 zqfyFB*U4}9DTv^UQU-hH3&f(#}qp5dV!+g*uW$=?Y&1=RKBIn;MLGpB#W?|9pq z0*z=rNIjqHZhm5+S#8@q=)PDivP>^%XwRgU@n;Bhuu^7~X*0%c-25n|tbd`4>Gq2V zEjKliK|X!Ac5Q~7@_qG`V>Km@hY`6}E)0shr^0##f402Mba;3b5=|r-w;tpaq@;c{yNVSK zlzR7yLOD03ebc*!*F@oS5A;-sD0{edc_KM&pHiz{QZ}ceVVg^bCFY@g>lw!= zW2vo17W?3#Zy5U6lL5`zezKw?XI`e=lO|R;tyS>b{H%3;hk?=U^ulbtQg?q{*ExKq zk2(7qtXuop0-4mBykt+8+@q-w*`^#o@sz0UF7})DFt_n59nFyaK<2x!yT5XQ2TrPw z3&9A6g%XuwA1Sksy$p87o)JJ7AQOz(>??iq3+%p_2PR7BiAu3TI*mzf*^1Bg4u>Sn zh*%@6h6=%$Xe@cQL&6JiEl8(@Wo;2ek9mQ$S1zN??4mN1;ku0p4* z>RrQsuZWte;cB`m?T`^82=xi3&)MJ#d*pPddkA!)qHbwpmGB(nb-S<@c;aV?)Q`>C z&!$0~Lizo?2 zby{O(*p}ERgB{YbE&p8SOBs`vfnJazMCBWpR(%X^%Wpqg@K(048#CJF0U@s*d~tFj z7uNf&5G>|Zz``JxJ1zmF{vbeEqt<@7a=J%AzLWC{Y^c7wThEZQC`YrL1fBBpJ++IZ zEZo7cqo>q~nQ2DG;ZZgM8*5%+5zVHF%jj?LoEQ>8x#b@A&OfU`u@{Zt_R_V(aIwyY z)a(b4a@?)FT4AGJC)k7y(s=1tP8OiF&l^-gvXoqvo9<4wEi0KCt_WGWvyv|R2({+V z2;^kNvhq#lK~)ro5Lg=a3tJ~X7aLhhpm{^@;jiyMxptq_ah9z|I#Oh>p3&N?_n~Hm zy^Y5+JzM0opJnCdL`2J{cO&Bwj9sG?BK5ci+w2pucT4D0=x$j?%i{1=xB81aa$P94 zo!1*iC-C9^BXfvfWYHyj_0NER!mxz9ci&GCN`WRmiD)8JPpU?{dk-_@Taioc>FT1(7Fce zk<&0|zK@~c74E7#+oN9+(JYnlIS;H(v!|Nt^O{oF4m-nEmj3<}yh=r5frKAH^R8`# z35qVMpW)7Hmt@jtMo(}CYP-9F=l%#T(O*YLpXQGeaR?>p+je$$yMGFv=d}E~FGrm+ z&vA~!shqapCgqNBzBNmjw0-}P%q5x8YjV3k_A2iyH&s_`?y?*#y9XwY7O1(`^lP{d z9)G~$euA%Gn>)x#_s50MRXmq@6c7L?eKgD*TlpXWy6Bc4E#l$;!IR)-fJKVrwTivlraak#s5k$b0BE zgAo=DO{E92gY&-Rxe|H?Hb6Ohj4R+P^nRGv>I8I&g^g}hv+160X0iXSK3_G_6^Oj> z)Ujb-tJk&SFQ$6p|3038EHS@|4npWTk9nnw2X1FvJdS{VcFe*VrUokm&JVcvt=DSZ z8!y9&VDZ}oFuF&NKDqq813>550@+^n!i2dMP$qB*fJX`!ZDqVC`I*Ni`AYPou%eTHHWau+X-n3 zWnVG^Ca%E2w0YUAf1D2DKxDvo0s&MlAA!Kdrb|X}5L55daDFfyFmuW128FqQj$rG* zeF3+W%dYsv^a_srZy@;%*P#>euGYWhSN)5be29_%orA7Ve17N~Tf_l^fIx5%+3VH& zn%z9c>%>TaI>2DL%|{~;OYb2i&^ZL%13_&(IU7K=EDNe2;73Zp?~~e(7ki~aVgJ}P z>i&7j2;d$Bx{SQX!gCW8h@SwqRh}k*$Or^3w%h=<9f-B2{kNrQj+h8_y@>2Tm5A0R zIe^YZI{{{Zhcg>P`iwTTl%})0PRv){QG`yiQj*7 zB+cypKL!QNzA1nY-^3FAQvmWy4l@Z-tVsI5^RyAC+6kRI|D#`6VG6G7y36|Cd0Vlj z;CM6n|KvSg?7d|4-`Q6~uD~x20(K{#m0^~7DF0)Fg^$P)eHe}=ELv{wi~>pNCdK3eB{7piJz zF{MGx_(AroLO2yA6AXdq{#~6H`}>ZGS$~rpBjVb>!%rvr3!!J^dvQk%^H*Cle?_%u zHjA3@{yTj2Kt!JWm3yWVevT)=Kfc{fPGHu;uk$X2NuKqQ?3PSm^*gair==*vQ(J&{-IeS=UnHR+s>QUe9J~rTEz7w`ClCYU$_Eq&^CI_ z`7M>h>$!csAm!zHU=Z-Px1SVdDHrvICcYP4GQ9AaYoaMvph|VzD$2eQ5esVO7lY!Y zWX;Y6bgMS{5iF`(g#UPeW@qLRy&<)aROQfL*>4oP z6C=r^IYi<@Y*$^AotYVp|570Hg{ip_IABL%Fy=ehHS&WFr-O3md$S$>oeI!bewv># zQ@k7H2u}z^|1-p#gsLS>~2c8O_TMWcUUDMhN*73B>eu>!lyRv_FlQU5ZTHobN&B2uI~CKwkbSgC zM)IC*QGVDMsLMr(8YN-}@0w`P|8y!+LFkLKY4@9JsZO zF35tbJsMp^-7cYS5jfC{MlUdtBRgW&krG9A6UlM*%d1_a;IWCySfOed&gCJ54Xl-B zN{qY#;=t5#1wBSAJoAVgx|e#(8Hn{1JZ4@o6f53Whh`hC{m$>dQ$kddp6*NRuz+6cCGw+S3VG*1WgMBTg?Zi2@m z_Wu+FuVFu{?6~(Sg*ncn6^5FR^Kns2W;O3j0MWo|XG zdRRs(%;>~sqY1ao94#`A>ozmbY>!@??-CkxKfhexHosH<0hBi#eaYxgi*6(#%D*i& zhc&RavGi&2fe480TKpS=!n1cCCE1SSgME9vZsH(!>Un?7C0+W7tJAyzDIC!hsH*+| zM?Hdeolz`*l99fq5f$&qiE?b*@HDx7;QX`Bo7*X5_Ug8mjMO##ufjiR8@lpS3tmqt>JzbboLLZo?3R@WEDA_0&WTuq zwc6@Nh$Jh|IvYi&_HBETfE$BuH@jr?dYFJH?b39XuPo647Qmml*H_p<=4>Fe1?Sma z2Sxd@lbC1{uRRe%Hm& zOY;dq;obHk4zEP5)S9;BY>cQ!FQUz-Hs3D~W{LFjR_p8AzP9RvS>!~&C}MD93mbks$*LH=@?}J9%QSs@Q z2nuDEIquS|g>U_M*mIc@KFACo9oo{akSgE(w&2Z(u;&6%C?Bq;<;Ja?2`stPT01g| zjt+SF{xqG82SVtAEUl3&!h-enq^LrZ^ak~g5GM4<6IWc>t$h!ae@G6dd!!1Q;V#pfiT0A4;LUlcGS(iXcYDKvDo)xDEEW4d^}|WHW>i?oyR|3O?++v2AT&9IzSH#$n9`Yj?!9}UxEBYr?n?{OS`LXC;_gl zYyLytUp47QZOT&K^h-nBL5BfTLPv%gsS*sDd`=L9`-_BZi zS!GR~l_+k)Iv;^pC{aRo?l#^DBesYpuBZ-U%8W6H+t)Y zi=foo>;z?4PV&v~-MR{onQpP;^^wgm zQ$2*qvJgh3Ij6jvxI;DIzSI{C%JUJOEAgmCOxBIp<)fREMP1FBb%{d|()P{+SK!!U z30XY-YXCM10E!SN1dko4y!CSE&z!%Ht52SNPr>7NZY9_XsCs>ko<6m;z7|nRA1DL! zc{7%CbTE84&c7@S;@H0;t64s`rVC;@sF+*xJ2z*B`CeZ(O4_9%VrBHI=|83X^6}NO zI1SwU8a|mIMyBH6?2)mQaOLo2Q;CUf$=q%&n>xA=Re?1_Ai}J++>gnGXY+Tz z=&~NcmeYuxC=So53jjeBcup0;;+-(HQ8V33s4cJs1uSh>$?=Q$L?N5VG;NIm`C1nn zh~xOdajk8Wt(@_(Douq1#wvaWxLSs(vv%jiPi}xFi&y~%PHqGxXFaUSToPmjP2%$B z@~I-9_F2k)*h+d2$~&DbDw+-_k}2x`{TL)GvLa6TSSphgbE4SE3-7=aSJPQDUoSGy#!N9aFGjFT$pR`}X8(+FDw(r1js6IkL=V?M72&Au4RI)_OoZ}YNveb$V3RHT6vE*p;gH0|mY zD^!Vc_F+0YOw7oW5~v((UT)*!=zgADv}0_nT3x+?+6^~YAEgAtp)Hi>ZFOaw#j%SQ7eSmhoo+?y9xL^_mjCT@vT~{SQj2 zF?~`V+V*N-j{}p@*ufrhIi-e?>iS%drasB+`px{oK9}oVqUMB?;wmfK_t5woHbT5)0-Rp;X zmQg`W%*Jt;RFTzK_S>M$L0FYbymGgs-dlFud3N-SMbnlXE-0Y& zs#UEw5-ee^2h=-$u`v~ZeY9^d9BW4xZsKZvuk(L7=nX+!TxCFjtXlc37haNLe_=F4HrV5M)k{Oe?7OJAz;?bn zMk-v>aoLGtCdXE+b!%ODRsGq5tsfMoTCr`Uc9(ZTiwY_8JQig# z!ePH$kPrwcqQDatifG>7tqe@uEIhMfFU?RWpIm{+SEtEit~yHg!`IX$;5Xj2l|6D?2%NcS#mRVuUOAH;* zEKzP*_w~T1-LIBCsNYW+tJyujHF0{ohP7ZIH>{RDK+;(35-29m{Ksl8u;8MCtC$In zJx=kj4@-^@C(jwsKkTQTs2*vRT$VLH(`{lww&IDv`pn1yA|f~ zj5-hK8@0 zVT7sZn-o>NMDkQ(4UeUoh=U!yVRsa|M)|eVA``l9(M+aCc1a_@yu$J{=AJJ!Pd+4) z-Y7Y0WVp|>6~TGz;8Ubs<_RN2;;w|disos*EY5{11D5uhS7;|7hdzG3}=eT21+H-Tux4^L<|U*6q&eOC?^Hi*7>!&+Od{TVEPXRtw3H z_wjUBW}@8G>Do6&{4{day2pI41(>0!b>|@#^X+}bdvDCPYXmH7ul`JkOcCz^Gd_j? zsq}?C19fa((=wam?2f`e_`dgb{Z|Dp=g6qYE+Ju64U(ZjL*jj8?JVO>*~3p4-P$@D z!#~eC%xm(qNi89x_+l2HRgM+9WF3UYb*veLYHbPXl=GK_Oec-V6oxYL?=#H{MVw~z zZ4VR~+y1;A?fL1knb;Vf_`=HaJX_H>8gtRg_dg1L6^4(DXFj2)jS?%=bmKR~MY}$IbCS9*{!TT> zqH5TmrPG)>jQve?zx)N5BQnY&HeWfyeJaKs&;L#O9E|Vyyp&c_Qz_it*|#eSRTW2D zf3Z%1RB+iqN))~PWw;Y-c|is+>uu#`%%+{@VkEL(-nXgP z6`}k9qp6mtx*^SaZYR?(Y?Bxsjkl```?8krlDh$K^Ybk-2+s&{+Z2kayCB=)ql5Tq zK!4AF9JWg>n^Za#Qxwz$X7|IV?T#8|sx^1IZYDixI}_0%uDSEpL-X>JjT3kSYJ{Zb zoeak}Gsg7?MTe#n9ieG!@7{Nua1oVWNW8fFURk8bPwe|(t$~GEz0}=|Y=uAt#p3U# z$z>`AJvxt}&N9hlLzzp4D#;~r`EO;QO$Mhi-pF0=9ZjQ_dp+xIApyq+iYLomZ-^AT zad_CKNf=+qyKGULarTC8*R4Cci(!{b@ed3zx3{IQL;IZ1uq8{{7O_9;ca;$Ond#yc{kc|1sLr6v{mAVIj6k`4KJe74b&u4+T`Dxc;>=$>yt24lU?%< zCO@6We+uTmv!3gBG~L_nq-Oo|;4m>U*P&B0W2lEAJX1a&rT2yy5W2|}a?RFIoGda6eG2TgK6-hRT3N?5bMRO@NMlH5-(@>r&ho zNAKJgHh!`$JVr=sXOUp9dY&S=%!T2Of1cJ7XEwN7M2A?$Vw#q};i^-D^umLxA1Mo6 zGQhJev3X|iGz91z7>tU=aXa6;epyPbCB|#hSq&}`wnPrD!^94dc$y0K`R5BNV=^X0 z0TDzU-v*v3W`RN1-V=6w#@8n;Q0bqkZOnF;$Kgn3mbm$?&K#B^`RiBt+WgtmXN;K< zB8rZTO>4K4BbIV!3p+RZT{+BSzx#4($EQr~dgyGiMl`g&DGz2vhZs&NAcptRZ@;25 zKIWH1si1pS^;# zb*}@J z!(R^Nr#y%R7GJ~fs{&+WsbY@%77a;jo8k))lfWz>y%q>DOgn!!xiXGDw54%!1=8vp z-ZDonobdgTWI$u%ZGFrLXTwj4xn(xp=fMiskADF8n^jVJ$`{z;M@VU#O8C=9r+`~1q&90|Ql!F^vxaI3Xa5i5?{{pCo`9JE@Zj(qI6%X5gqat*qJ)>#EG~W622j<@?BSbG=uA+VIz&kk zl5$8*CASTvnQtrS$x_ndxHcY}>7$d&XHn=F>#oM2n}C8b!Tp-WbqBU^dBJdFTQFmj zo((LH)G5B+`v;(=A-arFFVYT`>EZRt3p;mP_=*rTHo-<51O{Y7$Ynq;6mavc{TU4% z(onLNVjyaQZdXe>Cm;T7o9FvvsKAXtAnd|~?@BeF#e;87M>u8^YelenN#$Wg<$;>M zd&?%Q!Sx}O-1-eTIP}^S<{eu;Va>gM&u^C#VzMIomCS9&+rND7Wg-JMp9Nc3%~M1E zj|I!T(d+e&es>z8u!)09vE#T|eH#Nkb*UL*Z*Q+M^$2yh6n=X1(Cwu4mm8t0Y*OHg zQi0Ls!b=0?scfDI`caghn^EyM`fp-wsm4guCW!Ex8v!n15r|J%$8*>U>cW>&lZX+|bI_#Z0N zr8vMTK;imbBy#_J;r~%RH3Y5T&RMj5hx7+pqpRHUwPf-#1?Yqgns2 z$#U64ZpY7l_b=IJe8e_rJ?m2>WjGjH{F~v;ZIh;Dd7}ptx>3&rqqBBbD!jwx5t}Pz z^+?MD{jr{#llw=`M;6-xHO~$C)jIKA!I#!C3JYicWectVu}dB^EBZm-K;~qPCPZU) z#@P`s1`o0dZFap^ZDPe+yeIiJJ{H_IbR(tCemu(;ar*DFCDzb__mlR>heFvxn|V#! z*ncvOn^~Jb*>QjCTT@m~Q>F(O56)C!FYn2$$@mucb6G;n^l0>6GvT1{Skgskr>Y^} zH=9GkovxE`&L?!q-zs7$HB7JzNBL_^LRcQW7E#B&iMgb*?@rIa=x)j)uK2vWMVY3# zYcEXK1`TaqaNAs|Qh>S=v~5(yo)#@644YYY4uUhx>$jpVt&=6;d-{31hp0;!jfR=o zctumaJBJ@I*nxzgG;toej5E z+1B|(dLP6jqqx4jC9X_Dv*&Ea(oh7C$u_z0xt>;0NKqt{i>bA zUEj`G?xP<6yxlZ=YQ1fzBY6CJ2CG5y+6l#PCp-lg$u;qU5&fw@KO8Ebo+K}mpCi8Wv6x^YWcaEu84` zT?%cR$HY6TlYDbDR(S75gNhD>zk?w6Uyl*KFIhC0mVl_26Gy2iE2c9qoy?z&b@Z=z zmypM2mveQ=B{Q%pSe&&BDa^f^Ay=OF;82m;!tRil(KzNH(HBA~)0PW{8-BISXasIg zv5>5aD})x7RcKdoKa?N;)4 zReT3fKgp;j4Bw#u`R>8B_3wuQE4vz!`+a3OLwYVmo*T0)TE)r{I8({JU+uZIt9*c_0VCAg=_a!vi(oQe?M>w zCBt&xB&+4=s-?J^lLda{$H@L`M1kI)24u{<)z2#YLGj)>iOtL9nU|8C<)8i?^UIUO zj{Ycj>8%yThWxH3b`)2SpT}r7StHEOw~0i=*JT zNn-+_A3l==!sHxOe=9WvsI8*22etbbfBtAXGpwZjShaBN3%SMwwI2F3`pskt#zZlM6<#*8T>wX{Xwh&Ev z%7E!8h~fY96nnL)MRYR%rDhFJ>l=_#%H+hD;7d;|e>sw0b#_xSOzz<5(W)q=v=1)l z9VrJ8KLx%HIv1I$#9K>N!e9^DD z9;Y#2e*pu))#HvRf#&Z)9}_OC-%tPdmnkczV}-~0-?a`jA-uS!exngU*B7*7C?-Gi z3y%PflBRG|=-^!N`K><>9!X{GE^V2%X8xHH^g3Nb{}NNQt4fI|e$n!hK8pP>kSUNP z^#2;jqJKeL0RY$EhRZri2I8&13hz_+`ofS{QW3~rbHVyuK%S^m4=HKM}*YZ#~C zH^522$l-rEF)(xQFC2}653&0DD_q-LnLQlSUH)sI!AVnmsze|u`+xWPTK#_jI*LGo zZ5|Ym-k%Mjs3phmw|)aket}ECmz3;P{vSzP|9{sm{OyfB#QcTf0T2^ZyotcS8^d7) z)`!Wj|Ki!?Y|&sLN8vA04f{BapApMlaSOCTzeHT@V6Y58Yji&TD?{R!{mcJ02QpxJ z%l`hV+niDPuN-;M{H5q6JuAn58%QVqHejhs|EmR5r6LrSLNmK33i(YV5}9a{xT^gC z5cvCcOhIgmq_9?hR`Vr0jWRBKlW%hm0f*f`lV5vG` zR*(o`kM)ud-fmDx3pUZ}a_=vrjMeo#O*sIP-+; z9{mj~*UN_ox~orDT+0j&Zif8a3CP=AYO_lkGAz>jJ#PvrdXa{B6t)A;T79@cetz#D zQu@zE-*fS$`X5+dt?}#2)Q{IM{EG|=n`!57Eg@0g>y(|U)W|ly` z9}IrX_3LGDQU)whN{*ObUTwC>H<5 z_FxOQz42U#`mtZdfbwEr#py8psiWU0K_t`Zhs#>44FUR6#UH}9UbXIr>JM7NGY*4- z!LJDXb3pa0b35y?J90m-+r1w$J=rqb;`BY^_Jse;KbZ%=Ou^Hu^~W4biD6}|n?~L< z$t1>KqMNKK!kzRi*4xxQ6hq{6u+-k6r7THAIG*jxOCevx^2XxeupHu^8fkv9uy&T4 zqW>{8o5GJ9h4rHk%ele=N8~}$A*U?pm@B=bPQ6{Gx7z$e(8SAJ2D2NG&8lmI&%5t# zVhc?}`cM3NSw@1r5bFx0OZx-@zn9uXyEQfuT{Y48YRFhA-5;$fmzGq0Uzr8#@{lh447rA~OaScRn+CVxew zrfw$wp^9Vj<-5)1vl^ELC2?GeFRnMcAF>_lM>v_2Ib~mnH}_8(lx4v^?4$?e$(e-I zKb%R)u>WYy`mK#M1djVM-GX7W-jCg+za$a?uoRA!W zgP)@K8Gs1*?r3GZ2I3a?ru%PZ4kLd@9hA(=B|6rP7j2pCPf!0E0!%Qhl7c@oDfEIr zLFXcI9IHA9-FVOwiJp93F?9J)xFQ80KCcnGC|d$53glF06k#g2!uB_GTL^$1l+W7d zmfiXLTkT-25@f=WM({SjxVGsm1PN$fzJ2HRN$~qqpO-go^~f;3PAUqg(SDt9<26wS zmwNT`ODWAa_s#>EkeGlRl_s8^dtEPHyl0q)ynsxM*P!U04yR^j&do0u%+JQpR+Osr zSxyv_iV6D-v<%qW8vwfUaDpO$4A`$AlI`fB!S1CAP6|73ja;DefONfmwIS%6lK=*K zRC1Lzq7bDvx&m$A`8OZQy4+_HuSRD)hJZVkO ztlzY6A#r4fob6=3l*apcG2*_>RCmIo;_91pexs5+L+dX;!U-Qi6?^`yyCf?+DTdK)c@8(<5cqfMKU0%R$VLEbEt zUd4f6K^hxMQ#QXJWeih_gMgeGnINXq5--vIX^QIrn~eW7+1>$r(NUsYP!w2Ze@FrD zB7X+-@C2LU{%uMI=US2Zw8q8u%L^%|U&0t6|33M8YREj*qQ>4MDkLyE#6JNGHX zg7Eezc&90#{_FYzg`#>WBX;mOmZC&jDrgPx#n69;^9N^w|MKvVQ$fN2T%r$9wO{(U zvdEi`rdau>jq#K>)GB;=(~}JphtB4wZy1S#x(0e2QVF=y6ub&(uBpAYfEdw_C zsscC&=v=tyuXh)^r7AAfVKetmR!J499AHf@{ zv2IOAB;It(_pENb%M)^4T(9e@Nvne^`zVKM|N9mC{b`r6j`H|&ldK1e@g+(M0i{o- z=3!9P60<4;dIYp{^YZOythx<}jnISWMk7(Y!d3NIDSS(C)ijOGm+@e4uJ9};-we9y zQJH)`!rBhOTOJucJ+|I9On2|XPMltPN_1&Vtc#%Pz)WbU9?L+*&wWRvqjX8Ek|qp&n^*`IwK1_3xuz&-(8wZ}bqWZ6c}Mn>bWTGpvkBetu!m3*@Z?1xEAvBItWdOJoT`AnL?AF;5#84B1ZLW2^BefwDzav5@FAZryDom>DPH%cA?EO(1GMZ1 z6J-GR!?zx#4OhW&*8N1_cUkaQ?&DnHi4cqcr@3)#9ag4#&r-iYM zYhi#98)(w1cKo}5`ifXycD)Xmz{?zUZ)5jGEcUaZy&wq7j8XNPlMhjRX-!+cPOS^b zTwpZA{<_V37aT`8tqlYzy*yvVcppsFyV4@;d?bNn=^tv=CJXHVU-8f**Y!avx8#-W z%RNjG-d7c1kxTZ_G-6ZZkjn#Qcx$9$9eS**9{;e3LCVATFt|+G}iPfIZxM z0n7x{FJJz(4f2~cRx9TW%XP5PBc-EbAhJK`?7HYg%~#=o2ebLTPYCGBT0*5`eS-|( z-w?}i>rJ<_4tMw}FmrL_s_odLO&c=XY0uOH;}=-h*Y`v?u;<=*bwD)@n2(PLQS%Gy|$ z!ae+(R4Na1wDAsqU!Cr_)0N&)Fm_c5Wkh`N03eGGA6!OJPy-j!j zz+IVbd02l25>WhlEXJXJRPgu~^8hQI87pV6_v;o|xCb$iDI&)MctPKO!xH_m@C6`{$r-wV-+^UsVr4rG=mHtL= zuhO*ClT<)_rgIqDL_UXQHb5mWO;Y(17VZNweau^Ahte2Khh}_uJuR^s>BYW4PG*tD z!y-RKF7?tw)96%a-C2$#Svl2b`5?$|3Kafv&&mO^gkI(D%tQmd+JD@qiZYF{1t30b zjPdYJf=MQG#7d=SYTWXp^R`)1cYubEO^SMqj2`x59oq!M&*w-TmKMnzs1TNv@fn$6 zysll^qOs#j;f9fucW@oDX*T_kkv$xq{LKq36Dl;m>YR`L&v4PpCg5OlV8GGp?wHL7 zDXS-MK7l$1E*&xAGm)C~&2QI^)lWX8sKvF~B`V2V^HWG-iXPv3u?#*+-sI^DtTMys zWO$aJb@GU{$_TkfeneK5>1Z^v?V;6*ycshV{;=r+|F>3GWosaZ`-e1u$(!~y^Uq)l zC!7Oy@#=9OSdA?nUCG=TXo_=>8IZD%`!x~=Ui zIBVevdCdE;Sp})tx2{e%3;pKOD-7c4h<+GuOW+zH^ZgVt`yA=B>+6oyWCkpihf31w zc#Q1D!s{?krHBKyucY}iB92QW$Bye_JAF2zTTd22Vs3JBC~0|p{rP`XWy%V^#y3N_ul=- zujAm_R14S^s?LEgitkkH>iKvzPRjRrRA#x~$cBs?i}%Xl2qRz`vj)%aGUX z1LMB)div9CqogVQIoN?n+cxjj<5<(`Rl#Fe2JBTP?D~v&e?!B^m2?nY4^eX0EHQUI zU+8aRyGavy@(Vf-4qG`rwtL(=Fx&t&06qv)QJXUCkvGRLyf_D4O#!-l-C0=OCF(q=!-B#=id&0Sig(b_SVMk)^|n31AQ-8dI7e3Y+qRDgvg z$pK`UrltOl*^m}B87gf%q9rUbwA6Vp5Y9I(Iv=`FKx zY5M)8TxcDaT4Dll@CF!7+6s%JjAg3Pu1aHkZmfW2+X;wEcq`R+?Ix#ObG?GNVuNW> z=Nl>-V%a#y?gBd$Fkc+~!lQZz7H=G=FU_82yB$h~v=zNMbi z>OL#~Y9RNIjXksPz(HEk$n(<2fW`M!>=-}CXdwj2@M7y_%7RVgeQv49_yBe|zZ&U!C^?rcE zwf)&q1$|&=f^P|hcu=ZNUrjm$QO>ojSW~sLfn^}AqfodDURi@b!X0#dM7_AQP-C21 zm`d%T*=}5L1m^{_0c!cZbTlo|2vxTPZJo^|OwNWCyt99l=7|Q<(>y=w6Y>;y;>g{^ zb;Yi44fsouo3#~BR>+>wgnII+tYD+)tbOnRwO)JEj{_7Dm02}c%avSMRD&s~JZYY; zbcCu@KvlvT_i%Jslc&UeoXR7!O*iTAzz2Nni1#Oj9Bs3265Hp84xW4K%~5-Bv97qY zC7u0%nZ5`Je%tazJRw3e@@N>bEv#7o2P8|$W7veX`gDV%BavLl&Kg(p?7pj;cv?;V zq1&qp?{-hM)8XP5h9}As_nZw&+?uxF$fy~QFHRXpm)cicH#6$tPU}HS{{R}UI&}E; ziqGs1HazsGNsDnsa9Hc_Su|uOy8AwWQh@0y4Ym-l8Y~lnDK^Tt?H=%Zk8TZiPC%yv z)j)$|R{+Y3uhXUqEPj30eAdO96AHk}Fb(2Tcdlu4IRaKW_V{;)!X|)B7!daHv7)|) z>-Kne~bB-H=UIP$RBj&TlM_ZtlxeB4m_ee*OtXFvyZqivjml|nS{g& z?@kdak2V)kz~3dqOpSZ=+mG!7sp}l1%^;w`+T?wV`Z+*Dg~nJ9WkxY_ET;p}fqM@$ zsYv%+gM_{nah@A)@Or2j=jM{+ycRqlb94J9l_gzQOAw5j`-xBfe69)97E1j&jc&Syij?%Mb19 z(FQ>pV{vbZ+hdkrfmLl`WF;g3^vJI<#&C_E?vP7U7&{` z`?xWLJ839YA~r$8~)>OU&!W?d7Zwq5cMR3*MVF4J$`nm<4xB<=K#` zHfmDxg`ua>k3~CvV)^KZ?i7a!9-TT8y?nD#N}zcW7uR3Kf0J>zxYJ+>!ne&WF@XsO z<9+9*=dbO3X7T;hv#`f~9?;*~fr>@099@gsvgG2BU+!)6#^oef^l*54bWz09v#a|g zs9eQfr@}RaD2qVzNcVNS=c0tTCit_o1o&rBR)lFF1>9T-6h1$NGGWCTv7bOUq*Uv_cg4&1xm%`D$G5OO z2YSI5QuAJbTi}%hE({qVn!)CTbd7*-Ugkueg#=9TWo+ln*kHOPhuD%3RmcX+(%O0B z??6Zmz+`VO8wCl_03&dmEpXKHdh{SHJG_5gZ)`N|9ku0}*)eRNyh&SdWL6E4Ta7I| zN-D9$d*G3q1W2f>X!P{1v9^GX0?M@_FP)5R@y3C!sH7@k}JR33c- z-9O*2rO7Pz(ORNk{rtXNkEAA)A;FtRyXzl50u8jrA>WE$^~;BP-SU}Ij$)aEz_?ftWJ{C z*AF+M6yn<#jcAdBFk3;d8+$;$`D}Jg;=_N65>+Y%OkEJt@@BK-FQtcibHoO-lnF2E zne=-jK>Bp`$Tc8m5ez&`fMp!*42jC>%Kde@1Tf0xF#C_Q@ z3CaglCpM8%FuYW!!og>j#avTMrDY?@IVfzHot{{okwq)|YcQ^GMCZ;w-nzUsJ@e4O zi1E1`SW znzvH4gJOfC-m@w#>VyooPkoRb^lL(l+i)ZAD%wZ>;2}8XPiyE%e0)v5T2@M9xn*z8 zgZ&2y1h`*&4>UMQ2p|l%E_oM$5slO@2~f;@Nj@`VPu0mzU=DOnOLo-J+`c!U%paaL z4~ZRsx5Z$b@myPnEj-dqZlL=!?s~ZIn%;Llp=pf2D+^oRd@QQrbwURWk>y>^g2gzO zWwcH?tPmr|Mie60f1l2pE?#U3z~#yvn0aSm#rN% zfPP%qHbMtd=&^rz6BJZNuA+{xJL!^uzcipRO;_)OpS%J=PWwB~?pEEn3J;%4?@NR1 zAY!W@y#vFZuy~5@G)DBV>xA4ns9|5qeNRj53tn{ai4v%Vf~Mfs_5&{bgdD4!v)*!< zH?D-r!@KR(@17Lf3yJKeh$_{^t!Kcp9Y$7edXs)PJJ-Z5}I&B%Tp(K?}{56%1e4B#=E zsd|`$e_SS`jwjS$v*sj3seQul(DpfQLNJq9hj|z7Y5^>NJ)(%0?<0zmUMu;gdk??~ z0pxR$u3Pgg?#VR|O1rYot}3eiW2*D2&F!cxYU^Gmwa1;p*l|PUy7+0igBf8!eC~EMx@y~8z&Rb}>#GysRc$D7{10&JZ=19o?76{AI;4f{yHeNTXz1m$r-Saa_<_}I2zA%#Mj zF@a$8ym*}7(gN$f)0ki}XfMCBOz_WV*7){l{KA9dSo&KIDvUaRR4{opksZIe6V?GP z%?@7j?USqbW$uVunem0>Zl8Sta#A@RvFK@>OX}*DY3}7&(kE0%#@i9_99Ye-H2X#v zAZI5oNu8x+l0Z3Dy`wn0^M1#8U&0tw~?so*&-Z^DcCI=DtS;k2YDjEg1^9C$6s!FnXVI8Nk`pFL&EO8@^71UPs;} zDp==KxP021TObd?y#%u{oaQsc_d7{c>e(xa;H9;am!>KB=@-3&bpbB*vUpeM$uL-d ziT)wI>^W}$7t9&(WfiIvg^_{bLuzHRyp7zwQQ#NUk96cj7hL}?KmExgVm6EQsrYjq z+*MW#SnfCPB<+$E`w=dlJYioO?H4`_yEnCH6KWMj`+Xh6`Wtt(eQn->R#b=fSmQT! zeswrmbVzB+SE&g)Yg#S)5Qxk6kMwi1);tD5Za|Q-a~dSdGtdJWG~);*Q)1Ifq6hg-dOM4_y)gFk+xd;9bDKyzoM7Omd4 zAg#dCKGAkY6g=LNejLuA8>%C`@E4KNJF{nG{qWvBLe<9;=!J&yW}Dqq4u--Hc0zsU(&Axb%&Y%AtlX#8@QRNthUde0}ILAQvcZ zaSqHY_(dRI*9;tXwa0%ucQF)3cxk}pyCLSY>0_6{eD`oJrm+(*{?)4n zpW2A74Bp#otZOV-Ivj7QTE9^=Z^Vthz?0tKSKNUojhv)jT{NxoY}_)>Za!a*xKZe* zHA@OmhnuprE;bG61)5(V6=cxb&=6Tt37f9$S3G=iK~={3O2_VcuW?rCbYsCBN@LaF z4LUy+xjB`ok>037Fr}iZr4l~SOb?r=yHJYP+&t$_#x|Ge3e&JYWh-K5s#H9%N@a3E z*$2oObApyw<$k<{OY*6R*{3qF+0mc>8S2!lzmU%t9CkMYBa~l)(yLE9xXpWPY+4eA zQOifqy?;$hVDr<*i17>s?VBZcy^U35T^L#I@TYquvY6Iaa(oOalTW`lG*;P$sxSo0 zL6nkMm3HD=KWtf*!X5lm9AkoWRJQRqYzh6Den;mgLMrZSgE=P0Sf(QPagvuAtPBu7 zZ3@QD!GY%0Bq3Xy{jtx~xXuh-o985R_+@cWsr_5O)eb9Ev8fu1B8?;6k;90pNRrU~ z4c>2o?Ziss@9vpgi9$4Oyx80Q0z)BqAErq(tk~JN_4`M5_qu5Ih$(gG3V%ECyTrsq z%i`BipN#u9C`;$Dx}XxZtN=$>f|24dTD`&}puiec>u7Z8;GoNKBKwQ;>kd=*K@ppO z^iaxkaIK&7d^k>NTEeSDVyU40KF*aPj+L%SDR(ub*+4e0ocPcL0c!ZE!Xa+= zy7h*VDiZH_cHP$79;>Xc(>}z0)P^2|dbU+Jf|g*~XU=L<#{8IGxc{r*XvSFgf?2|LDj>BC<*y7|lnykRk!>5k9^$Crp@~tIa z@kW8ig^^xI(z}*}Y++Mp{5}wVKl-M9^Rin3VgUQbX zCLjh&6Ml=?11{74_>5~AG5Vk#Uycd(uFaV2+tLyBd7u(_ujcBZ8Z`+bhVa7vAhR6|~oy&o~x3Sw3*CA>mj<&+$fir~W4vdJdfKVz|17 z>SFrI*t?5s+=jEdCHoJMwMz>o3|1D1uD0cbZicBX`nCl(Wx3nIy_O?veBv&_-I-P1 zM&>=yWzvreOsz9flYV;#ypqckXqDw69M5v(ND5IEU2Aive)di=%3t19+^2Bm%VR68 zi5fqgvt63e-sd5rW%M^u<@6ONZ-a|wS*IRr=~s_z=5q;g&noW>Y#9cy-C0o1D%eyt zgdmAMU6B*YlSx=`O5aK(Z>XPVyYIn%k^SD z#dcaOYz5m6sV~$BghVm!*OR|;%(Grr=JA-~AOv_u>xbGKisH-;TPqSCsaa<+xm1PL zf97=;DxZG=H8_SVQ1L0|w=`z0Yr@|}LY7z;?xaa8j|W@I44V#y0n}1PUw{BrqHEq z=TKxE#;b2{cf+)e{Ge(&R@l{M@UTg%C8#v-&|*|(VrSvT_VUOXE5&}4YEK$rRQU87gB#p^Hc zYZ;f#cjfn3B?mw7v@h&eGwz5W+01{&`Dn&W_$P-dbBA3= zXXf@s3oB(Joa{p-$Lhzwi?zP(H}n6Q3k!$q9`Zp4HqXT+j%|NoJHiFzi1(bI?MH>o z<}Wo*Nk6U!zmYKPdiwL#=|x`W!NH??)|&;qZWgS&TKg(T&;vBDq?>KJHyZa{Afjq2 z1)=7GR9>390H0jo>@ltK9*WuXY9q&W3Jkc~wADMQjqOb;!!EUAzBQ4Rv`6p2L?g=v zoA1YGBh}9zFlUX*9$Bhtux1oC@o{@jR_qW_D@6;aK7O=sz0*Ns+Mn+&9Gp(5(dB1I^j>)x`n*>DD^qxd2KU?3TI4}kuI;x zX8$!)|ESD|$s4D9u3RmsYbjiKl$S4XvR^cK?FY!zj+OJ$j|mg@NmH3H@cJz`pQwl1HI@!1QRy2b~dWMrz1u6Rg?j2UDN zJ!l_*xtkB6LRZ#@d&fY!V+4x_}q`|+3{XG-B*g0hmA2gRTpM2pMGz5)-`w~^A$&u z^k~kww10%Al2wgU;&8h`zRFiE+001i`YN2OHCu@#%74`PAW-7;a-?HbrcPu#y z>b+~74A}b95$@?WB(?Vy6OvDf9Ad-#L1*P!iTJMjQcDf_jQt~f%wtzM1f#gKPDdG= zGx-Ipy$v!OBXVkMCA@DTs?^g*u9)DZs$;#~--f8f8*Utqn`8-z=`U~5Jx&Y zsG7fO{(8zl9Di7OZ+7=9Ge%+gd>7;Sf}OgWQ#lTL-PbpR0&JMpFEp2tIRq5sd~Pkm zH=5fHs?GUwc}63x5W$y-tAh!Y2i+N7GrztJfW!0_f}DZJUemfvA;%Mn1Fz? zx>-i^#qr3+((8u}68df6$4f7DGGf(EN3`*Bs2b!)kGZ0Jz4^6BUjuTF8R`(&k^;>d zsss7t{lBCwaD2SDs@|rgP@oWKh_e3Bo3#9OB@dz1J-tcfy<%2>T4YDY)1C)^E&WFB zaKYf$GM>7PK{56A^}UwmAzOjRN;#z@O(p(dYZaNc)Qe(@u99(&VubHs^z3?&*&}_P zq0M-QiFV@JBhTmd|Fq4fo_gGrb^iWlY>icwG2fHtz_wb!ZpqvdOSC7H%gs{5x&^9Y zcbiXmIa(>fZ@FoOee6S@y-34xNnCnJ?{q>6?92w!a9GV=5x*)6@b_g_+_MhAo#PNp)3QYV%B= z1G&%n9Y$ptVe+L%bVI*=;^1rHiAc_4VCCn~f|T7@;0&^3ZrwaC&mFJKbxD1Pd9W(` zd~IzolD%d-$M&C`SVil*=`j0JVw-l#r9hju!Asl`QBVKV{9@%kN|_P+^^WYx@L(01 z!2*B~nHJeYufep;f@8Hx(%tT< zoo+axN60o~{Os9_vgle4x#y~MwH0w1uZFy|b7e$No!^3|XysHyk~EVW!;#3&oxNG+ zIRb1u{2zlrdaLcHFtMD)SM>I$Pd&J(3%I2j4UlUZt7**!8BZ5=a*B>#-=?MiVY4gf zy6(9%mcEpLu1url7;L%Jec<~V?C-tK4p(pN*$V_y-5y!ERp0%eT!XGz=|yeA%t@YLQFY1iM7XzL z_=im|bf&B{X^p$S`#846N!FhMJLSK-tU(;=f_aqa%AGta(pBar#O}{*@Z)WntPktI z*Q(@>N>geh$PzUT*x(}Oex=nmm|>EuFMitxVoL#wlqf3Ji)D-O%6J-L1U<2+dXARL zV`}-y>e!Si9kD?U@8kOUquc;;YcG#kQ4fSxX7eWu-mkY5q$G>C1zgP$xnOELWxOg3 zEIoRI_=*cKEtK8%vyQWH3t-~;1uR~qTE7E&hApxBlp!z(AVIHtA)smny~|8E*8qWJ6X*QJzjelfY2S30u^AcvDt39 zKs_LFfmpB7?YeVRMK*Mx!W3Eb*&Da^l1XBhCRJ?e;w%(ZB^ybrXlwT8O&% zwgt(qFM&I^LMHre*ie`z*xI0AYry=nCO6Y&m2F6^n<}RPBw+>d;@4E>g4z7<33onB;u^I7!aX?gGDolPuGXyhmR1;V7?f44Lm%2}LwdCMdQybG_ zaV1D0Z@4a`cIAgXYBBfp>FlIb$3X265A(JkwkIqhUZOcEUQpF$St?MjdUEcIwE_oN zX`>w2TvQk|GEp(+!+A*(N&m5f1Cuv<5@@i8RCt1%8%;Q@#ZYiNu{ql0AP?IFPyqfO2&a{;I%Q}OzeLmXJzXD)FJ z>lxd5!3QcfyZ^Wk-tFRLtid>ulu9P-hxF|85_0Jb?McKQ@=Er73ex%da*Sy+#!s3W zOQ6CwYx4%N!AhqnNE!;Jk@4WKRra71zq1+fvcf))#D-r`yh+P3lo6s5+G2r~W>Sz; zqU0@G*@Lv(-5Kl-U^~9<_EwGqo!qEEXLCt zZYc`Xy-jZl=mSv%lDzSdg62b|Rn0e7bs@;B<${0%AJ~)_?6-~&xC#_U8yt75VyXZu z>?aF5`Tz_4=xWN8XXJdXUD3j7NY@t80TW{KHWjv=7JGEp@a+LHNPqAlV{so5sDJ5s z*xD0`tlDCL{C>Q<3m6JGz(+5wkAv!#@k#rUaf|U&jbcVgk~d9?`}EoKK#A|{fm;dj z*dNNrAud&j&sc`0^c~E5W_ElMd9XQ`+rRs&Kxs}DN*hNaJD8!KrU@O8zi}faA{FD; zD37WzjP0JPV~m-JWDF$AF$Ov}zo5ci5V#jcLHdF}u=TM~wxAL+pN1-RB5AR>j?J+7 zgyar1Gv zB<4NCpf`@)?tQhxgGd^=w9VTyymtU<(vvjcyS|gU%m!CbU>}Arh>zEfFZUF>RY_*M zDwkGi67((Va?#%~WX4~!Y2b531RS9tO zhwIoq6m7%$w%QazI0PD8cN^P0ytPw4rhMy=YZU0Z=tFi@$!3TGZ>r*ReAQizE|TZE zEn7o$?bO&Khx?@*zwk;_?23MV6B_!yYFqq71 z#mtUfBWK+a${_Q!yEEJHUJQzS27)INCNZW24lT}^cT6shfx{PObA1`xMFz0e^=E-o zaT2+$pJn;1D)xZ^K!LF2k%S^=?WmbAUwG9^-ZKlUI6XGD=0<^dPk~q(lTBP)ox52f z><3BUYO@=~29(^Y^^6MdSXE_)gG~Jpu;ms)jWsuapf_y|1y+zZu}?#qR-$*7ji8nm zBWYC(v3tVv2Pj|sJ|@?$;Z|(!590PQTAas?`pwzb61kD&vrMcjTtUebd*XVtH_+WSg_)0Zp$5`tq~gOek1NFYYTGP zR%~7(v{h(PUby8@JoBZ0TH1~**a%9*X}=c@^A{AHz~Ppa9k&>g+LStS}Tb8TKBr|K& z-{ms6h-;^yne)$Iiet!SI_VExT6}r+ggb)E^|y9 z94#ayl&vscJ~FQoElHnbXGT}U_$8=$%`dYs&*Yq zcEeYLuu9|eAen*Nk@XiI{i$&91!gu+k(+nfP`k@mBb%*(N$P!tQ5>RU$cqhB$sCo( zz3-U9yDfosk)Jn0D zXV9wS3!2H1*_JP^KEw@FQY2!mX#i5hx5bwoDIt&)iJlsYGN~g zKY@f$mRuuRPiXPdHW}gAyjA8B$XRc6H52&(5tQO}^L>RX*~25mXG0mzTDyM3kC1-2 zM>+HS13OQ#*rk+~ugJ)P5a*j=@u8i%HXNLt*jTl33TVO%5($cpJeEfCUCD0U8OoVZ%wQN8 zm1a}Uu<`IIjsltNK#{8s2E`s)!GIG@ZOXFWFe)eCZgfp=m$=-<#L_xYHyPrjFEs2M znK&elK^i8jz`8dz)?bnJ>s7=U&yKS`=gC{c=GiFy(jkuSx~@juGqPL66@Ag1)a|W0 z{`$FM2NkA^17}KBdicVM&_9Z=C~K$=S}O%8s{|(G!N@j8805v=`@%@VDEim(3RRV# zu!!k-wm@`|9p2RwZEZ?dD={>kfa4c=wHhwhOS}tjZ{^)6`Pi;Td?&H4*IUuBR>_=> ztX9U~JzR^GKqoBihLxA?I2oTbQ$FUMkhHVgAkFl`i)lcxc6VnMyCE2ee8@6pm&-j* z%+=;QE@lw0a&I8>_8?8DTF`n4jhbAip2WLuA>EH@HbJN#Sg#$sXj!!iT}L0>(qgXT z*!)7`lG07HKtECjjoM}br^ryO;tfi&LApE+Rcaw_l3q%QWaB*@OrOECpUB-hOIpX$ zqLKT#2NbRqDcMJ39v_{yt=&PsEWE35y06WCV3ZAwPZg`}s@);9(=;jEdM~xJ6Fx)S zz8j6KIGughO1|E+Tn#S0O*|`8Dz#SoC|N;e+iiyBdbJVbfZ^(OO{7YPXos2?qoEEm zuwUNMZjJJRb{O?mQ3Kr)pK}}2C}m7k*!FO1!&=L1Uh?{a@b1ow8=I>kHvTbnh*WY)&T6_|`okID~C~ zkI@xdB{?2wc&*xx-3%3J&o@;nLfa8KzO3@fq4C^&p<7j^$rk?8MDRu9D-TR6(sorA zZhTv9S2H?jv-xZ?da1RS8|%m)NUY!w^n5Fwq$bspzl^0)#aVQ1JTOUAtOf=M>gNm^ zW8BVEVn^)0Fto>9$<|-$u*;nMOyJ*9YvEDO)x{K9E%)W}nTp_FRBs70vo0KXt>G8s zQkOTo?R%#vAZvCft8n#;A)52fviG#z3NJWfc33uP=RFH-1F1nZuVJW#Ty2yJo>~y6 z=`n1DMP}AQql)mvmP2{fHV44$V6O=!#d<#8?va;!pQ>BTE~?>@F}RurTba5;?J%`e z5GLroVcUPblSwoJ-!6ex-`%Ze@}Gj$){pbH-Kwj&*Uil$qPerW7`naM&`x_@DduW^cyqid?53Jd!!=}&3mupi3t5B0%B6M-9>=#Ge6(wxt^eO*BerTL z?#oL@_x^kDd98NK`0M)`jDi!@=7@&c4Y!ze|0|jmZXiDAS6xiG@|ifsA1LI>jCAR@ zt@u$kQa3|nCcfQ{U|K%7ennBwua3oL9WJ+LGJERXt1C9<=s(LSh;?dY|5(YP26bM;?ri_(ZM^IhevoikutTi@lLcWX!=`V`Mtu$s0=aqn8T ze(_4?mB2}^F1y%^-A)`r>Z^~L zg+8p4`LJx{Q1#I?8RQqwz95$I?n9~P`fSub%4vHRBi?d&C+hB)k+gWcVMLhadk-g5 z-3T`(F`fzOFjT*TJ!^JqyIRj7sSG%PGkssG=8Uu7*&@})T_huZnav8j>&dUu?p@@G z+?=#-=8U;h=WfEwJ%N$s9`m{#vEYTuQuXs(Z#aDIV;F^N-NBoCz7HgMbK*|V zM9P2VEeF*~OccNK?|gTh!B|K)O4T~h*X(hd(!IYb^_nNw(*-w4?iGw^`p9ae4b>oR zNAjVa&8DZPI2Br+?v`Lzmh7BL)b{2Tn(640wCf`yNUjE9{X>~b%eJ+_`0N>s)zxfN zvU2Fu&fS3EoD`RDF2!5w^=p-D7Q`aSUz2>0vY9UhlCR-Z!z$nEv$f4N)AWjt91MzK zvNz|KS9Rw3mmU%Emxjnyo(aqpO|I7NrWgN?V`|}R9?@EdwCht-Odpj8s(-g8$Se}Z zsAn+SN49sLeOxv=3%B09|I-VDE^uJvD(?c}k2=P$t|4 zb+T7gzTXoBz<@b#vMhNj3r&*|qC2}f15|tNT974+P>X4SDbgq(Pfp_nKR+3NSumcq zf?BkXb8nWHr8pyLtjbusNn_T}yLSg#dXggbS+w`(b7;x*Ai3TPfb&xd&`&YHNwp|L zaVX0~B-518a$Uo*1?%SH-#nXa(fY6m(#OXnE{X_QJ)fzC$RBGh-b)mhIag(_Y&mvM zIqF}AXvnvLXEz;TCM~w3u{n_OX0IX!1oDO34oU7~Vk9(|f-_3xj{C_O#1KS&995!` z7ntXeC+uxUYR0ZQiY!D-*sM?sgS2E&i2)G!)X5X!N=m{GpfO73ZHez{=cNCF@~$^U z^D9U$Mc8_5GP8sHWe7S0sG4mNvmcQwuic>p=ztG+A*fu2!F=nPULo^YGT9IWTEB5> z3g!yh#H;`9@ZDw!1s9T!!Laz>c8aBU(3-n}4XRXs`@Rd=c;W&%H@;72~adQ*k#j|G<@1n?X~Mw$be?D*vGP$s|&5ck9F^?_U}}DO71P z)DVk_{}31-xoi(x%l@BNPo-;yqkhbv?9bAS=h+f8Yx8O^<+l`TuE_f0vmH znd(sp`v>WptGp6Bsw|b}++;-tAMosrm=hknoQhs7`nM_82R)`@4(*i5lJy&W0Nc)( z+#4Dr?ru9flGO{`1IifmN&aI4Lrrow4Ouh62RsoD_w%cS{A2%p_0V_R{HU{gnCCy; z9pcG_l)W?m=hjvv1qtg{Nck7*Cr&w|&dr;J)c@QfP}N~xJKHn-l_mQS{0T}wK3#=8 zsKAG;2G=AFmI7k53+exPbN2EDjGfOB?+8!WU%FVfE8Y@Wi@PQMq2cw3CsnECh6Dd_ z%AWJYO2n+JBjsP}->#?KB)%KYcKiqIaZzgIl{t9VvwqsYV!Pn_=o~RlqD%E35HBQ! zmm+5Vx0#fH_tg|H9c~Bye`u?ddQ!>Q+@|r*yH9qnjKe$B(XRb;{{SKFyD%I|=>DIE z2JAlIiz8$y{*rse-{g5oN=G5fKYX#yp&*vZcIo{CBKRY_cY}<&66}wz93jRq1!mU_ zv=Kw5>_z_~I6icade~(sJ@FqdP;m*9B5;a$s=v%Sw)PSL(fb$3*9r>`ulP@~#s8o; zJd{&95Djyr{`;n~nZR1bD4~>>E~eY^FN$9aY}mcx7j*w^TDh4r>SW;5W%zG1g?$ye zN``*_P}1sjx!18jr=Q^u_fEc^qN<=K?h8D;<-l6^PACLJW2GDJtx2my`SyE znrY@8DIi2Wm_PUe`VLwFJ&JvT#iPa1j_5>mLx^ASu3q>iik_FAmpSQjY4i8kie9G} zJv&C@46XU8SZ$>wF}2TbAyesamC#CI$4Cb%N#@%bj&6pR8D(`MLDF1txktZc3A3l( zfTMEvsun!1ctnm$lSY%;`O<*f9K!VyLZd&YRd>MC-ABYzr_9ONEOMBwQVtZFNWn&6 zQ}1l<8vF8=3oR$3<$`n5kUJ+;WC^<4B8e&^J2wL1%Kz~y{YdWE-L)kRVcpO}GpD{B z;*z-3Bakzthqm?&bQ?1GJ-G^}Ote!xcxy5h-mbr26nlEXc%xun+G*S;nue{rGjtnk z5!kU{T!>`G!-g20>R8Sw=Zg#@o7C~?)ZRV%zg0DqXXDW^+UWKoG&Q=!kwI}f z#oc}JA$tGM=Rh?S?Vg;YhTwT0f0_%R%ZV%){04I!J=8x3vUABc1ZcB+{V6mbhY(~R zwb}0Q>`zDI6_8cz6ExXj;H zNj+jH!ya~q#?$b3v!i^f?+~yvp-FVr+=Hwfd{W^AEwjH!pA$q{?+IunW%d+j{RC zaoEQUx%U+gS_<_fCSJ2DGXceD#I+{AGz|v*a#6;IUC|hJK4|)d*Z9g|1$jyG`RG%5 zQ%AKh3UcM^G-L))TMIv3<~RaP2d_@9^vhq?rRhm}v41WQ(eC9bydz;cH_1(I26%Eg z=jtQqje0e_@_0$lmy@$4<%syb+BXxO);}&dRsNb*_RVBIVTB4C?LwLteYWF`Hr)^# zrdl3j4^yW7Y_BS8AAV>>?(i}c`q@nVK80{Tk}8slnQE2BiWzL9>ma-kMr7SwYGtM( z(mOFYF*?yZ?ayJzVUl2$pqJR+La)ad!NA14!sy9p3BPyf9%tpLIKFEk)z+RTMdLWL z5|ctwyMnJ9v@#nyS-)JGvOJviDo}6h`*JYpLI^hvLr8XrO0aqeTl<7XLQbs=x*4t1 zvAlhze24E+N#js$ackntiVw0}*J)qQehE4~EfjwaPH{$Q(k*c4BUj)@gUsVyx+OXy zcIMm~+m_knGGSyTx-23j%nGAkI#05iQMmWoO+okBT4MR_t3&rbrnycoFSDSWuaZlL z?eRM^S40pdu-Jg@n4ue@Zdt5yMD&ZE8}V}Z!Jx`2;B{gspemOhUV!Bqlj2bl#;0&lhaxDpMALx zMFtAShzh=9pBluda(IxfQo;COWm^%y=IA4gZVBB(Oi335Dx95M5sd5?_zvjQ!GmJ_ zCB6OfH1jfWmnxdIG4mCMSBGwe`~Fxh{K)aU<-tjFYM!8C?_+DX^W=L4j2mS#i#|Jp zs!qNmtKV^dI<&{CK0F*0QK42U(A(%?&y!E8=rdgYoa^%*h{`}`O*V%zT7z)S5 zrWxov^YXyWc`GHwkMEisr4xRS$xOVRqovd%hESm!z1EsfQaU~A_52USs#`_YAKhPf zb_6p6-37+-eGQfSnYnihQH34fve8;IS@iK#qDuOac9sEOh@B_?rMTvJ7P}7PBy}#6 z9^LQ@J)7+mU|w5;HA95hOW%Mbq^+ZCh6sHU5nU1N5W>(NjCVqFo|TE?s66eRPhUJbY18gvdU}#dBC}U&|$iRwAP+oBxS5Pf`fB8k>TZ zqE_BVm@gD>(Br@`RKMW7X;WdvlfYXkB`@F0?|nV*N}i!&u&2pX)Qdw2M#-~21=)Yt z6hv>TXe{M@ z00A97LToX}gB$S3RH-Xc_)WO`A{;{(^cR(ZdCPprg5s9cVL;vZFm>T4KroEXuzWY1 zhIjA3Q{he@jAl!by)sYzIoNU}vfvcf702?ZxcYa?~shw*VzW2`Feb zEHtC$edTBQuocD|U!27@Bs|&ntO-zN_Fg{@KaQYAlMy*i2fN&;CDh1xnk%}3oG78c z(Nd{86OPn7zm@m~$lt?7aVjbo1i)1?>;aN41)xsgo(~<}=EqL%Lu*7XqBF6;vK^d| z)S5_$AJidR4ze$=)0+q!WnW?Avzu@5lj&dqGb?_H&-0S-*6_3-E4dLM^bAbiW|g+d zp0BV9BtQHZKu$jf2~8S%Lzmo1A;GE6lG<9)VlDCI{+8D)!}ESuuYxR)nP(k0-C{_MF zJ41flKm8G4x&dZ!6ZXKdcbFaZ+H0*-!&yJgFJM@kr`k2(x3?BDnu=`YC{-A);r+^g zJ_WY#jGs7s>s-FqHMJhqxHY9fJHe157L<_SOE)K1X*x2F^k?A1uWlmipwZdbnwmo&rEbW zbamNK^yP;Aux{~18p|TLQ>51_Q9A(<7e&I7Utfz*m3z;#8k zP~Fbz%C6rZ^$+4P=J!6AZv-oKxX_pKd2UeQtl~FWPMfjmevjK}{7#udA<`LwAqBI$ zR-(i8$&b~PoVU};t!vZD=RaRA9a267l6!7hf5gAIpSY?=S6iUJ-R$(lF4HWmJN(!??5dz z)|AEbjp~NX6aHIa`|{PPd*rXmKH|5vIa<83I*&<3T zz!R!h3N-6mUPW=*b|m*%r{1z|G75V;x3YY5ZY4#yqN%#E#$i=^V^s}gDs9ti?cT^d zzO9)Mb8Xn$v+4kM_`A$7mi$c1>ym{UDbS|-QO{}O=)1m@bB@^u{pzGI^o|U&lV{mI zee=YLfEcpc`n@s{g`RN`p!BOc`pT~l2$cjZ<=wH73w=?a>H5Ybul%>?IRLZvW_*Lg zx4|8mr<21_bK}yae6}kdUhJeu?vgxMr+paMeSf-$UR?ddpqTwlnK2T||Np-P>9+KpO291guL0`0lZ zWXWFP$^AOeru@^fC1W7`qyPl1{pSC^Tw&&k&Ds{n~s(&yS z&bd17IKBKY-aR4_I@Dr4#$j+V_1`-LPAW?0dG8U2BbO5E^5?7vV z_#s=%uFUVbK4k019wdZiMlblB*kP(4B_`#98KA=C@8cNK4FH{&!ScVzQKD&M4UUPi z4&x>Kr~jIFd*tw;XDyOfe0+s>2%nrcTCC)_Aoxmxbt?8NwjAq#&J9LV>D$h=qM3vB zsWNH(XbkX_kf8lT>jCR4r2AN8h?=c`v+v0@4N?R)&$`W%_SDLPbr1Fn7863GVJ;cr z-AYDN&M4qxu)WqFm(1^~k#(HC9SCIXb$?gDX$Or{dCd{z3Z4sy8%Q_OE`G?P(!6ea zcV3`Wo8IvF(Q|VILK!{hX?qGPFRv2l{_%?s9=xHKpuf;hAex;i!be5mh!q?@Cb-vav^u{&K zOIaE#ik(9pOCML zG46-UllYT7NrCAhuL*^e@5&8~^h~~1a~+>inKA&?hAk?OC$TX1wjna`F(_N^Z3 zzpvE)<+uxKEpn`N#QBjK-osH~w&m4XisUcuLqBlM$|oduw+(u8Gq!K=_2SUsD~IM( zT|!DbEnACOeDc|0C!r&HJqJjJr;l_inx^XuqR@|YJm-8~&E?zG2iWk9tu-AK=?Lq3 z$bFAijcb=opDv*90idrhx;ib#I@fMj2dfYm4?iMhw+~lj_1$)t6^;0B@iP`0or#+UM_(g$ktBCAamZNx0;I-cp4YA*rsL0L=M92z}7+tExC= z&E|=cbWx(X1~hnzq5Y7YcIKcQQ>WHzN^7H>05%1_Sy?^*S0ul#s++pAuc6)>$mURO z3dwT=@B4(6v?ZS5Cr7BV%_lx^!{6iF=z>_D$Kv;oo~5dRf`q>x!DidTDXf2A#M>7w z(Ce12h4V+LYII8u!Ci0rxEjs!XTwPB4LNE`2+M1>sxH@`5Dm(+M)e9QDXf{N#ky4s z)BElvGOqGuKKo8!N4qV=@&-xFuM$A)YE}gTbS6jrrvlZJ^4i)E=Id`{Itut7#HS z%3*D?bU`XU4T?wV@H15XXDmWay@C&d`o3H^j|&vmg*7Fk<5~Qh`fNE+&_K&k0CUwC zXWv$%bMjV^&dR&SS9A0o^x(9b4!E|WEJs;da9?!YEN< z&hYTe(%owl?)?m~JRx_?vWfxghQDTj=BPma(Vp}V;wJ*#3nkR_+pwk-)~Mq4M~*c- zBm1CI$5TLpsdRrNn0V^BTiN?zPHr|xbCRJV&7oZhS>Bg!W|0+ANL|CH5ctgJQ46jk zwmHK~|GUP}xy_@{%GYdqKbq;fCrY|r*>SNB*Gz~?mSL?2Abt(~N6J9E-&peiAW^@S zgg(e7vnf=yUSZHRiY|!1z%y7Wa3LZ#;5CJ{RZi5{)j=0sZ`Nn=bY}tU7H)PE1xQ{g zOg1J`HZnQaEwfHXeR=tkE+}eb;T}WGV?lEmR07wjG5gU+RrCAG5B249CDaoRQ(!MX z)w0fPh4DuFnJQjxCC!zeI%b>LZBrI|x=xGg7*!4LdXydh!75VjUb9S7Q&oo75h#?u zniaYTdWM+t9*wR%Y9!!2bl(5LIzRZ zGyc@F5H@=n_IsRDU^B{|E}oL=7J_mH9C^)i11DV81!bV5m^_ECc~o7`!qNT6xl7#3 zIfHLD4@R&eSJG5w3T7#&s(^g^3hNxLrRL!iNp!6OONufAHmEw0SaqxYqCAL7?ED*^`O$W z>geR7?1+VRowi(O!IGZmujU>cdNud%y^CtExC7r8Be%8#`Kgr5mq4Ym(VK^|Pta)Q z9p{CegdtK5Nm~yu+G$TGy=>?|v&oOtw>@7Z zzwo|wrkePnTR3n4H+ir0L1pViffYrL{t(+%!s5%ga7g}Y^5deq58~4XqFWJ+U%GEv zN$HtOCUIee-evQi1HM;d-n+FY|CMCzKDux-_oJoCsj?yF=0e@LLtb=qp90-al~9o> z;g?;UtDB;&V85EcKU?DBAA0?Ly4(~$kgOh)d2*=GJLM>M=OiU=iHZ1e6Iw4{bG1zQ z%Yjw{XZTxOy?gUzJvfP4dNeh-FWy_J@M;>pJEJaYvwhpx3oiEiTcfF)`mVRBE@Tbn zENpEo-T9pC2KJjHIyI#%`9*%HK9H{4Oe*dg08>{DQn6rJ_uTNO>H_KXvFwjv46eL1 z`Zn%07<(WNjKMglw+Hg3)wgsnbQ7CGRP-76k7${IqLt<)n7ZD22|Z&I!I@!yyGt=i zN<$qRWsUbgDb+v!WITBPled<+z1?&kA0YOnvqJEo!H=_QDLtu`@am0UaMf|KPm}BRLQ!}ZOFBxE}q=ib1Sv)YWhpV1?z`N zIAzg}D1()R0xlRrgc5rb_Za2VW{5co(Lg8_ov&uy#+kFC@l8~ew6|Tu65<+@ypJYw z{j|NRR5cC?Tc;LFEv|O07N>2p1kBhU+ReNmIkC+;c(${OdO|Levd5FqoQ{wCr1v8@ zUHPMsQH+rVI^T6&owDKWX29@0TIC5J0Ly7nqt@2{ zt&pv}SbD2M6AP572ua9x&lhiafl)^4;iLCyIy`Nj$jEa<`y4a99Tce$%e4Qe9f_sH zK7?rcY$wbY(WBxaSswrdZpl}Zaxsp>nGi_z-tP3?2Hz!x)y zNJ==QW0e!^*m_8bXor3~=w*yiXH0$MNu$9`3)&RYQbEpA_k)%mD|X`@&$nkn}x0$9aaz?%Tvo_AU#?w=do^I_GV-s@&y=O-NL2 zt%<|gX_B=>-*DA8j4ZHI&;BbBFFc)n&UH^yc8^Q@v?Nh(I3)N=z}`gAlwUG z=l%$5tE!nZ4Q2%t)}A3%50)GUhWOR>=+?(4B=|6dW*3?)wI>%u z{i~a8a$5aV-ZzM{1zO$_TDHA;aMg$cOGRB{Cl69v1UR$C7F%y9O<2f>F3Pvwc8TR* zUdW&%YUVbW(`a8?^LLa&R%?>|*QSH`wgT)AvOe?K_Ea-GSldTSddU<6@SADD_qxN zcI5IVF6oV5O>@NeHNTJHu09Hrt?u#LJY25MIrhHaJv{WECCd?PugWc@S!ym+-Z*!I zF{9iCd|`s;X(+HR-z{S}h~TNIx_huiDzk)EULj*lO<-~?Ft5IKlNhH)ya*H=%UZ7F zj{3$%HUJx}lihPLXI#Yrs*=7Vy>l%7hk>BU7vh?rqU*he_Qq@NjuD+dH0aA|m8aW2 znX7RI#FWO~SI(X;FmI?T%VLr0>DujLX<2s1Dz!GJh}-+rXlI@ce!BZpsEotAN#tz zb1j$yLSS4+kq^pc6nZv&`R=XKwMpI&7O+vGPIPpvXW6okhX%-kO7KB8-gU*5POnI5|tr zbEG)wcI)YEI~*9)48fWU`Yo~32TLygv=|y&usnvu!^ljx_gU9@?jQ}=+?m(xy73dm zW{xiY(~?lAqO|tw|~^)NHm_DVy(w4{Icjjp0BBbjFs@1!UDt8w1K13 ztjkv3y(^OTZ}hy}#}5i30$%Ijj5qEypb~|! z)e2v@ACzC;%$-gu^*lMii-cS}xAO+W%9e`g1NHj#JiYkpSq`t|O>h|UgAary4YQhZ~~ z97(MXQl0k|2OjRt&)jhPSmJ>XP!J#CNN;w(v%wF+ebT`L{b249-1wxqHyB14gSa?$ zU9hEr7p7QYi2WjXr2xa=TmflOp3NttK*+bswoE)_|OstU-fBB!olHzEta%Qy5 z6F&tQ;@lv|mBa|waqsTYEmuV`hwISXIqPzRFAb;b@7tI@m(_*L$9p8QI?uhMfCAUx zAn_nV_xgbFcRDA&7mba)=sq^!9R9p2DjTB8pS7fanq=Wqx~$(LILDiJmm}GQNM)%R z@qV;))73et?h{kzfRCUnLK$=As^=4CzKZa;G`S=TL7s!E zx*s}*BX}@XWGg`Y{32Udxf;hKU!A0zQ_ILBUoQ>fWIl*C!hCV}BAGpL=km;6O4gnG zz3u=E7@YVuUpF)4{c)q)&$3^E38uxvf6_>~(#ijFgG)|pRiAw0{f&Xt3k|--lnvz< zzU3~L_t(51wL0NnT^8i00?P}@D12jS;gd7ntk9ia1b?3#_S)TFNFH%-v*ewGz6aL0 zL;%wBfRu-y;~F165CL-?KfTw-1y_*)+TWIOx!Y~L*WGi1!u@pfZ3E5Ose*f@TrMvN zu;rF%pPE#M`VJ4?G0wqweZHA9+ZJ7;Hi4p(V_kMHFO(8mZ#RLsaC3?tS??274o2-4 z3Gu9+LlHcSlJX7Z?p6HDZr*GWjyALrxYz6ws0wy+gkU{HyCJHXBUHO57&A74;l$BD zg0XhK&1Zg2Gb#$sF}pxDOv&VgEl8e2FTZ_mvRsj|HC*|Ea(R__Fv9N;ZN#QZ&gOOE zozl!wg&VC3n9C(pHBvup5mONZLIM+r~rlM9f3*W$7CZ3Kdsm_^ECZ5$y zs+#xvgY>=bT$W4O2d&+3{5G*CzQ5`eLzk*0#!T}%sUBW|Mo{10{FKqzf21oWMrGA zE-A>DS=n)7syBRemcEMi>9cA1n6Kpp-*;SD;2mkv!|_74^cT%P9X)#9;QNUPE-QJ< zUB^xi7~)GHILetd@bD91ji9Sr!`>4<@<#Iik2pc=5n#5(J93#i-nH@eh zTqy;Au4mfa;jmuWhiI)oA*+K}FW@EE4<&0&gB6_PI7MR=t%9KjSNf82#X!EVvCL;1 zvsgEbRuz2^+Z_g4+B4!>WCzy7c#99_Y(jjj@OMeFYPiF_i`_#pam;#M@)g#v=7Atj zN1_x@!APa}&(W2=as_Ebr;h`Q^xmZ9Qd>F;Nc1 z1#0yMVr-a-a;2EM=lLTNLy%fO*1N=UElDV9E)8%0rg`O&tP;8)lXKP7E0Q};_r`FU zV~tjuS%1NMN50?2WVzxqnkz=>YTNtGvXF`D>z|1*A-S2-`a;q{J?C~Q9o@dFb-3b@ zM9v-+Yo`Iuh5=|5#mL*hr&7w-Jkoo$P?U8`kpp6WhLtz(pB(jUnDvHf=A!x80-fN~ z)~}H4^HSBH3m$av9*6WEh{GMNP%~R5^uV(6?H(H!$2t8Oz}WIi+;mgZji(2kUcEn2 zN}GWBVlz-oos1+32JRFXxI0(JM`%hW^rmg}1i3J~6s;LCM2wwaCTMdjUL)cTkde^5 z&KEA}8(YhvpGK%jf~Vtt2}-NgaM@r8|_Vev z!NVSy>W0{J+r7_SFLlTsxU(;aLDe-aac20Dw$im8O+|lrpzf0X8RNxgwYHUk4;mDo z+tKFU(}Lz+&PSAF66e0L9@<+{hytN$qWWHxPF{pJFCp+&9>)mz`W1_HhDF5AV6M6| z#|;PDbeiZ{X|BCmHm$zJpy~xyp5fE>OYNr^JJ;tU;7!jWhChbNUsd(Rn?J?M{eb29 zwxqbZvJER#Vl>MV7*vEi68V*fWxQ{b+83y5^ah4Ty4n@Z(mGuZyn}+&c#i{#c>k41 zs2K4_(}H6k!b+j1|9ix!xc)a%QS=aHMhVF{c`+;S1urCdfi|_o{XjDhW9RlV6K@Aq z1Ho)n@v4O6fsKq~kYS0EyJ`~la$+*B(Zj@uQLzr~cH2e&93~ilOnIfw+mh5hMOD+X zY+^ZkL&@~rpbHBb4JZC_afjo7MN-R2TAEll%r^;dx}+{Jl)2`^+=N534^w8?(X+Q7 zhLD$NQ+u53^LCQmmr9*j7VG^974Ex-8;ay*V0ntQDaBry>dS#~VLkRkl55xUFNHVV z&5Ju)y`1;`ZBoDvZ|gqG3Vslwnnw)TEU#;q99Gb;oOK!S$$UK~a2W<6%`bqZsLOx3 z^0hKPD0ZgxOX|%Ry>$|9W!5hna{Z1;2x;`jPBy{Kit|{e(wBJ8KnX3-+F39jrVMZn#h`9AjIHKl%Csaz}TRcRpw>e9bPHHrUe4 zk^2Z4V;v#Dz$1SdIJ?qv&aqo>UL^>U4*=^e35$yp}Pi#a3eS{&*_*iTlzXt9v^5X2 z<;-8tEB2=p=N;~$&n-zcNz>{Ha~8&&zgz!Cm;!stxMW!N%o_@5NB;hslC<2q*1sE7 z@gfc3u|`RgWQn#zYbq_f=POk1H??GRS?8drY7)1aQdZVA8~VRoFqf`$MX(Kg=8l#u zP^J$uu(_A&7<7gj+L8(dol1TQ{%vxpYQ$YAByZ|e3J+Vux?5(r_`yZ=9y(N?a7Or+88Nxcs)o%a37bC)SE6^y{Y+a^*9B#Q;Gk> z9_~~%TBPi0y+0;o@ah!l=466Q8SMcmQy${Qef9Bn3m*8EFlwOr%_sFy#2rQgTzr|g z$D9Hyl7KU=u;|ZU$xpKoiE((ML~uM?#fSfzKm448BQ8cnFUA1Z$KAZs@iia_?23J& zC4Kr!PX58xaML-%d02Aw+J87VM}<%G4&o}$Q25c|Np|lB zv-}nvPR#B1M5oeQJy|d($bqlPim3{u58^I)YJ=ai`u4V#=5OGi37Iin)jjZ@C(i-iG@PbCT6A-o?4P z;knz=5BB1C>Mvdq(Yrr%m0J?)A_v3ny2i%!RLLsIY~@x3<KSZs5+rrDEN3<){H- zuZ~~jgmjq&bIUFLYOocF62M!zWHn2UHWrx@uW-3O`_S*D%WHfsdkiiTRhToG_d(s` zGEsG$swR8&L2u_vPclb#4(bC7%l^m0T01XsqDCeVoL2JfedhuD=W45bRKi+@o}X|A zyENChw2MzBY;vq-cc3^l%LZie`!SDMw+L#A9o3hTb^K7(udKn&wo~$zS94liEYN0I z=DK=BA}v3Nj)=#0nq}^BuJ)hR&FTE{n(1XT(+OnD|F!ic@KA35|05b?&n+QRE-E68 zb;g>cvK86ah_UZ8mN5vCh*nGXvQ+kcH;81Lv1DHcWgUYoGZ@SN8Tb3U_y1kIUZv+e zr?Y&vb3W(1Kc7b)n^|j|1{#qcBa@gCC_Ic*+s4V@-}U~ly*+h7UMa}D!W@o2`?K}t zmCv@oSf@X&_H;pg_^nhZl9pasN=mUyJBXx3E`6`Hy)cFex41S)P+S+?>3#441-N^? zm?V&nqodv91W^Dx=NOyHWvkd2gnI;E!z7k2tfS1|XAa->jqG#;`v+y6YoALzIYgnc}f;OF`Y%%w9L?s zO`zu_b>}P`5*YnigSE~(h8bMwJTlPeB&wyk+xyENqzi6)(bIV7<44`k=8m1AiNAA` za=c8YXaY=3*Bh7ds?oac5hCV_Oj%n09{YZIIjsH0>}kU6*#W%&=fzi{32)j;XZUp! zzCFYyLar=jA>n`;FlBd!ezFwfe)s{$m7ct@h9Py&6-OMeE&sSNJuhG?oM%CnM7jHgFa zr-jYybF-fEYxa1d*&uA6b5p0nc=sj3_wD&FF0PumB0^J{ci#A#I*A|JM*6(smd-M+ zt_%BqRoXQHFhTpCN*CQt+_P%Y%>1Xt-G_$aJNP6e2V@>abg5nkNkxmZ!1`l-lr$$? z;d%sS@T4snBkl7iT1e@%#5gO3(0XoMvdomP%bf<)`|f1x^*Uz4pRn&sNiNkIxl?tG zc{5YnFH(h$p6^6gYipG&qd48|%N9}HC2;#Oom#m!gBj#%NKgLM{#lrPsPxbc2x=Tf z?xsVdk`>WCA;v7edH(MG*2c6p{=S{W;Cit$2TTcJ)=gZKD+1H0tq#0;iZ(`Fy(Wkw z4rN1sKFSm}Bv?~HWR+`S)C6yq`}n>rNNW8&!c-X5;uCf}*^|Izk2t$>8dDwEU7$C? zcBL9RosUJj|6~B)ul3&EaeUPNr8}#G&*P@Bvdy^GYDP&?WmH1sQH})bTj0yj4kSoN zqW4$Td=ag#UnJ;N&U{Q1qtiHQ`+Ap5@?7#by#{&eao9AHlP-?cNbeE@7z9$H$80EM zqZM|wE4$0d=C1&qQt+a^=&t_NQ6x3g+Ja~Ad(@*uHFoCS8Yrn=%dm+4#+z@zce=-T zl2q(_%gIYZl&G$iq~^T$Nm4}7qL+*jg>O3e73Ejh-xV$J%sx=O;A@ye{H57)z_NYv zKA|CTl`DAm>5aN&=H-g)vQaEUf;b-MhADTSovB;w6DiIqv$gu0WpyU2 z=0%fx6DqqjpsOb_qD3Q;9n#xVS!u!e7e&lnJ==BxPTf)ZP#P#!o0{4pgdM;7Ym)E|tjZ39$e;Q_GIeU&h z)Q~x}Pey{4eFJvEy6{O(Sn1EC)+gkgS?*>&)$2UVF+2RxE#)-c z2etzD=HTRdr8)@-S{YRMTveAUvT|SR1R?=kwzzx}`n{j|e5MYlRr;sz(@{gMOxyE- zvH^~{x=621Cy~PiPIm!$N>h!NaaHPS3O;Ax4*&kRk!42;W!txKY;8d3+#E*U#4-*2 z&DQV}+(a0HAv6oGOJ}|cZ#0m2^_d3a!o~jGC9j^Y*_EzY+ssB$*(=wBali#RRNJTQ z++H};yk78CAXY$b6tcNJ=0AHfY2PjKaVSFOWyamKo;{Ki(WQNsBoY>?^+|yCWEhJ^ zqN1Vpx%}9R?6;+eO3p@?I^A#F2)rQa+Bo^5w`!ul_lEJ!uxXjl=`q+x#JeXY3lYOL zA588%N~OO9q(u23STP%%E9%5L3^{EEs6y3Ew9dG>Q zJ~ov|Ind6}M<=G92i=8z7G_wrg&s&V2)8;;X=Y+cRMU_doRiY`!{mg9E+eg#MN7?0 zQ`LL)H+uWhNL>b4ioyKVmR6|7Vxu8*% zk^J)+kZ{?t39-mLz6s%!3{GBYR^)d+A)GL;+u5n2$Lbv3nBPkc9pL6CjG=K7?>V~t zd$T_>HLv-f-gi4^srjX5bwtIu=*8}~%xdCAm({0t)3iUYMQ@xhSo@_qo>9!~)F9E_ ze>={0Jl;^@=cwz$?#*h}`V}V+D>Jk9O{<_yBBVx5m(|E-0NK+}F74CCXz|Ee27X`W z4mQS$bHC8o6C}qs1DcN0VDu9WNJQ%WjIs8Y@8G`*3Ds@5!OVSW)N0jc6t!Vfr_ty5 znXq-^Nn-a)DTxgz)T|@DOhb!7<3z6K!@7vO&=aOsi61j0a5pw3-`UB3Tb&VIFbo=3 zB#J#blgzbacq+8Q^@7sHlo$8lx6wYkN*1U3#Ai|zoxFsraFTkM(hIX=U%R^%AgG54 z-^J1n`eI_;7(=BqI@ijB{Tgf>o5#YemZk@?(|Y_(uIyYpLq{kj^p`lq-xFdW$?goQ zg3O3x+bix8EfO%b>fz`1xc#byaNb&ueE2+B0!IH#EyF((won z()7g_RSynK_L!>OS?eBKRH{wHQyo$DcmF6tbSA3SP!-AjTTE z_VUq>=ND2Rn61N_!5I6`3(JP=A7}xy^7~J~tl*yuIf_b0$koz)YV^BZQI!&LhKG5H zU%l>v)9ok897SW2k8j(iabGjCJSAf87Rsxi7O>wYvGKIY;_#YtJZ?L1=yCR5Z_)** z?E}jXS}(K9(xi<0uit{b;my`GD69?Gp==3V>p=A{t2Hh;ytSLD9PcX6H9mp&8fLFc z&oU8y0Ov1uaoEw5+e`6~xK7ks^qYYxGyAcff73xME=nRgk`B&4h6gQbolrE=%GHa> zjH9EB)x6t$dO7-V|7oBGdiFZ0k%dx2yG z0viYHKJ*}-2qg9zff*~bYg-f|ld^*lj1Lh~C z=bdUUnxG9u$et?le~>_KI-J>jHsL(((^QH>y(6eR;`{R=tw;~d`;TV#&+pD}?W)Fa z57$bP7r)@&>coNShnD!OY&%_I(}hm zR8nLOJa3{KmT7{uvd=SQ4&1IeT>m{2)?tIUYbow@(tc6Z{4D7>{>biyKI8t0NCZ6z}Ry&h{hIX!^@c;Ib@1i1f~wwnvCw_xkYE_KX56d)XqN!=fvlVFP&ed9l{gYA9-9nzi4M^Svq!QdGjg=S&7hC z7`_60TS|+zwS+W>>1F&Zh_>?|o+ykAUx-{eumdaR*XS6_K%GCup(Uj>*V&k@!05$A zaWR&LXhBUi!rD+%^J-pBSWI`t9gVGoI;+KooAG6Xga?nn5%Rau{;_)OQn`XgH_xP& zWTcw&RPO%}7tBcg3YAlZ`60el`S{E|4mRcUc#XfaItpb%Z)e)~zSvdZ30l-EY@@~a zus^F@G8n!dhS@kxcV-nAVELF$UVepvH`uW{D;N7#$9g?4(;Gj?&PRpR=wcnu#9~H=OOm48Sk><8I*g6%*W7PYKP}4k!omk5U|t zLallC?d{f0>R4@v3iqp%)@FGk?k3vB3y3SUFTwjM#s0cO@bTzwytA1M`EXlS=1HPb z)pA9nMY_dynlSCzo&yW`{Z27kPQyiOXsV(KL*n+XF}yYjeoD8Bv(J_r)hJKz6Yqd} z#7MCpOd5KlkPy*T4W9QOOu^`}37>!M-M@ucguJW2@Sewo-^F=Ggfbhj;H5>1ksDu)-kV8fB~iaNpVJZiACr`;J_Cc3WCUkSz<#oqG92x4PX}M;SBfzAL3{iPVyh)GVJ9vSy0N=NC^Nc0WVztclG`5{Y66M{u17$B7A9<8l$IeQaqh3RQcF;A8 zCJ9t$LqfHuQHJd*{KWNB5rvxr49fy{?7thjsTdUTKZ~Gb z@wOnGqr2E{OKKg!YNlR!6t67tUiCquoy*v$W2{ws?$fRy!iqaf%WIx}q`cq=w);)? z>KH@ln}!PwU}NN&=-7jKaF*ECHF?46^qRt@pi$R?Q=!qcR-+%y;B)j)-p|)}ZX^_~ zK_Bj<1^}ED!@g%W=6Bh!f^-@wE$E`ovd-`Quwz&F zR09bmrp-J}(tvUr7{y&*sBCRfLx zT=3!pL{w^LP-nKB4kL7eyX?=xlp_1v#aR1Cq9KpFU507Qd9DLj3PjqKZuP?E?1YHb z;i`d{>1PQa2H|FqujCOe0a`Pdp-Jb$fPn%Y1Ux>*`|`_nJ%j>??1_7}#@qr~j-} zqBQL{UY5YTv7t{rmSKCz1|NN{*m?7RhtzW$*N^e=9i{zJbxpAb010lhEWrJHOlCt9 zUZA>QSGAZikVzA&i~D8QF=qXu;_HFiYxY~qVL?{@{ufG@bs?cY6VayiK9%5@oRr~H zR@YC>rNm>QWxB<5(1X3i>@Kb9+%C%svEC$}e!L^LV4(Z8o4tvdEBR@5?FndwGh--X ztu*i>ijIrEv2?=H66zIJF?{4$iI@f7&X~t)G8*+LeXkAlIZqMrmArmYq6-i}-ht2W z-zo-u{3OAWxy7GbcET0^b7R#RQz5>>)uSyb{E6H}7D6-ZtM3YX{qYm5* z6*OLpgN9`DLq$928o83EZzr@!;o?j$)F^B}2u$yNC;1rsWlB`aN4iTt-S3ezJY|fg zVzk4k11@(Zg(Dd_##&ja_fY3feUjhBlPpj=SjNY+=1}{#A;m0U?NpIBBmZNE)jrj8 z9t1596G@e?5)d_GT!O7&+C?!t7sIta*U~Nd4rcyY^G|tGK7+J;4xjQCzMnG4N!v=1 zG&`-Gd@#j9`NE)lvQhZ)yMj@lb&VY@j7@dGEpyjQ5B^0JI@dS5Tg+nfk57P9t6WJ+AZ|~+f_=y#G6I>xA8@bQylyr7%XVg&1 zbzm*mhOTDZfKDEhy-RMbg7ae9D?ZI2(G!mr?DyU$$(LfjEaeXqFK^^m_bwhto|Eg% zC)tof-wG+^N()^Xb?EA_9sv8FPJ*l`P>sudARCJtK+B zD@+#d0xs}c2_kIct8Sn|EX`5yFc#F;?35PP$dvAT(q(tH&-Wi> z4%A9HN5g#<{c|+GC$rLcaa)6%)6gf-X z7hCE1h>vOhp33QFb=5}z1GF`2$s%14E zh&7%3`aUW9&KM&1nz?=Xq?l?wT0_8mlX)ign)fMk)W;UM_9`bwiCu0Ozfa{y=39Lx zVNg_WKFEetQs7yU)KXVAHI9%Lp2XA_-#dvI+`h`s!_d{(>u+K+wSv!w+>3+3m~o{Xa`V(An^$Y)X@)p9c-I*c zySTaU^EJ+&;>@&G4Z2&UWJYZAP|xBJS(fDw#09g_&}O=39)+c*J}ksQdl5CSYsTkV zqSoFdxtvErm7BEPquq3J;O0E6;i4lMSo{y(jLWXzt4}tP1fl?ji@8UE*u{W9d@)%(aem;CgXYnovSoTxoZ|?h^@3 zsBE^|sj`=k*VR_1IlsPY)6=aLvkPjKd360FpXylB$5g+!ydkr(vz9Y1`ROOrVFQ}> zx#gqii{7a7{BBRJ?cGYU%JfrvDs?t^UB$P#u>Y$KIKFLe<0<;g$N@>BeAjg zP$YdJ>?wCk?H`^&J2Lh%$%XfP(VYBooV@tZwqwsrUGcd^FuB`E9g)|#(Icw^v&AEg-sz|(|!xD7m)net3 zTyvvS+UAS96~9#ACmu2W_J*x;MKssiXqbjg&k8vS?ewgbV@y8(*~<-G4Ht-xZfIrx zeUo<0_d*o@x$Q0Y!Yc;cM(qv1c_=9tw&C0l+P8HvL4vECXSx>6+o#kEE86?6sUxZ$ zyulBb7yb01Xkqfc=jI3gEHAl6iEL#9Ty&pHxUkuWlHF}8BQEked5eop&a(D9g+>dC zw&8*Y@AtNI*@1)vPJ_t+L>1R-nNs{-kf?fLNH$mL(socP;`d33JZXw=_d?vzXq(N8 zKZcvj((s%|FGHaLU$EG7;Oza!C&!ydR!DwP#==kMV7Y$do6a3)bP&eNr3E+2q%mI` z?RBA(r5Asz7W1dOX+KJE>n0+fnGB7J5f`!dg!0BRD&NrtS55OwWupjU1Mia8O>PhF zny}6&J?O*m1#p28&xCzSj+mIR=2Ot*4mbnyB~^?>9-{~49+c(&bV`1+ zG$I0u{gc7cKU>QZPDj{yTKhILk7l(`|IemWVF=Q0exrJ`r}2l~SIfzEBa9BlvQeh$ zWB0m+ZGnfbUR7nzh*tg2J21si0`&OO+oSWubYaSO>Ef$<;vQ9kh@7$pt`9oYTTL2) z1~0lg1Jn!BO^|sr+=gcPlf8)g!^2$t_$hExTcO}F41hHBl(nLdKpmA^Vs~gyhs$)O zzBob#*l>)&vOD=Z;QmZvv=>nJ9x@deM1y*ehxIZ!_w5jy#$L3I8@ z4?D={5p4Oh6G6kARChmTioWTj*3D+j8`&rI_H-0`e^L_Mnt!DJYi_UIEr7Y9h2k@M{CGh}_r%Pf zHX_e19k5g;$bQayKIPh9vsaQBwxN9sl=4fbmaKpgQMwD4di^l9lDrh1 z{4gH>%&66>?I#ISBwMn29JvKVv#6B1suY`5thITSbGy?{2f!2ebDqeQ$!r_+D{3oQ zrDwzluKD;k#Nr3U-5iU>rMFDNp@e=%M7(2csURr!yCtHs*_W`7){NHWsZKkuS-0B1 z1?}=)x|P60;{mzmgfIBuH(&RFxzKw%)Y}z~62u%S^4`d`bN?6KyNY&ta(TxhAPC5V zUa{W)$6(I@$0WulLr;;iv3%GS85Ml{ddJ+ZfFg>P%70pwtF9WwW^dBUlYX3RTE;?y z7w#|TX||Uy1~xn^MSdBWYXmQEI4q0xzgkGvN76NMeD=ka>GJG1B)r=lg*wlAm6RE$ zApz2q}PBUO|TYZ~!v6JAX zL#@0qlacJS=%Qj;S|If6AR4H64y5O3c60Lm*2kW#8=yjX5jNpdp1AA7g|0>{=)i~% zUw6iC^?$`1aLbw@B&`0yvDeR-t-QmhV->U$; z*P`N~jEQ4S(g82RhdY*7l*p@nzi_gc!~_bEYl0e&KXMpeE0%m0WZpladO#`2SFz}^ zuYq=L?11U>w?3?-F8S2tV4;C8p;X;Q8a|{r9crJyyK|G;_98Gl18cwsp+1ebL_SMUXOku> z>Zj#$6NtA2=ne&?J%qysOulYxk5_WQPtH6SeUWCw`)9Sd zj6;U`&_W{>7{d&DHb4hRmWx*MMUc4diET)71wpI}Z?IF7JG|>n&emC8^@$3LaE!ih zMa7CunHbgc4_4y$|P{TE=JN*H>fdI0f};j-t2r4IO!l%EhWurJw`g#8(@ z=g$A1@F0hru6&Ai<-MG)Iw5v|RDm#NidG|t;R8w(qyU-r*uO39!79<^e-C(Po>0?Thj`nUr+sK%H1s=ZN(hAP zl+b_3h32`Ot5yR<%pXgq)Vupt!l(iJdX|vf!Ci{fW&hkKGTvtQ2uox{{BWQ2VOqiR zz9Q~42GD- z>_zJvAK?H#kd+A7!P0NET}2 z@%il!+*n%b04#S);kh6vNZ;J~Ys$Ntc&Z?XOiid(Z*4>YVAf=Ypm`nymgg$J!>vgw z1>4~T;B=u%hrR`c4kf^4-o980s^-Q8ZHtShRf2XFpdLG}S}?3%jVz98%?;K`hVBaEo8xs$T`d zq~6I^A5yyof|yIQMpVr&A3jjuo#63aC_zs2XpB=0QS6{Ri&E7=c2s&ne|Kw~3n-wR zZ;NU@R>Ioa*bwo9CLF=>WxR<`YcJDy7?29qy^nr0ZTnWdRhFpG=q2~&TN~n)mBcqu z!R53UE7<&!--7q3;{mAvUrPdjWu;0k8N{u7s*{1!Oa2E}fHtPidV8__1%qU55!&Ld zStYrwlda4a)&>urE@ZeKOUP;jLmGao#(RK4sp|$3_{!dy(;$)2MDW-)ngbm{&$Gkq zIPpAh3RL}GDNmI5Q*@aAVRyOXEpoIZ2Rh9Y_`^xn3xGHwpmTIhO7u+--ksE3=@GY8 zP1S}8;%?0W{MZm$vx9~o2ht5sH7xHmR7tH<+;T0b1AzT}{VaX%&i4hx(Rt(nB%cgoA9%h{b+six`B5k&_+t0?e%~}Y* zvcgmF3x6>$3{)+wf2kgfaSFRLS>*y5cdWN!7gyd@xrGTkXs5RHM*huJ9dgb}fGVi_ zxppdAw6XN)#U32%uue4z5GDvBzN$SIpyY2CEnWS!sJp&&VKlF$D6@h4Cx6HxwHx8laJ&qbs>#hPG? z)LAH1f~YfiSg!0E0jfUrQD2YCeaJ+~y?k+V5XQOVEUA3F^*j#F?U^qqn|S|JCx|)? zK%;~};ya(7)Y?|m_dbXvJ>b(g)>v8~^~}}rco{X2 zd1%B7wBAEDU)lSHqUDPa@yRB5;LBq|9S8PZ)M)on9^wvnesD-tfT-T8Cry&4YOR*} z%>@B4w+cBFEt<2bh1VR)WKCfRF%Z-YKYAaK+_of1evtZ=0%TJHmfjFhB04Rwtdf=p zBYwN!GO<)@r3Hr0nX z7zbxIU}SzOQ1rCiXELmPG$M`)D1s>@EWgWD=L`V|kEXEoCjg>J|I!cgpiG zR-1OmHqpbIC9?-a^v?bzSI6OZLSeFGr@l(ME`7i zZ*#Yk?KtSHuJo<3QOci%fA~IMa225#&GL>*9_FOVZ<={lj&xW?IuKOsqQ@y~4zR_0 zYhAk0y3bd~t;UXyqWL~~CdnD*V>6!rFbTi$sdHbZSn)e+Z7+>~?TJ2()U-(Dv%#mU zo=3MnB2ms}F{=X0I*`i5@WL21S08nsNB#YmH9zuU%o1{d0M7u20m_3wz+o7mkcTK3 zLqd4=B=yCyAp-$TP*-l$D)$&Q>FB>T<8fo+CW_|xKT|C+)#?G&bs|d2sVV6IZUS3Y zYCAx$(~n2qkWK(ke=ViJ%X2{W6lOx7B~S>A>V)<2OIlzc2jry0xc{s?U3!IjkP%z? zAB{}cG6Ll_@BXu#+W1FDQEKD=Hp7l$CRb{f#J{#A)R(|m(@3a_~Uy_RNs8dfb+p+Ifhd zW$w#sC9?_UznB=dQIj-O5Yw3+1G@TWI_E;%4+(d6NF{#vQk=v3KDt~t-8YebiK=>@ zzWwB2&q`|D|4^jz>fWUHGlMoyne+R#-Q>f}Matf@vNi(8+dkN=p9 zqYDz_+-H-V{5hIM_c;ia`J9Fxs(I+j+g@dwc2KWfZgF)9u+nIw|68Sxd@uxVvTgk(U=Ajs z+0?E~V#rwn6T{{AEf0vEoMSXSa)jxP^I0fRQdJLXqNjo?Gs=xZ!Uyo zHya@gZ(M;L1{?jB#&h*F4hn6qiSJeMlh(LaeJ&)bo#zbK#3oQidDaj0%sInRZ!ef{ zK~8n;95qjc`EVa5w`$5%&-1eY-T!S`a?`}$Dd?RSvVWf7y09l#^3v~9v%t*j4^rrYXr={Y#v=&oIb+-^q3ViuT!fyB)t|81LX*meV{uUc^ zXp&phP`F55LR0R-KKj4rOU$-w@MJG6ExC+S1jhR}TS&>`+57hFt55RE5=d3SxtdOg zSJh`?uuV{veABPwt-=q12<~Om`1B#6yGJ&#xug4A4TV+cFMtK0659SD` zXq-JH=ATy9hpmw-R4Hq)p2OO&L3lb!SYD)x+7)OH&WU?%e~ zGxT$he+=`G0KUAkvyZ+P_>EA{9m$L{)j+$hdojE|U@!FvE=e_@Eti)8;I0)ymLjALz&J`{YAS83qLrRZm z`G6}FjU9GPM%PB;f5+*}y;<_v&l`PmUM z_jclHFR|`Rm)-EF+>ev_KC~xSI55oyNYdXXP%iYkY;kjjcr?nH3lbfeQ^sM%K^{pe z;9Lm?vdC-g4`N@Ud%>enh+~0x=Uv*qqvUu>aE3DXT!3& zygB4O_ElZeovPH8{EoO4jKpB5ZJ&o9cS#mY0K~3E>wtoA1u*586?#Sl%FHkCN3hHe zpPhW(=-#KJoV&TkYah1$0BKL0A}L%8> zEn4^)^5F?X+rp?G|76{2`L^+w)r1OS88?RVS{y7}{+d<1riYbh<|44VQ8&rdB~HA* z2g@+7Ywo0m)#X%RGJ?gVmqB!JrI3YyKEJ+8g2d_2p64z7dZfx`^D2g*wMLm=DOKOr zU|4V~&xZC{`f+x5CfLZM;#31uB98Egw$c9G+E6N%j0-a%XI(?E{4jZCHCkMkS6uUB zWBP7ZSqa-|&4k_el1Rgv%D6@gI>OqSo?keh_t%ssB1SMJ7x(530);273=_nTYC8%0 zRU~L|g)8OhlRt})rcu@pC`W9FJ@oJx#;Sug=;}7gt(=y3HF}*tn~Z02t;si)3i((T zb*9rLkaO*$l`qftl4>;a8KLp;9Xe<z1YGt5pBInN~NMJH0=7AFD;SMcX@`xa|5` zvAwWz0c)Omvlgp)g>sv|!rbzYw8>%h`}vw(ZboxB*c8^sJg!o|tmga2-pdCUvm6@zJP&vrR6hy?GG`1)D1#Uw(zPsxwy_!hl)VPG zueKILB*}kE3w@?zu&Zb=~=U zb$KZj6y*H?_iZP>{w1=rT#)y5!t295t?934f&A1o_Ita!6|{=wZsgK1H6LBSNlx@5 zEbvvIzisRECx`2M3s9J}GDAy)6C$cd)1`gBxe}sJE^uXPv4bVBwV*F@eb@kjKAC>b z9z}#SN2(5^F2z-nXYPgN%oo!ZZc+HxX7SS1X(sdB(`+Q}4?l4DS}Z(T8S%T!OX%iG zl^}xy##2geh{m_}A-PUwap<7t0!Puas!$L4l}O?#CKNcE{DPiJPU;B=NG0@yb?QuQ zwwzJDxqS|{tCEvk{$U;I8QlT~HDN~EQ;&4W{YrS&5!2#sGV7VDct)YM{PlyA13U~$ zvW2q?x<#9Cg3IE?)Sw|z{_abKzan+QKr*(yzz*$SJ%pswtOr)UF@gs!_CKq=wK1kz zZB!usL_y@_Ns!9&8TzNS1!1f17^9@33j^v+JGZ#3a#36tgoE7a-)a}*F6?dNp69LL zpiSWRHs!VU)2j1zcJ_*SOaulEWe!mT6GQLu;{ARGdoDaY4!wH{Xh@?s`t1`sjySG$4BadTjvS1LWynOTNcrFyO?iT$2J3MM4G4 zxwGUFud#9=?#^<$Oq(sk9bdx48OV)_o#0m%=t(zqW%BbI%I>A-+>Z8(5B79jF9)ix z51j6UUS3TUnD8ZcGj!Pt@_H$s@o8u;i5MGC*a2>YcTp4sF3Li%uqrRCpTb7K5Rm7y z;|XcQtd|0{Cd7qDo6>}`Y5qU2mb{uc!RpwyTXi?|b)0!9kxvahGj{aYo@_Mi&hnywm0K9a}eng9%eDf}tLF2`$4t&dS3o6!-(b02j9mnY{+@ZS_arVmKYp3n2OoSONBbm)&ChmKeN%x zO<7=T1N_5*>i6jta&y30%s;{fs{W`graw0#Y*`0g|(&_Oi#a1QN z>h$&Zs@3h+=cq?m#}@UkL4|}Hn=*5~3H7j=aeK=MjNzZNJP_!$d{}j)@x5+!|NkH! z(E8syM=E`QpTEfP*P%+ay-rEUfy5lm|MjI3bEpi&_CxcPN~z8~=&riXt-_nuq5ltn C>~tjn literal 0 HcmV?d00001 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b60e260 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,312 @@ + + + + End of Year Statements + + + +
+ +

End of Year Statements Processor

+

Upload PDF files or folders to automatically sort by address and merge by page count

+ + +
+ {% if session.oauth_token %} + + {% else %} + + {% endif %} +
+
+ + + +
+
+ + +
+
+
+ +
+ + + +
+
+

Clearing temporary files...

+ +
+
+ + + + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + + + + + \ No newline at end of file diff --git a/templates/job.html b/templates/job.html new file mode 100644 index 0000000..ddc5aae --- /dev/null +++ b/templates/job.html @@ -0,0 +1,227 @@ + + + + Job Status - End of Year Statements + + + +
+ +

Job Status

+ + +
+ {% if session.oauth_token %} + + {% else %} + + {% endif %} +
+
+ + + +
+

Job Information

+
+
Job Name: {{ job.id }}
+
Started: {{ job.timestamp.strftime('%Y-%m-%d %H:%M:%S') if job.timestamp else 'Unknown' }}
+
Files: {{ job.file_count if job.file_count else 'Processing...' }}
+
Status: + + {{ job.status.title() }} + +
+
+ + {% if job.status == 'processing' %} +
+

Processing Progress

+

{{ job.message }}

+ +
+ {{ job.progress }}% Complete +
+
+ {% endif %} + + {% if job.status == 'completed' %} +
+

Processing Complete!

+

{{ job.message }}

+ + +
+ {% endif %} + + {% if job.status == 'failed' %} +
+

Processing Failed

+

{{ job.message }}

+ {% if job.error %} +
+ Error: {{ job.error }} +
+ {% endif %} + +
+ {% endif %} +
+ + + + {% if job.status == 'processing' %} + + {% endif %} + + \ No newline at end of file diff --git a/templates/jobs.html b/templates/jobs.html new file mode 100644 index 0000000..d449eb4 --- /dev/null +++ b/templates/jobs.html @@ -0,0 +1,242 @@ + + + + All Jobs - End of Year Statements + + + +
+ +

All Jobs

+

View all your PDF processing jobs

+ + +
+ {% if session.oauth_token %} + + {% else %} + + {% endif %} +
+
+ + + + ← Back to Upload + 🔄 Reprocess Files + +
+

All Jobs

+ {% if jobs %} + {% for job in jobs %} +
+
+
+ Job Name: {{ job.id }}
+ Started: {{ job.timestamp.strftime('%Y-%m-%d %H:%M:%S') if job.timestamp else 'Unknown' }}
+ {% if job.file_count %} + Files: {{ job.file_count }}
+ {% endif %} + Status: + {% if job.status == 'completed' %} + Completed + {% elif job.status == 'processing' %} + Processing + {% elif job.status == 'failed' %} + Failed + {% elif job.status == 'queued' %} + Queued + {% else %} + {{ job.status|title }} + {% endif %} + {% if job.get('error') %} +
Error: {{ job.error }} + {% endif %} + {% if job.get('message') %} +
Message: {{ job.message }} + {% endif %} +
+
+ View Details + {% if job.status == 'completed' %} +
+ +
+ {% endif %} +
+ +
+
+
+
+ {% endfor %} + {% else %} +

No jobs yet. Upload some files to get started.

+ {% endif %} +
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + \ No newline at end of file diff --git a/templates/results.html b/templates/results.html new file mode 100644 index 0000000..b1413c9 --- /dev/null +++ b/templates/results.html @@ -0,0 +1,219 @@ + + + + Processing Results - End of Year Statements + + + +
+ +

Processing Results

+

View and download your completed PDF processing jobs

+ + +
+ {% if session.oauth_token %} + + {% else %} + + {% endif %} +
+
+ + + + ← Back to Upload + 🔄 Reprocess Files + +
+

Completed Jobs

+ {% if jobs %} + {% for job in jobs %} +
+
+
+ Job Name: {{ job.id }}
+ Processed: {{ job.timestamp.strftime('%Y-%m-%d %H:%M:%S') if job.timestamp else 'Unknown' }}
+ Files: {{ job.file_count }}
+ Status: + {% if job.status == 'completed' %} + Completed + {% else %} + Failed + {% endif %} + {% if job.get('error') %} +
Error: {{ job.error }} + {% endif %} +
+
+ {% if job.status == 'completed' %} +
+ +
+ {% endif %} +
+ +
+
+
+
+ {% endfor %} + {% else %} +

No completed jobs yet. Upload some files to get started.

+ {% endif %} +
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + \ No newline at end of file diff --git a/ubuntu-install.sh b/ubuntu-install.sh new file mode 100644 index 0000000..c922325 --- /dev/null +++ b/ubuntu-install.sh @@ -0,0 +1,237 @@ +#!/bin/bash + +# PDF Sort and Merge Application - Ubuntu Installation Script +# This script installs and configures the Flask web application for PDF processing + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if running as root +if [[ $EUID -eq 0 ]]; then + log_error "This script should not be run as root. Please run as a regular user with sudo privileges." + exit 1 +fi + +# Check Ubuntu version +UBUNTU_VERSION=$(lsb_release -rs) +log_info "Detected Ubuntu version: $UBUNTU_VERSION" + +# Update system +log_info "Updating system packages..." +sudo apt update && sudo apt upgrade -y + +# Install Python 3.9+ and pip +log_info "Installing Python and pip..." +sudo apt install -y python3 python3-pip python3-venv python3-dev + +# Install system dependencies for PDF processing +log_info "Installing system dependencies..." +sudo apt install -y build-essential libpoppler-cpp-dev pkg-config + +# Install Node.js and npm (for potential frontend assets) +log_info "Installing Node.js and npm..." +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt install -y nodejs + +# Create application directory +APP_DIR="$HOME/pdf-sort-merge" +log_info "Creating application directory: $APP_DIR" +mkdir -p "$APP_DIR" +cd "$APP_DIR" + +# Clone or download the application +# Note: Replace with your actual repository URL +REPO_URL="https://github.com/yourusername/pdf-sort-merge.git" +log_info "Cloning application repository..." +if command -v git &> /dev/null; then + sudo apt install -y git + git clone "$REPO_URL" . +else + log_warning "Git not available. Please manually download and extract the application files to $APP_DIR" + exit 1 +fi + +# Create Python virtual environment +log_info "Creating Python virtual environment..." +python3 -m venv venv +source venv/bin/activate + +# Install Python dependencies +log_info "Installing Python dependencies..." +pip install --upgrade pip +pip install -r requirements.txt + +# Create .env file template +log_info "Creating environment configuration..." +if [ ! -f .env ]; then + cat > .env << 'EOF' +# Microsoft Entra (Azure AD) Configuration +# Get these values from your Azure AD app registration in the Azure portal + +# Azure AD Tenant ID (Directory ID) +AZURE_TENANT_ID=your-tenant-id-here + +# Azure AD Client ID (Application ID) +AZURE_CLIENT_ID=your-client-id-here + +# Azure AD Client Secret +AZURE_CLIENT_SECRET=your-client-secret-here + +# Azure AD Authority URL (usually https://login.microsoftonline.com/{tenant_id}) +AZURE_AUTHORITY=https://login.microsoftonline.com/your-tenant-id-here + +# Redirect URI (must match what's configured in Azure AD app registration) +AZURE_REDIRECT_URI=http://localhost:5000/auth/callback + +# Scopes to request (openid, profile, email are common) +AZURE_SCOPES=openid profile email + +# Flask Configuration +SECRET_KEY=your-secret-key-here-change-this-in-production +EOF + log_warning "Created .env template. You MUST edit it with your Azure AD credentials before running the app." +else + log_info ".env file already exists" +fi + +# Create systemd service file +log_info "Creating systemd service..." +SERVICE_FILE="/etc/systemd/system/pdf-sort-merge.service" +sudo tee "$SERVICE_FILE" > /dev/null << EOF +[Unit] +Description=PDF Sort and Merge Flask Application +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$APP_DIR +Environment=PATH=$APP_DIR/venv/bin +ExecStart=$APP_DIR/venv/bin/python $APP_DIR/app.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +# Set proper permissions +sudo chmod 644 "$SERVICE_FILE" + +# Create log directory +log_info "Creating log directory..." +sudo mkdir -p /var/log/pdf-sort-merge +sudo chown $USER:$USER /var/log/pdf-sort-merge + +# Create application data directories +log_info "Creating application data directories..." +mkdir -p results static templates + +# Set up firewall (optional) +log_info "Configuring firewall..." +sudo ufw allow 5000/tcp || log_warning "Failed to configure firewall - you may need to do this manually" + +# Enable and start service +log_info "Enabling and starting service..." +sudo systemctl daemon-reload +sudo systemctl enable pdf-sort-merge +sudo systemctl start pdf-sort-merge + +# Check service status +sleep 3 +if sudo systemctl is-active --quiet pdf-sort-merge; then + log_success "Service started successfully!" +else + log_error "Service failed to start. Check logs with: sudo journalctl -u pdf-sort-merge -f" +fi + +# Create management script +log_info "Creating management script..." +cat > manage.sh << 'EOF' +#!/bin/bash + +case "$1" in + start) + sudo systemctl start pdf-sort-merge + echo "Service started" + ;; + stop) + sudo systemctl stop pdf-sort-merge + echo "Service stopped" + ;; + restart) + sudo systemctl restart pdf-sort-merge + echo "Service restarted" + ;; + status) + sudo systemctl status pdf-sort-merge + ;; + logs) + sudo journalctl -u pdf-sort-merge -f + ;; + update) + cd /home/$USER/pdf-sort-merge + source venv/bin/activate + git pull + pip install -r requirements.txt + sudo systemctl restart pdf-sort-merge + echo "Application updated" + ;; + *) + echo "Usage: $0 {start|stop|restart|status|logs|update}" + exit 1 + ;; +esac +EOF + +chmod +x manage.sh + +# Final instructions +log_success "Installation completed!" +echo "" +echo "==================================================================" +echo "NEXT STEPS:" +echo "1. Edit the .env file with your Azure AD credentials:" +echo " nano $APP_DIR/.env" +echo "" +echo "2. Configure your Azure AD app registration with redirect URI:" +echo " http://your-server-ip:5000/auth/callback" +echo "" +echo "3. Access the application at:" +echo " http://localhost:5000" +echo "" +echo "MANAGEMENT COMMANDS:" +echo " Start service: sudo systemctl start pdf-sort-merge" +echo " Stop service: sudo systemctl stop pdf-sort-merge" +echo " Check status: sudo systemctl status pdf-sort-merge" +echo " View logs: sudo journalctl -u pdf-sort-merge -f" +echo " Update app: ./manage.sh update" +echo "==================================================================" + +# Check if .env needs configuration +if grep -q "your-tenant-id-here" .env; then + log_warning "IMPORTANT: Configure your .env file before using the application!" +fi \ No newline at end of file