diff --git a/app/services/newsletter.py b/app/services/newsletter.py index 5500e85..0a8aa7e 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -263,6 +263,28 @@ OUTLOOK_MUTED = "#666666" OUTLOOK_LINK = "#0563C1" OUTLOOK_HEADING = "#003366" OUTLOOK_BORDER = "#DDDDDD" +OUTLOOK_WHITE = "#FFFFFF" + + +def _outlook_font( + text: str, + *, + color: str = OUTLOOK_TEXT, + size: str = "2", + bold: bool = False, +) -> str: + inner = escape(text) + if bold: + inner = f"{inner}" + return f'{inner}' + + +def _outlook_link(title: str, url: str) -> str: + return ( + f'' + f'{escape(title)}' + f"" + ) def create_outlook_html( @@ -271,18 +293,18 @@ def create_outlook_html( display: DisplayOptions | None = None, category: str = "", ) -> str: - """Vereinfachtes HTML für Outlook/Word – nur Tabellen und Inline-Styles.""" + """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) period_start, period_end = _period_range(days) created_at = datetime.now().strftime("%d.%m.%Y %H:%M") - intro = escape(_intro_sentence(period_start, period_end)) + intro = _intro_sentence(period_start, period_end) subject = escape(create_subject(days, category)) if not new_articles and not edited_articles: body_rows = f""" - - Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden. + + {_outlook_font("Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)} """ else: @@ -300,15 +322,18 @@ def create_outlook_html( category_block = "" if category.strip(): - category_block = f"""

- Filter: Kategorie {escape(category.strip())} -

""" + category_block = ( + f'

' + f'{_outlook_font("Filter: ", color=OUTLOOK_HEADING, bold=True)}' + f'{_outlook_font(f"Kategorie {category.strip()}", color=OUTLOOK_MUTED, size="1")}' + f"

" + ) total = len(new_articles) + len(edited_articles) summary_row = f""" - - Zusammenfassung: - {len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt + + {_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")} + {_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")} """ @@ -318,48 +343,42 @@ def create_outlook_html( + + {subject} - - - + +
- - - - + """ ) @@ -412,13 +430,15 @@ def _outlook_section( empty_text: str, ) -> str: header = f""" - """ if not items: return header + f""" - + """ return header + _outlook_article_rows(items, display) diff --git a/app/static/css/main.css b/app/static/css/main.css index e887869..571546f 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -24,3 +24,10 @@ th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; v .display-options legend { font-weight: 600; padding: 0 0.25rem; } .error { color: #b42318; background: #fef3f2; border: 1px solid #fecdca; padding: 0.6rem; border-radius: 4px; margin: 0 0 0.75rem; } .hint { color: #64748b; font-size: 0.92rem; margin: 0 0 0.6rem; } +.copy-dialog-overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(15, 23, 42, 0.55); display: flex; align-items: center; justify-content: center; padding: 1rem; } +.copy-dialog { width: min(900px, 100%); max-height: 90vh; background: #fff; border-radius: 8px; padding: 1rem 1.25rem; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); display: grid; gap: 0.75rem; } +.copy-dialog h4 { margin: 0; color: #003366; } +.copy-dialog-hint { margin: 0; color: #334155; } +.copy-dialog-textarea { width: 100%; min-height: 320px; font-family: Consolas, "Courier New", monospace; font-size: 0.85rem; line-height: 1.4; border: 1px solid #cbd5e1; border-radius: 4px; padding: 0.75rem; resize: vertical; } +.copy-dialog-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; } +.textarea-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.6rem; } diff --git a/app/static/js/newsletter.js b/app/static/js/newsletter.js index 592cd27..0a1a8ab 100644 --- a/app/static/js/newsletter.js +++ b/app/static/js/newsletter.js @@ -8,8 +8,7 @@ function setCopyStatus(message, isError) { function getNewsletterHtml() { const htmlSource = document.getElementById("newsletter-html-source"); const htmlDisplay = document.getElementById("newsletter-html-display"); - const value = htmlSource?.value.trim() || htmlDisplay?.value.trim() || ""; - return value; + return htmlSource?.value.trim() || htmlDisplay?.value.trim() || ""; } function getNewsletterOutlookHtml() { @@ -24,6 +23,10 @@ function getNewsletterPlain() { return plainSource ? plainSource.value : ""; } +function byteLength(text) { + return new TextEncoder().encode(text).length; +} + function wrapCfHtml(html) { const fragmentStart = ""; const fragmentEnd = ""; @@ -32,15 +35,20 @@ function wrapCfHtml(html) { const documentHtml = `${fragmentStart}${inner}${fragmentEnd}`; const pad = (value) => String(value).padStart(10, "0"); - const headerPlaceholder = "StartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n"; - const prefix = `Version:0.9\r\n${headerPlaceholder}`; - const startHtml = prefix.length; - const endHtml = startHtml + documentHtml.length; - const startFragment = startHtml + documentHtml.indexOf(fragmentStart) + fragmentStart.length; - const endFragment = startHtml + documentHtml.indexOf(fragmentEnd); + const headerTemplate = + "Version:0.9\r\n" + + "StartHTML:0000000000\r\n" + + "EndHTML:0000000000\r\n" + + "StartFragment:0000000000\r\n" + + "EndFragment:0000000000\r\n"; + + const startHtml = byteLength(headerTemplate); + const endHtml = startHtml + byteLength(documentHtml); + const startFragment = startHtml + byteLength(documentHtml.slice(0, documentHtml.indexOf(fragmentStart) + fragmentStart.length)); + const endFragment = startHtml + byteLength(documentHtml.slice(0, documentHtml.indexOf(fragmentEnd))); return ( - `Version:0.9\r\n` + + "Version:0.9\r\n" + `StartHTML:${pad(startHtml)}\r\n` + `EndHTML:${pad(endHtml)}\r\n` + `StartFragment:${pad(startFragment)}\r\n` + @@ -49,26 +57,33 @@ function wrapCfHtml(html) { ); } -function copyFromTextarea(textarea) { +function selectTextarea(textarea) { if (!textarea || !textarea.value) return false; - - const previousActive = document.activeElement; - textarea.removeAttribute("readonly"); + textarea.scrollIntoView({ behavior: "smooth", block: "center" }); textarea.focus({ preventScroll: true }); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); + return true; +} + +function copyFromTextarea(textarea) { + if (!textarea || !textarea.value) return false; + + const wasReadonly = textarea.hasAttribute("readonly"); + if (wasReadonly) textarea.removeAttribute("readonly"); + + selectTextarea(textarea); let ok = false; try { ok = document.execCommand("copy"); } catch (err) { console.warn("execCommand copy from textarea failed.", err); - ok = false; - } finally { - textarea.setAttribute("readonly", ""); - if (previousActive && typeof previousActive.focus === "function") { - previousActive.focus({ preventScroll: true }); - } + } + + if (wasReadonly) textarea.setAttribute("readonly", ""); + if (!ok) { + selectTextarea(textarea); } return ok; } @@ -92,47 +107,118 @@ function copyTextWithFallback(text) { const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("aria-hidden", "true"); - textarea.style.position = "fixed"; - textarea.style.top = "0"; - textarea.style.left = "0"; - textarea.style.width = "2px"; - textarea.style.height = "2px"; - textarea.style.padding = "0"; - textarea.style.border = "none"; - textarea.style.outline = "none"; - textarea.style.boxShadow = "none"; - textarea.style.background = "transparent"; + textarea.style.cssText = "position:fixed;top:0;left:0;width:2px;height:2px;opacity:0;border:none;padding:0;"; document.body.appendChild(textarea); let ok = false; try { - textarea.focus({ preventScroll: true }); - textarea.select(); - textarea.setSelectionRange(0, text.length); + selectTextarea(textarea); ok = document.execCommand("copy"); } catch (err) { console.warn("execCommand copy from temporary textarea failed.", err); - ok = false; } finally { document.body.removeChild(textarea); } return ok; } +let copyDialogKeyHandler = null; + +function closeCopyDialog() { + const overlay = document.getElementById("copy-dialog-overlay"); + if (overlay) overlay.remove(); + if (copyDialogKeyHandler) { + document.removeEventListener("keydown", copyDialogKeyHandler); + copyDialogKeyHandler = null; + } +} + +function showCopyDialog(text, title) { + closeCopyDialog(); + + const overlay = document.createElement("div"); + overlay.id = "copy-dialog-overlay"; + overlay.className = "copy-dialog-overlay"; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const textarea = overlay.querySelector(".copy-dialog-textarea"); + textarea.value = text; + selectTextarea(textarea); + + let copied = false; + try { + copied = document.execCommand("copy"); + } catch (err) { + console.warn("execCommand copy in dialog failed.", err); + } + + if (copied) { + setCopyStatus("HTML-Quelltext kopiert."); + } else { + setCopyStatus("Bitte im Fenster Strg+C drücken.", true); + } + + overlay.querySelector("#copy-dialog-close-btn").addEventListener("click", closeCopyDialog); + overlay.querySelector("#copy-dialog-select-btn").addEventListener("click", () => selectTextarea(textarea)); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) closeCopyDialog(); + }); + + copyDialogKeyHandler = (event) => { + if (event.key === "Escape") closeCopyDialog(); + }; + document.addEventListener("keydown", copyDialogKeyHandler); +} + +function copyViaIframe(html) { + const iframe = document.createElement("iframe"); + iframe.setAttribute("aria-hidden", "true"); + iframe.style.cssText = "position:fixed;width:700px;height:500px;left:-9999px;top:0;border:none;"; + document.body.appendChild(iframe); + + const doc = iframe.contentWindow.document; + doc.open(); + doc.write(html); + doc.close(); + + const range = doc.createRange(); + range.selectNodeContents(doc.body); + const selection = iframe.contentWindow.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + let ok = false; + try { + ok = doc.execCommand("copy"); + } catch (err) { + console.warn("execCommand copy via iframe failed.", err); + } finally { + selection.removeAllRanges(); + document.body.removeChild(iframe); + } + return ok; +} + function copyHtmlWithFallback(html) { + if (copyViaIframe(html)) return true; + const container = document.createElement("div"); container.innerHTML = html; container.contentEditable = "true"; container.setAttribute("aria-hidden", "true"); - container.style.position = "fixed"; - container.style.left = "0"; - container.style.top = "0"; - container.style.width = "600px"; - container.style.height = "1px"; - container.style.overflow = "hidden"; - container.style.opacity = "0"; - container.style.pointerEvents = "none"; - container.style.background = "#FFFFFF"; + container.style.cssText = + "position:fixed;left:0;top:0;width:700px;max-height:1px;overflow:hidden;opacity:0;pointer-events:none;background:#FFFFFF;"; document.body.appendChild(container); const range = document.createRange(); @@ -146,36 +232,13 @@ function copyHtmlWithFallback(html) { ok = document.execCommand("copy"); } catch (err) { console.warn("execCommand HTML copy failed.", err); - ok = false; } finally { selection.removeAllRanges(); document.body.removeChild(container); } - - if (!ok) { - ok = copyTextWithFallback(wrapCfHtml(html)); - } return ok; } -async function copyText(text) { - if (!text) return false; - - if (copyTextWithFallback(text)) { - return true; - } - - if (navigator.clipboard && window.isSecureContext) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch (err) { - console.warn("Clipboard API text copy failed.", err); - } - } - return false; -} - async function copyForOutlook() { const html = getNewsletterOutlookHtml(); const plain = getNewsletterPlain(); @@ -185,7 +248,7 @@ async function copyForOutlook() { } if (copyHtmlWithFallback(html)) { - setCopyStatus("Newsletter für Outlook kopiert. Mit Strg+V einfügen (ggf. „Format beibehalten“)."); + setCopyStatus("Newsletter kopiert. In Outlook: Strg+V, dann ggf. Rechtsklick → „Format beibehalten“."); return; } @@ -198,55 +261,24 @@ async function copyForOutlook() { "text/plain": new Blob([plain], { type: "text/plain" }), }), ]); - setCopyStatus("Newsletter für Outlook kopiert. Mit Strg+V einfügen (ggf. „Format beibehalten“)."); + setCopyStatus("Newsletter kopiert. In Outlook: Strg+V, dann ggf. Rechtsklick → „Format beibehalten“."); return; } catch (err) { console.warn("Clipboard API HTML copy failed.", err); } } - setCopyStatus("Kopieren fehlgeschlagen. Bitte „HTML herunterladen“ verwenden.", true); + setCopyStatus("Bitte „Im Browser öffnen“ nutzen und dort Strg+A → Strg+C.", true); } -async function copyHtmlSource() { +function copyHtmlSource() { const html = getNewsletterHtml(); if (!html) { setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); return; } - const displayArea = document.getElementById("newsletter-html-display"); - const sourceArea = document.getElementById("newsletter-html-source"); - const textarea = displayArea?.value ? displayArea : sourceArea; - - if (textarea && copyFromTextarea(textarea)) { - setCopyStatus("HTML-Quelltext kopiert."); - return; - } - - if (copyTextWithFallback(html)) { - setCopyStatus("HTML-Quelltext kopiert."); - return; - } - - if (navigator.clipboard && window.isSecureContext) { - try { - await navigator.clipboard.writeText(html); - setCopyStatus("HTML-Quelltext kopiert."); - return; - } catch (err) { - console.warn("Clipboard API HTML source copy failed.", err); - } - } - - if (textarea) { - textarea.focus({ preventScroll: true }); - textarea.select(); - } - setCopyStatus( - "Automatisches Kopieren nicht möglich. HTML-Feld unten ist markiert – bitte Strg+C drücken.", - true - ); + showCopyDialog(html, "HTML-Quelltext kopieren"); } function downloadHtml() { @@ -256,9 +288,23 @@ function downloadHtml() { return; } - const stamp = new Date().toISOString().slice(0, 10); - const filename = `wiki-newsletter-${stamp}.html`; - const blob = new Blob([html], { type: "text/html;charset=utf-8" }); + downloadFile(html, `wiki-newsletter-${new Date().toISOString().slice(0, 10)}.html`, "text/html;charset=utf-8"); + setCopyStatus("Outlook-HTML heruntergeladen."); +} + +function downloadHtmlSource() { + const html = getNewsletterHtml(); + if (!html) { + setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); + return; + } + + downloadFile(html, `wiki-newsletter-source-${new Date().toISOString().slice(0, 10)}.html`, "text/html;charset=utf-8"); + setCopyStatus("HTML-Quelltext heruntergeladen."); +} + +function downloadFile(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; @@ -267,7 +313,26 @@ function downloadHtml() { link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); - setCopyStatus(`HTML gespeichert als ${filename}. Datei in Outlook öffnen oder Inhalt kopieren.`); +} + +function openOutlookInBrowser() { + const html = getNewsletterOutlookHtml(); + if (!html) { + setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); + return; + } + + const popup = window.open("", "_blank"); + if (!popup) { + setCopyStatus("Pop-up blockiert. Bitte Pop-ups erlauben oder „HTML herunterladen“ nutzen.", true); + return; + } + + popup.document.open(); + popup.document.write(html); + popup.document.close(); + popup.document.title = "Wiki-Newsletter – zum Kopieren"; + setCopyStatus("Newsletter im Browser geöffnet: Strg+A → Strg+C, dann in Outlook einfügen."); } document.addEventListener("DOMContentLoaded", () => { @@ -279,7 +344,14 @@ document.addEventListener("DOMContentLoaded", () => { frame.srcdoc = previewHtml; } + const htmlDisplay = document.getElementById("newsletter-html-display"); + htmlDisplay?.addEventListener("focus", () => selectTextarea(htmlDisplay)); + htmlDisplay?.addEventListener("click", () => selectTextarea(htmlDisplay)); + document.getElementById("copy-outlook-btn")?.addEventListener("click", copyForOutlook); + document.getElementById("open-outlook-btn")?.addEventListener("click", openOutlookInBrowser); document.getElementById("copy-html-source-btn")?.addEventListener("click", copyHtmlSource); + document.getElementById("copy-html-source-btn-2")?.addEventListener("click", copyHtmlSource); document.getElementById("download-html-btn")?.addEventListener("click", downloadHtml); + document.getElementById("download-html-source-btn")?.addEventListener("click", downloadHtmlSource); }); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index c7c7d01..37bbbdc 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -98,8 +98,9 @@

Newsletter-Vorschau (mit Hyperlinks)

-

Für Outlook „Für Outlook kopieren“ nutzen und mit Strg+V einfügen (Rechtsklick → „Format beibehalten“). Falls Kopieren nicht klappt (z. B. per HTTP/IP), „HTML herunterladen“ verwenden und die Datei in Outlook öffnen.

+

Empfohlen für Outlook: „Im Browser öffnen“ → dort Strg+A, Strg+C → in Outlook Strg+V. Alternativ „Für Outlook kopieren“ und mit Rechtsklick → „Format beibehalten“ einfügen.

+ @@ -119,7 +120,11 @@

Output: HTML-Quelltext

-

Bei Problemen mit „Kopieren“: Textfeld markieren (Strg+A) und manuell kopieren (Strg+C).

+

Öffnet ein Fenster mit markiertem Text – dort Strg+C drücken. Funktioniert auch ohne HTTPS.

+
+ + +
@@ -195,5 +200,5 @@ {% endblock %} {% block scripts %} - + {% endblock %}
-

Interner Newsletter

-

Thomas-Krenn Wiki Update

-

{escape(ORG_NAME)}

+
+ {_outlook_font("Interner Newsletter", color="#A9C6E8", size="1")}
+ {_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_WHITE, size="5", bold=True)}
+ {_outlook_font(ORG_NAME, color="#D9E8F8", size="2")}
-

Liebe Kolleginnen und Kollegen,

-

{intro}

-

Erstellt am: {created_at}

+
+

{_outlook_font("Liebe Kolleginnen und Kollegen,")}

+

{_outlook_font(intro)}

+

{_outlook_font(f"Erstellt am: {created_at}", color=OUTLOOK_MUTED, size="1")}

{category_block} - +
{body_rows} {summary_row} - -
- Zum Thomas-Krenn-Wiki → + + Zum Thomas-Krenn-Wiki →
- Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)} + + {_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
@@ -385,20 +404,19 @@ def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str: def _outlook_article_rows(items: list[dict[str, Any]], display: DisplayOptions) -> str: rows: list[str] = [] for idx, item in enumerate(items, 1): - title = escape(item.get("title", "Ohne Titel")) - url = escape(_article_url(item.get("title", ""))) + title = item.get("title", "Ohne Titel") + url = _article_url(item.get("title", "")) meta = _outlook_meta_line(item, display) meta_html = "" if meta: - meta_html = ( - f'
' - f"{escape(meta)}" - ) + meta_html = f"
{_outlook_font(meta, color=OUTLOOK_MUTED, size='1')}" rows.append( f"""
{idx}. - {title}{meta_html} + + {_outlook_font(f"{idx}.")} + + {_outlook_link(title, url)}{meta_html}
- {escape(heading)} ({len(items)}) + + {_outlook_font(f"{heading} ({len(items)})", color=OUTLOOK_HEADING, size="3", bold=True)}
{escape(empty_text)} + {_outlook_font(empty_text, color=OUTLOOK_MUTED)} +