feat(writing): M10 — publish hooks (export + save-as-article)

Writing can now leave the module. Four outbound paths in this milestone:
Markdown to clipboard, plain text to clipboard, .md file download, and
a browser-native print / PDF. Plus a first cross-module hand-off: save
as a read-later article.

- utils/export.ts: pure helpers — draftToMarkdown (title as H1 + body),
  draftToPlainText, downloadFile via synthetic anchor + blob URL,
  fileStem (NFKD-normalise + slug-ify the title), and a
  copyTextToClipboard wrapper that falls back to document.execCommand
  for http contexts that can't touch navigator.clipboard.
- draftsStore.recordPublish(draftId, module, targetId) — idempotent
  per (module, targetId), appends to draft.publishedTo with a
  publishedAt timestamp, emits WritingDraftPublished so the Workbench
  timeline picks up the hand-off.
- ExportMenu.svelte: dropdown next to Generate / Checkpoint with five
  items. "Als Artikel speichern" calls articlesStore.saveFromExtracted
  with originalUrl='internal://writing/<draftId>' as both a dedupe-safe
  identifier and a back-reference to the source draft, then records
  the publish and navigates to the new article.
- DetailView surfaces draft.publishedTo as green chips under the meta
  row, with a click-through to the articles reader. Only 'articles' has
  a landing page today; website / presi / mail / social-relay chips
  render the label without a link (they arrive with their own
  milestones).

Not in M10: website-block publishing (M-website), presi import
(M-presi), mail draft creation. Those each need per-target block /
slide / message shapes that exceed this commit's scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:07:39 +02:00
parent 6432ef7e6b
commit 0dadc17771
4 changed files with 383 additions and 0 deletions

View file

@ -0,0 +1,210 @@
<!--
ExportMenu — drop-down next to the Generate/Checkpoint buttons in the
DetailView. Four M10 actions:
- Markdown kopieren
- .md herunterladen
- Drucken / PDF (uses the browser's native print dialog)
- Als Artikel speichern → hand-off to the articles module
The heavy lifting lives in utils/export.ts + the stores; this
component is just the menu surface + confirmation toasts.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
import { draftsStore } from '../stores/drafts.svelte';
import {
draftToMarkdown,
draftToPlainText,
downloadFile,
fileStem,
copyTextToClipboard,
} from '../utils/export';
import type { Draft, DraftVersion } from '../types';
let {
draft,
currentVersion,
}: {
draft: Draft;
currentVersion: DraftVersion | null;
} = $props();
let open = $state(false);
let feedback = $state<string | null>(null);
let feedbackTimer: ReturnType<typeof setTimeout> | null = null;
let busy = $state(false);
function flash(msg: string) {
feedback = msg;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => (feedback = null), 2200);
}
async function copyMd() {
const ok = await copyTextToClipboard(draftToMarkdown(draft, currentVersion));
flash(ok ? '✓ Markdown kopiert' : 'Kopieren fehlgeschlagen');
open = false;
}
async function copyPlain() {
const ok = await copyTextToClipboard(draftToPlainText(draft, currentVersion));
flash(ok ? '✓ Text kopiert' : 'Kopieren fehlgeschlagen');
open = false;
}
function downloadMd() {
downloadFile(
`${fileStem(draft.title)}.md`,
draftToMarkdown(draft, currentVersion),
'text/markdown;charset=utf-8'
);
flash('↓ Heruntergeladen');
open = false;
}
function printDraft() {
open = false;
if (typeof window !== 'undefined') window.print();
}
async function saveAsArticle() {
if (busy) return;
busy = true;
try {
const content = currentVersion?.content ?? '';
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
// `internal://` scheme avoids colliding with real URLs in the
// articles module's dedupe path while still giving the row a
// unique originalUrl — the format `internal://writing/<id>`
// doubles as a back-reference to the source draft.
const article = await articlesStore.saveFromExtracted({
originalUrl: `internal://writing/${draft.id}`,
title: draft.title || draft.briefing.topic || 'Unbenannt',
excerpt: content.slice(0, 240).trim() || null,
content,
htmlContent: content, // no HTML body yet — the articles reader handles plain text fine
author: null,
siteName: 'Writing',
wordCount,
readingTimeMinutes: Math.max(1, Math.round(wordCount / 200)),
});
await draftsStore.recordPublish(draft.id, 'articles', article.id);
flash('✓ Als Artikel gespeichert');
open = false;
// Give the toast a moment before navigating away.
setTimeout(() => goto(`/articles/${article.id}`), 600);
} catch (err) {
flash(err instanceof Error ? err.message : String(err));
} finally {
busy = false;
}
}
</script>
<div class="menu">
<button
type="button"
class="trigger"
class:active={open}
onclick={() => (open = !open)}
aria-expanded={open}
title="Exportieren / Veröffentlichen"
>
📤 Export
</button>
{#if open}
<div class="dropdown" role="menu">
<button type="button" role="menuitem" onclick={copyMd} disabled={busy}>
📋 Markdown kopieren
</button>
<button type="button" role="menuitem" onclick={copyPlain} disabled={busy}>
📋 Text kopieren
</button>
<button type="button" role="menuitem" onclick={downloadMd} disabled={busy}>
↓ Als .md herunterladen
</button>
<button type="button" role="menuitem" onclick={printDraft} disabled={busy}>
🖨 Drucken / PDF
</button>
<hr />
<button type="button" role="menuitem" onclick={saveAsArticle} disabled={busy}>
📚 Als Artikel speichern
</button>
</div>
{/if}
{#if feedback}
<span class="toast" role="status" aria-live="polite">{feedback}</span>
{/if}
</div>
<style>
.menu {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.trigger {
padding: 0.4rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.trigger:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
.trigger.active {
background: color-mix(in srgb, #0ea5e9 10%, transparent);
border-color: #0ea5e9;
color: #0ea5e9;
}
.dropdown {
position: absolute;
top: calc(100% + 0.3rem);
right: 0;
z-index: 20;
display: flex;
flex-direction: column;
min-width: 14rem;
padding: 0.3rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, white);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
.dropdown button {
text-align: left;
padding: 0.45rem 0.7rem;
border-radius: 0.4rem;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.dropdown button:hover:not(:disabled) {
background: color-mix(in srgb, #0ea5e9 10%, transparent);
color: #0ea5e9;
}
.dropdown button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdown hr {
margin: 0.2rem 0.1rem;
border: none;
border-top: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
}
.toast {
font-size: 0.8rem;
color: #0284c7;
white-space: nowrap;
}
</style>

View file

@ -32,6 +32,8 @@ import type {
DraftBriefing,
DraftReference,
DraftStyleOverrides,
DraftPublishTarget,
DraftPublishModule,
} from '../types';
function wordCountOf(text: string): number {
@ -283,6 +285,33 @@ export const draftsStore = {
});
},
/**
* Record that a draft has been handed off to another module (M10
* Publish-Hooks). Idempotent per (module, targetId) pair the same
* destination doesn't accumulate duplicate rows if the user exports
* twice. Emits a cross-module event so the Workbench timeline can
* surface the hand-off.
*/
async recordPublish(draftId: string, target: DraftPublishModule, targetId: string) {
const existing = await draftTable.get(draftId);
if (!existing) return;
const publishedTo = existing.publishedTo ?? [];
const now = new Date().toISOString();
const next: DraftPublishTarget[] = [
...publishedTo.filter((t) => !(t.module === target && t.targetId === targetId)),
{ module: target, targetId, publishedAt: now },
];
await draftTable.update(draftId, {
publishedTo: next,
updatedAt: now,
});
emitDomainEvent('WritingDraftPublished', 'writing', 'writingDrafts', draftId, {
draftId,
module: target,
targetId,
});
},
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await draftTable.get(id);
if (!existing) throw new Error(`Draft ${id} not found`);

View file

@ -0,0 +1,93 @@
/**
* Export helpers turn a draft + its current version into something
* the user can share outside the app. M10 exports: Markdown string,
* .md/.txt file download, print/PDF via the browser, and hand-off to
* the articles module as a saved read-later entry.
*
* Kept as pure utilities (no Svelte state) so they compose cleanly from
* the DetailView export menu + future AI tools / MCP endpoints.
*/
import type { Draft, DraftVersion } from '../types';
/**
* Assemble the user-facing Markdown for a draft. The title becomes an H1;
* the version body is appended verbatim. We trim trailing whitespace so
* the clipboard doesn't end with empty lines.
*/
export function draftToMarkdown(draft: Draft, version: DraftVersion | null): string {
const title = draft.title || draft.briefing.topic || 'Unbenannt';
const body = (version?.content ?? '').trimEnd();
return `# ${title}\n\n${body}\n`.trimEnd() + '\n';
}
export function draftToPlainText(draft: Draft, version: DraftVersion | null): string {
const title = draft.title || draft.briefing.topic || 'Unbenannt';
const body = (version?.content ?? '').trimEnd();
return `${title}\n\n${body}\n`.trimEnd() + '\n';
}
/**
* Browser-safe file download via a synthetic anchor. Revokes the blob
* URL asynchronously so the download completes first; Safari + Firefox
* are both fine with revoke-on-next-tick.
*/
export function downloadFile(filename: string, content: string, mime: string): void {
if (typeof document === 'undefined') return;
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Sanitise a draft title into a valid, short filename stem. */
export function fileStem(title: string): string {
const cleaned = title
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.toLowerCase()
.slice(0, 80);
return cleaned || 'draft';
}
/**
* Wrapper around the Clipboard API with a fallback for insecure
* contexts (HTTP on anything other than localhost). Returns true on
* success so the caller can show a "Kopiert" confirmation.
*/
export async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to the legacy path
}
// Legacy fallback — works on http, deprecated but still widely
// supported. The hidden textarea avoids layout shift.
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
document.body.removeChild(textarea);
return true;
} catch {
document.body.removeChild(textarea);
return false;
}
}

View file

@ -16,6 +16,7 @@
import VersionHistory from '../components/VersionHistory.svelte';
import GenerationStatus from '../components/GenerationStatus.svelte';
import SelectionToolbar from '../components/SelectionToolbar.svelte';
import ExportMenu from '../components/ExportMenu.svelte';
import type {
SelectionToolKind,
SelectionToolInvocation,
@ -332,6 +333,32 @@
</button>
</div>
{/if}
{#if draft.publishedTo.length > 0}
<div class="published-row">
<span class="published-label">📤 Veröffentlicht:</span>
{#each draft.publishedTo as target (`${target.module}:${target.targetId}`)}
<span
class="published-chip"
title={new Date(target.publishedAt).toLocaleString('de-DE')}
>
{#if target.module === 'articles'}
📚 <a href={`/articles/${target.targetId}`}>Artikel</a>
{:else if target.module === 'website'}
🌐 Website
{:else if target.module === 'presi'}
🎞 Präsi
{:else if target.module === 'mail'}
✉️ Mail
{:else if target.module === 'social-relay'}
💬 Social
{:else}
{target.module}
{/if}
</span>
{/each}
</div>
{/if}
</header>
<section class="briefing-section">
@ -386,6 +413,7 @@
>
{saving ? 'Speichert…' : ' Checkpoint'}
</button>
<ExportMenu {draft} {currentVersion} />
</div>
</div>
{#if visibleGeneration}
@ -550,6 +578,29 @@
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
word-break: break-all;
}
.published-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.8rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.published-label {
font-size: 0.75rem;
}
.published-chip {
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, #22c55e 10%, transparent);
color: #16a34a;
border: 1px solid color-mix(in srgb, #22c55e 30%, transparent);
font-size: 0.75rem;
}
.published-chip a {
color: inherit;
text-decoration: underline;
}
.status-picker {
display: inline-flex;
gap: 0.25rem;