diff --git a/sl/SL_Menu.py b/sl/SL_Menu.py index 89cf9aa..ef36230 100644 --- a/sl/SL_Menu.py +++ b/sl/SL_Menu.py @@ -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', @@ -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') @@ -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. @@ -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 @@ -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, diff --git a/tests/test_TimeTracker.py b/tests/test_TimeTracker.py index 1d619a1..28ea218 100644 --- a/tests/test_TimeTracker.py +++ b/tests/test_TimeTracker.py @@ -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("

Title

", res) + mock_pyperclip.copy.assert_called_with(res) + @unittest.mock.patch('TimeTrackerCLI.input') @unittest.mock.patch('TimeTrackerCLI.print') @unittest.mock.patch('os.path.isdir') diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 03bac4a..ce2b625 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -1,6 +1,7 @@ import json import os import imaplib +import re import email from email.header import decode_header from i18n import _ @@ -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 @@ -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. @@ -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): """ @@ -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): """ @@ -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): """ @@ -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): """ @@ -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 \ No newline at end of file + return self._format_and_copy_report("\n".join(report)) \ No newline at end of file