animations

This commit is contained in:
2025-08-09 07:55:42 -07:00
parent 608cd7357c
commit c8a5768aff
10 changed files with 423 additions and 18 deletions

6
.env
View File

@@ -3,7 +3,7 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/email_organizer_dev
# OPENAI_API_KEY=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 # OPENAI_API_KEY=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
# OPENAI_BASE_URL=https://openrouter.ai/api/v1 # OPENAI_BASE_URL=https://openrouter.ai/api/v1
# OPENAI_MODEL=qwen/qwen3-coder # OPENAI_MODEL=qwen/qwen3-coder
#
OPENAI_API_KEY=aaoeu OPENAI_API_KEY=aaoeu
OPENAI_BASE_URL=http://localhost:8082/v1 OPENAI_BASE_URL=http://localhost:5082/v1
OPENAI_MODEL= OPENAI_MODEL=Qwen3-Coder-480B-A35B-Instruct-GGUF-roo

View File

@@ -13,6 +13,9 @@ Here are special rules you must follow:
11. Design docs go into docs/design/*.md. These docs are always kept up to date. 11. Design docs go into docs/design/*.md. These docs are always kept up to date.
12. Before completing work, ensure that no design docs are left out of sync 12. Before completing work, ensure that no design docs are left out of sync
13. Plans go into docs/plans/*.md. These may not be kept in sync, as they are just for brainstorming. 13. Plans go into docs/plans/*.md. These may not be kept in sync, as they are just for brainstorming.
14. ****IMPORTANT**** Database migrations are automatically created via `flask db migrate -m 'message'`. **NEVER** create migrations by hand. You should never have to read the contents of migrations/
15. In the technical documentation, code should be used sparingly. Also, when needed, focus on the APIs, and only use snippets.
# Conventions # Conventions
1. modals are rendered server-side, by targeting #modal-holder, and using an hx-trigger response attribute for open-modal. 1. modals are rendered server-side, by targeting #modal-holder, and using an hx-trigger response attribute for open-modal.

View File

@@ -22,6 +22,65 @@ def imap_config_modal():
response.headers['HX-Trigger'] = 'open-modal' response.headers['HX-Trigger'] = 'open-modal'
return response return response
@imap_bp.route('/api/imap/folders/modal', methods=['GET'])
@login_required
def imap_folders_modal():
"""Return the folder selection modal after successful IMAP connection."""
try:
if not current_user.imap_config:
return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400
# Test connection first
imap_service = IMAPService(current_user)
# Get folders from IMAP server
imap_folders = imap_service.get_folders()
if not imap_folders:
return jsonify({'error': 'No folders found on IMAP server'}), 400
# Deduplicate folders by name to prevent creating multiple entries for the same folder
unique_folders = []
seen_names = set()
for imap_folder in imap_folders:
folder_name = imap_folder['name']
# Skip special folders that might not be needed
if folder_name.lower() in ['sent', 'drafts', 'spam', 'trash']:
continue
# Use case-insensitive comparison for deduplication
folder_name_lower = folder_name.lower()
if folder_name_lower not in seen_names:
# Check if this folder already exists in the database
existing_folder = Folder.query.filter_by(
user_id=current_user.id,
name=folder_name
).first()
# Add folder type if it exists
if existing_folder:
imap_folder['folder_type'] = existing_folder.folder_type
imap_folder['selected'] = True
else:
# Set default folder type
imap_folder['folder_type'] = 'tidy' if folder_name.lower().strip() == 'inbox' else 'destination'
imap_folder['selected'] = True
unique_folders.append(imap_folder)
seen_names.add(folder_name_lower)
# Return the folder selection modal
response = make_response(render_template('partials/folder_selection_modal.html', folders=unique_folders))
response.headers['hx-retarget'] = "#modal-holder"
response.headers['HX-Trigger'] = 'open-modal'
return response
except Exception as e:
logging.exception("Error getting IMAP folders modal: %s", e)
print(e)
return jsonify({'error': 'An unexpected error occurred while fetching folders'}), 500
@imap_bp.route('/api/imap/test', methods=['POST']) @imap_bp.route('/api/imap/test', methods=['POST'])
@login_required @login_required
def test_imap_connection(): def test_imap_connection():
@@ -75,10 +134,10 @@ def test_imap_connection():
current_user.imap_config = test_config current_user.imap_config = test_config
db.session.commit() db.session.commit()
response = make_response(render_template('partials/imap_config_modal.html', # Redirect to folder selection modal after successful connection
success=True, message=message)) response = make_response('')
response.headers['HX-Retarget'] = '#imap-modal' response.headers['HX-Trigger'] = 'open-modal'
response.headers['HX-Reswap'] = 'outerHTML' response.headers['HX-Location'] = '/api/imap/folders/modal'
else: else:
print(message) print(message)
response = make_response(render_template('partials/imap_config_modal.html', response = make_response(render_template('partials/imap_config_modal.html',
@@ -97,10 +156,168 @@ def test_imap_connection():
response.headers['HX-Reswap'] = 'outerHTML' response.headers['HX-Reswap'] = 'outerHTML'
return response return response
@imap_bp.route('/api/imap/folders', methods=['GET'])
@login_required
def get_imap_folders():
"""Get folders from IMAP server without creating database records."""
try:
if not current_user.imap_config:
return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400
# Test connection first
imap_service = IMAPService(current_user)
# Get folders from IMAP server
imap_folders = imap_service.get_folders()
if not imap_folders:
return jsonify({'error': 'No folders found on IMAP server'}), 400
# Deduplicate folders by name to prevent creating multiple entries for the same folder
unique_folders = []
seen_names = set()
for imap_folder in imap_folders:
folder_name = imap_folder['name']
# Skip special folders that might not be needed
if folder_name.lower() in ['sent', 'drafts', 'spam', 'trash']:
continue
# Use case-insensitive comparison for deduplication
folder_name_lower = folder_name.lower()
if folder_name_lower not in seen_names:
unique_folders.append(imap_folder)
seen_names.add(folder_name_lower)
return jsonify({'folders': unique_folders})
except Exception as e:
logging.exception("Error getting IMAP folders: %s", e)
print(e)
return jsonify({'error': 'An unexpected error occurred while fetching folders'}), 500
@imap_bp.route('/api/imap/sync-selected', methods=['POST'])
@login_required
def sync_selected_folders():
"""Sync only the selected folders from IMAP server with processed email tracking."""
try:
if not current_user.imap_config:
return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400
# Get selected folder names and types from form data
selected_folders = {}
for key, value in request.form.items():
if key.startswith('folder_'):
folder_name = value
# Check if there's a corresponding folder type
type_key = f'folder_type_{key.split("_")[1]}'
folder_type = request.form.get(type_key, 'destination') # Default to 'destination'
selected_folders[folder_name] = folder_type
if not selected_folders:
return jsonify({'error': 'No folders selected'}), 400
# Test connection first
imap_service = IMAPService(current_user)
# Get folders from IMAP server
imap_folders = imap_service.get_folders()
if not imap_folders:
return jsonify({'error': 'No folders found on IMAP server'}), 400
# Deduplicate folders by name to prevent creating multiple entries for the same folder
unique_folders = []
seen_names = set()
for imap_folder in imap_folders:
folder_name = imap_folder['name']
# Skip special folders that might not be needed
if folder_name.lower() in ['sent', 'drafts', 'spam', 'trash']:
continue
# Only include folders that were selected by the user
if folder_name in selected_folders:
# Use case-insensitive comparison for deduplication
folder_name_lower = folder_name.lower()
if folder_name_lower not in seen_names:
# Add the selected folder type
imap_folder['selected_type'] = selected_folders[folder_name]
unique_folders.append(imap_folder)
seen_names.add(folder_name_lower)
# Process each selected unique folder
synced_count = 0
processed_emails_service = ProcessedEmailsService(current_user)
# Create a list of folders to process
folders_to_process = []
for imap_folder in unique_folders:
folder_name = imap_folder['name'].strip()
folder_type = imap_folder.get('selected_type', 'destination') # Get the selected type
# Handle nested folder names (convert slashes to underscores or keep as-is)
# According to requirements, nested folders should be created with slashes in the name
display_name = folder_name
# Check if folder already exists
existing_folder = Folder.query.filter_by(
user_id=current_user.id,
name=display_name
).first()
if not existing_folder:
# Create new folder with the selected type
new_folder = Folder(
user_id=current_user.id,
name=display_name,
rule_text=f"Auto-synced from IMAP folder: {folder_name}",
priority=0, # Default priority
folder_type=folder_type
)
db.session.add(new_folder)
synced_count += 1
folders_to_process.append(new_folder)
else:
# Update existing folder with the selected type and email counts
existing_folder.folder_type = folder_type
# Get all email UIDs in this folder
email_uids = imap_service.get_folder_email_uids(folder_name)
# Sync with processed emails service
new_emails_count = processed_emails_service.sync_folder_emails(display_name, email_uids)
print("NEW", new_emails_count)
# Update counts
pending_count = processed_emails_service.get_pending_count(display_name)
existing_folder.pending_count = pending_count
existing_folder.total_count = len(email_uids)
# Get the most recent emails for this folder
recent_emails = imap_service.get_recent_emails(folder_name, 3)
existing_folder.recent_emails = recent_emails
folders_to_process.append(existing_folder)
db.session.commit()
# Just trigger the folder list update and close the modal
response = make_response('')
response.headers['HX-Trigger'] = 'close-modal, folder-list-invalidated'
return response
except Exception as e:
logging.exception("Error syncing selected IMAP folders: %s", e)
print(e)
db.session.rollback()
return jsonify({'error': 'An unexpected error occurred'}), 500
@imap_bp.route('/api/imap/sync', methods=['POST']) @imap_bp.route('/api/imap/sync', methods=['POST'])
@login_required @login_required
def sync_imap_folders(): def sync_imap_folders():
"""Sync folders from IMAP server with processed email tracking.""" """Create and sync folders from IMAP server with processed email tracking."""
try: try:
if not current_user.imap_config: if not current_user.imap_config:
return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400 return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400

View File

@@ -0,0 +1,98 @@
/* Modal transition animations using Tailwind classes */
/*
.slide-left-move {
transform: translateX(0);
opacity: 1;
transition: all 300ms ease-out;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-enter-to {
transform: translateX(0);
opacity: 1;
transition: all 300ms ease-out;
}
.slide-left-leave-from {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
transition: all 300ms ease-in;
}
.slide-right-move {
transform: translateX(0);
opacity: 1;
transition: all 300ms ease-out;
}
.slide-right-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-to {
transform: translateX(0);
opacity: 1;
transition: all 300ms ease-out;
}
.slide-right-leave-from {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-to {
transform: translateX(100%);
opacity: 0;
transition: all 300ms ease-in;
}
.translate-up-move {
transform: translateY(0);
opacity: 1;
transition: all 300ms ease-out;
}
.translate-up-enter-from {
transform: translateY(100%);
opacity: 0;
}
.translate-up-enter-to {
transform: translateY(0);
opacity: 1;
transition: all 300ms ease-out;
}
.translate-up-leave-from {
transform: translateY(0);
opacity: 1;
}
.translate-up-leave-to {
transform: translateY(-100%);
opacity: 0;
transition: all 300ms ease-in;
}
.step-1 .steps .step:first-child,
.step-2 .steps .step:last-child {
--tw-step-text: hsl(var(--bc));
--tw-step-primary: hsl(var(--p));
}
.step-1 .steps .step:last-child,
.step-2 .steps .step:first-child {
--tw-step-text: hsl(var(--bc));
color: hsl(var(--bc));
}
*/

View File

@@ -13,6 +13,7 @@
<script src="https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://unpkg.com/tippy.js@6/dist/tippy.css" /> <link rel="stylesheet" href="https://unpkg.com/tippy.js@6/dist/tippy.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="/static/css/animations.css">
<style> <style>
@keyframes shake { @keyframes shake {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }

View File

@@ -24,7 +24,7 @@
<div class="flex space-x-2"> <div class="flex space-x-2">
{% if current_user.imap_config %} {% if current_user.imap_config %}
<div data-loading-states> <div data-loading-states>
<button class="btn btn-outline" hx-post="/api/imap/sync" hx-target="#modal-holder" data-loading-disable> <button class="btn btn-outline" hx-get="/api/imap/config" hx-target="#modal-holder" data-loading-disable>
<i class="fas fa-sync mr-2" data-loading-class="!hidden"></i> <i class="fas fa-sync mr-2" data-loading-class="!hidden"></i>
<span data-loading-class="!hidden">Sync Folders</span> <span data-loading-class="!hidden">Sync Folders</span>
<span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span> <span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span>

View File

@@ -0,0 +1,72 @@
<div id="folder-selection-modal" class="modal-box step-2 slide-left-enter-from" @click.away="$refs.modal.close()">
<div class="flex items-center mb-4">
<div class="steps flex-1">
<span class="step">Step 1</span>
<span class="step step-primary">Step 2</span>
</div>
<h3 class="font-bold text-lg">Configure IMAP Folders</h3>
</div>
<p class="mb-4">The following folders were found on your IMAP server. Select which folders you want to sync and configure their processing types.</p>
<form id="folder-selection-form" hx-post="/api/imap/sync-selected" hx-target="#modal-holder" hx-swap="innerHTML slide-left:300ms">
<div class="overflow-x-auto mb-4">
<table class="table">
<thead>
<tr>
<th>Folder</th>
<th>Synced?</th>
<th>Select</th>
<th>Processing Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for folder in folders %}
<tr>
<td>{{ folder.name }}</td>
<td>
{% if folder.subscribed %}
<span class="badge badge-success">Synced</span>
{% else %}
{% endif %}
</td>
<td>
<select class="select select-bordered select-sm"
name="folder_type_{{ loop.index }}">
<option value="tidy" {% if folder.folder_type == 'tidy' %}selected{% endif %}>Tidy</option>
<option value="destination" {% if folder.folder_type == 'destination' %}selected{% endif %}>Destination</option>
<option value="ignore" {% if folder.folder_type == 'ignore' %}selected{% endif %}>Ignore</option>
</select>
</td>
<td>
{% if folder.folder_type == 'tidy' %}
Processed by AI to organize emails
{% elif folder.folder_type == 'destination' %}
Target for organized emails
{% else %}
Ignored during processing
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>Special folders like "Sent", "Drafts", "Spam", and "Trash" are automatically excluded from syncing.</span>
</div>
<div class="modal-action" data-loading-states>
<button type="button" class="btn btn-outline" @click="$dispatch('close-modal')">
Cancel
</button>
<button type="submit" class="btn btn-primary" id="continue-btn" data-loading-disable>
<span data-loading-class="!hidden">Save and Continue</span>
<span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span>
</button>
</div>
</form>
</div>

View File

@@ -1,8 +1,15 @@
<div id="folder-type-modal" class="modal-box" @click.away="$refs.modal.close()"> <div id="folder-type-modal" class="modal-box step-2 slide-left-enter-from" @click.away="$refs.modal.close()">
<h3 class="font-bold text-lg mb-4">Configure Folder Types</h3> <div class="flex items-center mb-4">
<div class="steps flex-1">
<span class="step">Step 1</span>
<span class="step step-primary">Step 2</span>
</div>
<h3 class="font-bold text-lg">Configure Folder Types</h3>
</div>
<p class="mb-4">Select the processing type for each folder. Inbox will default to Tidy, while Archive/Spam/Drafts will default to Ignore.</p> <p class="mb-4">Select the processing type for each folder. Inbox will default to Tidy, while Archive/Spam/Drafts will default to Ignore.</p>
<div class="overflow-x-auto"> <div class="overflow-x-auto mb-4">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -42,11 +49,11 @@
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-outline" hx-get="/api/imap/sync"> <button type="button" class="btn btn-outline" hx-get="/api/imap/config" hx-target="#modal-holder" hx-swap="innerHTML slide-right:300ms">
Skip and Use Defaults <i class="fas fa-arrow-left mr-2"></i>Back
</button> </button>
<button type="button" class="btn btn-primary" hx-post="/api/imap/sync"> <button type="button" class="btn btn-primary" hx-post="/api/imap/sync">
Save and Continue Save and Continue
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,11 @@
<div id="imap-modal" @click.away="$refs.modal.close()" class="modal-box" x-data="{ errors: {{ 'true' if errors else 'false' }} }" x-init="$nextTick(() => { if (errors) { document.querySelector('#submit-btn').classList.add('shake'); } })" > <div id="imap-modal" @click.away="$refs.modal.close()" class="modal-box step-1 translate-up-enter-from" x-data="{ errors: {{ 'true' if errors else 'false' }} }" x-init="$nextTick(() => { if (errors) { document.querySelector('#submit-btn').classList.add('shake'); } })" >
<h3 class="font-bold text-lg mb-4">Configure IMAP Connection</h3> <div class="flex items-center mb-4">
<div class="steps flex-1">
<span class="step step-primary">Step 1</span>
<span class="step">Step 2</span>
</div>
<h3 class="font-bold text-lg">Configure IMAP Connection</h3>
</div>
{% if success %} {% if success %}
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
@@ -80,7 +86,7 @@
{% if success %} {% if success %}
<div class="mt-4 pt-4 border-t border-base-300" data-loading-states> <div class="mt-4 pt-4 border-t border-base-300" data-loading-states>
<button class="btn btn-success w-full" hx-post="/api/imap/sync" hx-target="#modal-holder" hx-swap="innerHTML" data-loading-disable> <button class="btn btn-success w-full" hx-post="/api/imap/sync" hx-target="#modal-holder" hx-swap="innerHTML slide-left:300ms" data-loading-disable>
<span data-loading-class="!hidden"><i class="fas fa-sync mr-2"></i>Configure Folder Types</span> <span data-loading-class="!hidden"><i class="fas fa-sync mr-2"></i>Configure Folder Types</span>
<span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span> <span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span>
</button> </button>

View File

@@ -7,3 +7,4 @@ pytest==7.4.0
beautifulsoup4==4.13.4 beautifulsoup4==4.13.4
Flask-Migrate==4.1.0 Flask-Migrate==4.1.0
imapclient==3.0.1 imapclient==3.0.1
playwright