diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte new file mode 100644 index 000000000..189868364 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte @@ -0,0 +1,210 @@ + + + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts b/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts index 71d9a09b8..2d0eb4d65 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts @@ -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`); diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/export.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/export.ts new file mode 100644 index 000000000..738493c68 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/export.ts @@ -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