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("