From 14fc3b01dba160ffd581eebfb0c99ab97e5bf957 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:45:03 -0400 Subject: [PATCH 001/283] Fix suggestion menus blocked by placeholder marks (#11796) * Initial plan * fix: suggestion menus not opening when typing trigger inside placeholder mark Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> --- .../editor/plugins/SuggestionsMenuPlugin.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/shared/editor/plugins/SuggestionsMenuPlugin.ts b/shared/editor/plugins/SuggestionsMenuPlugin.ts index a9cab114565a..caa8c71f4f07 100644 --- a/shared/editor/plugins/SuggestionsMenuPlugin.ts +++ b/shared/editor/plugins/SuggestionsMenuPlugin.ts @@ -53,6 +53,37 @@ export class SuggestionsMenuPlugin extends Plugin { }); } + // Another plugin (e.g. the Placeholder mark) may consume the + // handleTextInput event by returning true, which prevents the + // InputRule from evaluating the trigger character. We use a timeout + // here so the re-evaluation happens after all synchronous handlers + // have run, ensuring the suggestion menu still opens in those cases. + if ( + !event.ctrlKey && + !event.metaKey && + !event.altKey && + event.key.length === 1 + ) { + setTimeout(() => { + const { pos: fromPos } = view.state.selection.$from; + this.execute( + view, + fromPos, + fromPos, + openRegex, + action((_, match) => { + if (match) { + if (match[0].length <= 2) { + extensionState.open = true; + } + extensionState.query = match[1]; + } + return null; + }) + ); + }); + } + // If the menu is open then just ignore the key events in the editor // itself until we're done. if ( From 9efcb2d534d79eb74a33f8278987a53669aa5636 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 19 Mar 2026 17:56:33 -0400 Subject: [PATCH 002/283] fix: GitLab work_items paste detection (#11820) closes #11819 --- app/utils/mention.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils/mention.ts b/app/utils/mention.ts index 53e82705af22..8e6c4e2b1f05 100644 --- a/app/utils/mention.ts +++ b/app/utils/mention.ts @@ -102,8 +102,8 @@ export const determineMentionType = ({ return MentionType.PullRequest; } if ( - /\/-\/issues\/\d+/.test(pathname) || - (/\/-\/issues\/?$/.test(pathname) && hasShowParam) + /\/-\/(issues|work_items)\/\d+/.test(pathname) || + (/\/-\/(issues|work_items)\/?$/.test(pathname) && hasShowParam) ) { return MentionType.Issue; } From 1bd6ad830e828f77a6c5445a87493a241b57f2bd Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 09:45:50 -0400 Subject: [PATCH 003/283] MCP improvements (#11822) * fix: Data always included in list_documents response * Remove resources, add fetch tool Fix pagination arguments do not accept string * type -> resource * Add URL resolving --- server/presenters/document.ts | 7 +- server/routes/mcp/index.test.ts | 38 ++++---- server/routes/mcp/index.ts | 7 +- server/test/McpHelper.ts | 29 ------ server/tools/collections.ts | 61 +----------- server/tools/comments.ts | 4 +- server/tools/documents.ts | 73 ++------------ server/tools/fetch.ts | 165 ++++++++++++++++++++++++++++++++ server/tools/users.ts | 77 ++------------- server/tools/util.ts | 31 ------ 10 files changed, 211 insertions(+), 281 deletions(-) create mode 100644 server/tools/fetch.ts diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 5fdd694819f8..7e2cac507d13 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -56,7 +56,12 @@ async function presentDocument( url: document.path, urlId: document.urlId, title: document.title, - data: asData || options?.includeData ? data : undefined, + data: + options?.includeData === false + ? undefined + : asData || options?.includeData + ? data + : undefined, text, icon: document.icon, color: document.color, diff --git a/server/routes/mcp/index.test.ts b/server/routes/mcp/index.test.ts index 62db324cbcd1..09d48068cab9 100644 --- a/server/routes/mcp/index.test.ts +++ b/server/routes/mcp/index.test.ts @@ -13,7 +13,6 @@ import { mcpRequest, parseMcpResponse, callMcpTool, - readMcpResource, } from "@server/test/McpHelper"; const server = getTestServer(); @@ -183,23 +182,22 @@ describe("POST /mcp/", () => { expect(data.url).toMatch(/^https?:\/\//); }); - it("get_collection resource returns collection details", async () => { + it("fetch collection returns collection details", async () => { const { user, accessToken } = await buildOAuthUser(); const collection = await buildCollection({ teamId: user.teamId, userId: user.id, }); - const res = await readMcpResource( - server, - accessToken, - `outline://collections/${collection.id}` - ); + const res = await callMcpTool(server, accessToken, "fetch", { + resource: "collection", + id: collection.id, + }); - expect(res?.result?.contents).toBeDefined(); - expect(res!.result!.contents!.length).toBeGreaterThanOrEqual(1); + expect(res?.result?.content).toBeDefined(); + expect(res!.result!.content!.length).toBeGreaterThanOrEqual(1); - const data = JSON.parse(res!.result!.contents![0].text ?? "{}"); + const data = JSON.parse(res!.result!.content![0].text ?? "{}"); expect(data.id).toEqual(collection.id); expect(data.url).toMatch(/^https?:\/\//); }); @@ -487,7 +485,7 @@ describe("POST /mcp/", () => { expect(res?.result?.isError).toBe(true); }); - it("get_document resource returns metadata and markdown", async () => { + it("fetch document returns metadata and markdown", async () => { const { user, accessToken } = await buildOAuthUser(); const collection = await buildCollection({ teamId: user.teamId, @@ -500,24 +498,22 @@ describe("POST /mcp/", () => { text: "# Hello\n\nWorld", }); - const res = await readMcpResource( - server, - accessToken, - `outline://documents/${document.id}` - ); + const res = await callMcpTool(server, accessToken, "fetch", { + resource: "document", + id: document.id, + }); - expect(res?.result?.contents).toBeDefined(); - expect(res!.result!.contents!.length).toEqual(2); + expect(res?.result?.content).toBeDefined(); + expect(res!.result!.content!.length).toEqual(2); // First content is JSON metadata - const metadata = JSON.parse(res!.result!.contents![0].text ?? "{}"); + const metadata = JSON.parse(res!.result!.content![0].text ?? "{}"); expect(metadata.id).toEqual(document.id); expect(metadata.title).toEqual(document.title); expect(metadata.url).toMatch(/^https?:\/\//); // Second content is markdown text - expect(res!.result!.contents![1].mimeType).toEqual("text/markdown"); - expect(res!.result!.contents![1].text).toContain("Hello"); + expect(res!.result!.content![1].text).toContain("Hello"); }); }); diff --git a/server/routes/mcp/index.ts b/server/routes/mcp/index.ts index 5bf92fc5810c..6fa3023ef61c 100644 --- a/server/routes/mcp/index.ts +++ b/server/routes/mcp/index.ts @@ -15,6 +15,7 @@ import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { collectionTools } from "@server/tools/collections"; import { commentTools } from "@server/tools/comments"; import { documentTools } from "@server/tools/documents"; +import { fetchTool } from "@server/tools/fetch"; import { userTools } from "@server/tools/users"; import { version } from "../../../package.json"; @@ -22,8 +23,8 @@ const app = new Koa(); const router = new Router(); /** - * Creates a fresh MCP server instance with tools and resources filtered by - * the OAuth scopes granted to the current token. + * Creates a fresh MCP server instance with tools filtered by the OAuth + * scopes granted to the current token. * * @param scopes - the OAuth scopes granted to the access token. * @returns a configured McpServer ready to be connected to a transport. @@ -36,7 +37,6 @@ function createMcpServer(scopes: string[]): McpServer { }, { capabilities: { - resources: {}, tools: {}, }, } @@ -45,6 +45,7 @@ function createMcpServer(scopes: string[]): McpServer { collectionTools(server, scopes); commentTools(server, scopes); documentTools(server, scopes); + fetchTool(server, scopes); userTools(server, scopes); return server; diff --git a/server/test/McpHelper.ts b/server/test/McpHelper.ts index 482db8ee652b..ecef88d9e62e 100644 --- a/server/test/McpHelper.ts +++ b/server/test/McpHelper.ts @@ -97,32 +97,3 @@ export async function callMcpTool( } | undefined; } - -/** - * Shorthand to read an MCP resource via the test server. - * - * @param server - the TestServer instance. - * @param accessToken - the OAuth access token. - * @param uri - the resource URI to read. - * @returns the parsed resource read result. - */ -export async function readMcpResource( - server: { post: (path: string, opts: unknown) => Promise }, - accessToken: string, - uri: string -) { - const { body } = mcpRequest("resources/read", { uri }); - - const res = await server.post("/mcp/", { - headers: mcpHeaders(accessToken), - body, - }); - - const parsed = await parseMcpResponse(res); - return parsed as - | { - result?: { contents?: { text?: string; mimeType?: string }[] }; - error?: unknown; - } - | undefined; -} diff --git a/server/tools/collections.ts b/server/tools/collections.ts index 748b5fcdc1aa..edca4cd5c396 100644 --- a/server/tools/collections.ts +++ b/server/tools/collections.ts @@ -1,10 +1,6 @@ import { z } from "zod"; import { Sequelize, Op, type WhereOptions } from "sequelize"; -import { - type McpServer, - ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CollectionPermission } from "@shared/types"; import { Collection, Team } from "@server/models"; import { authorize } from "@server/policies"; @@ -18,12 +14,11 @@ import { buildAPIContext, pathToUrl, withTracing, - withResourceTracing, } from "./util"; /** - * Registers collection-related MCP tools and resources on the given server, - * filtered by the OAuth scopes granted to the current token. + * Registers collection-related MCP tools on the given server, filtered by + * the OAuth scopes granted to the current token. * * @param server - the MCP server instance to register on. * @param scopes - the OAuth scopes granted to the access token. @@ -47,13 +42,13 @@ export function collectionTools(server: McpServer, scopes: string[]) { .describe( "An optional search query to filter collections by name." ), - offset: z + offset: z.coerce .number() .int() .min(0) .optional() .describe("The pagination offset. Defaults to 0."), - limit: z + limit: z.coerce .number() .int() .min(1) @@ -144,52 +139,6 @@ export function collectionTools(server: McpServer, scopes: string[]) { ); } - if (AuthenticationHelper.canAccess("collections.info", scopes)) { - server.registerResource( - "get_collection", - new ResourceTemplate("outline://collections/{id}", { list: undefined }), - { - title: "Get collection", - description: - "Fetches the details of a collection by its ID, including its document structure.", - mimeType: "application/json", - }, - withResourceTracing("get_collection", async (uri, variables, extra) => { - try { - const { id } = variables; - const user = getActorFromContext(extra); - const collection = await Collection.findByPk(String(id), { - includeDocumentStructure: true, - rejectOnEmpty: true, - }); - - authorize(user, "read", collection); - - const presented = await presentCollection(undefined, collection); - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify(pathToUrl(user.team, presented)), - }, - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify(collection.documentStructure ?? []), - }, - ], - }; - } catch (err) { - throw new McpError( - ErrorCode.InvalidParams, - err instanceof Error ? err.message : String(err) - ); - } - }) - ); - } - if (AuthenticationHelper.canAccess("collections.create", scopes)) { server.registerTool( "create_collection", diff --git a/server/tools/comments.ts b/server/tools/comments.ts index ef2228f3f403..5fd03814e593 100644 --- a/server/tools/comments.ts +++ b/server/tools/comments.ts @@ -71,13 +71,13 @@ export function commentTools(server: McpServer, scopes: string[]) { .describe( "Filter by resolution status: resolved, unresolved, or both." ), - offset: z + offset: z.coerce .number() .int() .min(0) .optional() .describe("The pagination offset. Defaults to 0."), - limit: z + limit: z.coerce .number() .int() .min(1) diff --git a/server/tools/documents.ts b/server/tools/documents.ts index 727dd6667087..1a79ef869384 100644 --- a/server/tools/documents.ts +++ b/server/tools/documents.ts @@ -1,13 +1,6 @@ import { z } from "zod"; -import { - type McpServer, - ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - type CallToolResult, - McpError, - ErrorCode, -} from "@modelcontextprotocol/sdk/types.js"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import documentCreator from "@server/commands/documentCreator"; import documentMover from "@server/commands/documentMover"; import documentUpdater from "@server/commands/documentUpdater"; @@ -27,71 +20,17 @@ import { getActorFromContext, pathToUrl, withTracing, - withResourceTracing, } from "./util"; import { TextEditMode } from "@shared/types"; /** - * Registers document-related MCP tools and resources on the given server, - * filtered by the OAuth scopes granted to the current token. + * Registers document-related MCP tools on the given server, filtered by + * the OAuth scopes granted to the current token. * * @param server - the MCP server instance to register on. * @param scopes - the OAuth scopes granted to the access token. */ export function documentTools(server: McpServer, scopes: string[]) { - if (AuthenticationHelper.canAccess("documents.info", scopes)) { - server.registerResource( - "get_document", - new ResourceTemplate("outline://documents/{id}", { list: undefined }), - { - title: "Get document", - description: "Fetches the content of a document by its ID.", - mimeType: "text/markdown", - }, - withResourceTracing("get_document", async (uri, variables, extra) => { - try { - const { id } = variables; - const user = getActorFromContext(extra); - const document = await Document.findByPk(String(id), { - userId: user.id, - rejectOnEmpty: true, - }); - - authorize(user, "read", document); - - const { text, ...attributes } = await presentDocument( - undefined, - document, - { - includeData: false, - includeText: true, - includeUpdatedAt: true, - } - ); - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify(pathToUrl(user.team, attributes)), - }, - { - uri: uri.href, - mimeType: "text/markdown", - text: String(text ?? ""), - }, - ], - }; - } catch (err) { - throw new McpError( - ErrorCode.InvalidParams, - err instanceof Error ? err.message : String(err) - ); - } - }) - ); - } - if (AuthenticationHelper.canAccess("documents.list", scopes)) { server.registerTool( "list_documents", @@ -114,13 +53,13 @@ export function documentTools(server: McpServer, scopes: string[]) { .string() .optional() .describe("An optional collection ID to filter documents by."), - offset: z + offset: z.coerce .number() .int() .min(0) .optional() .describe("The pagination offset. Defaults to 0."), - limit: z + limit: z.coerce .number() .int() .min(1) diff --git a/server/tools/fetch.ts b/server/tools/fetch.ts new file mode 100644 index 000000000000..4c7e3be712de --- /dev/null +++ b/server/tools/fetch.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Collection, Document, User } from "@server/models"; +import { authorize, can } from "@server/policies"; +import { + presentCollection, + presentDocument, + presentUser, +} from "@server/presenters"; +import AuthenticationHelper from "@shared/helpers/AuthenticationHelper"; +import { + error, + success, + getActorFromContext, + pathToUrl, + withTracing, +} from "./util"; + +const SELF_TOKENS = new Set(["self", "me", "current_user"]); + +/** + * Extracts a resource identifier from a value that may be a URL or a plain ID. + * When a URL is detected the last non-empty path segment is returned as the + * slug, which the model's findByPk override can resolve. + * + * @param value - a URL string or plain identifier. + * @returns the extracted identifier. + */ +function extractId(value: string): string { + if (/^https?:\/\//.test(value)) { + try { + const pathname = new URL(value).pathname; + const segments = pathname.split("/").filter(Boolean); + return segments[segments.length - 1] ?? value; + } catch { + return value; + } + } + return value; +} + +/** + * Registers the unified "fetch" MCP tool on the given server. The tool is + * only registered when at least one of the underlying info scopes is granted. + * + * @param server - the MCP server instance to register on. + * @param scopes - the OAuth scopes granted to the access token. + */ +export function fetchTool(server: McpServer, scopes: string[]) { + const canReadDocuments = AuthenticationHelper.canAccess( + "documents.info", + scopes + ); + const canReadCollections = AuthenticationHelper.canAccess( + "collections.info", + scopes + ); + const canReadUsers = AuthenticationHelper.canAccess("users.info", scopes); + + if (!canReadDocuments && !canReadCollections && !canReadUsers) { + return; + } + + const allowedTypes = [ + ...(canReadDocuments ? ["document"] : []), + ...(canReadCollections ? ["collection"] : []), + ...(canReadUsers ? ["user"] : []), + ] as [string, ...string[]]; + + server.registerTool( + "fetch", + { + title: "Fetch", + description: + 'Fetches a document, collection, or user by type and ID. For users, "current_user" can be used as the ID to get the authenticated user.', + annotations: { + idempotentHint: true, + readOnlyHint: true, + }, + inputSchema: { + resource: z.enum(allowedTypes).describe("The resource to fetch."), + id: z + .string() + .describe( + 'The unique identifier or URL. For users, "current_user" returns the authenticated user.' + ), + }, + }, + withTracing("fetch", async ({ resource, id: rawId }, extra) => { + try { + const actor = getActorFromContext(extra); + const id = extractId(rawId); + + switch (resource) { + case "document": { + const document = await Document.findByPk(id, { + userId: actor.id, + rejectOnEmpty: true, + }); + + authorize(actor, "read", document); + + const { text, ...attributes } = await presentDocument( + undefined, + document, + { + includeData: false, + includeText: true, + includeUpdatedAt: true, + } + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(pathToUrl(actor.team, attributes)), + }, + { + type: "text" as const, + text: String(text ?? ""), + }, + ], + } satisfies CallToolResult; + } + + case "collection": { + const collection = await Collection.findByPk(id, { + includeDocumentStructure: true, + rejectOnEmpty: true, + }); + + authorize(actor, "read", collection); + + const presented = await presentCollection(undefined, collection); + return success([ + pathToUrl(actor.team, presented), + collection.documentStructure ?? [], + ]); + } + + case "user": { + const user = SELF_TOKENS.has(id.toLowerCase()) + ? actor + : await User.findByPk(id, { rejectOnEmpty: true }); + + authorize(actor, "read", user); + + return success( + presentUser(user, { + includeEmail: !!can(actor, "readEmail", user), + includeDetails: !!can(actor, "readDetails", user), + }) + ); + } + + default: + return error(`Unknown resource: ${resource}`); + } + } catch (message) { + return error(message); + } + }) + ); +} diff --git a/server/tools/users.ts b/server/tools/users.ts index 2bd2a95d235a..9df1cb925e37 100644 --- a/server/tools/users.ts +++ b/server/tools/users.ts @@ -1,87 +1,22 @@ import { z } from "zod"; import { Op, Sequelize } from "sequelize"; import type { WhereOptions } from "sequelize"; -import { - type McpServer, - ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { UserRole } from "@shared/types"; import { User, Team } from "@server/models"; import { authorize, can } from "@server/policies"; import { presentUser } from "@server/presenters"; import AuthenticationHelper from "@shared/helpers/AuthenticationHelper"; -import { error, success, getActorFromContext, withTracing, withResourceTracing } from "./util"; +import { error, success, getActorFromContext, withTracing } from "./util"; /** - * Resolves a user identifier to a User model instance. Accepts special - * tokens "self", "me", or "current_user" to return the authenticated user, - * otherwise looks the user up by primary key. - * - * @param id - the user identifier or self-referencing token. - * @param actor - the currently authenticated user. - * @returns the resolved User instance. - */ -async function resolveUser(id: string, actor: User): Promise { - if (new Set(["self", "me", "current_user"]).has(id.toLowerCase())) { - return actor; - } - - return await User.findByPk(id, { - rejectOnEmpty: true, - }); -} - -/** - * Registers user-related MCP tools and resources on the given server, - * filtered by the OAuth scopes granted to the current token. + * Registers user-related MCP tools on the given server, filtered by the + * OAuth scopes granted to the current token. * * @param server - the MCP server instance to register on. * @param scopes - the OAuth scopes granted to the access token. */ export function userTools(server: McpServer, scopes: string[]) { - if (AuthenticationHelper.canAccess("users.info", scopes)) { - server.registerResource( - "get_user", - new ResourceTemplate("outline://users/{id}", { list: undefined }), - { - title: "Get user", - description: - 'Fetches a user by their ID. Use "current_user" as the ID to get the currently authenticated user.', - mimeType: "application/json", - }, - withResourceTracing("get_user", async (uri, variables, extra) => { - try { - const { id } = variables; - const actor = getActorFromContext(extra); - const user = await resolveUser(String(id), actor); - - authorize(actor, "read", user); - - const presented = presentUser(user, { - includeEmail: !!can(actor, "readEmail", user), - includeDetails: !!can(actor, "readDetails", user), - }); - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify(presented), - }, - ], - }; - } catch (err) { - throw new McpError( - ErrorCode.InvalidParams, - err instanceof Error ? err.message : String(err) - ); - } - }) - ); - } - if (AuthenticationHelper.canAccess("users.list", scopes)) { server.registerTool( "list_users", @@ -114,13 +49,13 @@ export function userTools(server: McpServer, scopes: string[]) { .describe( "Filter users by status. 'suspended' is only available to admins. Defaults to active, non-suspended users." ), - offset: z + offset: z.coerce .number() .int() .min(0) .optional() .describe("The pagination offset. Defaults to 0."), - limit: z + limit: z.coerce .number() .int() .min(1) diff --git a/server/tools/util.ts b/server/tools/util.ts index bbfb33dbc6f8..9fcb05d63619 100644 --- a/server/tools/util.ts +++ b/server/tools/util.ts @@ -107,37 +107,6 @@ export function withTracing any>( } as F); } -/** - * Wraps an MCP resource handler with Datadog tracing. Each invocation creates - * a span under the `outline-mcp` service with the resource name, and tags it - * with the acting user and team IDs. - * - * @param resourceName - the name of the MCP resource being traced. - * @param handler - the handler function to wrap. - * @returns the wrapped handler with tracing enabled. - */ -export function withResourceTracing any>( - resourceName: string, - handler: F -): F { - return traceFunction({ - serviceName: "mcp", - spanName: "resource", - resourceName: resourceName, - })(function tracedHandler(this: any, ...args: any[]) { - const context = args[args.length - 1]; - const user = getActorFromContext(context); - if (user) { - addTags({ - "mcp.resource": resourceName, - "request.userId": user.id, - "request.teamId": user.teamId, - }); - } - return handler.apply(this, args); - } as F); -} - /** * Builds a map from document ID to its zero-based index among siblings, * derived from a collection's document structure. From 5256cdc18517fc973655beea8d2297aaed7fcc25 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 11:18:19 -0400 Subject: [PATCH 004/283] fix: Guard editDiagram usage (#11830) closes #11827 --- shared/editor/nodes/Image.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index 71aa769431fb..489e1d68b8d2 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -365,6 +365,9 @@ export default class Image extends SimpleImage { ({ getPos, view }: ComponentProps) => () => { const { commands } = this.editor; + if (!commands.editDiagram) { + return; + } const pos = getPos(); const $pos = view.state.doc.resolve(pos); view.dispatch(view.state.tr.setSelection(new NodeSelection($pos))); From beec9f567506022ddbaf12cad6a48c129827d364 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 12:09:18 -0400 Subject: [PATCH 005/283] fix: Empty screen after login with post-redirect (#11833) closes #11811 --- app/components/AuthenticatedLayout.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index bb91b5a45159..7df20e56eedd 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; import * as React from "react"; -import { Switch, Route, Redirect } from "react-router-dom"; +import { Switch, Route } from "react-router-dom"; import ErrorSuspended from "~/scenes/Errors/ErrorSuspended"; import Layout from "~/components/Layout"; import RegisterKeyDown from "~/components/RegisterKeyDown"; @@ -57,15 +57,17 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { history.push(newDocumentPath(activeCollectionId)); }; + React.useEffect(() => { + const postLoginPath = spendPostLoginPath(); + if (postLoginPath) { + history.replace(postLoginPath); + } + }, [spendPostLoginPath]); + if (auth.isSuspended) { return ; } - const postLoginPath = spendPostLoginPath(); - if (postLoginPath) { - return ; - } - const sidebar = ( From fa17f78ae6af79cdc500bb55807c0c71a0a2e731 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 23:25:26 -0400 Subject: [PATCH 006/283] fix: Disable embed option for internal link pastes (#11837) Co-authored-by: Claude Opus 4.6 --- app/editor/components/PasteMenu.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/editor/components/PasteMenu.tsx b/app/editor/components/PasteMenu.tsx index fb032d6dca9a..92c1220fd81f 100644 --- a/app/editor/components/PasteMenu.tsx +++ b/app/editor/components/PasteMenu.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import type { EmbedDescriptor } from "@shared/editor/embeds"; import type { MenuItem } from "@shared/editor/types"; import { MentionType } from "@shared/types"; -import { isUrl } from "@shared/utils/urls"; +import { isInternalUrl, isUrl } from "@shared/utils/urls"; import type Integration from "~/models/Integration"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; @@ -67,6 +67,7 @@ function useItems({ const singleUrl = typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null; + const isInternal = singleUrl ? isInternalUrl(singleUrl) : false; const matchedEmbed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null; @@ -74,7 +75,7 @@ function useItems({ // Check embeddability for single URL useEffect(() => { - if (!singleUrl || !embed) { + if (!singleUrl || !embed || isInternal) { setEmbedCheck({ loading: false }); return; } @@ -101,7 +102,7 @@ function useItems({ return () => { cancelled = true; }; - }, [singleUrl, embed]); + }, [singleUrl, embed, isInternal]); // single item is pasted. if (typeof pastedText === "string") { @@ -143,8 +144,10 @@ function useItems({ name: "embed", title: t("Embed"), subtitle: - embedCheck.embeddable === false ? t("Not supported") : undefined, - disabled: embedCheck.loading || !embedCheck.embeddable, + embedCheck.embeddable === false || isInternal + ? t("Not supported") + : undefined, + disabled: isInternal || embedCheck.loading || !embedCheck.embeddable, icon: embed?.icon, keywords: embed?.keywords, }, From a0039b2a09efbe8b56f03a99682485ef57f55745 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 23:25:43 -0400 Subject: [PATCH 007/283] Add keyboard access to mermaid diagram editing (#11834) --- app/editor/index.tsx | 2 +- app/editor/menus/code.tsx | 2 ++ shared/editor/extensions/Mermaid.ts | 25 +++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 8d9170b379e9..d115975706c5 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -443,8 +443,8 @@ export class Editor extends React.PureComponent< schema: this.schema, doc, plugins: [ - ...this.keymaps, ...this.plugins, + ...this.keymaps, anchorPlugin(), dropCursor({ color: this.props.theme.cursor, diff --git a/app/editor/menus/code.tsx b/app/editor/menus/code.tsx index fd1979b66b4b..f3d3826542f7 100644 --- a/app/editor/menus/code.tsx +++ b/app/editor/menus/code.tsx @@ -14,6 +14,7 @@ import { import { isMermaid } from "@shared/editor/lib/isCode"; import type { MenuItem } from "@shared/editor/types"; import type { Dictionary } from "~/hooks/useDictionary"; +import { metaDisplay } from "@shared/utils/keyboard"; export default function codeMenuItems( state: EditorState, @@ -67,6 +68,7 @@ export default function codeMenuItems( name: "edit_mermaid", icon: , tooltip: dictionary.editDiagram, + shortcut: `${metaDisplay} Enter`, visible: isMermaid(node) && !isEditingMermaid && !readOnly, }, { diff --git a/shared/editor/extensions/Mermaid.ts b/shared/editor/extensions/Mermaid.ts index 644f13b50efd..1361af6513b4 100644 --- a/shared/editor/extensions/Mermaid.ts +++ b/shared/editor/extensions/Mermaid.ts @@ -439,6 +439,31 @@ export default function Mermaid({ decorations(state) { return this.getState(state)?.decorationSet; }, + handleKeyDown(view, event) { + if (event.key === "Enter" && event.metaKey && !editor.props.readOnly) { + const { selection } = view.state; + const isNodeSel = selection instanceof NodeSelection; + const isMermaidNode = + isNodeSel && isMermaid((selection as NodeSelection).node); + if (isNodeSel && isMermaidNode) { + editor.commands.edit_mermaid(); + return true; + } + } + + if (event.key === "Escape") { + const mermaidState = pluginKey.getState(view.state) as MermaidState; + const codeBlock = findParentNode(isCode)(view.state.selection); + + if (mermaidState?.editingId) { + if (codeBlock && isMermaid(codeBlock.node)) { + editor.commands.edit_mermaid(); + return true; + } + } + } + return false; + }, handleDOMEvents: { click(_view, event: MouseEvent) { const target = event.target as HTMLElement; From 5693618de4c9c06d9a8aacea2b6ac2a102a1f1c5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 20 Mar 2026 23:28:51 -0400 Subject: [PATCH 008/283] Add translation hooks to transactional emails (#11785) * First pass * fix: Missing translations * fix: Missing translations * welcome * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * translations --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/email/server/auth/email.ts | 3 + .../email/templates/PasskeyCreatedEmail.tsx | 38 +++-- .../processors/PasskeyCreatedProcessor.ts | 1 + .../server/tasks/DeliverWebhookTask.ts | 1 + server/commands/accountProvisioner.ts | 1 + server/commands/userInviter.ts | 1 + server/commands/userProvisioner.ts | 1 + server/emails/templates/BaseEmail.tsx | 20 ++- .../templates/CollectionCreatedEmail.tsx | 32 +++-- .../templates/CollectionSharedEmail.tsx | 32 +++-- .../emails/templates/CommentCreatedEmail.tsx | 50 +++++-- .../templates/CommentMentionedEmail.tsx | 40 ++++-- .../emails/templates/CommentResolvedEmail.tsx | 37 +++-- .../templates/ConfirmTeamDeleteEmail.tsx | 19 ++- .../emails/templates/ConfirmUpdateEmail.tsx | 36 +++-- .../templates/ConfirmUserDeleteEmail.tsx | 24 ++-- .../templates/DocumentMentionedEmail.tsx | 22 +-- .../DocumentPublishedOrUpdatedEmail.tsx | 56 ++++++-- .../emails/templates/DocumentSharedEmail.tsx | 27 ++-- .../emails/templates/ExportFailureEmail.tsx | 29 ++-- .../emails/templates/ExportSuccessEmail.tsx | 27 ++-- .../templates/GroupCommentMentionedEmail.tsx | 45 ++++-- .../templates/GroupDocumentMentionedEmail.tsx | 28 ++-- .../emails/templates/InviteAcceptedEmail.tsx | 32 +++-- server/emails/templates/InviteEmail.tsx | 38 +++-- .../emails/templates/InviteReminderEmail.tsx | 48 ++++--- server/emails/templates/SigninEmail.tsx | 53 ++++--- .../emails/templates/WebhookDisabledEmail.tsx | 24 ++-- server/emails/templates/WelcomeEmail.tsx | 44 ++++-- server/emails/templates/components/Footer.tsx | 7 +- server/queues/processors/EmailsProcessor.ts | 10 ++ server/queues/tasks/ExportTask.ts | 2 + server/queues/tasks/InviteReminderTask.ts | 1 + server/routes/api/teams/teams.ts | 1 + server/routes/api/users/users.ts | 3 + shared/i18n/locales/en_US/translation.json | 131 ++++++++++++++++++ 36 files changed, 711 insertions(+), 253 deletions(-) diff --git a/plugins/email/server/auth/email.ts b/plugins/email/server/auth/email.ts index b1291ac149f7..e329b180d84d 100644 --- a/plugins/email/server/auth/email.ts +++ b/plugins/email/server/auth/email.ts @@ -80,6 +80,7 @@ router.post( // send email to users email address with a short-lived token and code await new SigninEmail({ to: user.email, + language: user.language, token, teamUrl: team.url, client, @@ -171,6 +172,7 @@ const emailCallback = async (ctx: APIContext) => { if (user.isInvited) { await new WelcomeEmail({ to: user.email, + language: user.language, role: user.role, teamUrl: user.team.url, }).schedule(); @@ -179,6 +181,7 @@ const emailCallback = async (ctx: APIContext) => { if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) { await new InviteAcceptedEmail({ to: inviter.email, + language: inviter.language, inviterId: inviter.id, invitedName: user.name, teamUrl: user.team.url, diff --git a/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx b/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx index d8701973a1ba..af080247d3cc 100644 --- a/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx +++ b/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx @@ -29,29 +29,31 @@ export class PasskeyCreatedEmail extends BaseEmail { } protected subject() { - return `New passkey added to your ${env.APP_NAME} account`; + return this.t("New passkey added to your {{ appName }} account", { + appName: env.APP_NAME, + }); } protected preview() { - return "A new passkey was created for your account."; + return this.t("A new passkey was created for your account."); } protected renderAsText({ passkeyName, teamUrl }: Props) { return ` -New Passkey Created +${this.t("New Passkey Created")} -A new passkey has been added to your ${env.APP_NAME} account: +${this.t("A new passkey has been added to your {{ appName }} account", { appName: env.APP_NAME }) + ":"} ${passkeyName} -Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately. +${this.t("Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.")} -You can manage your passkeys at any time: +${this.t("You can manage your passkeys at any time")}: ${teamUrl}/settings/passkeys --- -If you have any concerns about your account security, please contact a workspace admin. +${this.t("If you have any concerns about your account security, please contact a workspace admin.")} `; } @@ -63,24 +65,30 @@ If you have any concerns about your account security, please contact a workspace
- New Passkey Created -

A new passkey has been added to your {env.APP_NAME} account:

+ {this.t("New Passkey Created")} +

+ {this.t( + "A new passkey has been added to your {{ appName }} account", + { appName: env.APP_NAME } + ) + ":"} +

{passkeyName}

- Passkeys provide a secure, passwordless way to sign in to your - account. If you did not create this passkey, please review your - account security settings immediately. + {this.t( + "Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately." + )}

- +

- If you have any concerns about your account security, please contact - a workspace admin. + {this.t( + "If you have any concerns about your account security, please contact a workspace admin." + )}

diff --git a/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts b/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts index 7020e880aafe..86fa185dd305 100644 --- a/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts +++ b/plugins/passkeys/server/processors/PasskeyCreatedProcessor.ts @@ -19,6 +19,7 @@ export class PasskeyCreatedProcessor extends BaseProcessor { await new PasskeyCreatedEmail({ to: user.email, + language: user.language, userId: user.id, passkeyId: userPasskey.id, passkeyName: userPasskey.name, diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index ccb19ce4588e..883c7409271c 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -886,6 +886,7 @@ export default class DeliverWebhookTask extends BaseTask { if (createdBy && team) { await new WebhookDisabledEmail({ to: createdBy.email, + language: createdBy.language, teamUrl: team.url, webhookName: subscription.name, }).schedule(); diff --git a/server/commands/accountProvisioner.ts b/server/commands/accountProvisioner.ts index 32664cd9ccac..e5fc6c1e87a3 100644 --- a/server/commands/accountProvisioner.ts +++ b/server/commands/accountProvisioner.ts @@ -198,6 +198,7 @@ async function accountProvisioner( if (isNewUser) { await new WelcomeEmail({ to: user.email, + language: user.language, role: user.role, teamUrl: team.url, }).schedule(); diff --git a/server/commands/userInviter.ts b/server/commands/userInviter.ts index af985cead505..25e493ebffc9 100644 --- a/server/commands/userInviter.ts +++ b/server/commands/userInviter.ts @@ -96,6 +96,7 @@ export default async function userInviter( await new InviteEmail({ to: invite.email, + language: newUser.language, name: invite.name, actorName: user.name, actorEmail: user.email, diff --git a/server/commands/userProvisioner.ts b/server/commands/userProvisioner.ts index e326f6aee5e9..d2d15cece973 100644 --- a/server/commands/userProvisioner.ts +++ b/server/commands/userProvisioner.ts @@ -185,6 +185,7 @@ export default async function userProvisioner( if (inviter) { await new InviteAcceptedEmail({ to: inviter.email, + language: inviter.language, inviterId: inviter.id, invitedName: existingUser.name, teamUrl: existingUser.team.url, diff --git a/server/emails/templates/BaseEmail.tsx b/server/emails/templates/BaseEmail.tsx index fe9c4d599a78..fd4521b12fbe 100644 --- a/server/emails/templates/BaseEmail.tsx +++ b/server/emails/templates/BaseEmail.tsx @@ -2,10 +2,12 @@ import type { EmailAddress } from "addressparser"; import addressparser from "addressparser"; import type Bull from "bull"; import invariant from "invariant"; +import { t as i18nT } from "i18next"; import { subMinutes } from "date-fns"; import type { Node } from "prosemirror-model"; import { randomString } from "@shared/random"; import { TeamPreference } from "@shared/types"; +import { unicodeCLDRtoBCP47 } from "@shared/utils/date"; import { Day } from "@shared/utils/time"; import mailer from "@server/emails/mailer"; import env from "@server/env"; @@ -31,6 +33,8 @@ export enum EmailMessageCategory { export interface EmailProps { /** The email address being sent to. */ to: string | null; + /** The language of the receiving user in CLDR format (e.g. "en_US"). */ + language?: string | null; /** The notification that triggered the email, if any. */ notification?: Notification; } @@ -151,7 +155,7 @@ export default abstract class BaseEmail< let subject = this.subject(data); if (notification) { if (notification.createdAt < subMinutes(new Date(), 30)) { - subject = `Delayed notification: ${subject}`; + subject = `${this.t("Delayed notification")}: ${subject}`; } } @@ -224,6 +228,20 @@ export default abstract class BaseEmail< return ; } + /** + * Translate a string using the receiving user's language preference. + * + * @param key The translation key (plain English string). + * @param options Optional interpolation values. + * @returns The translated string. + */ + protected t(key: string, options?: Record): string { + return i18nT(key, { + ...options, + lng: unicodeCLDRtoBCP47(this.props.language ?? env.DEFAULT_LANGUAGE), + }) as string; + } + /** * Returns the subject of the email. * diff --git a/server/emails/templates/CollectionCreatedEmail.tsx b/server/emails/templates/CollectionCreatedEmail.tsx index c562c7a155ed..d3434a88ae43 100644 --- a/server/emails/templates/CollectionCreatedEmail.tsx +++ b/server/emails/templates/CollectionCreatedEmail.tsx @@ -60,20 +60,24 @@ export default class CollectionCreatedEmail extends BaseEmail< } protected subject({ collection }: Props) { - return `“${collection.name}” created`; + return this.t("“{{ collectionName }}” created", { + collectionName: collection.name, + }); } protected preview({ collection }: Props) { - return `${collection.user.name} created a collection`; + return this.t("{{ userName }} created a collection", { + userName: collection.user.name, + }); } protected renderAsText({ teamUrl, collection }: Props) { return ` ${collection.name} -${collection.user.name} created the collection "${collection.name}" +${this.t("{{ userName }} created the collection “{{ collectionName }}”", { userName: collection.user.name, collectionName: collection.name })} -Open Collection: ${teamUrl}${collection.path} +${this.t("Open Collection")}: ${teamUrl}${collection.path} `; } @@ -84,22 +88,34 @@ Open Collection: ${teamUrl}${collection.path} return (
{collection.name}

- {collection.user.name} created the collection "{collection.name}". + {this.t( + "{{ userName }} created the collection “{{ collectionName }}”.", + { + userName: collection.user.name, + collectionName: collection.name, + } + )}

- +

-