From a87c6d40dfc7721dd667533d6681c48c545bdacb Mon Sep 17 00:00:00 2001 From: Martin Mauch Date: Mon, 18 Nov 2024 00:26:21 +0100 Subject: [PATCH] Implement native sharing --- plugs/share/share.plug.yaml | 12 +++++ plugs/share/share.ts | 100 ++++++++++++++++++++++++++++++++++++ web/manifest.json | 12 ++++- website/CHANGELOG.md | 2 + website/SETTINGS.md | 4 ++ 5 files changed, 129 insertions(+), 1 deletion(-) diff --git a/plugs/share/share.plug.yaml b/plugs/share/share.plug.yaml index f783b8ae4..e6d877514 100644 --- a/plugs/share/share.plug.yaml +++ b/plugs/share/share.plug.yaml @@ -12,6 +12,11 @@ functions: events: - share:options + handleShareTarget: + path: share.ts:handleShareTarget + events: + - http:request:/share_target + clipboardMarkdownShare: path: share.ts:clipboardMarkdownShare events: @@ -31,3 +36,10 @@ functions: path: publish.ts:publishShare events: - share:publish + +config: + # Built-in configuration schemas + schema.config.properties: + shareTargetPage: + type: string + format: page-ref diff --git a/plugs/share/share.ts b/plugs/share/share.ts index d1530b750..61bd24ad4 100644 --- a/plugs/share/share.ts +++ b/plugs/share/share.ts @@ -2,6 +2,7 @@ import { editor, events, markdown, + space, system, } from "@silverbulletmd/silverbullet/syscalls"; import { findNodeOfType, renderToText } from "../../plug-api/lib/tree.ts"; @@ -11,6 +12,11 @@ import { encodePageURI, parsePageRef, } from "@silverbulletmd/silverbullet/lib/page_ref"; +import type { EndpointRequest } from "@silverbulletmd/silverbullet/types"; +import { localDateString } from "$lib/dates.ts"; +import { cleanPageRef } from "@silverbulletmd/silverbullet/lib/resolve"; +import { builtinFunctions } from "$lib/builtin_query_functions.ts"; +import { renderTheTemplate } from "$common/syscalls/template.ts"; type ShareOption = { id: string; @@ -134,3 +140,97 @@ export async function clipboardRichTextShare(text: string) { await editor.copyToClipboard(new Blob([html], { type: "text/html" })); await editor.flashNotification("Copied to rich text to clipboard!"); } + +function parseMultipartFormData(body: string, boundary: string) { + const parts = body.split(`--${boundary}`); + return parts.slice(1, -1).map((part) => { + const [headers, content] = part.split("\r\n\r\n"); + const nameMatch = headers.match(/name="([^"]+)"/); + if (!nameMatch) { + throw new Error("Could not parse form field name"); + } + const name = nameMatch[1]; + const value = content.trim(); + return { name, value }; + }); +} +export async function handleShareTarget(request: EndpointRequest) { + console.log("Share target received:", { + method: request.method, + headers: request.headers, + body: request.body, + }); + + try { + // Parse multipart form data + const contentType = request.headers["content-type"]; + if (!contentType) { + throw new Error( + `No content type found in ${JSON.stringify(request.headers)}`, + ); + } + const boundary = contentType.split("boundary=")[1]; + if (!boundary) { + throw new Error(`No multipart boundary found in ${contentType}`); + } + const formData = parseMultipartFormData(request.body, boundary); + const { title = "", text = "", url = "" } = formData.reduce( + (acc: Record, curr: { name: string; value: string }) => { + acc[curr.name] = curr.value; + return acc; + }, + {}, + ); + + // Format the shared content + const timestamp = localDateString(new Date()); + const sharedContent = `\n\n## ${title} +${text} +${url ? `URL: ${url}` : ""}\nAdded at ${timestamp}`; + + // Get the target page from space config, with fallback + let targetPage = "Inbox"; + try { + targetPage = cleanPageRef( + await renderTheTemplate( + await system.getSpaceConfig("shareTargetPage", "Inbox"), + {}, + {}, + builtinFunctions, + ), + ); + } catch (e: any) { + console.error("Error parsing share target page from config", e); + } + + // Try to read existing page content + let currentContent = ""; + try { + currentContent = await space.readPage(targetPage); + } catch (_e) { + // If page doesn't exist, create it with a header + currentContent = `# ${targetPage}\n`; + } + + // Append the new content + const newContent = currentContent + sharedContent; + + // Write the updated content back to the page + await space.writePage(targetPage, newContent); + + // Return a redirect response to the target page + return { + status: 303, // "See Other" redirect + headers: { + "Location": `/${targetPage}`, + }, + body: "Content shared successfully", + }; + } catch (e: any) { + console.error("Error handling share:", e); + return { + status: 500, + body: "Error processing share: " + e.message, + }; + } +} diff --git a/web/manifest.json b/web/manifest.json index d7811553b..7b2368332 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -14,5 +14,15 @@ "display_override": ["window-controls-overlay"], "scope": "/", "theme_color": "#e1e1e1", - "description": "Markdown as a platform" + "description": "Markdown as a platform", + "share_target": { + "action": "/_/share_target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + } } diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 8839a76a9..b7ace2086 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -4,6 +4,8 @@ An attempt at documenting the changes/new features introduced in each release. ## Edge _These features are not yet properly released, you need to use [the edge builds](https://community.silverbullet.md/t/living-on-the-edge-builds/27) to try them._ +* Native [[Share]] functionality, allowing you to use your OS'es native share functionality to share data with SilverBullet. + Target page can be configured as `shareTargetPage` in [[^SETTINGS]]. * (Security) Implemented a lockout mechanism after a number of failed login attempts for [[Authentication]] (configured via [[Install/Configuration#Authentication]]) (by [Peter Weston](https://github.com/silverbulletmd/silverbullet/pull/1152)) diff --git a/website/SETTINGS.md b/website/SETTINGS.md index f6a576cf0..cb2fbcf25 100644 --- a/website/SETTINGS.md +++ b/website/SETTINGS.md @@ -82,4 +82,8 @@ emoji: aliases: smile: 😀 sweat_smile: 😅 + +# Share Configuration +# Page where shared content will be stored (defaults to "Shared Items") +shareTargetPage: "Inbox" ```