Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
116 changes: 116 additions & 0 deletions sl/SL_Menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
Expand Down
95 changes: 94 additions & 1 deletion tt/TimeTracker.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,10 +32,11 @@ 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"
HIDDEN_PROJECT = "hide"

def __init__(self, file_path=None):
"""
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading