diff --git a/CHANGELOG.md b/CHANGELOG.md index e79631b..6c006ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ### Added - Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`). - „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts). +- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server). ## [0.10.0] - 2026-10-30 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 074558a..d5addb8 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,8 +7,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) | | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | +| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index e81f1f8..31af9ac 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -47,6 +47,22 @@ "purpose": "maskable" } ], + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "media", + "accept": ["image/*"] + } + ] + } + }, "shortcuts": [ { "name": "Gedanke sofort erfassen", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 1328369..c96ccdd 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -5,10 +5,12 @@ // - Icons and manifest: from the cache, refreshed in the background. // - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts). // Raise VERSION when this file changes in a way that needs fresh caches. -const VERSION = 'v2'; +const VERSION = 'v3'; const SHELL_CACHE = `mindsift-shell-${VERSION}`; const ASSET_CACHE = `mindsift-assets-${VERSION}`; const MAX_ASSETS = 80; +const SHARE_DB_NAME = 'mindsift-share-target'; +const SHARE_STORE_NAME = 'shared_items'; const SHELL_URLS = [ '/', '/index.html', @@ -80,8 +82,78 @@ async function staleWhileRevalidate(request) { return hit || refresh; } +function openShareDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(SHARE_DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) { + db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function savePendingShare(item) { + const db = await openShareDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(SHARE_STORE_NAME, 'readwrite'); + tx.objectStore(SHARE_STORE_NAME).add(item); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }); +} + +async function handleShareTarget(event) { + try { + const formData = await event.request.formData(); + const title = formData.get('title'); + const text = formData.get('text'); + const url = formData.get('url'); + const media = formData.get('media'); + + let fileName = ''; + let hasMedia = false; + if (media && typeof media === 'object') { + fileName = media.name || 'image'; + hasMedia = true; + } + + await savePendingShare({ + title: typeof title === 'string' ? title.trim() : '', + text: typeof text === 'string' ? text.trim() : '', + url: typeof url === 'string' ? url.trim() : '', + fileName, + hasMedia, + createdAt: Date.now(), + }); + } catch (err) { + console.error('[SW] Share-Target Fehler:', err); + } + return Response.redirect('/?shared=pending', 303); +} + self.addEventListener('fetch', (event) => { const { request } = event; + if (request.method === 'POST') { + const url = new URL(request.url); + if (url.origin === self.location.origin && url.pathname === '/share-target') { + event.respondWith(handleShareTarget(event)); + } + return; + } if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a815c4..74fa278 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,7 @@ import { UserAvatar } from './components/UserAvatar'; import { CommandPalette } from './components/CommandPalette'; import { AppFooter } from './components/AppFooter'; import { AboutModal } from './components/AboutModal'; +import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; import { APP_VERSION } from './version'; type ActiveTab = @@ -555,6 +556,46 @@ export const App: React.FC = () => { ); }, [authStatus.demoMode, authStatus.demoResetInSeconds]); + // Web Share Target: import shared thoughts from external apps + useEffect(() => { + if (!user) return; + const params = new URLSearchParams(window.location.search); + const hasPendingShare = params.get('shared') === 'pending'; + + const processShares = async () => { + try { + const shares = await consumePendingShares(); + if (shares.length === 0) return; + + let importedCount = 0; + for (const share of shares) { + const content = formatSharedThought(share); + if (content) { + await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' }); + importedCount++; + } + } + + if (importedCount > 0) { + triggerHaptic(15); + setDemoNotice('Erfolgreich im Quick Dump abgelegt'); + setTimeout(() => setDemoNotice(null), 4000); + refreshAll(); + } + } catch (err) { + console.error('Fehler beim Import geteilter Daten:', err); + } finally { + if (hasPendingShare) { + const url = new URL(window.location.href); + url.searchParams.delete('shared'); + window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash); + } + } + }; + + processShares(); + }, [user, refreshAll]); + // The demo database was reset: drop cached answers of the old data, remount the views (they load // fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs). const handleDemoReset = useCallback(() => { diff --git a/frontend/src/lib/__tests__/shareTarget.test.ts b/frontend/src/lib/__tests__/shareTarget.test.ts new file mode 100644 index 0000000..09604e6 --- /dev/null +++ b/frontend/src/lib/__tests__/shareTarget.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { formatSharedThought } from '../shareTarget'; + +describe('formatSharedThought', () => { + it('formats pure text correctly', () => { + expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall'); + }); + + it('formats pure url correctly', () => { + expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article'); + }); + + it('combines title and url when text is empty', () => { + expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' })) + .toBe('Toller Artikel\nhttps://example.com/article'); + }); + + it('does not duplicate URL when text already contains it', () => { + const input = { + title: 'Artikel', + text: 'Lies das hier: https://example.com/article unbedingt!', + url: 'https://example.com/article', + }; + expect(formatSharedThought(input)) + .toBe('Artikel\nLies das hier: https://example.com/article unbedingt!'); + }); + + it('does not duplicate URL when text is exactly the URL', () => { + const input = { + title: 'News', + text: 'https://news.example.com', + url: 'https://news.example.com', + }; + expect(formatSharedThought(input)) + .toBe('News\nhttps://news.example.com'); + }); + + it('does not duplicate title if text starts with title', () => { + const input = { + title: 'MindSift Update', + text: 'MindSift Update ist jetzt verfügbar.', + }; + expect(formatSharedThought(input)) + .toBe('MindSift Update ist jetzt verfügbar.'); + }); + + it('combines title, text and distinct URL', () => { + const input = { + title: 'Empfehlung', + text: 'Ein großartiges Tool für schnelles Erfassen.', + url: 'https://mindsift.app', + }; + expect(formatSharedThought(input)) + .toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app'); + }); + + it('handles image with text', () => { + const input = { + title: 'Foto vom Ausflug', + fileName: 'berge.jpg', + hasMedia: true, + }; + expect(formatSharedThought(input)) + .toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'); + }); + + it('handles image only with filename', () => { + const input = { + fileName: 'screenshot.png', + hasMedia: true, + }; + expect(formatSharedThought(input)) + .toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'); + }); + + it('handles image only without filename', () => { + const input = { + hasMedia: true, + }; + expect(formatSharedThought(input)) + .toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'); + }); + + it('returns empty string if nothing is provided', () => { + expect(formatSharedThought({})).toBe(''); + }); +}); diff --git a/frontend/src/lib/shareTarget.ts b/frontend/src/lib/shareTarget.ts new file mode 100644 index 0000000..099fa51 --- /dev/null +++ b/frontend/src/lib/shareTarget.ts @@ -0,0 +1,117 @@ +export interface SharedData { + id?: number; + title?: string; + text?: string; + url?: string; + fileName?: string; + hasMedia?: boolean; + createdAt?: number; +} + +const SHARE_DB_NAME = 'mindsift-share-target'; +const SHARE_STORE_NAME = 'shared_items'; + +/** + * Combines title, text, url and media note into a single thought string. + * Ensures URLs are not duplicated if already present in the shared text or title. + */ +export function formatSharedThought(share: SharedData): string { + const title = (share.title || '').trim(); + const text = (share.text || '').trim(); + const url = (share.url || '').trim(); + const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'; + + let content = ''; + + // Check if url is already contained in text or title + const urlInText = url !== '' && text.includes(url); + const urlInTitle = url !== '' && title.includes(url); + + // Combine title and text + if (title && text) { + if (text === title) { + content = text; + } else if (text.startsWith(title)) { + content = text; + } else { + content = `${title}\n${text}`; + } + } else if (title) { + content = title; + } else if (text) { + content = text; + } + + // Append URL if not already included in text or title + if (url && !urlInText && !urlInTitle) { + if (content) { + content = `${content}\n${url}`; + } else { + content = url; + } + } + + // Media note handling + if (share.hasMedia || share.fileName) { + if (content) { + content = `${content}\n${mediaNote}`; + } else if (share.fileName) { + content = `[${share.fileName}] ${mediaNote}`; + } else { + content = mediaNote; + } + } + + return content.trim(); +} + +/** + * Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker. + */ +export async function consumePendingShares(): Promise { + if (typeof indexedDB === 'undefined') return []; + + return new Promise((resolve) => { + try { + const req = indexedDB.open(SHARE_DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) { + db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' }); + } + }; + req.onsuccess = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) { + db.close(); + return resolve([]); + } + + const tx = db.transaction(SHARE_STORE_NAME, 'readwrite'); + const store = tx.objectStore(SHARE_STORE_NAME); + const getAllReq = store.getAll(); + + getAllReq.onsuccess = () => { + const items: SharedData[] = getAllReq.result || []; + store.clear(); + tx.oncomplete = () => { + db.close(); + resolve(items); + }; + tx.onerror = () => { + db.close(); + resolve(items); + }; + }; + + getAllReq.onerror = () => { + db.close(); + resolve([]); + }; + }; + req.onerror = () => resolve([]); + } catch { + resolve([]); + } + }); +} diff --git a/internal/api/server.go b/internal/api/server.go index 743b21b..02b96f7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -308,6 +308,11 @@ func (s *Server) Router() http.Handler { }) }) + // Web Share Target fallback when service worker is not active yet: redirect to / + r.Post("/share-target", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusSeeOther) + }) + // Embedded Frontend SPA Handler if s.staticFS != nil { fileServer := http.FileServer(http.FS(s.staticFS)) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3a27b3a..d4d5198 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1533,6 +1533,24 @@ func TestPasswordResetFlow(t *testing.T) { } } +func TestShareTargetFallbackRedirects(t *testing.T) { + store, err := storage.NewStore(":memory:") + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + router := NewServer(&config.Config{Port: "8080", DatabasePath: ":memory:", JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil).Router() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/share-target", nil) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected 303 See Other, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Fatalf("expected Location '/', got %q", loc) + } +} + // testSession starts a server-side session, as a login would, for tokens made directly in tests. func testSession(t *testing.T, store *storage.Store, userID string) string { t.Helper()