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
60 changes: 55 additions & 5 deletions sl/SL_Menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def render_icon_button_css():
'[class*="st-key-toolbar_info_btn"] button',
'[class*="st-key-toolbar_stop_btn"] button',
'[class*="st-key-toolbar_settings_btn"] button',
'[class*="st-key-toolbar_export_btn"] button',
'[class*="st-key-main_done_button"] button',
'[class*="st-key-main_edit_button"] button',
'[class*="st-key-start_task_planning"] button',
Expand Down Expand Up @@ -869,6 +870,7 @@ def view_settings():
if st.button(_("1. Change Language"), use_container_width=True): navigate_to('settings_language')
if st.button(_("2. Restore Previous Version"), use_container_width=True): navigate_to('settings_restore')
if st.button(_("3. Change Data Storage Location"), use_container_width=True): navigate_to('settings_storage')
if st.button(_("Report Format"), use_container_width=True): navigate_to('settings_report_format')
if st.button(_("4. Change Streamlit Port"), use_container_width=True): navigate_to('settings_port')
if st.button(_("Email Settings"), use_container_width=True, key="settings_email"): navigate_to('settings_email')
if st.button(_("Change CSS Style"), use_container_width=True, key="settings_css"): navigate_to('settings_css')
Expand Down Expand Up @@ -1993,6 +1995,24 @@ def view_settings_storage():
if st.button(_("Cancel"), use_container_width=True):
navigate_to('settings')

def view_settings_report_format():
"""Renders the form to change the report format."""
render_header(_("Report Format"))
config = get_config()
current_format = config.get('report_format', 'markdown')
formats = ['markdown', 'html', 'rtf']

with st.form("report_format_form"):
selected_format = st.selectbox(_("Select Format"), formats, index=formats.index(current_format))
submitted = st.form_submit_button(_("Save"), use_container_width=True)
if submitted:
config['report_format'] = selected_format
save_config(config)
set_feedback(_("Report format updated."))
navigate_to('settings')
st.rerun()
if st.button(_("Cancel"), use_container_width=True): navigate_to('settings')

def view_settings_css():
"""
Renders the form to change the CSS file.
Expand Down Expand Up @@ -2325,11 +2345,40 @@ def view_report_display():
"""
render_header(_("Report Result"))
report = st.session_state.context.get('report', '')
# The report is a Markdown string. Wrapping it inside an HTML div with
# unsafe_allow_html=True prevents Streamlit from rendering the Markdown.
# By passing the report string directly to st.markdown, it will be correctly parsed and displayed.
st.markdown(report)
if st.button(_("Back"), use_container_width=True): navigate_to('reporting')

config = get_config()
report_format = config.get('report_format', 'markdown')

# Preview based on format
if report_format == 'html':
st.markdown(report, unsafe_allow_html=True)
elif report_format == 'rtf':
st.code(report, language='rtf')
else:
st.markdown(report)

st.divider()

# Export Button Logic (unten rechts angeordnet)
ext_map = {'markdown': 'md', 'html': 'html', 'rtf': 'rtf'}
mime_map = {'markdown': 'text/markdown', 'html': 'text/html', 'rtf': 'application/rtf'}
filename = f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext_map.get(report_format, 'md')}"

col_spacer, col_btn = st.columns([11, 1])
with col_btn:
st.download_button(
label="⍈",
data=report,
file_name=filename,
mime=mime_map.get(report_format, 'text/plain'),
key="toolbar_export_btn",
help=_("Export Report")
)

st.divider()

if st.button(_("Back"), use_container_width=True):
navigate_to('reporting')

# --- Generic List/Action View Helper ---
# To avoid creating 50 separate functions, we can use a generic pattern for simple lists/actions
Expand Down Expand Up @@ -2403,6 +2452,7 @@ def view_generic_placeholder(title):
'report_detailed_main': view_report_detailed_main,
'report_detailed_daily': view_report_detailed_daily,

'settings_report_format': view_settings_report_format,
'settings_language': view_settings_language,
'settings_restore': view_settings_restore,
'settings_storage': view_settings_storage,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_TimeTracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,46 @@ def test_generate_main_project_report(self):
self.assertIn(f"- **Sub 2**: 2:00:00 ({sessions_str_1}, 57.1%)", report) # 2h of 3.5h total
self.assertIn(f"- **Sub 1**: 1:30:00 ({sessions_str_2}, 42.9%)", report) # 1.5h of 3.5h total

def test_markdown_to_rtf_logic(self):
"""Tests basic Markdown to RTF conversion logic."""
md = "# H1\n## H2\n### H3\n- Bullet\n**Bold**\nLine"
rtf = self.tracker._markdown_to_rtf(md)
self.assertIn(r"\fs32 H1", rtf)
self.assertIn(r"\fs28 H2", rtf)
self.assertIn(r"\fs26 H3", rtf)
self.assertIn(r"\bullet Bullet", rtf)
self.assertIn(r"\b Bold\b0", rtf)
self.assertIn("Line", rtf)

@unittest.mock.patch('tt.TimeTracker.pyperclip')
@unittest.mock.patch('tt.TimeTracker.os.path.exists')
@unittest.mock.patch('builtins.open', new_callable=unittest.mock.mock_open)
def test_format_and_copy_report_formats(self, mock_open, mock_exists, mock_pyperclip):
"""Tests that reports are correctly formatted and copied to clipboard based on config."""
md_text = "# Title"

# Test RTF
mock_exists.return_value = True
mock_open.return_value.__enter__.return_value.read.return_value = json.dumps({"report_format": "rtf"})
res = self.tracker._format_and_copy_report(md_text)
self.assertIn(r"{\rtf1", res)
mock_pyperclip.copy.assert_called_with(res)

# Test Markdown
mock_open.return_value.__enter__.return_value.read.return_value = json.dumps({"report_format": "markdown"})
res = self.tracker._format_and_copy_report(md_text)
self.assertEqual(res, md_text)
mock_pyperclip.copy.assert_called_with(md_text)

# Test HTML (if markdown module is present)
import tt.TimeTracker
if tt.TimeTracker.markdown:
mock_open.return_value.__enter__.return_value.read.return_value = json.dumps({"report_format": "html"})
res = self.tracker._format_and_copy_report(md_text)
# Expect basic HTML wrapping for a header
self.assertIn("<h1>Title</h1>", res)
mock_pyperclip.copy.assert_called_with(res)

@unittest.mock.patch('TimeTrackerCLI.input')
@unittest.mock.patch('TimeTrackerCLI.print')
@unittest.mock.patch('os.path.isdir')
Expand Down
63 changes: 48 additions & 15 deletions tt/TimeTracker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os
import imaplib
import re
import email
from email.header import decode_header
from i18n import _
Expand All @@ -19,6 +20,10 @@
import pyperclip
except ImportError:
pyperclip = None # Set to None if the library is not installed
try:
import markdown
except ImportError:
markdown = None
try:
# For version comparison in the update mechanism
from packaging.version import parse as parse_version
Expand Down Expand Up @@ -247,6 +252,44 @@ def _format_duration(self, duration_td):
dlp_str = str(dlp_decimal.quantize(quantizer, rounding=ROUND_HALF_UP)).replace('.', ',')
return _("{hours} hours ({dlp} DLP)").format(hours=hours_str, dlp=dlp_str)

def _markdown_to_rtf(self, text):
"""Converts basic Markdown to RTF format."""
rtf = r"{\rtf1\ansi\deff0{\fonttbl{\f0\fnil\fcharset0 Arial;}}"
rtf += r"\viewkind4\uc1\pard\lang1031\f0\fs24 "
for line in text.split('\n'):
# Escape RTF control characters
line = line.replace('\\', '\\\\').replace('{', '\{').replace('}', '\}')
if line.startswith('# '):
rtf += r"\b\fs32 " + line[2:] + r"\b0\fs24\par "
elif line.startswith('## '):
rtf += r"\b\fs28 " + line[3:] + r"\b0\fs24\par "
elif line.startswith('### '):
rtf += r"\b\fs26 " + line[4:] + r"\b0\fs24\par "
elif line.startswith('- '):
rtf += r"\bullet " + line[2:] + r"\par "
else:
# Basic bold replacement **text** -> \b text \b0
line = re.sub(r'\*\*(.*?)\*\*', r'\\b \1\\b0', line)
rtf += line + r"\par "
rtf += "}"
return rtf

def _format_and_copy_report(self, markdown_text):
"""Formats the report based on config and copies it to clipboard."""
config_format = "markdown"
if os.path.exists('config.json'):
try:
with open('config.json', 'r', encoding='utf-8') as f:
config_format = json.load(f).get('report_format', 'markdown')
except: pass
final_text = markdown_text
if config_format == 'html' and markdown:
final_text = markdown.markdown(markdown_text)
elif config_format == 'rtf':
final_text = self._markdown_to_rtf(markdown_text)
self._copy_to_clipboard(final_text)
return final_text

def get_version(self):
"""
Returns the current version of the TimeTracker application.
Expand Down Expand Up @@ -1308,9 +1351,7 @@ def generate_daily_report(self, report_date=None):
else:
report.append(_("No time tracked for {date}.").format(date=today.strftime('%Y-%m-%d')))

report_text = "\n".join(report)
self._copy_to_clipboard(report_text)
return report_text
return self._format_and_copy_report("\n".join(report))

def generate_task_report(self, main_project_name, task_name):
"""
Expand Down Expand Up @@ -1407,9 +1448,7 @@ def generate_task_report(self, main_project_name, task_name):
report.append(f"\n### {date.strftime('%Y-%m-%d')}")
report.extend(daily_breakdown[date])

report_text = "\n".join(report)
self._copy_to_clipboard(report_text)
return report_text
return self._format_and_copy_report("\n".join(report))

def generate_main_project_report(self, main_project_name):
"""
Expand Down Expand Up @@ -1513,9 +1552,7 @@ def generate_main_project_report(self, main_project_name):
f"- **{stat['name']}**: {duration_str} ({_('{num_sessions} sessions').format(num_sessions=stat['sessions'])}, {percentage:.1f}%)"
)

report_text = "\n".join(report)
self._copy_to_clipboard(report_text)
return report_text
return self._format_and_copy_report("\n".join(report))

def generate_date_range_report(self, start_date, end_date):
"""
Expand Down Expand Up @@ -1572,9 +1609,7 @@ def generate_date_range_report(self, start_date, end_date):
else:
report.append(_("No time tracked between {start_date} and {end_date}.").format(start_date=start_date.strftime('%Y-%m-%d'), end_date=end_date.strftime('%Y-%m-%d')))

report_text = "\n".join(report)
self._copy_to_clipboard(report_text)
return report_text
return self._format_and_copy_report("\n".join(report))

def generate_detailed_daily_report(self, report_date=None):
"""
Expand Down Expand Up @@ -1633,6 +1668,4 @@ def generate_detailed_daily_report(self, report_date=None):
report.append("Generated by TimeControl")
report.append("https://github.com/frankfaulstich/TimeControl")

report_text = "\n".join(report)
self._copy_to_clipboard(report_text)
return report_text
return self._format_and_copy_report("\n".join(report))
Loading