Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement native sharing #1158

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions plugs/share/share.plug.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ functions:
events:
- share:options

handleShareTarget:
path: share.ts:handleShareTarget
env: client
events:
- http:request:/share_target
Comment on lines +15 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zefhemel I guess just specifying env: client here is not enough to trigger handleShareTarget through an incoming http request, right?
Is there already logic to delegate http:request events to plugs on the client side?
It looks like this would need to be done in service_worker.ts which currently only handles read requests.
Do you think it's a good idea to enhance service_worker.ts with dispatchEvent logic similar to http_server.ts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zefhemel ping 😉

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RIght. Right now handling HTTP requests through a service worker (so fully locally and therefore offline) is not possible. It's technically possible to do, but it's not there at this moment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would service_worker.ts be the right place? Do you see any issues with using a similar approach as on the server-side, i.e. checking the plugs if one of them wants to handle the URL?


clipboardMarkdownShare:
path: share.ts:clipboardMarkdownShare
events:
Expand All @@ -31,3 +37,10 @@ functions:
path: publish.ts:publishShare
events:
- share:publish

config:
# Built-in configuration schemas
schema.config.properties:
shareTargetPage:
type: string
format: page-ref
100 changes: 100 additions & 0 deletions plugs/share/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
editor,
events,
markdown,
space,
system,
} from "@silverbulletmd/silverbullet/syscalls";
import { findNodeOfType, renderToText } from "../../plug-api/lib/tree.ts";
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, string>, 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,
};
}
}
12 changes: 11 additions & 1 deletion web/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
2 changes: 2 additions & 0 deletions website/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
4 changes: 4 additions & 0 deletions website/SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,8 @@ emoji:
aliases:
smile: 😀
sweat_smile: 😅

# Share Configuration
# Page where shared content will be stored (defaults to "Shared Items")
shareTargetPage: "Inbox"
```
Loading