From c4bf378eae8bfd0fee116cb2035bad7a59484101 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:45:33 +0100 Subject: [PATCH 01/13] feat: public GitHub unfurls and base embed paste (#2589) --- .../public/locales/en-US/translation.json | 3 +- .../ee/base/components/cells/cell-email.tsx | 3 +- .../components/cells/cell-long-text.test.tsx | 107 ++++++++++ .../base/components/cells/cell-long-text.tsx | 22 +-- .../ee/base/components/cells/cell-number.tsx | 2 - .../base/components/cells/cell-text.test.tsx | 45 +++++ .../ee/base/components/cells/cell-text.tsx | 3 +- .../src/ee/base/components/cells/cell-url.tsx | 3 +- .../fields/field-long-text.tsx | 26 +-- .../row-detail-modal/fields/field-number.tsx | 11 +- .../row-detail-modal/fields/field-text.tsx | 26 +-- .../row-detail-fields.test.tsx | 185 +++++++++++++++++ .../row-detail-modal.test.tsx | 116 +++++++++++ .../row-detail-modal/row-detail-modal.tsx | 20 +- .../row-detail-modal/row-detail-title.tsx | 31 +-- apps/client/src/ee/base/constants/autosave.ts | 2 + .../ee/base/hooks/use-editable-text-cell.ts | 7 +- .../base/hooks/use-grid-keyboard-nav.test.ts | 56 ++++++ .../ee/base/hooks/use-grid-keyboard-nav.ts | 2 + .../components/base-embed/base-embed-view.tsx | 21 +- .../base-embed/base-paste-menu.test.tsx | 125 ++++++++++++ .../components/base-embed/base-paste-menu.tsx | 186 ++++++++++++++++++ .../components/base-embed/base-paste.test.ts | 151 ++++++++++++++ .../convert-mention-to-base-embed.ts | 33 ++++ .../common/editor-paste-handler.tsx | 6 +- .../groups/more-inserts-group.tsx | 8 + .../integration-link/integration-display.ts | 76 +------ .../components/link/internal-link-paste.ts | 5 + .../editor/extensions/base-paste-menu.ts | 39 ++++ .../features/editor/extensions/extensions.ts | 2 + .../src/features/editor/page-editor.tsx | 2 + .../features/editor/utils/block-placement.ts | 74 +++++++ .../core/integration/integration.module.ts | 8 +- .../providers/github/github.provider.spec.ts | 4 +- .../providers/github/github.provider.ts | 40 +++- .../providers/github/github.service.spec.ts | 64 +++++- .../providers/github/github.service.ts | 28 +-- .../integration-provider.interface.ts | 5 + .../registry/integration-registry.spec.ts | 26 +++ .../registry/integration-registry.ts | 8 + .../integration/unfurl/unfurl.service.spec.ts | 117 ++++++++++- .../core/integration/unfurl/unfurl.service.ts | 61 +++++- 42 files changed, 1560 insertions(+), 199 deletions(-) create mode 100644 apps/client/src/ee/base/components/cells/cell-long-text.test.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-text.test.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx create mode 100644 apps/client/src/ee/base/constants/autosave.ts create mode 100644 apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts create mode 100644 apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx create mode 100644 apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx create mode 100644 apps/client/src/features/editor/components/base-embed/base-paste.test.ts create mode 100644 apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts create mode 100644 apps/client/src/features/editor/extensions/base-paste-menu.ts create mode 100644 apps/client/src/features/editor/utils/block-placement.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3f4bde97..7c2e3c6f 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Move right", "Delete tab": "Delete tab", "Tab label": "Tab label", - "Tab actions for {{name}}": "Tab actions for {{name}}" + "Tab actions for {{name}}": "Tab actions for {{name}}", + "Inline base": "Inline base" } diff --git a/apps/client/src/ee/base/components/cells/cell-email.tsx b/apps/client/src/ee/base/components/cells/cell-email.tsx index 0d93f69f..78b78e19 100644 --- a/apps/client/src/ee/base/components/cells/cell-email.tsx +++ b/apps/client/src/ee/base/components/cells/cell-email.tsx @@ -15,13 +15,12 @@ type CellEmailProps = { const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); const parse = (draft: string) => draft || null; -export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) { +export function CellEmail({ value, property, rowId, isEditing, onCommit }: CellEmailProps) { const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.test.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.test.tsx new file mode 100644 index 00000000..2527e4a8 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-long-text.test.tsx @@ -0,0 +1,107 @@ +import { MantineProvider } from "@mantine/core"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBaseProperty } from "@/ee/base/types/base.types"; +import { CellLongText } from "./cell-long-text"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +const property = { + id: "p1", + pageId: "base-1", + name: "Long text", + type: "text", + position: "a", + typeOptions: {}, + isPrimary: false, +} as unknown as IBaseProperty; + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +beforeEach(() => { + setMediaMatch(); + // Textarea autosize listens for font loads, which jsdom doesn't implement. + Object.defineProperty(document, "fonts", { + configurable: true, + value: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); +}); + +afterEach(cleanup); + +describe("CellLongText", () => { + it("saves what was typed when Esc closes the editor", () => { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + const textarea = screen.getByRole("textbox"); + + fireEvent.change(textarea, { target: { value: "what is love" } }); + fireEvent.keyDown(textarea, { key: "Escape" }); + + expect(onCommit).toHaveBeenCalledWith("what is love"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("autosaves after a 1 s typing pause", () => { + vi.useFakeTimers(); + const onValueChange = vi.fn(); + render( + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "what is love" }, + }); + act(() => vi.advanceTimersByTime(1000)); + + expect(onValueChange).toHaveBeenCalledWith("what is love"); + vi.useRealTimers(); + }); +}); diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.tsx index 2c390056..32616db4 100644 --- a/apps/client/src/ee/base/components/cells/cell-long-text.tsx +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -4,6 +4,7 @@ import { useDebouncedCallback } from "@mantine/hooks"; import { IBaseProperty } from "@/ee/base/types/base.types"; import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters"; import cellClasses from "@/ee/base/styles/cells.module.css"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; type CellLongTextProps = { value: unknown; @@ -27,11 +28,9 @@ export function CellLongText({ isEditing, onCommit, onValueChange, - onCancel, onTabNavigate, }: CellLongTextProps) { const [draft, setDraft] = useState(() => toText(value)); - const cancelledRef = useRef(false); const committedRef = useRef(false); const wasEditingRef = useRef(false); const textareaRef = useRef(null); @@ -40,7 +39,6 @@ export function CellLongText({ // value changes mid-edit so the user's typing is not clobbered. useEffect(() => { if (isEditing && !wasEditingRef.current) { - cancelledRef.current = false; committedRef.current = false; setDraft(toText(value)); requestAnimationFrame(() => { @@ -53,11 +51,11 @@ export function CellLongText({ wasEditingRef.current = isEditing; }, [isEditing, value]); - // Autosave after a typing pause; commit/cancel clear the pending fire so - // a closed editor can never write a stale or discarded draft. + // Autosave after a typing pause; commit clears the pending fire so a + // closed editor can never write a stale draft. const debouncedAutosave = useDebouncedCallback(() => { onValueChange(normalize(draft)); - }, 10_000); + }, AUTOSAVE_DELAY_MS); const commit = () => { if (committedRef.current) return; @@ -65,11 +63,6 @@ export function CellLongText({ debouncedAutosave.cancel(); onCommit(normalize(draft)); }; - const cancel = () => { - cancelledRef.current = true; - debouncedAutosave.cancel(); - onCancel(); - }; const preview = formatLongTextPreview(toText(value)); @@ -78,11 +71,6 @@ export function CellLongText({ opened={isEditing} onChange={(opened) => { if (opened) return; - // Programmatic close after cancel must not re-commit. - if (cancelledRef.current) { - cancelledRef.current = false; - return; - } commit(); }} position="bottom-start" @@ -135,7 +123,7 @@ export function CellLongText({ onTabNavigate?.(e.shiftKey); } else if (e.key === "Escape") { e.preventDefault(); - cancel(); + commit(); } else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); commit(); diff --git a/apps/client/src/ee/base/components/cells/cell-number.tsx b/apps/client/src/ee/base/components/cells/cell-number.tsx index cbb50a99..d964de06 100644 --- a/apps/client/src/ee/base/components/cells/cell-number.tsx +++ b/apps/client/src/ee/base/components/cells/cell-number.tsx @@ -95,7 +95,6 @@ export function CellNumber({ rowId, isEditing, onCommit, - onCancel, }: CellNumberProps) { const typeOptions = property.typeOptions as NumberTypeOptions | undefined; const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = @@ -103,7 +102,6 @@ export function CellNumber({ value, isEditing, onCommit, - onCancel, toDraft, parse: parseNumberDraft, rowId, diff --git a/apps/client/src/ee/base/components/cells/cell-text.test.tsx b/apps/client/src/ee/base/components/cells/cell-text.test.tsx new file mode 100644 index 00000000..68dd0138 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-text.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { IBaseProperty } from "@/ee/base/types/base.types"; +import { CellText } from "./cell-text"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +const property = { + id: "p1", + pageId: "base-1", + name: "Text", + type: "text", + position: "a", + typeOptions: {}, + isPrimary: false, +} as unknown as IBaseProperty; + +afterEach(cleanup); + +describe("CellText", () => { + it("saves what was typed when Esc ends the edit", () => { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + const input = screen.getByRole("textbox"); + + fireEvent.change(input, { target: { value: "hello" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onCommit).toHaveBeenCalledWith("hello"); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/ee/base/components/cells/cell-text.tsx b/apps/client/src/ee/base/components/cells/cell-text.tsx index 172e0350..12cd8e01 100644 --- a/apps/client/src/ee/base/components/cells/cell-text.tsx +++ b/apps/client/src/ee/base/components/cells/cell-text.tsx @@ -16,13 +16,12 @@ type CellTextProps = { const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); const parse = (draft: string) => draft; -export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) { +export function CellText({ value, property, rowId, isEditing, onCommit }: CellTextProps) { const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, diff --git a/apps/client/src/ee/base/components/cells/cell-url.tsx b/apps/client/src/ee/base/components/cells/cell-url.tsx index c7c4de35..cef3f262 100644 --- a/apps/client/src/ee/base/components/cells/cell-url.tsx +++ b/apps/client/src/ee/base/components/cells/cell-url.tsx @@ -16,13 +16,12 @@ type CellUrlProps = { const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); const parse = (draft: string) => draft || null; -export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) { +export function CellUrl({ value, property, rowId, isEditing, onCommit }: CellUrlProps) { const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx index 7fd11e8a..a7b96d30 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Textarea } from "@mantine/core"; +import { useDebouncedCallback } from "@mantine/hooks"; import { FieldProps, FieldShell } from "./detail-field"; import classes from "@/ee/base/styles/row-detail-modal.module.css"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; const toText = (value: unknown) => (typeof value === "string" ? value : ""); const normalize = (s: string) => { @@ -19,23 +21,21 @@ export function FieldLongText({ const text = toText(value); const [draft, setDraft] = useState(text); const [focused, setFocused] = useState(false); - // Esc sets this; blur() then runs commit synchronously with the stale - // draft, so the revert must be decided here, not via setDraft. - const cancelRef = useRef(false); useEffect(() => { if (!focused) setDraft(text); }, [text, focused]); + const save = () => { + if (normalize(draft) !== normalize(text)) onChange(normalize(draft)); + }; + const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS); + const commit = () => { setFocused(false); onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setDraft(text); - return; - } - if (normalize(draft) !== normalize(text)) onChange(normalize(draft)); + autosave.cancel(); + save(); }; if (readOnly) { @@ -61,11 +61,13 @@ export function FieldLongText({ setFocused(true); onEditingChange?.(true); }} - onChange={(e) => setDraft(e.currentTarget.value)} + onChange={(e) => { + setDraft(e.currentTarget.value); + autosave(); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx index 62ab2255..8ceaf3f6 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { NumberTypeOptions } from "@/ee/base/types/base.types"; import { formatNumber, @@ -22,9 +22,6 @@ export function FieldNumber({ const numValue = typeof value === "number" ? value : null; const [draft, setDraft] = useState(toDraft(value)); const [focused, setFocused] = useState(false); - // Esc sets this; blur() then runs commit synchronously with the stale - // draft, so the revert must be decided here, not via setDraft. - const cancelRef = useRef(false); useEffect(() => { if (!focused) setDraft(toDraft(value)); @@ -43,11 +40,6 @@ export function FieldNumber({ const commit = () => { setFocused(false); onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setDraft(toDraft(value)); - return; - } if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft)); }; @@ -86,7 +78,6 @@ export function FieldNumber({ e.preventDefault(); e.currentTarget.blur(); } else if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } }} diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx index dbf1abbd..d2002a92 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "@mantine/hooks"; import { IconExternalLink, IconMail } from "@tabler/icons-react"; import { FieldProps, FieldShell } from "./detail-field"; import classes from "@/ee/base/styles/row-detail-modal.module.css"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; const toText = (value: unknown) => (typeof value === "string" ? value : ""); @@ -15,24 +17,22 @@ export function FieldText({ const text = toText(value); const [draft, setDraft] = useState(text); const [focused, setFocused] = useState(false); - // Esc sets this; blur() then runs commit synchronously with the stale - // draft, so the revert must be decided here, not via setDraft. - const cancelRef = useRef(false); // Track remote/navigation updates while not typing. useEffect(() => { if (!focused) setDraft(text); }, [text, focused]); + const save = () => { + if (draft !== text) onChange(draft); + }; + const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS); + const commit = () => { setFocused(false); onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setDraft(text); - return; - } - if (draft !== text) onChange(draft); + autosave.cancel(); + save(); }; if (readOnly) { @@ -65,14 +65,16 @@ export function FieldText({ setFocused(true); onEditingChange?.(true); }} - onChange={(e) => setDraft(e.currentTarget.value)} + onChange={(e) => { + setDraft(e.currentTarget.value); + autosave(); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } else if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } }} diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx new file mode 100644 index 00000000..cce45da4 --- /dev/null +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-fields.test.tsx @@ -0,0 +1,185 @@ +import { MantineProvider } from "@mantine/core"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types"; +import { RowDetailTitle } from "./row-detail-title"; +import { FieldProps } from "./fields/detail-field"; +import { FieldText } from "./fields/field-text"; +import { FieldNumber } from "./fields/field-number"; +import { FieldLongText } from "./fields/field-long-text"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +const property = (type: IBaseProperty["type"]) => + ({ + id: "p1", + pageId: "base-1", + name: "Field", + type, + position: "a", + typeOptions: {}, + isPrimary: false, + }) as IBaseProperty; + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +// Real focus, so the blur that Esc triggers reaches onBlur as in the browser. +function typeThenEscape(value: string) { + const input = screen.getByRole("textbox"); + act(() => input.focus()); + fireEvent.change(input, { target: { value } }); + fireEvent.keyDown(input, { key: "Escape" }); + return input; +} + +beforeEach(() => { + setMediaMatch(); + // Textarea autosize listens for font loads, which jsdom doesn't implement. + Object.defineProperty(document, "fonts", { + configurable: true, + value: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); +}); +afterEach(cleanup); + +describe("Esc in the row detail modal", () => { + it("saves a typed title before leaving the field", () => { + const onCommit = vi.fn(); + render( + , + ); + + const input = typeThenEscape("dddddd"); + + expect(onCommit).toHaveBeenCalledWith("dddddd"); + expect(document.activeElement).not.toBe(input); + }); + + it.each([ + ["text", FieldText, "text", "hello", "hello"], + ["number", FieldNumber, "number", "42", 42], + ["long text", FieldLongText, "text", "notes", "notes"], + ] as [string, ComponentType, IBaseProperty["type"], string, unknown][])( + "saves a typed %s field before leaving it", + (_, Field, type, typed, saved) => { + const onChange = vi.fn(); + render( + , + { wrapper: MantineProvider }, + ); + + const input = typeThenEscape(typed); + + expect(onChange).toHaveBeenCalledWith(saved); + expect(document.activeElement).not.toBe(input); + }, + ); +}); + +describe("Typing in the row detail modal", () => { + const titleRow = (title: string) => + ({ id: "r1", cells: { p1: title } }) as unknown as IBaseRow; + const primary = { ...property("text"), isPrimary: true }; + + function type(value: string) { + const input = screen.getByRole("textbox"); + act(() => input.focus()); + fireEvent.change(input, { target: { value } }); + return input; + } + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("saves a typed title after a 1 s pause, without leaving the field", () => { + const onCommit = vi.fn(); + render( + , + ); + + const input = type("dddddd"); + act(() => vi.advanceTimersByTime(999)); + expect(onCommit).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + + expect(onCommit).toHaveBeenCalledWith("dddddd"); + expect(document.activeElement).toBe(input); + }); + + it("keeps a typed title when the row's title changes elsewhere", () => { + const props = { primaryProperty: primary, canEdit: true, onCommit: vi.fn() }; + const { rerender } = render( + , + ); + + const input = type("My draft"); + rerender(); + + expect(input).toHaveProperty("value", "My draft"); + }); + + it.each([ + ["text", FieldText, "hello"], + ["long text", FieldLongText, "notes"], + ] as [string, ComponentType, string][])( + "saves a typed %s field after a 1 s pause", + (_, Field, typed) => { + const onChange = vi.fn(); + render( + , + { wrapper: MantineProvider }, + ); + + const input = type(typed); + act(() => vi.advanceTimersByTime(1000)); + + expect(onChange).toHaveBeenCalledWith(typed); + expect(document.activeElement).toBe(input); + }, + ); +}); diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx new file mode 100644 index 00000000..34bd9371 --- /dev/null +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.test.tsx @@ -0,0 +1,116 @@ +import { MantineProvider } from "@mantine/core"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBase, IBaseRow } from "@/ee/base/types/base.types"; +import { RowDetailModal } from "./row-detail-modal"; + +const mocks = vi.hoisted(() => ({ updateRow: vi.fn() })); + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("@/ee/base/queries/base-row-query", () => ({ + useBaseRowQuery: () => ({ data: undefined, isError: false }), + useUpdateRowMutation: () => ({ mutate: mocks.updateRow, isPending: false }), + useDeleteRowMutation: () => ({ mutate: vi.fn() }), +})); + +vi.mock("@/ee/base/context/base-editable", () => ({ + useBaseEditable: () => true, +})); + +vi.mock("@/ee/base/components/property/create-property-popover", () => ({ + CreatePropertyPopover: () => null, +})); + +const base = { + id: "base-1", + properties: [ + { + id: "p1", + pageId: "base-1", + name: "Name", + type: "text", + position: "a", + typeOptions: {}, + isPrimary: true, + }, + ], +} as unknown as IBase; + +const row = { + id: "r1", + pageId: "base-1", + cells: {}, + position: "a", +} as unknown as IBaseRow; + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderModal(onClose: () => void) { + render( + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); +} + +beforeEach(() => { + setMediaMatch(); + mocks.updateRow.mockReset(); +}); + +afterEach(cleanup); + +describe("RowDetailModal Esc", () => { + it("saves a typed title, then closes, in one press", () => { + const onClose = vi.fn(); + renderModal(onClose); + const title = screen.getByRole("textbox", { name: "Name" }); + + act(() => title.focus()); + fireEvent.change(title, { target: { value: "dddddd" } }); + fireEvent.keyDown(title, { key: "Escape" }); + + expect(mocks.updateRow).toHaveBeenCalledWith({ + rowId: "r1", + pageId: "base-1", + cells: { p1: "dddddd" }, + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(mocks.updateRow.mock.invocationCallOrder[0]).toBeLessThan( + onClose.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx index be303a0f..9efe8e32 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx @@ -168,10 +168,11 @@ export function RowDetailModal({ // Mantine's closeOnEscape runs a capture-phase window listener that fires // before inner popovers and inputs see the key, so we manage Esc ourselves - // and yield to: nested dialogs (delete confirm), open popovers - // ([data-position]) and editable elements. Arrows step records under the - // same yield rules. Mantine puts role="dialog" and our content class on - // the same element, which distinguishes this modal from nested ones. + // and yield to nested dialogs (delete confirm) and open popovers + // ([data-position]). Esc in an inline field blurs it first, which saves the + // edit, then closes; arrows stay with the field. Mantine puts role="dialog" + // and our content class on the same element, which distinguishes this + // modal from nested ones. const handleKeyDown = useCallback( (event: KeyboardEvent) => { const isEscape = event.key === "Escape"; @@ -183,11 +184,10 @@ export function RowDetailModal({ if (dialog && !dialog.classList.contains(classes.modalContent)) { return; } - if ( - target.closest("[data-position]") || - target.matches("input, textarea, select, [contenteditable='true']") - ) { - return; + if (target.closest("[data-position]")) return; + if (target.matches("input, textarea, select, [contenteditable='true']")) { + if (!isEscape) return; + target.blur(); } } if (isEscape) { @@ -383,7 +383,7 @@ export function RowDetailModal({ Esc - {t("to reset")} + {t("to close")} ) : ( diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx index 2e023815..f254f756 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; +import { useDebouncedCallback } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types"; import { timeAgo } from "@/lib/time.ts"; +import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave"; import classes from "@/ee/base/styles/row-detail-modal.module.css"; type RowDetailTitleProps = { @@ -24,21 +26,23 @@ export function RowDetailTitle({ ? (((row.cells ?? {})[primaryProperty.id] as string) ?? "") : ""; const [value, setValue] = useState(initial); - const cancelRef = useRef(false); + const focusedRef = useRef(false); - const commit = () => { - onEditingChange?.(false); - if (cancelRef.current) { - cancelRef.current = false; - setValue(initial); - return; - } + const save = () => { if (value !== initial) onCommit(value); }; + const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS); - // Re-sync when the row changes underneath us (navigation or remote edit). + const commit = () => { + focusedRef.current = false; + onEditingChange?.(false); + autosave.cancel(); + save(); + }; + + // Re-sync when the row changes underneath us (navigation or remote edit), but not mid-typing. useEffect(() => { - setValue(initial); + if (!focusedRef.current) setValue(initial); }, [initial]); const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : ""; @@ -55,13 +59,16 @@ export function RowDetailTitle({ value={value} maxLength={1000} onFocus={() => { + focusedRef.current = true; onEditingChange?.(true); }} - onChange={(e) => setValue(e.currentTarget.value)} + onChange={(e) => { + setValue(e.currentTarget.value); + autosave(); + }} onBlur={commit} onKeyDown={(e) => { if (e.key === "Escape") { - cancelRef.current = true; e.currentTarget.blur(); } else if (e.key === "Enter") { e.preventDefault(); diff --git a/apps/client/src/ee/base/constants/autosave.ts b/apps/client/src/ee/base/constants/autosave.ts new file mode 100644 index 00000000..b06524b3 --- /dev/null +++ b/apps/client/src/ee/base/constants/autosave.ts @@ -0,0 +1,2 @@ +// Typing pause after which a text edit saves itself. +export const AUTOSAVE_DELAY_MS = 1000; diff --git a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts index 8fa5d934..21489797 100644 --- a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts +++ b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts @@ -6,7 +6,6 @@ export type UseEditableTextCellParams = { value: unknown; isEditing: boolean; onCommit: (value: unknown) => void; - onCancel: () => void; /** value -> the draft string shown in the input when editing begins */ toDraft: (value: unknown) => string; /** draft string -> the value passed to onCommit */ @@ -27,7 +26,6 @@ export function useEditableTextCell({ value, isEditing, onCommit, - onCancel, toDraft, parse, rowId, @@ -85,11 +83,10 @@ export function useEditableTextCell({ commitOnce(parse(draft)); } else if (e.key === "Escape") { e.preventDefault(); - committedRef.current = true; - onCancel(); + commitOnce(parse(draft)); } }, - [draft, parse, commitOnce, onCancel], + [draft, parse, commitOnce], ); const handleBlur = useCallback(() => { diff --git a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts new file mode 100644 index 00000000..bac36567 --- /dev/null +++ b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useGridKeyboardNav } from "./use-grid-keyboard-nav"; + +type Options = Parameters[0]; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("useGridKeyboardNav while editing", () => { + it("blurs the cell input, which saves it, before leaving edit mode on Esc", () => { + const container = document.createElement("div"); + const input = document.createElement("input"); + container.appendChild(input); + document.body.appendChild(container); + + const order: string[] = []; + input.addEventListener("blur", () => order.push("blur")); + const setEditingCell = vi.fn(() => { + order.push("stop editing"); + }); + + renderHook(() => + useGridKeyboardNav({ + table: { + getVisibleLeafColumns: () => [], + getRowModel: () => ({ rows: [] }), + }, + properties: [], + containerRef: { current: container }, + focusedCell: null, + setFocusedCell: vi.fn(), + editingCell: { rowId: "r1", propertyId: "p1" }, + setEditingCell, + openEditor: vi.fn(), + clearCell: vi.fn(), + beginTypeToEdit: vi.fn(), + scrollCellIntoView: vi.fn(), + selectionCount: 0, + clearSelection: vi.fn(), + deleteSelected: vi.fn(), + toggleRowSelection: vi.fn(), + expandRow: vi.fn(), + addRow: vi.fn(), + } as unknown as Options), + ); + + input.focus(); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + expect(order).toEqual(["blur", "stop editing"]); + }); +}); diff --git a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts index 09d140e4..168ebf9e 100644 --- a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts +++ b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts @@ -171,6 +171,8 @@ export function useGridKeyboardNav({ } case "Escape": { e.preventDefault(); + // Blurring saves the draft, as on Enter. + (document.activeElement as HTMLElement | null)?.blur(); setEditingCell(null); setFocusedCell(editingCell); break; diff --git a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx index 69f7db1e..12a453ce 100644 --- a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx +++ b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx @@ -8,8 +8,12 @@ import { useBaseQuery } from "@/ee/base/queries/base-query"; import { pinOffsetWatcher } from "@docmost/editor-ext"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; -import { IconDots, IconTable, IconX } from "@tabler/icons-react"; +import { IconDots, IconLink, IconTable, IconX } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; import { usePageQuery } from "@/features/page/queries/page-query"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { getAppUrl } from "@/lib/config.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; import classes from "./base-embed.module.css"; const SIDE_GUTTER = 8; @@ -63,6 +67,14 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) { pendingKey ? "" : pageId ?? "", ); const { data: page } = usePageQuery({ pageId: pageId ?? undefined }); + const clipboard = useClipboard({ timeout: 500 }); + + const handleCopyLink = () => { + clipboard.copy( + getAppUrl() + buildPageUrl(page.space?.slug, page.slugId, page.title), + ); + notifications.show({ message: t("Link copied") }); + }; useEffect(() => { const wrapper = wrapperRef.current; @@ -159,6 +171,13 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) { + } + disabled={!page} + onClick={handleCopyLink} + > + {t("Copy link")} + } onClick={() => deleteNode()} diff --git a/apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx b/apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx new file mode 100644 index 00000000..115e7e88 --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/base-paste-menu.test.tsx @@ -0,0 +1,125 @@ +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BasePasteMenu } from "./base-paste-menu"; + +const mocks = vi.hoisted(() => ({ + pasteState: null as { pos: number } | null, + hasBases: true, + convert: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("@tiptap/react", () => ({ + posToDOMRect: vi.fn(), + useEditorState: ({ editor, selector }: Record) => + selector({ editor }), +})); + +vi.mock("@tiptap/react/menus", () => ({ + BubbleMenu: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/features/editor/extensions/base-paste-menu", () => ({ + basePasteMenuKey: { getState: () => mocks.pasteState }, +})); + +vi.mock("@/ee/hooks/use-feature", () => ({ + useHasFeature: () => mocks.hasBases, +})); + +vi.mock("./convert-mention-to-base-embed", () => ({ + convertMentionToBaseEmbed: mocks.convert, +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderMenu() { + const editor = { + state: { + doc: { nodeAt: () => ({ type: { name: "mention" }, nodeSize: 1 }) }, + tr: { setMeta: vi.fn() }, + }, + view: { dispatch: vi.fn(), nodeDOM: vi.fn(), hasFocus: () => true }, + commands: { focus: vi.fn() }, + isDestroyed: false, + on: vi.fn(), + off: vi.fn(), + }; + + render( + + ["editor"] + } + /> + , + ); + + return editor; +} + +describe("BasePasteMenu", () => { + beforeEach(() => { + setMediaMatch(); + mocks.pasteState = { pos: 3 }; + mocks.hasBases = true; + mocks.convert.mockReset(); + }); + + afterEach(cleanup); + + it("embeds the pasted base when its option is clicked", () => { + const editor = renderMenu(); + + fireEvent.click(screen.getByRole("option", { name: "Inline base" })); + + expect(mocks.convert).toHaveBeenCalledWith(editor, 3); + }); + + it("leaves Enter to the editor until the option is highlighted", () => { + const editor = renderMenu(); + + expect(fireEvent.keyDown(window, { key: "Enter" })).toBe(true); + expect(mocks.convert).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "ArrowDown" }); + expect(fireEvent.keyDown(window, { key: "Enter" })).toBe(false); + expect(mocks.convert).toHaveBeenCalledWith(editor, 3); + }); + + it("closes on Escape and keeps the mention", () => { + const editor = renderMenu(); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(editor.view.dispatch).toHaveBeenCalled(); + expect(mocks.convert).not.toHaveBeenCalled(); + }); + + it("stays hidden without the Bases feature", () => { + mocks.hasBases = false; + renderMenu(); + + expect(screen.queryByRole("option")).toBeNull(); + }); +}); diff --git a/apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx b/apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx new file mode 100644 index 00000000..9da99fcf --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/base-paste-menu.tsx @@ -0,0 +1,186 @@ +import { Button, Paper, Text, VisuallyHidden } from "@mantine/core"; +import { posToDOMRect, useEditorState } from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; +import { basePasteMenuKey } from "@/features/editor/extensions/base-paste-menu"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { convertMentionToBaseEmbed } from "./convert-mention-to-base-embed"; +import classes from "../integration-link/integration-paste-menu.module.css"; + +function getPastedBasePos(editor: EditorMenuProps["editor"]): number | null { + const pasteState = basePasteMenuKey.getState(editor.state); + if (!pasteState) return null; + const node = editor.state.doc.nodeAt(pasteState.pos); + return node?.type.name === "mention" ? pasteState.pos : null; +} + +export function BasePasteMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const hasBases = useHasFeature(Feature.BASES); + const [highlighted, setHighlighted] = useState(false); + const optionId = useId(); + const promptRef = useRef(null); + + const pastedPos = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => + currentEditor ? getPastedBasePos(currentEditor) : null, + }); + const isOpen = hasBases && pastedPos !== null; + + const shouldShow = useCallback( + () => hasBases && getPastedBasePos(editor) !== null, + [editor, hasBases], + ); + + const getReferencedVirtualElement = useCallback(() => { + const pos = getPastedBasePos(editor); + if (pos === null) return undefined; + const node = editor.state.doc.nodeAt(pos); + if (!node) return undefined; + + const dom = editor.view.nodeDOM(pos) as HTMLElement | null; + const domRect = + dom?.getBoundingClientRect?.() ?? + posToDOMRect(editor.view, pos, pos + node.nodeSize); + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const dismiss = useCallback(() => { + if (!basePasteMenuKey.getState(editor.state)) return; + editor.view.dispatch(editor.state.tr.setMeta(basePasteMenuKey, null)); + + requestAnimationFrame(() => { + const activeElement = document.activeElement; + if ( + !activeElement || + activeElement === document.body || + !activeElement.isConnected + ) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }); + }, [editor]); + + const embed = useCallback(() => { + const pos = getPastedBasePos(editor); + if (pos === null) return; + + dismiss(); + if (convertMentionToBaseEmbed(editor, pos)) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, [dismiss, editor]); + + useEffect(() => setHighlighted(false), [pastedPos]); + + // Focus stays in the editor, so capture keys before ProseMirror handles them. + useEffect(() => { + if (!isOpen) return; + + const ownsFocus = () => + editor.view.hasFocus() || + Boolean(promptRef.current?.contains(document.activeElement)); + + const onKeyDown = (event: KeyboardEvent) => { + if (!ownsFocus()) return; + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dismiss(); + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + setHighlighted(true); + return; + } + + // Until the option is highlighted, Enter is still a new line. + if (event.key === "Enter" && highlighted) { + event.preventDefault(); + event.stopPropagation(); + embed(); + } + }; + + // Leaving the editor dismisses the prompt, unless focus moved into the prompt itself. + const onBlur = ({ event }: { event: FocusEvent }) => { + const next = event.relatedTarget; + if (next instanceof Node && promptRef.current?.contains(next)) return; + if (editor.isDestroyed) return; + editor.view.dispatch(editor.state.tr.setMeta(basePasteMenuKey, null)); + }; + + window.addEventListener("keydown", onKeyDown, true); + editor.on("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + editor.off("blur", onBlur); + }; + }, [dismiss, editor, embed, highlighted, isOpen]); + + return ( + + {isOpen ? ( + + + {highlighted ? t("Inline base") : ""} + + + {t("Paste as")} + + + + ) : null} + + ); +} diff --git a/apps/client/src/features/editor/components/base-embed/base-paste.test.ts b/apps/client/src/features/editor/components/base-embed/base-paste.test.ts new file mode 100644 index 00000000..8211b6ff --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/base-paste.test.ts @@ -0,0 +1,151 @@ +import { BaseEmbed, Mention } from "@docmost/editor-ext"; +import { Editor, type JSONContent } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BasePasteMenuExtension, + basePasteMenuKey, +} from "@/features/editor/extensions/base-paste-menu"; +import { handlePaste } from "@/features/editor/components/common/editor-paste-handler"; +import { convertMentionToBaseEmbed } from "./convert-mention-to-base-embed"; + +const mocks = vi.hoisted(() => ({ getPageById: vi.fn() })); + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +vi.mock("@/features/page/services/page-service.ts", async (importOriginal) => ({ + ...(await importOriginal()), + getPageById: mocks.getPageById, +})); + +const PAGE_ID = "019a2b3c-0000-7000-8000-000000000001"; +const LINK = `${window.location.origin}/s/general/p/roadmap-x1y2z3`; + +let editor: Editor; + +function createEditor(content: JSONContent[], cursor: number) { + editor = new Editor({ + extensions: [StarterKit, Mention, BaseEmbed, BasePasteMenuExtension], + content: { type: "doc", content }, + }); + editor.commands.setTextSelection(cursor); +} + +function mentionPos(): number | null { + let found: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "mention") found = pos; + }); + return found; +} + +async function pasteLink({ + isBase, + url = LINK, +}: { + isBase: boolean; + url?: string; +}) { + mocks.getPageById.mockResolvedValue({ + id: PAGE_ID, + slugId: "x1y2z3", + title: "Roadmap", + isBase, + }); + const event = { + clipboardData: { + getData: (type: string) => (type === "text/plain" ? url : ""), + files: [], + }, + preventDefault: vi.fn(), + }; + handlePaste(editor, event as unknown as ClipboardEvent, "page-id"); + await vi.waitFor(() => expect(mentionPos()).not.toBeNull()); +} + +afterEach(() => editor?.destroy()); + +describe("pasting a link to a base", () => { + it("offers to embed it", async () => { + createEditor([{ type: "paragraph" }], 1); + + await pasteLink({ isBase: true }); + + expect(basePasteMenuKey.getState(editor.state)).toEqual({ + pos: mentionPos(), + }); + }); + + it("offers to embed a link to one of its views", async () => { + createEditor([{ type: "paragraph" }], 1); + + await pasteLink({ + isBase: true, + url: `${LINK}?view=0195f3c2-1111-7000-8000-000000000abc`, + }); + + expect(mocks.getPageById).toHaveBeenCalledWith({ pageId: "x1y2z3" }); + expect(basePasteMenuKey.getState(editor.state)).toEqual({ + pos: mentionPos(), + }); + }); + + it("does not offer it for a document", async () => { + createEditor([{ type: "paragraph" }], 1); + + await pasteLink({ isBase: false }); + + expect(basePasteMenuKey.getState(editor.state)).toBeNull(); + }); + + it("does not offer it where a block cannot go", async () => { + createEditor( + [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Plan " }], + }, + ], + 6, + ); + + await pasteLink({ isBase: true }); + + expect(basePasteMenuKey.getState(editor.state)).toBeNull(); + }); +}); + +describe("embedding a pasted base", () => { + it("replaces a mention on its own line with the base", async () => { + createEditor([{ type: "paragraph" }], 1); + await pasteLink({ isBase: true }); + + expect(convertMentionToBaseEmbed(editor, mentionPos()!)).toBe(true); + + // The trailing paragraph comes from the trailing node, as in the page editor. + expect(editor.getJSON().content).toMatchObject([ + { type: "base", attrs: { pageId: PAGE_ID } }, + { type: "paragraph" }, + ]); + }); + + it("splits the paragraph around a mention pasted mid-sentence", async () => { + createEditor( + [{ type: "paragraph", content: [{ type: "text", text: "See for dates" }] }], + 5, + ); + await pasteLink({ isBase: true }); + + expect(convertMentionToBaseEmbed(editor, mentionPos()!)).toBe(true); + + expect(editor.getJSON().content).toMatchObject([ + { type: "paragraph", content: [{ type: "text", text: "See" }] }, + { type: "base", attrs: { pageId: PAGE_ID } }, + { type: "paragraph", content: [{ type: "text", text: "for dates" }] }, + ]); + }); +}); diff --git a/apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts b/apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts new file mode 100644 index 00000000..4db885d9 --- /dev/null +++ b/apps/client/src/features/editor/components/base-embed/convert-mention-to-base-embed.ts @@ -0,0 +1,33 @@ +import type { Editor } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { getBlockPlacement } from "@/features/editor/utils/block-placement"; + +function getEmbedPlacement(doc: ProseMirrorNode, pos: number) { + const mention = doc.nodeAt(pos); + const baseType = doc.type.schema.nodes.base; + if (mention?.type.name !== "mention" || !baseType) return null; + + return getBlockPlacement( + doc, + pos, + pos + mention.nodeSize, + baseType.create({ pageId: mention.attrs.entityId }), + ); +} + +export function canConvertMentionToBaseEmbed( + doc: ProseMirrorNode, + pos: number, +): boolean { + return getEmbedPlacement(doc, pos) !== null; +} + +export function convertMentionToBaseEmbed(editor: Editor, pos: number): boolean { + const placement = getEmbedPlacement(editor.state.doc, pos); + if (!placement) return false; + + return editor.commands.command(({ tr }) => { + tr.replaceWith(placement.range.from, placement.range.to, placement.content); + return true; + }); +} diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 642d05a9..212af981 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -131,10 +131,12 @@ export const handlePaste = ( } } - if (INTERNAL_LINK_REGEX.test(clipboardData)) { + // A query, such as a base's ?view=, still links to the same page. + const pageLink = clipboardData.replace(/\?[^#]*/, ""); + if (INTERNAL_LINK_REGEX.test(pageLink)) { // we have to do this validation here to allow the default link extension to takeover if needs be event.preventDefault(); - const url = clipboardData.trim(); + const url = pageLink.trim(); const { from: pos, empty } = editor.state.selection; const match = INTERNAL_LINK_REGEX.exec(url); diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx index f2ae8b91..d2f8f1a1 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -8,6 +8,7 @@ import { IconChevronDown, IconInfoCircle, IconLayoutKanban, + IconLayoutNavbar, IconMath, IconMathFunction, IconRotate2, @@ -87,6 +88,13 @@ export const MoreInsertsGroup: FC = ({ editor, templateMode }) => { > {t("Toggle block")} + } + disabled={!editor.can().insertTabs()} + onClick={() => editor.chain().focus().insertTabs().run()} + > + {t("Tabs")} + } onClick={() => diff --git a/apps/client/src/features/editor/components/integration-link/integration-display.ts b/apps/client/src/features/editor/components/integration-link/integration-display.ts index 61b6e330..838ac027 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-display.ts +++ b/apps/client/src/features/editor/components/integration-link/integration-display.ts @@ -1,6 +1,7 @@ import { getMarkRange, type Editor } from "@tiptap/core"; -import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { Fragment } from "@tiptap/pm/model"; import { NodeSelection } from "@tiptap/pm/state"; +import { getBlockPlacement } from "@/features/editor/utils/block-placement"; export type IntegrationDisplay = "card" | "mention" | "url"; @@ -66,74 +67,13 @@ function getCardPlacement( range: null; content: null; } { - const $from = editor.state.doc.resolve(from); - if ($from.parent.type.name !== "paragraph") { - return { canPlace: false, range: null, content: null }; - } - - const parentStart = $from.start(); - const parentEnd = parentStart + $from.parent.content.size; - if (from < parentStart || to > parentEnd || from > to) { - return { canPlace: false, range: null, content: null }; - } - - const depth = $from.depth; - const parentContainer = $from.node(depth - 1); - const parentIndex = $from.index(depth - 1); const cardType = editor.state.schema.nodes.integrationCard; - if (!cardType) { - return { canPlace: false, range: null, content: null }; - } - - const before = trimInlineBoundary( - $from.parent.content.cut(0, from - parentStart), - "end", - ); - const after = trimInlineBoundary( - $from.parent.content.cut(to - parentStart), - "start", - ); - const replacement: ProseMirrorNode[] = []; - if (before.size) replacement.push($from.parent.copy(before)); - replacement.push(cardType.create(attrs)); - if (after.size) replacement.push($from.parent.copy(after)); - - const content = Fragment.fromArray(replacement); - if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) { - return { canPlace: false, range: null, content: null }; - } - - return { - canPlace: true, - range: { from: $from.before(depth), to: $from.after(depth) }, - content, - }; -} - -function trimInlineBoundary( - fragment: Fragment, - boundary: "start" | "end", -): Fragment { - const children: ProseMirrorNode[] = []; - fragment.forEach((child) => children.push(child)); - - while (children.length) { - const index = boundary === "start" ? 0 : children.length - 1; - const child = children[index]; - if (!child?.isText) break; - - const text = - boundary === "start" - ? child.text?.replace(/^\s+/, "") - : child.text?.replace(/\s+$/, ""); - if (text) { - children[index] = child.type.schema.text(text, child.marks); - break; - } - children.splice(index, 1); - } - - return Fragment.fromArray(children); + const placement = cardType + ? getBlockPlacement(editor.state.doc, from, to, cardType.create(attrs)) + : null; + return placement + ? { canPlace: true, ...placement } + : { canPlace: false, range: null, content: null }; } function replaceWithCard( diff --git a/apps/client/src/features/editor/components/link/internal-link-paste.ts b/apps/client/src/features/editor/components/link/internal-link-paste.ts index 61aa2b69..b59d9b49 100644 --- a/apps/client/src/features/editor/components/link/internal-link-paste.ts +++ b/apps/client/src/features/editor/components/link/internal-link-paste.ts @@ -3,6 +3,8 @@ import { getPageById } from "@/features/page/services/page-service.ts"; import { IPage } from "@/features/page/types/page.types.ts"; import { v7 } from "uuid"; import { extractPageSlugId } from "@/lib"; +import { basePasteMenuKey } from "@/features/editor/extensions/base-paste-menu"; +import { canConvertMentionToBaseEmbed } from "@/features/editor/components/base-embed/convert-mention-to-base-embed"; export type LinkFn = ( url: string, @@ -42,6 +44,9 @@ export const handleInternalLink = if (!node) return; const transaction = view.state.tr.replaceWith(pos, pos, node); + if (page.isBase && canConvertMentionToBaseEmbed(transaction.doc, pos)) { + transaction.setMeta(basePasteMenuKey, { pos }); + } view.dispatch(transaction); }, () => { diff --git a/apps/client/src/features/editor/extensions/base-paste-menu.ts b/apps/client/src/features/editor/extensions/base-paste-menu.ts new file mode 100644 index 00000000..d9d08b95 --- /dev/null +++ b/apps/client/src/features/editor/extensions/base-paste-menu.ts @@ -0,0 +1,39 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export type BasePasteMenuState = { pos: number } | null; + +export const basePasteMenuKey = new PluginKey( + "basePasteMenu", +); + +// Position of a just-pasted base mention, where the "Paste as" menu offers its embed. +export const BasePasteMenuExtension = Extension.create({ + name: "basePasteMenu", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: basePasteMenuKey, + state: { + init: (): BasePasteMenuState => null, + apply(tr, prev): BasePasteMenuState { + const meta = tr.getMeta(basePasteMenuKey); + if (meta !== undefined) return meta; + if (!prev) return null; + // Clicking or typing elsewhere dismisses. + if (tr.selectionSet) return null; + // Follow-up transactions (unique ids, trailing node) keep the anchor. + if (tr.docChanged) { + const pos = tr.mapping.map(prev.pos); + return tr.doc.nodeAt(pos)?.type.name === "mention" + ? { pos } + : null; + } + return prev; + }, + }, + }), + ]; + }, +}); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 8079eaf7..484b6d7c 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -104,6 +104,7 @@ import SubpagesView from "@/features/editor/components/subpages/subpages-view.ts import IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx"; import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx"; import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu"; +import { BasePasteMenuExtension } from "@/features/editor/extensions/base-paste-menu"; import TabsView from "@/features/editor/components/tabs/tabs-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; @@ -408,6 +409,7 @@ export const mainExtensions = [ view: IntegrationMentionView, }), IntegrationPasteMenuExtension, + BasePasteMenuExtension, Status.configure({ view: StatusView, }), diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 4f7a8b18..79b57ec6 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -83,6 +83,7 @@ import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx"; +import { BasePasteMenu } from "@/features/editor/components/base-embed/base-paste-menu.tsx"; import { getInstalledIntegrations } from "@/features/integration/services/integration-service"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { useTranslation } from "react-i18next"; @@ -527,6 +528,7 @@ function CollabPageEditor({ + )} {editor && !editorIsEditable && (editable || canComment) && ( diff --git a/apps/client/src/features/editor/utils/block-placement.ts b/apps/client/src/features/editor/utils/block-placement.ts new file mode 100644 index 00000000..e60084c2 --- /dev/null +++ b/apps/client/src/features/editor/utils/block-placement.ts @@ -0,0 +1,74 @@ +import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export type BlockPlacement = { + range: { from: number; to: number }; + content: Fragment; +}; + +// Swaps an inline range for a block, splitting its paragraph around it; null where the block can't go. +export function getBlockPlacement( + doc: ProseMirrorNode, + from: number, + to: number, + block: ProseMirrorNode, +): BlockPlacement | null { + const $from = doc.resolve(from); + if ($from.parent.type.name !== "paragraph") return null; + + const parentStart = $from.start(); + const parentEnd = parentStart + $from.parent.content.size; + if (from < parentStart || to > parentEnd || from > to) return null; + + const depth = $from.depth; + const parentContainer = $from.node(depth - 1); + const parentIndex = $from.index(depth - 1); + + const before = trimInlineBoundary( + $from.parent.content.cut(0, from - parentStart), + "end", + ); + const after = trimInlineBoundary( + $from.parent.content.cut(to - parentStart), + "start", + ); + const replacement: ProseMirrorNode[] = []; + if (before.size) replacement.push($from.parent.copy(before)); + replacement.push(block); + if (after.size) replacement.push($from.parent.copy(after)); + + const content = Fragment.fromArray(replacement); + if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) { + return null; + } + + return { + range: { from: $from.before(depth), to: $from.after(depth) }, + content, + }; +} + +function trimInlineBoundary( + fragment: Fragment, + boundary: "start" | "end", +): Fragment { + const children: ProseMirrorNode[] = []; + fragment.forEach((child) => children.push(child)); + + while (children.length) { + const index = boundary === "start" ? 0 : children.length - 1; + const child = children[index]; + if (!child?.isText) break; + + const text = + boundary === "start" + ? child.text?.replace(/^\s+/, "") + : child.text?.replace(/\s+$/, ""); + if (text) { + children[index] = child.type.schema.text(text, child.marks); + break; + } + children.splice(index, 1); + } + + return Fragment.fromArray(children); +} diff --git a/apps/server/src/core/integration/integration.module.ts b/apps/server/src/core/integration/integration.module.ts index 3b152145..d6aad24a 100644 --- a/apps/server/src/core/integration/integration.module.ts +++ b/apps/server/src/core/integration/integration.module.ts @@ -11,11 +11,17 @@ import { IntegrationRepo } from './repos/integration.repo'; import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; import { IntegrationListener } from './integration.listener'; import { IntegrationProcessor } from './integration.processor'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; @Module({ controllers: [IntegrationController, OAuthController, UnfurlController], providers: [ - IntegrationRegistry, + { + provide: IntegrationRegistry, + useFactory: (environmentService: EnvironmentService) => + new IntegrationRegistry(environmentService.isCloud()), + inject: [EnvironmentService], + }, IntegrationService, IntegrationConnectionService, OAuthService, diff --git a/apps/server/src/core/integration/providers/github/github.provider.spec.ts b/apps/server/src/core/integration/providers/github/github.provider.spec.ts index 4afb54f0..0e0cf3e2 100644 --- a/apps/server/src/core/integration/providers/github/github.provider.spec.ts +++ b/apps/server/src/core/integration/providers/github/github.provider.spec.ts @@ -61,7 +61,7 @@ describe('GitHubProvider host matching', () => { const { provider, unfurlPullRequest } = buildProvider(); await unfurlWith(provider, PUBLIC_PR); expect(unfurlPullRequest).toHaveBeenCalledWith( - 'token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR, + 'Bearer token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR, ); }); }); @@ -87,7 +87,7 @@ describe('GitHubProvider host matching', () => { const { provider, unfurlPullRequest } = buildProvider(); await unfurlWith(provider, GHE_PR); expect(unfurlPullRequest).toHaveBeenCalledWith( - 'token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR, + 'Bearer token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR, ); }); diff --git a/apps/server/src/core/integration/providers/github/github.provider.ts b/apps/server/src/core/integration/providers/github/github.provider.ts index a179e029..6850eb94 100644 --- a/apps/server/src/core/integration/providers/github/github.provider.ts +++ b/apps/server/src/core/integration/providers/github/github.provider.ts @@ -5,6 +5,7 @@ import { LinkDescription, OAuthConfig, ProviderAccount, + PublicUnfurlOpts, ResolveAccountOpts, UnfurlPattern, UnfurlOpts, @@ -13,6 +14,7 @@ import { import { GitHubService } from './github.service'; import { buildGitHubPatterns } from './github-patterns'; import { normalizeBaseUrl } from '../../utils/integration.utils'; +import { credentialEnvKey } from '../../oauth/credential-env'; const DEFAULT_BASE_URL = 'https://github.com'; @@ -63,13 +65,35 @@ export class GitHubProvider extends IntegrationProvider { async resolveAccount(opts: ResolveAccountOpts): Promise { return this.githubService.getAccount( - opts.accessToken, + `Bearer ${opts.accessToken}`, this.resolveApiBaseUrl(), ); } async unfurl(opts: UnfurlOpts): Promise { - const { match, patternType, accessToken, url } = opts; + return this.unfurlWith(`Bearer ${opts.accessToken}`, opts); + } + + // The OAuth app's own credentials only see public repos, so private ones 404. + async unfurlPublic(opts: PublicUnfurlOpts): Promise { + const clientId = + process.env[credentialEnvKey('CLIENT_ID', this.definition.type)]; + const clientSecret = + process.env[credentialEnvKey('CLIENT_SECRET', this.definition.type)]; + if (!clientId || !clientSecret) { + throw new Error('GitHub client ID or secret is not set'); + } + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64', + ); + return this.unfurlWith(`Basic ${credentials}`, opts); + } + + private async unfurlWith( + authorization: string, + opts: PublicUnfurlOpts, + ): Promise { + const { match, patternType, url } = opts; const apiBaseUrl = this.resolveApiBaseUrl(); const owner = match[1]; const repo = match[2]; @@ -78,33 +102,33 @@ export class GitHubProvider extends IntegrationProvider { case 'github-pr': { const number = parseInt(match[3], 10); return this.githubService.unfurlPullRequest( - accessToken, apiBaseUrl, owner, repo, number, url, + authorization, apiBaseUrl, owner, repo, number, url, ); } case 'github-issue': { const number = parseInt(match[3], 10); return this.githubService.unfurlIssue( - accessToken, apiBaseUrl, owner, repo, number, url, + authorization, apiBaseUrl, owner, repo, number, url, ); } case 'github-repo': return this.githubService.unfurlRepo( - accessToken, apiBaseUrl, owner, repo, url, + authorization, apiBaseUrl, owner, repo, url, ); case 'github-commit': { const sha = match[3]; return this.githubService.unfurlCommit( - accessToken, apiBaseUrl, owner, repo, sha, url, + authorization, apiBaseUrl, owner, repo, sha, url, ); } case 'github-pr-commit': { const sha = match[4]; return this.githubService.unfurlCommit( - accessToken, apiBaseUrl, owner, repo, sha, url, + authorization, apiBaseUrl, owner, repo, sha, url, ); } @@ -122,7 +146,7 @@ export class GitHubProvider extends IntegrationProvider { case 'github-issues-list': case 'github-releases-list': return this.githubService.unfurlCollectionPage( - accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url, + authorization, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url, ); default: diff --git a/apps/server/src/core/integration/providers/github/github.service.spec.ts b/apps/server/src/core/integration/providers/github/github.service.spec.ts index 63703b7b..24d6b0ab 100644 --- a/apps/server/src/core/integration/providers/github/github.service.spec.ts +++ b/apps/server/src/core/integration/providers/github/github.service.spec.ts @@ -28,11 +28,11 @@ const BODY = { const service = new GitHubService(); const REQUESTS: [string, (api: string, owner: string, repo: string) => Promise, string][] = [ - ['pull request', (api, owner, repo) => service.unfurlPullRequest('token', api, owner, repo, 1, PAGE), '/pulls/1'], - ['issue', (api, owner, repo) => service.unfurlIssue('token', api, owner, repo, 2, PAGE), '/issues/2'], - ['repo', (api, owner, repo) => service.unfurlRepo('token', api, owner, repo, PAGE), ''], - ['commit', (api, owner, repo) => service.unfurlCommit('token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'], - ['collection page', (api, owner, repo) => service.unfurlCollectionPage('token', api, owner, repo, 'pulls-list', PAGE), ''], + ['pull request', (api, owner, repo) => service.unfurlPullRequest('Bearer token', api, owner, repo, 1, PAGE), '/pulls/1'], + ['issue', (api, owner, repo) => service.unfurlIssue('Bearer token', api, owner, repo, 2, PAGE), '/issues/2'], + ['repo', (api, owner, repo) => service.unfurlRepo('Bearer token', api, owner, repo, PAGE), ''], + ['commit', (api, owner, repo) => service.unfurlCommit('Bearer token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'], + ['collection page', (api, owner, repo) => service.unfurlCollectionPage('Bearer token', api, owner, repo, 'pulls-list', PAGE), ''], ]; function requestedUrls(): string[] { @@ -144,3 +144,57 @@ describe('GitHub connected account', () => { ); }); }); + +describe('GitHub public unfurl', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + INTEGRATION_GITHUB_CLIENT_ID: 'app-id', + INTEGRATION_GITHUB_CLIENT_SECRET: 'app-secret', + }; + delete process.env.INTEGRATION_GITHUB_URL; + fetchMock.mockReset(); + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify(BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function unfurlPublic(url: string) { + const provider = new GitHubProvider(new GitHubService()); + for (const pattern of provider.getUnfurlPatterns({})) { + const match = url.match(pattern.regex); + if (match) { + return provider.unfurlPublic({ url, match, patternType: pattern.type }); + } + } + throw new Error(`No GitHub pattern matches ${url}`); + } + + it('reads a pull request with the app client ID and secret instead of a member token', async () => { + await expect(unfurlPublic(PAGE)).resolves.toMatchObject({ + title: 'Add dark mode', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/o/r/pulls/1'); + // base64 of "app-id:app-secret" + expect(init.headers.Authorization).toBe('Basic YXBwLWlkOmFwcC1zZWNyZXQ='); + }); + + it('never calls GitHub without the app credentials', async () => { + delete process.env.INTEGRATION_GITHUB_CLIENT_SECRET; + + await expect(unfurlPublic(PAGE)).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/core/integration/providers/github/github.service.ts b/apps/server/src/core/integration/providers/github/github.service.ts index e5f02241..9d32d3b5 100644 --- a/apps/server/src/core/integration/providers/github/github.service.ts +++ b/apps/server/src/core/integration/providers/github/github.service.ts @@ -11,10 +11,10 @@ import { repoApiPath } from './github.utils'; @Injectable() export class GitHubService { async getAccount( - accessToken: string, + authorization: string, apiBaseUrl: string, ): Promise { - const data = await this.apiGet(accessToken, apiBaseUrl, '/user'); + const data = await this.apiGet(authorization, apiBaseUrl, '/user'); return toProviderAccount({ id: data.id, displayName: data.name, @@ -23,7 +23,7 @@ export class GitHubService { } async unfurlPullRequest( - accessToken: string, + authorization: string, apiBaseUrl: string, owner: string, repo: string, @@ -31,7 +31,7 @@ export class GitHubService { url: string, ): Promise { const data = await this.apiGet( - accessToken, + authorization, apiBaseUrl, `${repoApiPath(owner, repo)}/pulls/${number}`, ); @@ -66,7 +66,7 @@ export class GitHubService { } async unfurlIssue( - accessToken: string, + authorization: string, apiBaseUrl: string, owner: string, repo: string, @@ -74,7 +74,7 @@ export class GitHubService { url: string, ): Promise { const data = await this.apiGet( - accessToken, + authorization, apiBaseUrl, `${repoApiPath(owner, repo)}/issues/${number}`, ); @@ -107,14 +107,14 @@ export class GitHubService { } async unfurlRepo( - accessToken: string, + authorization: string, apiBaseUrl: string, owner: string, repo: string, url: string, ): Promise { const data = await this.apiGet( - accessToken, + authorization, apiBaseUrl, repoApiPath(owner, repo), ); @@ -143,7 +143,7 @@ export class GitHubService { } async unfurlCommit( - accessToken: string, + authorization: string, apiBaseUrl: string, owner: string, repo: string, @@ -151,7 +151,7 @@ export class GitHubService { url: string, ): Promise { const data = await this.apiGet( - accessToken, + authorization, apiBaseUrl, `${repoApiPath(owner, repo)}/commits/${sha}`, ); @@ -217,7 +217,7 @@ export class GitHubService { } async unfurlCollectionPage( - accessToken: string, + authorization: string, apiBaseUrl: string, owner: string, repo: string, @@ -225,7 +225,7 @@ export class GitHubService { url: string, ): Promise { const data = await this.apiGet( - accessToken, + authorization, apiBaseUrl, repoApiPath(owner, repo), ); @@ -265,13 +265,13 @@ export class GitHubService { } private async apiGet( - accessToken: string, + authorization: string, apiBaseUrl: string, path: string, ): Promise { const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, { headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: authorization, Accept: 'application/vnd.github.v3+json', 'User-Agent': 'Docmost', }, diff --git a/apps/server/src/core/integration/registry/integration-provider.interface.ts b/apps/server/src/core/integration/registry/integration-provider.interface.ts index 43d1aab4..df00a528 100644 --- a/apps/server/src/core/integration/registry/integration-provider.interface.ts +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -140,6 +140,8 @@ export type UnfurlOpts = { integrationId: string; }; +export type PublicUnfurlOpts = Pick; + // UnfurlService shows no card for it instead of logging an error. export class UnfurlForbiddenError extends Error { constructor(message = 'Not authorized to unfurl this link') { @@ -221,6 +223,9 @@ export abstract class IntegrationProvider { unfurl?(opts: UnfurlOpts): Promise; + // For members without a connection; must only read public data, since every workspace shares the card. + unfurlPublic?(opts: PublicUnfurlOpts): Promise; + // Tokenless summary for the connect prompt, e.g. "Pull Request #13337". describeLink?( patternType: string, diff --git a/apps/server/src/core/integration/registry/integration-registry.spec.ts b/apps/server/src/core/integration/registry/integration-registry.spec.ts index 48ad4f53..7607d0db 100644 --- a/apps/server/src/core/integration/registry/integration-registry.spec.ts +++ b/apps/server/src/core/integration/registry/integration-registry.spec.ts @@ -22,4 +22,30 @@ describe('IntegrationRegistry', () => { 'Integration provider "linear" is already registered', ); }); + + describe('available integrations', () => { + const types = ['github', 'figma', 'linear', 'jira', 'gitlab']; + + it('offers only GitHub, GitLab and Linear on Cloud', () => { + const registry = new IntegrationRegistry(true); + types.forEach((type) => registry.register(provider(type))); + + expect(registry.getAvailableIntegrations().map((d) => d.type)).toEqual([ + 'github', + 'linear', + 'gitlab', + ]); + // Existing installs of the others keep unfurling. + expect(registry.getProvider('figma')).toBeDefined(); + }); + + it('offers every provider on self-hosted', () => { + const registry = new IntegrationRegistry(); + types.forEach((type) => registry.register(provider(type))); + + expect(registry.getAvailableIntegrations().map((d) => d.type)).toEqual( + types, + ); + }); + }); }); diff --git a/apps/server/src/core/integration/registry/integration-registry.ts b/apps/server/src/core/integration/registry/integration-registry.ts index 02edfd4f..5c1ba44d 100644 --- a/apps/server/src/core/integration/registry/integration-registry.ts +++ b/apps/server/src/core/integration/registry/integration-registry.ts @@ -4,15 +4,23 @@ import { IntegrationProvider, } from './integration-provider.interface'; +// Docmost Cloud only offers the providers it has OAuth apps for. +const CLOUD_INTEGRATION_TYPES = new Set(['github', 'gitlab', 'linear']); + @Injectable() export class IntegrationRegistry { private providers = new Map(); + constructor(private readonly cloud = false) {} + register(provider: IntegrationProvider): void { const { type } = provider.definition; if (this.providers.has(type)) { throw new Error(`Integration provider "${type}" is already registered`); } + if (this.cloud && !CLOUD_INTEGRATION_TYPES.has(type)) { + provider.definition.hidden = true; + } this.providers.set(type, provider); } diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts b/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts index 5621389d..0c755373 100644 --- a/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts +++ b/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts @@ -1,16 +1,32 @@ import * as crypto from 'crypto'; +import { Logger } from '@nestjs/common'; import { UnfurlService } from './unfurl.service'; import { IntegrationRegistry } from '../registry/integration-registry'; import { GitHubProvider } from '../providers/github/github.provider'; import { IntegrationDefinition, IntegrationProvider, + ProviderApiError, TokenExpiredError, TokenInvalidError, } from '../registry/integration-provider.interface'; const GHE_BASE = 'https://github.acme.com'; const CONNECTED_AT = new Date('2026-10-01T09:00:00Z'); +const ORIGINAL_ENV = process.env; + +// A configured server always has the app credentials, since GitHub cannot be installed without them. +beforeEach(() => { + process.env = { + ...ORIGINAL_ENV, + INTEGRATION_GITHUB_CLIENT_ID: 'app-id', + INTEGRATION_GITHUB_CLIENT_SECRET: 'app-secret', + }; +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; +}); class WorkspaceScopedProvider extends IntegrationProvider { definition: IntegrationDefinition = { @@ -102,7 +118,7 @@ describe('UnfurlService provider resolution', () => { title: 'PR', }); expect(unfurlPullRequest).toHaveBeenCalledWith( - 'token', apiBaseUrl, 'o', 'r', 1, url, + 'Bearer token', apiBaseUrl, 'o', 'r', 1, url, ); }, ); @@ -216,16 +232,42 @@ describe('UnfurlService cache', () => { }); it('asks a disconnected member to connect instead of serving their cached card', async () => { - const { service, connectionRepo, redis } = build(); - redis.get.mockResolvedValue(cachedCard); + const { service, unfurlPullRequest, connectionRepo, redis } = build(); + redis.get.mockImplementation(async (key: string) => + key.startsWith('unfurl:w1:') ? cachedCard : null, + ); connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined); + unfurlPullRequest.mockRejectedValue( + new ProviderApiError('GitHub', 404, 'Not Found'), + ); await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({ needsConnection: true, }); - expect(redis.get).not.toHaveBeenCalled(); - expect(redis.set).not.toHaveBeenCalled(); + expect(redis.get).not.toHaveBeenCalledWith( + expect.stringMatching(/^unfurl:w1:/), + ); + }); + + it('checks a link the member cannot see again a minute later', async () => { + const { service, unfurlPullRequest, redis } = build(); + unfurlPullRequest.mockRejectedValue( + new ProviderApiError('GitHub', 404, 'Not Found'), + ); + const error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toBeNull(); + + expect(redis.set).toHaveBeenCalledWith( + `unfurl:w1:u1:c1:${CONNECTED_AT.getTime()}:${urlDigest}`, + 'null', + 'EX', + 60, + ); + error.mockRestore(); }); it('serves no cached card once the integration is uninstalled', async () => { @@ -322,3 +364,68 @@ describe('UnfurlService cache', () => { }); }); }); + +describe('UnfurlService public cards', () => { + const url = 'https://github.com/o/r/pull/1'; + let warn: jest.SpyInstance; + + beforeEach(() => { + delete process.env.INTEGRATION_GITHUB_URL; + warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => warn.mockRestore()); + + function buildDisconnected() { + const built = build(); + built.connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined); + return built; + } + + it('shows a member without a connection the card for a public repo', async () => { + const { service, unfurlPullRequest } = buildDisconnected(); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toEqual({ + title: 'PR', + }); + // base64 of "app-id:app-secret" + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'Basic YXBwLWlkOmFwcC1zZWNyZXQ=', 'https://api.github.com', 'o', 'r', 1, url, + ); + }); + + it('fetches a public card once for every member and workspace', async () => { + const { service, unfurlPullRequest, redis } = buildDisconnected(); + const store = new Map(); + redis.get.mockImplementation(async (key: string) => store.get(key) ?? null); + redis.set.mockImplementation(async (key: string, value: string) => { + store.set(key, value); + return 'OK'; + }); + + await service.unfurl(url, 'u1', 'w1'); + await expect(service.unfurl(url, 'u2', 'w2')).resolves.toEqual({ + title: 'PR', + }); + + expect(unfurlPullRequest).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['a repo that is not public', new ProviderApiError('GitHub', 404, 'Not Found'), 0], + ['a rate limit', new ProviderApiError('GitHub', 403, 'rate limit exceeded'), 1], + ['rejected app credentials', new TokenInvalidError(), 1], + ])('asks to connect after %s and retries a minute later', async (_, error, warnings) => { + const { service, unfurlPullRequest, redis } = buildDisconnected(); + unfurlPullRequest.mockRejectedValue(error); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({ + needsConnection: true, + }); + + expect(redis.set).toHaveBeenCalledWith(expect.any(String), 'null', 'EX', 60); + expect(warn).toHaveBeenCalledTimes(warnings); + }); +}); diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.ts b/apps/server/src/core/integration/unfurl/unfurl.service.ts index 98071c35..8d190137 100644 --- a/apps/server/src/core/integration/unfurl/unfurl.service.ts +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -19,7 +19,7 @@ import type { Redis } from 'ioredis'; import * as crypto from 'crypto'; const UNFURL_CACHE_TTL = 300; // 5 minutes -// Short negative cache for transient failures; a 404 uses the normal TTL. +// Short negative cache, so a card shows up soon after access changes. const UNFURL_ERROR_CACHE_TTL = 60; const UNFURL_CACHE_PREFIX = 'unfurl:'; @@ -71,6 +71,15 @@ export class UnfurlService { if (connectionScope === 'workspace') { return null; } + const publicResult = await this.unfurlPublic( + provider, + patternType, + match, + url, + ); + if (publicResult) { + return publicResult; + } // Not cached, so the card loads once the user connects. return this.buildNeedsConnection( provider, @@ -152,11 +161,7 @@ export class UnfurlService { ); } this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`); - const ttl = - err instanceof ProviderApiError && err.status === 404 - ? UNFURL_CACHE_TTL - : UNFURL_ERROR_CACHE_TTL; - await this.cacheNull(cacheKey, ttl); + await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL); return null; } } @@ -165,6 +170,50 @@ export class UnfurlService { await this.redis.set(cacheKey, 'null', 'EX', ttl); } + // One cached card per URL for every member and workspace. + private async unfurlPublic( + provider: IntegrationProvider, + patternType: string, + match: RegExpMatchArray, + url: string, + ): Promise { + if (!provider.unfurlPublic) { + return null; + } + + const hash = crypto.createHash('sha256').update(url).digest('hex'); + const cacheKey = `${UNFURL_CACHE_PREFIX}public:${hash}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + try { + const unfurlResult = await provider.unfurlPublic({ + url, + match, + patternType, + }); + await this.redis.set( + cacheKey, + JSON.stringify(unfurlResult), + 'EX', + UNFURL_CACHE_TTL, + ); + return unfurlResult; + } catch (err) { + // A 404 is the normal answer for a private repo. + const notFound = err instanceof ProviderApiError && err.status === 404; + if (!notFound) { + this.logger.warn( + `Public unfurl failed for ${url}: ${(err as Error).message}`, + ); + } + await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL); + return null; + } + } + private buildNeedsConnection( provider: IntegrationProvider, integrationId: string, From ded9b7ca9f147e987c5998abf047b59c731d14ff Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:54:26 +0100 Subject: [PATCH 02/13] New Crowdin updates (#2590) --- apps/client/public/locales/de-DE/translation.json | 3 ++- apps/client/public/locales/es-ES/translation.json | 3 ++- apps/client/public/locales/fr-FR/translation.json | 3 ++- apps/client/public/locales/it-IT/translation.json | 3 ++- apps/client/public/locales/ja-JP/translation.json | 3 ++- apps/client/public/locales/ko-KR/translation.json | 3 ++- apps/client/public/locales/nl-NL/translation.json | 3 ++- apps/client/public/locales/pt-BR/translation.json | 3 ++- apps/client/public/locales/ru-RU/translation.json | 3 ++- apps/client/public/locales/uk-UA/translation.json | 3 ++- apps/client/public/locales/zh-CN/translation.json | 3 ++- 11 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 91f79d69..78a8035d 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Nach rechts verschieben", "Delete tab": "Tab löschen", "Tab label": "Tab-Beschriftung", - "Tab actions for {{name}}": "Tab-Aktionen für {{name}}" + "Tab actions for {{name}}": "Tab-Aktionen für {{name}}", + "Inline base": "Inline-Basis" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index 50ab7de9..c74c610a 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Mover a la derecha", "Delete tab": "Eliminar pestaña", "Tab label": "Etiqueta de la pestaña", - "Tab actions for {{name}}": "Acciones de pestaña para {{name}}" + "Tab actions for {{name}}": "Acciones de pestaña para {{name}}", + "Inline base": "Base integrada" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 8a14001b..267cbe4c 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Déplacer vers la droite", "Delete tab": "Supprimer l’onglet", "Tab label": "Libellé de l’onglet", - "Tab actions for {{name}}": "Actions de l’onglet pour {{name}}" + "Tab actions for {{name}}": "Actions de l’onglet pour {{name}}", + "Inline base": "Base en ligne" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 0374bf1f..1b8cfa23 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Sposta a destra", "Delete tab": "Elimina scheda", "Tab label": "Etichetta scheda", - "Tab actions for {{name}}": "Azioni scheda per {{name}}" + "Tab actions for {{name}}": "Azioni scheda per {{name}}", + "Inline base": "Base inline" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 33979763..bcba16ea 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -1573,5 +1573,6 @@ "Move right": "右に移動", "Delete tab": "タブを削除", "Tab label": "タブラベル", - "Tab actions for {{name}}": "{{name}} のタブアクション" + "Tab actions for {{name}}": "{{name}} のタブアクション", + "Inline base": "インラインベース" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 46d5ffe8..790edd03 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -1573,5 +1573,6 @@ "Move right": "오른쪽으로 이동", "Delete tab": "탭 삭제", "Tab label": "탭 레이블", - "Tab actions for {{name}}": "{{name}} 탭 작업" + "Tab actions for {{name}}": "{{name}} 탭 작업", + "Inline base": "인라인 베이스" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 4e6579a9..9247f2ac 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Naar rechts verplaatsen", "Delete tab": "Tabblad verwijderen", "Tab label": "Tabbladlabel", - "Tab actions for {{name}}": "Tabbladacties voor {{name}}" + "Tab actions for {{name}}": "Tabbladacties voor {{name}}", + "Inline base": "Inline basis" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index d308dadf..1761d6f9 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Mover para a direita", "Delete tab": "Excluir aba", "Tab label": "Rótulo da aba", - "Tab actions for {{name}}": "Ações da aba para {{name}}" + "Tab actions for {{name}}": "Ações da aba para {{name}}", + "Inline base": "Base embutida" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index dff11b0d..dd0dc502 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Переместить вправо", "Delete tab": "Удалить вкладку", "Tab label": "Название вкладки", - "Tab actions for {{name}}": "Действия с вкладкой для {{name}}" + "Tab actions for {{name}}": "Действия с вкладкой для {{name}}", + "Inline base": "Базовый встроенный элемент" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 340aa45c..9316b415 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -1573,5 +1573,6 @@ "Move right": "Перемістити праворуч", "Delete tab": "Видалити вкладку", "Tab label": "Назва вкладки", - "Tab actions for {{name}}": "Дії вкладки для {{name}}" + "Tab actions for {{name}}": "Дії вкладки для {{name}}", + "Inline base": "Вбудована основа" } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 374d747b..1d57e4f1 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -1573,5 +1573,6 @@ "Move right": "右移", "Delete tab": "删除标签页", "Tab label": "标签页标签", - "Tab actions for {{name}}": "{{name}}的标签页操作" + "Tab actions for {{name}}": "{{name}}的标签页操作", + "Inline base": "内联基础" } From 2d24a1286776776a0db08ad285e6ea1a05db3a69 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:06:49 +0100 Subject: [PATCH 03/13] fix(base): show embed controls on the pending skeleton (#2594) --- .../features/editor/components/base-embed/base-embed-view.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx index 12a453ce..9dd32687 100644 --- a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx +++ b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx @@ -110,7 +110,8 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) { // mounts) is reserved only for the skeleton/loading/table states. const isCompact = !pendingKey && (!pageId || isError); - const showControls = editor.isEditable && !pendingKey; + // Also on the skeleton, so a placeholder stuck pending can still be removed. + const showControls = editor.isEditable; let content: React.ReactNode; if (pendingKey) { From 611452112da5c6302bfec2057563e288b2a25a9a Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:45:52 +0100 Subject: [PATCH 04/13] fix: email throttle (#2595) * fix: update throttle config * sync --- apps/server/src/ee | 2 +- apps/server/src/integrations/throttle/throttle.module.ts | 2 ++ apps/server/src/integrations/throttle/throttler-names.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index 7eb0ef1d..481e7791 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 7eb0ef1dfa2f5ed9e6331e8d068cbbe15be25d7d +Subproject commit 481e7791f8bbcdc368e31c012f34cc52a4227587 diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index e867f170..60551963 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -12,6 +12,7 @@ import { OAUTH_AUTHORIZE_THROTTLER, SIEM_TEST_THROTTLER, UNFURL_THROTTLER, + CLOUD_EMAIL_THROTTLER, } from './throttler-names'; import Redis from 'ioredis'; @@ -31,6 +32,7 @@ import Redis from 'ioredis'; { name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 }, { name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 }, { name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 }, + { name: CLOUD_EMAIL_THROTTLER, ttl: 60_000, limit: 5 }, ], errorMessage: 'Too many requests', storage: new ThrottlerStorageRedisService( diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts index db913f51..9c042963 100644 --- a/apps/server/src/integrations/throttle/throttler-names.ts +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -5,6 +5,7 @@ export const OAUTH_TOKEN_THROTTLER = 'oauth-token'; export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize'; export const SIEM_TEST_THROTTLER = 'siem-test'; export const UNFURL_THROTTLER = 'integration-unfurl'; +export const CLOUD_EMAIL_THROTTLER = 'cloud-email'; // Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false. export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { @@ -15,4 +16,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { [OAUTH_AUTHORIZE_THROTTLER]: true, [SIEM_TEST_THROTTLER]: true, [UNFURL_THROTTLER]: true, + [CLOUD_EMAIL_THROTTLER]: true, }; From 9250ca8305cbb7d04dc1973b8b83346784fc361a Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:48:49 +0100 Subject: [PATCH 05/13] fix: default page content format to json (#2596) --- .../src/core/page/dto/create-page.dto.spec.ts | 16 ++++++++++++++++ .../server/src/core/page/dto/create-page.dto.ts | 2 +- .../src/core/page/dto/update-page.dto.spec.ts | 17 +++++++++++++++++ .../server/src/core/page/dto/update-page.dto.ts | 2 +- 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/core/page/dto/create-page.dto.spec.ts create mode 100644 apps/server/src/core/page/dto/update-page.dto.spec.ts diff --git a/apps/server/src/core/page/dto/create-page.dto.spec.ts b/apps/server/src/core/page/dto/create-page.dto.spec.ts new file mode 100644 index 00000000..a919c06d --- /dev/null +++ b/apps/server/src/core/page/dto/create-page.dto.spec.ts @@ -0,0 +1,16 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreatePageDto } from './create-page.dto'; + +describe('CreatePageDto', () => { + it('defaults format to json when content is sent without one', async () => { + const dto = plainToInstance(CreatePageDto, { + spaceId: '0199f2a4-8c1e-7b3a-9d4e-2f6a8b1c3d5e', + content: { type: 'doc', content: [] }, + }); + const errors = await validate(dto); + + expect(errors.flatMap((e) => Object.keys(e.constraints ?? {}))).toEqual([]); + expect(dto.format).toBe('json'); + }); +}); diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts index 2cad291c..261d8922 100644 --- a/apps/server/src/core/page/dto/create-page.dto.ts +++ b/apps/server/src/core/page/dto/create-page.dto.ts @@ -32,5 +32,5 @@ export class CreatePageDto { @ValidateIf((o) => o.content !== undefined) @Transform(({ value }) => value?.toLowerCase() ?? 'json') @IsIn(['json', 'markdown', 'html']) - format?: ContentFormat; + format?: ContentFormat = 'json'; } diff --git a/apps/server/src/core/page/dto/update-page.dto.spec.ts b/apps/server/src/core/page/dto/update-page.dto.spec.ts new file mode 100644 index 00000000..f8edfd39 --- /dev/null +++ b/apps/server/src/core/page/dto/update-page.dto.spec.ts @@ -0,0 +1,17 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UpdatePageDto } from './update-page.dto'; + +describe('UpdatePageDto', () => { + it('defaults format to json when content is sent without one', async () => { + const dto = plainToInstance(UpdatePageDto, { + pageId: '0199f2a4-8c1e-7b3a-9d4e-2f6a8b1c3d5e', + content: { type: 'doc', content: [] }, + operation: 'replace', + }); + const errors = await validate(dto); + + expect(errors.flatMap((e) => Object.keys(e.constraints ?? {}))).toEqual([]); + expect(dto.format).toBe('json'); + }); +}); diff --git a/apps/server/src/core/page/dto/update-page.dto.ts b/apps/server/src/core/page/dto/update-page.dto.ts index 026b3076..1e139f2d 100644 --- a/apps/server/src/core/page/dto/update-page.dto.ts +++ b/apps/server/src/core/page/dto/update-page.dto.ts @@ -20,5 +20,5 @@ export class UpdatePageDto extends PartialType(CreatePageDto) { @ValidateIf((o) => o.content !== undefined) @Transform(({ value }) => value?.toLowerCase() ?? 'json') @IsIn(['json', 'markdown', 'html']) - format?: ContentFormat; + format?: ContentFormat = 'json'; } From de0b7d75600e462cc96559feda2e156eefe09ebe Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:48:22 +0100 Subject: [PATCH 06/13] chore(deps): package updates (#2598) --- package.json | 2 +- pnpm-lock.yaml | 36 ++++++++++++++++++------------------ pnpm-workspace.yaml | 4 ++-- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 34adafb5..0fd56881 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "date-fns": "4.1.0", "diff": "8.0.3", "docx": "9.7.1", - "dompurify": "3.4.16", + "dompurify": "3.4.17", "fractional-indexing-jittered": "1.0.0", "highlight.js": "11.11.1", "image-dimensions": "2.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a046f1f2..840e9b49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ overrides: prosemirror-changeset: 2.4.0 glob: 13.0.6 ws: 8.21.3 - dompurify: 3.4.16 + dompurify: 3.4.17 mermaid: 11.16.1 katex: 0.19.0 undici: 7.29.1 @@ -17,7 +17,7 @@ overrides: lodash-es: 4.18.1 flatted: 3.4.2 find-my-way: 9.7.0 - fastify: 5.12.5 + fastify: 5.12.7 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.12 axios: 1.20.0 @@ -176,8 +176,8 @@ importers: specifier: 9.7.1 version: 9.7.1 dompurify: - specifier: 3.4.16 - version: 3.4.16 + specifier: 3.4.17 + version: 3.4.17 fractional-indexing-jittered: specifier: 1.0.0 version: 1.0.0 @@ -6077,8 +6077,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.16: - resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6418,8 +6418,8 @@ packages: fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.12.5: - resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + fastify@5.12.7: + resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -6477,8 +6477,8 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -12495,7 +12495,7 @@ snapshots: '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) '@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 - fastify: 5.12.5 + fastify: 5.12.7 find-my-way: 9.7.0 light-my-request: 6.6.0 path-to-regexp: 8.4.2 @@ -14977,7 +14977,7 @@ snapshots: axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) + follow-redirects: 1.16.1(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 https-proxy-agent: 5.0.1(supports-color@10.2.2) proxy-from-env: 2.1.0 @@ -15883,7 +15883,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.16: + dompurify@3.4.17: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16363,7 +16363,7 @@ snapshots: fastify-plugin@6.0.0: {} - fastify@5.12.5: + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.0.0 @@ -16377,7 +16377,7 @@ snapshots: pino: 10.1.0 process-warning: 5.1.0 rfdc: 1.3.1 - secure-json-parse: 4.0.0 + secure-json-parse: 4.1.0 semver: 7.7.4 toad-cache: 3.7.0 @@ -16441,7 +16441,7 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)): + follow-redirects@1.16.1(debug@4.4.3(supports-color@10.2.2)): optionalDependencies: debug: 4.4.3(supports-color@10.2.2) @@ -17786,7 +17786,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.16 + dompurify: 3.4.17 es-toolkit: 1.46.1 katex: 0.19.0 khroma: 2.1.0 @@ -18514,7 +18514,7 @@ snapshots: '@posthog/core': 1.35.3 '@posthog/types': 1.390.2 core-js: 3.43.0 - dompurify: 3.4.16 + dompurify: 3.4.17 fflate: 0.4.9 preact: 10.29.2 query-selector-shadow-dom: 1.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b0fa25a..a134e8d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ overrides: prosemirror-changeset: 2.4.0 glob: 13.0.6 ws: 8.21.3 - dompurify: 3.4.16 + dompurify: 3.4.17 mermaid: 11.16.1 katex: 0.19.0 undici: 7.29.1 @@ -16,7 +16,7 @@ overrides: lodash-es: 4.18.1 flatted: 3.4.2 find-my-way: 9.7.0 - fastify: 5.12.5 + fastify: 5.12.7 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.12 axios: 1.20.0 From 1da7529c0cdc4c22f7b34e3c1cf50d83b105db94 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:17:53 +0100 Subject: [PATCH 07/13] chore: package updates (#2599) --- apps/client/package.json | 4 +- pnpm-lock.yaml | 392 +++++++++++++++++++-------------------- 2 files changed, 198 insertions(+), 198 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 80836fa8..e7fe6f47 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -94,7 +94,7 @@ "prettier": "3.8.1", "typescript": "5.9.3", "typescript-eslint": "8.57.1", - "vite": "8.0.16", - "vitest": "4.1.11" + "vite": "8.3.4", + "vitest": "4.1.12" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 840e9b49..4f4e606c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,7 +443,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: 6.0.1 - version: 6.0.1(vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.1(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: 9.28.0 version: 9.28.0(jiti@2.4.2)(supports-color@10.2.2) @@ -484,11 +484,11 @@ importers: specifier: 8.57.1 version: 8.57.1(eslint@9.28.0(jiti@2.4.2)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) vite: - specifier: 8.0.16 - version: 8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + specifier: 8.3.4 + version: 8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) vitest: - specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + specifier: 4.1.12 + version: 4.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) apps/server: dependencies: @@ -2704,12 +2704,6 @@ packages: '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@nest-lab/throttler-storage-redis@1.2.0': resolution: {integrity: sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw==} peerDependencies: @@ -3058,8 +3052,8 @@ packages: resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} engines: {node: '>=14'} - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -3783,97 +3777,98 @@ packages: '@node-rs/xxhash': optional: true - '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -4924,11 +4919,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/expect@4.1.12': + resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==} - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@4.1.12': + resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4938,20 +4933,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/pretty-format@4.1.12': + resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==} - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/runner@4.1.12': + resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==} - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/snapshot@4.1.12': + resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==} - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@4.1.12': + resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==} - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/utils@4.1.12': + resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -7444,78 +7439,78 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -8192,6 +8187,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + picospinner@3.0.0: resolution: {integrity: sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg==} engines: {node: '>=18.0.0'} @@ -8297,6 +8296,10 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -8710,8 +8713,8 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -9511,13 +9514,13 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite@8.0.16: - resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 + '@vitejs/devtools': ^0.7.1 esbuild: 0.28.1 jiti: '>=1.21.0' less: ^4.0.0 @@ -9554,20 +9557,20 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + vitest@4.1.12: + resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 + '@vitest/browser-playwright': 4.1.12 + '@vitest/browser-preview': 4.1.12 + '@vitest/browser-webdriverio': ^4.1.11 + '@vitest/coverage-istanbul': 4.1.12 + '@vitest/coverage-v8': 4.1.12 + '@vitest/ui': 4.1.12 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -12353,13 +12356,6 @@ snapshots: '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.9.0 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - '@nest-lab/throttler-storage-redis@1.2.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.7)(reflect-metadata@0.2.2))(ioredis@5.10.1(supports-color@10.2.2))(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) @@ -12716,7 +12712,7 @@ snapshots: '@opentelemetry/semantic-conventions@1.39.0': optional: true - '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.154.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -13490,53 +13486,49 @@ snapshots: dependencies: cluster-key-slot: 1.1.2 - '@rolldown/binding-android-arm64@1.0.3': + '@rolldown/binding-android-arm-eabi@1.2.14': optional: true - '@rolldown/binding-darwin-arm64@1.0.3': + '@rolldown/binding-android-arm64@1.2.14': optional: true - '@rolldown/binding-darwin-x64@1.0.3': + '@rolldown/binding-darwin-arm64@1.2.14': optional: true - '@rolldown/binding-freebsd-x64@1.0.3': + '@rolldown/binding-darwin-x64@1.2.14': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + '@rolldown/binding-freebsd-x64@1.2.14': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.3': + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.3': + '@rolldown/binding-linux-arm64-gnu@1.2.14': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.3': + '@rolldown/binding-linux-arm64-musl@1.2.14': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.3': + '@rolldown/binding-linux-ppc64-gnu@1.2.14': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.3': + '@rolldown/binding-linux-s390x-gnu@1.2.14': optional: true - '@rolldown/binding-linux-x64-musl@1.0.3': + '@rolldown/binding-linux-x64-gnu@1.2.14': optional: true - '@rolldown/binding-openharmony-arm64@1.0.3': + '@rolldown/binding-linux-x64-musl@1.2.14': optional: true - '@rolldown/binding-wasm32-wasi@1.0.3': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@rolldown/binding-openharmony-arm64@1.2.14': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.3': + '@rolldown/binding-win32-arm64-msvc@1.2.14': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.3': + '@rolldown/binding-win32-x64-msvc@1.2.14': optional: true '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -14629,49 +14621,49 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/expect@4.1.11': + '@vitest/expect@4.1.12': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.12(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.11 + '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/pretty-format@4.1.11': + '@vitest/pretty-format@4.1.12': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.11': + '@vitest/runner@4.1.12': dependencies: - '@vitest/utils': 4.1.11 + '@vitest/utils': 4.1.12 pathe: 2.0.3 - '@vitest/snapshot@4.1.11': + '@vitest/snapshot@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/pretty-format': 4.1.12 + '@vitest/utils': 4.1.12 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.11': {} + '@vitest/spy@4.1.12': {} - '@vitest/utils@4.1.11': + '@vitest/utils@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.11 + '@vitest/pretty-format': 4.1.12 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -17581,54 +17573,54 @@ snapshots: process-warning: 4.0.0 set-cookie-parser: 2.6.0 - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 lines-and-columns@1.2.4: {} @@ -18365,6 +18357,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + picospinner@3.0.0: {} pino-abstract-transport@2.0.0: @@ -18493,6 +18487,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postcss@8.5.31: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postgres-array@2.0.0: optional: true @@ -19000,26 +19000,26 @@ snapshots: robust-predicates@3.0.2: {} - rolldown@1.0.3: + rolldown@1.2.14: dependencies: - '@oxc-project/types': 0.133.0 + '@oxc-project/types': 0.154.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.3 - '@rolldown/binding-darwin-arm64': 1.0.3 - '@rolldown/binding-darwin-x64': 1.0.3 - '@rolldown/binding-freebsd-x64': 1.0.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.3 - '@rolldown/binding-linux-arm64-musl': 1.0.3 - '@rolldown/binding-linux-ppc64-gnu': 1.0.3 - '@rolldown/binding-linux-s390x-gnu': 1.0.3 - '@rolldown/binding-linux-x64-gnu': 1.0.3 - '@rolldown/binding-linux-x64-musl': 1.0.3 - '@rolldown/binding-openharmony-arm64': 1.0.3 - '@rolldown/binding-wasm32-wasi': 1.0.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.3 - '@rolldown/binding-win32-x64-msvc': 1.0.3 + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 rope-sequence@1.3.4: {} @@ -19875,12 +19875,12 @@ snapshots: vary@1.1.2: {} - vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): + vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.25 - rolldown: 1.0.3 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.14 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.19.1 @@ -19892,27 +19892,27 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/expect': 4.1.12 + '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.12 + '@vitest/runner': 4.1.12 + '@vitest/snapshot': 4.1.12 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 From b007d8cb214e95fddda861eb15d24e51eb0b90b3 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:53:37 +0100 Subject: [PATCH 08/13] chore(deps): package updates (#2601) --- apps/server/package.json | 4 ++-- pnpm-lock.yaml | 33 ++++++++++----------------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index fbe8b01c..c0517e7e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -90,14 +90,14 @@ "kysely-migration-cli": "0.4.2", "kysely-postgres-js": "3.0.0", "ldapts": "8.1.7", - "mammoth": "1.12.0", + "mammoth": "1.13.0", "mime-types": "3.0.2", "msgpackr": "1.11.9", "nanoid": "5.1.16", "nestjs-cls": "6.2.0", "nestjs-kysely": "3.1.2", "nestjs-pino": "4.6.1", - "nodemailer": "10.0.10", + "nodemailer": "10.1.7", "openid-client": "6.8.2", "otpauth": "9.5.0", "p-limit": "7.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f4e606c..ed2addb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -673,8 +673,8 @@ importers: specifier: 8.1.7 version: 8.1.7 mammoth: - specifier: 1.12.0 - version: 1.12.0 + specifier: 1.13.0 + version: 1.13.0 mime-types: specifier: 3.0.2 version: 3.0.2 @@ -694,8 +694,8 @@ importers: specifier: 4.6.1 version: 4.6.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) nodemailer: - specifier: 10.0.10 - version: 10.0.10 + specifier: 10.1.7 + version: 10.1.7 openid-client: specifier: 6.8.2 version: 6.8.2 @@ -5305,9 +5305,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bluebird@3.4.7: - resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} - blueimp-load-image@5.16.0: resolution: {integrity: sha512-3DUSVdOtlfNRk7moRZuTwDmA3NnG8KIJuLcq3c0J7/BIr6X3Vb/EpX3kUH1joxUhmoVF4uCpDfz7wHkz8pQajA==} @@ -7643,8 +7640,8 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - mammoth@1.12.0: - resolution: {integrity: sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==} + mammoth@1.13.0: + resolution: {integrity: sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==} engines: {node: '>=12.0.0'} hasBin: true @@ -7860,8 +7857,8 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} - nodemailer@10.0.10: - resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==} + nodemailer@10.1.7: + resolution: {integrity: sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==} engines: {node: '>=20.0.0'} normalize-path@3.0.0: @@ -8094,10 +8091,6 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -15102,8 +15095,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bluebird@3.4.7: {} - blueimp-load-image@5.16.0: {} boolbase@1.0.0: {} @@ -17730,16 +17721,14 @@ snapshots: dependencies: tmpl: 1.0.5 - mammoth@1.12.0: + mammoth@1.13.0: dependencies: '@xmldom/xmldom': 0.8.15 argparse: 1.0.10 base64-js: 1.5.1 - bluebird: 3.4.7 dingbat-to-unicode: 1.0.1 jszip: 3.10.1 lop: 0.4.2 - path-is-absolute: 1.0.1 underscore: 1.13.8 xmlbuilder: 10.1.1 @@ -17925,7 +17914,7 @@ snapshots: node-releases@2.0.54: {} - nodemailer@10.0.10: {} + nodemailer@10.1.7: {} normalize-path@3.0.0: {} @@ -18272,8 +18261,6 @@ snapshots: path-expression-matcher@1.5.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} From 90f263849a3661c3df316a30ae763a3609c0560a Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:59:47 +0100 Subject: [PATCH 09/13] fix: editor get started design and cache fix (#2602) --- .../empty-page-get-started.module.css | 20 +++++++++++++++++++ .../empty-page/empty-page-get-started.tsx | 18 +++++++++++++---- .../src/features/page/queries/page-query.ts | 20 +++++++++++++------ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css index 70b19a63..3f4af274 100644 --- a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css +++ b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css @@ -27,3 +27,23 @@ flex-wrap: wrap; gap: 8px; } + +.chip { + font-weight: 500; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-6) + ); + + &:hover { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + } + + &:disabled { + opacity: 0.6; + } +} diff --git a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx index acc0ce88..eb584b4c 100644 --- a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx +++ b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx @@ -99,6 +99,7 @@ export function EmptyPageGetStarted({ key: "base", label: t("Base"), icon: IconTable, + color: "blue", onClick: () => convertMutation.mutate({ pageId }), disabled: convertMutation.isPending, }, @@ -106,6 +107,7 @@ export function EmptyPageGetStarted({ key: "kanban", label: t("Kanban"), icon: IconLayoutKanban, + color: "violet", onClick: () => convertMutation.mutate({ pageId, template: "kanban" }), disabled: convertMutation.isPending, @@ -118,6 +120,7 @@ export function EmptyPageGetStarted({ key: "template", label: t("Template"), icon: IconTemplate, + color: "pink", onClick: openTemplatePicker, disabled: false, }, @@ -137,10 +140,17 @@ export function EmptyPageGetStarted({ {chips.map((chip) => ( - + )) )} From 9bf523dbd9f21c00885ac4c7a627ff64a5f2a196 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:37:56 +0100 Subject: [PATCH 11/13] chore(deps): package updates (#2604) --- apps/client/package.json | 2 +- apps/server/package.json | 2 +- pnpm-lock.yaml | 108 +++++++++++++++++++-------------------- 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index e7fe6f47..1010e0d3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -66,7 +66,7 @@ "semver": "7.7.4", "socket.io-client": "4.8.3", "yet-another-react-lightbox": "^3.32.2", - "zod": "4.3.6" + "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "9.28.0", diff --git a/apps/server/package.json b/apps/server/package.json index c0517e7e..6fc917f9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -123,7 +123,7 @@ "undici": "7.29.1", "ws": "8.21.3", "yauzl": "3.4.0", - "zod": "4.3.6" + "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "^9.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed2addb6..fe316995 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,7 +358,7 @@ importers: version: 3.3.0 mantine-form-zod-resolver: specifier: 1.3.0 - version: 1.3.0(@mantine/form@9.3.2(react@19.2.7))(zod@4.3.6) + version: 1.3.0(@mantine/form@9.3.2(react@19.2.7))(zod@4.6.7) mermaid: specifier: 11.16.1 version: 11.16.1 @@ -405,8 +405,8 @@ importers: specifier: ^3.32.2 version: 3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@eslint/js': specifier: 9.28.0 @@ -494,13 +494,13 @@ importers: dependencies: '@ai-sdk/google': specifier: 3.0.123 - version: 3.0.123(zod@4.3.6) + version: 3.0.123(zod@4.6.7) '@ai-sdk/openai': specifier: 3.0.113 - version: 3.0.113(zod@4.3.6) + version: 3.0.113(zod@4.6.7) '@ai-sdk/openai-compatible': specifier: 2.0.75 - version: 2.0.75(zod@4.3.6) + version: 2.0.75(zod@4.6.7) '@aws-sdk/client-s3': specifier: 3.1050.0 version: 3.1050.0 @@ -542,10 +542,10 @@ importers: version: 5.1.6(keyv@5.6.0) '@langchain/core': specifier: 1.1.46 - version: 1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3) + version: 1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) '@langchain/textsplitters': specifier: 1.0.1 - version: 1.0.1(@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3)) + version: 1.0.1(@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3)) '@modelcontextprotocol/server': specifier: 2.3.1 version: 2.3.1 @@ -611,10 +611,10 @@ importers: version: 2.8.0 ai: specifier: 6.0.285 - version: 6.0.285(zod@4.3.6) + version: 6.0.285(zod@4.6.7) ai-sdk-ollama: specifier: 3.8.1 - version: 3.8.1(ai@6.0.285(zod@4.3.6))(zod@4.3.6) + version: 3.8.1(ai@6.0.285(zod@4.6.7))(zod@4.6.7) bcrypt: specifier: 6.0.0 version: 6.0.0 @@ -772,8 +772,8 @@ importers: specifier: 3.4.0 version: 3.4.0 zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@eslint/js': specifier: ^9.28.0 @@ -9934,8 +9934,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} zustand@4.5.6: resolution: {integrity: sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==} @@ -9958,38 +9958,38 @@ snapshots: '@adobe/css-tools@4.4.3': {} - '@ai-sdk/gateway@3.0.196(zod@4.3.6)': + '@ai-sdk/gateway@3.0.196(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.16 - '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.51(zod@4.6.7) '@vercel/oidc': 3.2.0 - zod: 4.3.6 + zod: 4.6.7 - '@ai-sdk/google@3.0.123(zod@4.3.6)': + '@ai-sdk/google@3.0.123(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.16 - '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.51(zod@4.6.7) + zod: 4.6.7 - '@ai-sdk/openai-compatible@2.0.75(zod@4.3.6)': + '@ai-sdk/openai-compatible@2.0.75(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.16 - '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.51(zod@4.6.7) + zod: 4.6.7 - '@ai-sdk/openai@3.0.113(zod@4.3.6)': + '@ai-sdk/openai@3.0.113(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.16 - '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 4.0.51(zod@4.6.7) + zod: 4.6.7 - '@ai-sdk/provider-utils@4.0.51(zod@4.3.6)': + '@ai-sdk/provider-utils@4.0.51(zod@4.6.7)': dependencies: '@ai-sdk/provider': 3.0.16 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.1 undici: 7.29.1 - zod: 4.3.6 + zod: 4.6.7 '@ai-sdk/provider@3.0.16': dependencies: @@ -12213,15 +12213,15 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3)': + '@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.8.9(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3) + langsmith: 0.8.9(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) mustache: 4.2.0 p-queue: 6.6.2 - zod: 4.3.6 + zod: 4.6.7 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -12229,9 +12229,9 @@ snapshots: - openai - ws - '@langchain/textsplitters@1.0.1(@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3))': + '@langchain/textsplitters@1.0.1(@langchain/core@1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3))': dependencies: - '@langchain/core': 1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3) + '@langchain/core': 1.1.46(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) js-tiktoken: 1.0.21 '@lifeomic/attempt@3.1.0': {} @@ -12311,12 +12311,12 @@ snapshots: '@modelcontextprotocol/core@2.3.1': dependencies: - zod: 4.3.6 + zod: 4.6.7 '@modelcontextprotocol/server@2.3.1': dependencies: '@modelcontextprotocol/core': 2.3.1 - zod: 4.3.6 + zod: 4.6.7 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -14779,23 +14779,23 @@ snapshots: agent-base@7.1.4: {} - ai-sdk-ollama@3.8.1(ai@6.0.285(zod@4.3.6))(zod@4.3.6): + ai-sdk-ollama@3.8.1(ai@6.0.285(zod@4.6.7))(zod@4.6.7): dependencies: '@ai-sdk/provider': 3.0.16 - '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) - ai: 6.0.285(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.51(zod@4.6.7) + ai: 6.0.285(zod@4.6.7) jsonrepair: 3.13.3 ollama: 0.6.3 transitivePeerDependencies: - zod - ai@6.0.285(zod@4.3.6): + ai@6.0.285(zod@4.6.7): dependencies: - '@ai-sdk/gateway': 3.0.196(zod@4.3.6) + '@ai-sdk/gateway': 3.0.196(zod@4.6.7) '@ai-sdk/provider': 3.0.16 - '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.51(zod@4.6.7) '@opentelemetry/api': 1.9.0 - zod: 4.3.6 + zod: 4.6.7 ajv-formats@2.1.1: dependencies: @@ -16129,8 +16129,8 @@ snapshots: '@babel/parser': 7.29.7 eslint: 9.28.0(jiti@2.4.2)(supports-color@10.2.2) hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) + zod: 4.6.7 + zod-validation-error: 4.0.2(zod@4.6.7) transitivePeerDependencies: - supports-color @@ -17495,7 +17495,7 @@ snapshots: micromatch: 4.0.8 minimist: 1.2.8 pluralize: 8.0.0 - zod: 4.3.6 + zod: 4.6.7 optionalDependencies: pg: 8.16.3 transitivePeerDependencies: @@ -17522,13 +17522,13 @@ snapshots: vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.0.8 - langsmith@0.8.9(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3): + langsmith@0.8.9(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.21.3)(zod@4.6.7))(ws@8.21.3): dependencies: p-queue: 6.6.2 optionalDependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - openai: 6.2.0(ws@8.21.3)(zod@4.3.6) + openai: 6.2.0(ws@8.21.3)(zod@4.6.7) ws: 8.21.3 layout-base@1.0.2: {} @@ -17732,10 +17732,10 @@ snapshots: underscore: 1.13.8 xmlbuilder: 10.1.1 - mantine-form-zod-resolver@1.3.0(@mantine/form@9.3.2(react@19.2.7))(zod@4.3.6): + mantine-form-zod-resolver@1.3.0(@mantine/form@9.3.2(react@19.2.7))(zod@4.6.7): dependencies: '@mantine/form': 9.3.2(react@19.2.7) - zod: 4.3.6 + zod: 4.6.7 marked@15.0.12: {} @@ -18112,10 +18112,10 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 - openai@6.2.0(ws@8.21.3)(zod@4.3.6): + openai@6.2.0(ws@8.21.3)(zod@4.6.7): optionalDependencies: ws: 8.21.3 - zod: 4.3.6 + zod: 4.6.7 optional: true openid-client@6.8.2: @@ -20241,11 +20241,11 @@ snapshots: yoctocolors@2.1.2: {} - zod-validation-error@4.0.2(zod@4.3.6): + zod-validation-error@4.0.2(zod@4.6.7): dependencies: - zod: 4.3.6 + zod: 4.6.7 - zod@4.3.6: {} + zod@4.6.7: {} zustand@4.5.6(@types/react@19.2.17)(react@19.2.7): dependencies: From f34c33f3c3d138a94fb4f5699e676ebaa289b46c Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:51:50 +0100 Subject: [PATCH 12/13] chore(deps): package updates (#2605) - undici 7.30.1 - fast-uri 3.1.9 - happy-dom 21.0.4 - remove unused @tiptap/html --- apps/server/package.json | 4 +-- package.json | 1 - pnpm-lock.yaml | 77 ++++++++++++++++++---------------------- pnpm-workspace.yaml | 4 +-- 4 files changed, 39 insertions(+), 47 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 6fc917f9..f3f46632 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -82,7 +82,7 @@ "csv-stringify": "6.8.0", "fast-bm25": "0.0.5", "fs-extra": "11.3.4", - "happy-dom": "20.8.9", + "happy-dom": "21.0.4", "ioredis": "5.10.1", "js-tiktoken": "1.0.21", "jsonwebtoken": "9.0.3", @@ -120,7 +120,7 @@ "tlds": "1.261.0", "tmp-promise": "3.0.3", "typesense": "3.0.5", - "undici": "7.29.1", + "undici": "7.30.1", "ws": "8.21.3", "yauzl": "3.4.0", "zod": "4.6.7" diff --git a/package.json b/package.json index 0fd56881..7e137f51 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "@tiptap/extension-typography": "3.31.3", "@tiptap/extension-unique-id": "3.31.3", "@tiptap/extension-youtube": "3.31.3", - "@tiptap/html": "3.31.3", "@tiptap/pm": "3.31.3", "@tiptap/react": "3.31.3", "@tiptap/starter-kit": "3.31.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe316995..50c83700 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: dompurify: 3.4.17 mermaid: 11.16.1 katex: 0.19.0 - undici: 7.29.1 + undici: 7.30.1 tmp: 0.2.7 nanoid@^3: 3.3.18 lodash-es: 4.18.1 @@ -21,7 +21,7 @@ overrides: yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.12 axios: 1.20.0 - fast-uri: 3.1.8 + fast-uri: 3.1.9 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 @@ -142,9 +142,6 @@ importers: '@tiptap/extension-youtube': specifier: 3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/html': - specifier: 3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(happy-dom@20.8.9) '@tiptap/pm': specifier: 3.31.3 version: 3.31.3 @@ -488,7 +485,7 @@ importers: version: 8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 4.1.12 - version: 4.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@21.0.4)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) apps/server: dependencies: @@ -649,8 +646,8 @@ importers: specifier: 11.3.4 version: 11.3.4 happy-dom: - specifier: 20.8.9 - version: 20.8.9 + specifier: 21.0.4 + version: 21.0.4 ioredis: specifier: 5.10.1 version: 5.10.1(supports-color@10.2.2) @@ -763,8 +760,8 @@ importers: specifier: 3.0.5 version: 3.0.5(@babel/runtime@7.29.2)(supports-color@10.2.2) undici: - specifier: 7.29.1 - version: 7.29.1 + specifier: 7.30.1 + version: 7.30.1 ws: specifier: 8.21.3 version: 8.21.3 @@ -4338,13 +4335,6 @@ packages: '@tiptap/core': 3.31.3 '@tiptap/pm': 3.31.3 - '@tiptap/html@3.31.3': - resolution: {integrity: sha512-kE0/bIopSdECaNX4f/Z+TNgAqWTACFcExJmzhevE7Kx1zsDKcl9rW6poMbOEWdq2HSYxowm3wGbtTHXu6RXZHA==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - happy-dom: ^20.8.9 - '@tiptap/pm@3.31.3': resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} @@ -5350,6 +5340,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.6.0: resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} @@ -6391,8 +6385,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -6629,8 +6623,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.8.9: - resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} + happy-dom@21.0.4: + resolution: {integrity: sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==} engines: {node: '>=20.0.0'} has-bigints@1.0.2: @@ -9410,8 +9404,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.29.1: - resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} engines: {node: '>=20.18.1'} unicode-canonical-property-names-ecmascript@2.0.0: @@ -9988,7 +9982,7 @@ snapshots: '@ai-sdk/provider': 3.0.16 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.1 - undici: 7.29.1 + undici: 7.30.1 zod: 4.6.7 '@ai-sdk/provider@3.0.16': @@ -11657,7 +11651,7 @@ snapshots: dependencies: ajv: 8.18.0 ajv-formats: 3.0.1 - fast-uri: 3.1.8 + fast-uri: 3.1.9 '@fastify/busboy@3.2.2': {} @@ -13945,12 +13939,6 @@ snapshots: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 - '@tiptap/html@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(happy-dom@20.8.9)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - happy-dom: 20.8.9 - '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.0 @@ -14046,7 +14034,7 @@ snapshots: '@turbopuffer/turbopuffer@2.8.0': dependencies: pako: 2.2.0 - undici: 7.29.1 + undici: 7.30.1 '@tybys/wasm-util@0.10.2': dependencies: @@ -14824,7 +14812,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -15147,6 +15135,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 25.5.0 + buffer@5.6.0: dependencies: base64-js: 1.5.1 @@ -15248,7 +15240,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.29.1 + undici: 7.30.1 whatwg-mimetype: 4.0.0 chevrotain-allstar@0.3.1(chevrotain@11.0.3): @@ -16297,7 +16289,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-ref-resolver: 2.0.1 rfdc: 1.3.1 @@ -16306,7 +16298,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-ref-resolver: 3.0.0 rfdc: 1.3.1 @@ -16324,7 +16316,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.8: {} + fast-uri@3.1.9: {} fast-wrap-ansi@0.2.0: dependencies: @@ -16587,11 +16579,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.8.9: + happy-dom@21.0.4: dependencies: '@types/node': 25.5.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 ws: 8.21.3 @@ -18019,7 +18012,7 @@ snapshots: tmp: 0.2.7 tsconfig-paths: 4.2.0 tslib: 2.8.1 - undici: 7.29.1 + undici: 7.30.1 util-deprecate: 1.0.2 wcwidth: 1.0.1 which: 3.0.1 @@ -19767,7 +19760,7 @@ snapshots: undici-types@7.18.2: {} - undici@7.29.1: {} + undici@7.30.1: {} unicode-canonical-property-names-ecmascript@2.0.0: {} @@ -19879,7 +19872,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.12(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@21.0.4)(jsdom@25.0.0(supports-color@10.2.2))(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.12 '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.4.2)(sugarss@5.0.1(postcss@8.5.25))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -19904,7 +19897,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 22.19.1 - happy-dom: 20.8.9 + happy-dom: 21.0.4 jsdom: 25.0.0(supports-color@10.2.2) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a134e8d1..5d685849 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,7 +10,7 @@ overrides: dompurify: 3.4.17 mermaid: 11.16.1 katex: 0.19.0 - undici: 7.29.1 + undici: 7.30.1 tmp: 0.2.7 nanoid@^3: 3.3.18 lodash-es: 4.18.1 @@ -20,7 +20,7 @@ overrides: yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.12 axios: 1.20.0 - fast-uri: 3.1.8 + fast-uri: 3.1.9 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 From e113d652dfe776015c076415709844bfa92014f7 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:26:30 +0100 Subject: [PATCH 13/13] v0.97.0 (#2606) --- apps/client/package.json | 2 +- apps/server/package.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 1010e0d3..a8af382c 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.96.0", + "version": "0.97.0", "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/apps/server/package.json b/apps/server/package.json index f3f46632..ad2b1b88 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.96.0", + "version": "0.97.0", "description": "", "author": "", "private": true, diff --git a/package.json b/package.json index 7e137f51..77660f28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "docmost", "homepage": "https://docmost.com", - "version": "0.96.0", + "version": "0.97.0", "private": true, "scripts": { "build": "nx run-many -t build",