From e8deaabbe5cc25b473c1cc4b29e7edf42a650205 Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Wed, 6 May 2026 13:13:00 +0200 Subject: [PATCH 1/2] Add tasks via email Fixes #314 --- config.json | 10 +++- sl/SL_Menu.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++ tt/TimeTracker.py | 93 +++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index 3eeff2d..79fd57b 100644 --- a/config.json +++ b/config.json @@ -11,5 +11,13 @@ "window_y": -662, "css_file": "style.css", "view_mode": "webview", - "soap_port": 8600 + "soap_port": 8600, + "email": { + "imap_server": "", + "imap_port": 993, + "user": "", + "password": "", + "use_ssl": true, + "enabled": false + } } \ No newline at end of file diff --git a/sl/SL_Menu.py b/sl/SL_Menu.py index c60698a..5a16d79 100644 --- a/sl/SL_Menu.py +++ b/sl/SL_Menu.py @@ -224,6 +224,9 @@ def view_main(): if st.button(_("Today View"), use_container_width=True): navigate_to('today_view') + if st.button(_("Zuordnung von E-Mail-Tasks"), use_container_width=True): + navigate_to('email_assignment') + st.divider() if st.button(t_label("4. Handle projects and tasks"), use_container_width=True): @@ -394,6 +397,80 @@ def view_today_tasks(): if st.button(_("Back"), use_container_width=True): navigate_to('main') +def view_email_assignment(): + """ + Renders the view to assign tasks fetched from emails to active projects. + """ + render_header(_("Zuordnung von E-Mail-Tasks")) + + if 'confirm_delete_email_task_id' not in st.session_state: + st.session_state.confirm_delete_email_task_id = None + + if 'email_fetched' not in st.session_state: + with st.spinner(_("Rufe E-Mails ab...")): + count, error = st.session_state.tracker.fetch_emails_to_tasks() + if error: + st.error(_("Fehler beim E-Mail-Abruf: {error}").format(error=error)) + else: + if count > 0: + st.success(_("{count} neue Tasks aus E-Mails erstellt.").format(count=count)) + else: + st.info(_("Keine neuen E-Mails gefunden.")) + st.session_state.email_fetched = True + + hidden_tasks = st.session_state.tracker.list_tasks(main_project_name="hide", status_filter='open') + + if not hidden_tasks: + st.info(_("Keine nicht zugeordneten E-Mail-Tasks vorhanden.")) + else: + active_projects = st.session_state.tracker.list_main_projects(status_filter='open') + project_names = [""] + [p['main_project_name'] for p in active_projects] + + for i, task in enumerate(hidden_tasks): + if i > 0: + st.divider() + col_name, col_move, col_del_btn_placeholder = st.columns([10, 5, 1]) + with col_name: + st.write(f"**{task['task_name']}**") + with col_move: + selected_proj = st.selectbox(_("Projekt zuordnen"), options=project_names, key=f"move_email_{task['id']}", label_visibility="collapsed") + if selected_proj: + success, msg = st.session_state.tracker.move_task("hide", task['task_name'], selected_proj, task_id=task['id']) + if success: + st.session_state.confirm_delete_email_task_id = None # Clear any pending delete + st.rerun() + else: + st.error(msg) + + # Confirmation logic for deletion + if st.session_state.confirm_delete_email_task_id == task['id']: + st.warning(_("Are you sure you want to delete this task?")) + col_yes, col_no = st.columns(2) + with col_yes: + if st.button(_("Yes, delete"), key=f"confirm_del_yes_{task['id']}", use_container_width=True): + if st.session_state.tracker.delete_task("hide", task['task_name'], task_id=task['id']): + st.session_state.confirm_delete_email_task_id = None + st.rerun() + with col_no: + if st.button(_("No, cancel"), key=f"confirm_del_no_{task['id']}", use_container_width=True): + st.session_state.confirm_delete_email_task_id = None + st.rerun() + else: + with col_del_btn_placeholder: + if st.button("🗑️", key=f"del_email_{task['id']}", help=_("Delete")): + st.session_state.confirm_delete_email_task_id = task['id'] + st.rerun() + + with st.expander(_("Notiz bearbeiten")): + edited_note = st.text_area(_("Notes (Markdown)"), value=task['note'], key=f"note_edit_{task['id']}", height=150, label_visibility="collapsed") + if st.button(_("Save Changes"), key=f"save_note_{task['id']}", use_container_width=True): + if st.session_state.tracker.update_task("hide", task['task_name'], note=edited_note, task_id=task['id']): + st.rerun() + + if st.button(_("Zurück"), use_container_width=True): + if 'email_fetched' in st.session_state: del st.session_state.email_fetched + navigate_to('main') + def view_project_management(): """ Renders the project management submenu view. @@ -488,6 +565,7 @@ def view_settings(): if st.button(t_label("2. Restore Previous Version"), use_container_width=True): navigate_to('settings_restore') if st.button(t_label("3. Change Data Storage Location"), use_container_width=True): navigate_to('settings_storage') if st.button(t_label("4. Change Streamlit Port"), use_container_width=True): navigate_to('settings_port') + if st.button(_("E-Mail-Einstellungen"), use_container_width=True): navigate_to('settings_email') if st.button(_("Change CSS Style"), use_container_width=True): navigate_to('settings_css') if st.button(_("Change View Mode"), use_container_width=True): navigate_to('settings_view_mode') @@ -1680,6 +1758,42 @@ def view_settings_language(): if st.button(_("Cancel"), use_container_width=True): navigate_to('settings') +def view_settings_email(): + """ + Renders the form to configure email account settings. + """ + render_header(_("E-Mail-Einstellungen")) + config = get_config() + email_cfg = config.get('email', { + "imap_server": "", "imap_port": 993, "user": "", "password": "", "use_ssl": True, "enabled": False + }) + + with st.form("email_settings_form"): + enabled = st.checkbox(_("E-Mail-Import aktivieren"), value=email_cfg.get('enabled', False)) + server = st.text_input(_("IMAP Server"), value=email_cfg.get('imap_server', '')) + port = st.number_input(_("Port"), value=email_cfg.get('imap_port', 993)) + user = st.text_input(_("Benutzer"), value=email_cfg.get('user', '')) + password = st.text_input(_("Passwort"), value=email_cfg.get('password', ''), type="password") + use_ssl = st.checkbox(_("SSL verwenden"), value=email_cfg.get('use_ssl', True)) + + submitted = st.form_submit_button(_("Speichern"), use_container_width=True) + if submitted: + config['email'] = { + "imap_server": server, + "imap_port": port, + "user": user, + "password": password, + "use_ssl": use_ssl, + "enabled": enabled + } + save_config(config) + set_feedback(_("E-Mail-Einstellungen gespeichert.")) + navigate_to('settings') + st.rerun() + + if st.button(_("Abbrechen"), use_container_width=True): + navigate_to('settings') + def view_report_specific_day(): """ Renders the form to generate a daily report for a specific date. @@ -1865,6 +1979,7 @@ def view_generic_placeholder(title): 'main': view_main, 'task_planning': view_task_planning, 'today_view': view_today_tasks, # New view for today's tasks + 'email_assignment': view_email_assignment, 'project_management': view_project_management, 'main_project_mgmt': view_main_project_mgmt, 'task_mgmt': view_task_mgmt, @@ -1913,6 +2028,7 @@ def view_generic_placeholder(title): 'settings_language': view_settings_language, 'settings_restore': view_settings_restore, 'settings_storage': view_settings_storage, + 'settings_email': view_settings_email, 'settings_css': view_settings_css, 'settings_view_mode': view_settings_view_mode, } diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 85917ce..9dd0071 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -1,5 +1,8 @@ import json import os +import imaplib +import email +from email.header import decode_header from i18n import _ from decimal import Decimal, ROUND_HALF_UP from datetime import datetime, timedelta, date @@ -33,6 +36,7 @@ class TimeTracker: STATUS_OPEN = "open" STATUS_CLOSED = "closed" STATUS_DONE = "done" + HIDDEN_PROJECT = "hide" def __init__(self, file_path=None): """ @@ -315,6 +319,8 @@ def list_main_projects(self, status_filter='all'): """ projects = [] for project in self.data["projects"]: + if project["main_project_name"] == self.HIDDEN_PROJECT: + continue status = project.get("status", self.STATUS_OPEN) if status_filter == 'all' or status == status_filter: projects.append({ @@ -459,6 +465,9 @@ def list_tasks(self, main_project_name=None, status_filter='all', planning_filte # If a specific main project is given, filter the list of projects to search if main_project_name: projects_to_search = [p for p in projects_to_search if p.get("main_project_name") == main_project_name] + else: + # Exclude hidden project when listing all + projects_to_search = [p for p in projects_to_search if p.get("main_project_name") != self.HIDDEN_PROJECT] today_dt = date.today() today_str = today_dt.isoformat() @@ -969,6 +978,84 @@ def start_work(self, main_project_name, task_name=None, task_id=None): return False + def fetch_emails_to_tasks(self): + """ + Fetches emails from the configured IMAP account and creates tasks in the 'hide' project. + """ + config_data = {} + if os.path.exists('config.json'): + try: + with open('config.json', 'r', encoding='utf-8') as f: + config_data = json.load(f).get('email', {}) + except: pass + + if not config_data.get('enabled'): + return 0, _("Email import is not enabled.") + + server = config_data.get('imap_server') + port = config_data.get('imap_port', 993) + user = config_data.get('user') + password = config_data.get('password') + use_ssl = config_data.get('use_ssl', True) + + if not all([server, user, password]): + return 0, _("Email settings are incomplete.") + + try: + if use_ssl: + mail = imaplib.IMAP4_SSL(server, port) + else: + mail = imaplib.IMAP4(server, port) + + mail.login(user, password) + mail.select("inbox") + + status, messages = mail.search(None, "ALL") + if status != "OK": + return 0, _("Error searching emails.") + + mail_ids = messages[0].split() + count = 0 + + if not self._get_project(self.HIDDEN_PROJECT): + self.add_main_project(self.HIDDEN_PROJECT) + + for m_id in mail_ids: + status, data = mail.fetch(m_id, "(RFC822)") + if status != "OK": continue + + msg = email.message_from_bytes(data[0][1]) + + # Decode Subject + subject_header = msg.get("Subject", _("No Subject")) + decoded_parts = decode_header(subject_header) + subject = "" + for part, encoding in decoded_parts: + if isinstance(part, bytes): + subject += part.decode(encoding or "utf-8", errors="replace") + else: subject += part + + # Extract Body + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='replace') + break + else: + body = msg.get_payload(decode=True).decode(msg.get_content_charset() or 'utf-8', errors='replace') + + self.add_task(self.HIDDEN_PROJECT, subject, note=body) + count += 1 + mail.store(m_id, '+FLAGS', '\\Deleted') + + mail.expunge() + mail.logout() + return count, None + + except Exception as e: + return 0, str(e) + def stop_work(self): """ Stops the currently active time tracking session by adding the end time @@ -1023,6 +1110,8 @@ def list_inactive_tasks(self, inactive_weeks): inactive_projects = [] for project in self.data["projects"]: + if project["main_project_name"] == self.HIDDEN_PROJECT: + continue for task in project["tasks"]: if not task.get("time_entries"): # Ignore sub-projects with no entries @@ -1080,6 +1169,8 @@ def list_inactive_main_projects(self, inactive_weeks): inactive_main_projects = [] for project in self.data["projects"]: + if project["main_project_name"] == self.HIDDEN_PROJECT: + continue # Skip closed main projects or those where all sub-projects are closed if project.get("status", self.STATUS_OPEN) == self.STATUS_CLOSED: continue @@ -1132,6 +1223,8 @@ def list_completed_main_projects(self): """ completed_projects = [] for project in self.data["projects"]: + if project["main_project_name"] == self.HIDDEN_PROJECT: + continue tasks = project.get("tasks", []) # If no sub-projects, it is considered completed/inactive in this context From 845d5cdf468f20553dfd1df2c2221adf3c7418e0 Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Wed, 6 May 2026 13:13:25 +0200 Subject: [PATCH 2/2] TimeTracker.py --- tt/TimeTracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 9dd0071..471d3b2 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -32,7 +32,7 @@ class TimeTracker: The data is loaded from and saved to a JSON file. """ - VERSION = "3.7.8" + VERSION = "3.8" STATUS_OPEN = "open" STATUS_CLOSED = "closed" STATUS_DONE = "done"