Compare commits

...

16 Commits

Author SHA1 Message Date
Philipinho 7a198ac231 sync 2026-06-19 23:41:25 +01:00
Philipinho ba3c83bc1d feat(ee): docx word export 2026-06-19 13:10:55 +01:00
Philipinho 2b56c09afc vendorize - wip 2026-06-19 01:24:21 +01:00
Philip Okugbe 6191acfa14 fix: a11y (#2275) 2026-06-09 22:51:55 +01:00
Peter Tripp d86d51c27e fix: Table jitter on edit/read toggle (#2252) 2026-06-03 11:31:45 +01:00
Philipinho ef04c22aea sync 2026-05-28 16:57:59 +01:00
Philipinho b6760c63c4 fix: package updates 2026-05-28 16:39:47 +01:00
Philipinho 2b68879e72 0.90.1 2026-05-28 16:36:18 +01:00
Philipinho db32910634 fix; change inline code text color 2026-05-28 16:35:37 +01:00
Philip Okugbe 33895b0607 bug fixes (#2250)
* util

* fix page position collation

* support fixed toolbar in templates editor

* date localization

* fix clipped emoji in templates editor

* fix page updated time object

* fix flickers

* fix: remove redundant breadcrumb from destination modal
2026-05-28 16:20:37 +01:00
Philipinho 830b5b4d45 fix synced block 2026-05-25 19:17:14 +01:00
Philipinho d7c4f0551e fix: strip html styles on paste 2026-05-22 19:00:30 +01:00
Philipinho 61a91cd086 fix: remove duplicate storage key 2026-05-22 14:54:52 +01:00
Philipinho f010f6a83a fix: internal links 2026-05-21 17:01:20 +01:00
Philipinho 13a7f1372f fix: update pdf-inspector package 2026-05-21 13:44:11 +01:00
Philip Okugbe 4295ea09f6 feat(storage): add Azure Blob Storage driver (#2222) 2026-05-21 12:18:58 +01:00
88 changed files with 2777 additions and 661 deletions
+6 -1
View File
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
REDIS_URL=redis://127.0.0.1:6379
# options: local | s3
# options: local | s3 | azure
STORAGE_DRIVER=local
# S3 driver config
@@ -21,6 +21,11 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE=
# Azure Blob Storage driver config
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
AZURE_STORAGE_CONTAINER=
# default: 50mb
FILE_UPLOAD_SIZE_LIMIT=
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.0",
"version": "0.90.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -43,7 +43,7 @@
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.5",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "3.3.0",
@@ -424,6 +424,7 @@
"Names do not match": "Names do not match",
"Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}",
"now": "now",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
@@ -977,7 +978,7 @@
"Search pages and spaces...": "Search pages and spaces...",
"No results found": "No results found",
"You don't have permission to create pages here": "You don't have permission to create pages here",
"Chat menu": "Chat menu",
"Chat menu for {{title}}": "Chat menu for {{title}}",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
"Slash commands": "Slash commands",
@@ -1063,7 +1064,7 @@
"Filter": "Filter",
"Page title": "Page title",
"Page content": "Page content",
"Member actions": "Member actions",
"Member actions for {{name}}": "Member actions for {{name}}",
"Toggle password visibility": "Toggle password visibility",
"Send comment": "Send comment",
"Token actions": "Token actions",
@@ -6,13 +6,21 @@ import {
Select,
Switch,
Divider,
Tooltip,
Badge,
} from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import {
exportPage,
exportPageToDocx,
} from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
import { Feature } from "@/ee/features";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
interface ExportModalProps {
id: string;
@@ -32,17 +40,25 @@ export default function ExportModal({
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation();
const upgradeLabel = useUpgradeLabel();
const isDocx = format === ExportFormat.Docx;
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
const blockedByLicense = isDocx && !docxEntitled;
const handleExport = async () => {
setIsExporting(true);
try {
if (type === "page") {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
if (format === ExportFormat.Docx) {
await exportPageToDocx({ pageId: id });
} else {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
}
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
@@ -88,10 +104,15 @@ export default function ExportModal({
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
<ExportFormatSelection
format={format}
onChange={handleChange}
includeDocx={type === "page"}
docxEntitled={docxEntitled}
/>
</Group>
{type === "page" && (
{type === "page" && !isDocx && (
<>
<Divider my="sm" />
@@ -143,7 +164,16 @@ export default function ExportModal({
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
<Button
onClick={handleExport}
loading={isExporting}
disabled={blockedByLicense}
data-disabled={blockedByLicense || undefined}
>
{t("Export")}
</Button>
</Tooltip>
</Group>
</Modal.Body>
</Modal.Content>
@@ -154,23 +184,49 @@ export default function ExportModal({
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
includeDocx?: boolean;
docxEntitled?: boolean;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
function ExportFormatSelection({
format,
onChange,
includeDocx,
docxEntitled,
}: ExportFormatSelection) {
const { t } = useTranslation();
const data = [
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
...(includeDocx
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
: []),
];
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
data={data}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
comboboxProps={{ width: 200 }}
allowDeselect={false}
withCheckIcon={false}
aria-label={t("Select export format")}
renderOption={({ option }) =>
option.value === "docx" && !docxEntitled ? (
<div>
<Text size="sm" c="dimmed">
{option.label}
</Text>
<Badge size="xs" mt={4}>
{t("Enterprise")}
</Badge>
</div>
) : (
<Text size="sm">{option.label}</Text>
)
}
/>
);
}
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
<Text size="xs" c="dimmed" pl="xs" py={4}>
{t("Favorite spaces appear here")}
+18 -10
View File
@@ -16,13 +16,10 @@ interface CustomAvatarProps {
mt?: string | number;
}
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
// - filled: white text on the shade as bg
// - light: shade as text on the color's light-bg (10% color.6 over white)
// Avoids lime/yellow/green/orange — even their dark shades have weak
// contrast. grape and indigo were bumped from .7 to darker shades because
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
// indigo.7 was 4.98/4.39 (light fails by a hair).
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
// Avoids lime/yellow/green/orange, too light even at dark shades.
// For non-filled variants, initials text is forced to the .9 shade at render time:
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
@@ -54,12 +51,21 @@ function sanitizeInitialsSource(name: string) {
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const isInitials = !color || color === "initials";
const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
const placeholderStyles =
isInitials && variant !== "filled"
? {
placeholder: {
color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`,
},
}
: undefined;
return (
<Avatar
ref={ref}
@@ -67,6 +73,8 @@ export const CustomAvatar = React.forwardRef<
name={initialsSource}
alt={name}
color={resolvedColor}
variant={variant}
styles={placeholderStyles}
{...props}
/>
);
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Modal, Button, Group } from "@mantine/core";
import { Modal, Button, Group, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DestinationPicker } from "./destination-picker";
import {
@@ -52,7 +52,9 @@ export function DestinationPickerModal({
searchSpacesOnly={searchSpacesOnly}
/>
<Group justify="flex-end" mt="md">
<Divider my="md" />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
@@ -89,14 +89,6 @@
}
}
.selectedIndicator {
padding: 8px 12px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: var(--mantine-spacing-xs);
}
.emptyState {
padding: 12px;
text-align: center;
@@ -221,14 +221,6 @@ export function DestinationPicker({
))
)}
</ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</>
);
}
@@ -0,0 +1,12 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
export const RadioMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
));
RadioMenuItem.displayName = "RadioMenuItem";
@@ -66,6 +66,8 @@ export default function AiChatSidebarItem({
[chat.updatedAt, i18n.language],
);
const chatTitle = chat.title || t("Untitled chat");
useEffect(() => {
if (renaming) {
// Wait for the input to be mounted before selecting.
@@ -120,9 +122,7 @@ export default function AiChatSidebarItem({
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemTitle}>{chatTitle}</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
>
<IconDots size={14} />
</ActionIcon>
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { useCallback, useId, useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
@@ -107,6 +107,7 @@ export default function ChatInput({
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const plusMenuId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
@@ -342,6 +343,7 @@ export default function ChatInput({
position="top-start"
width={220}
shadow="md"
withRoles={false}
trapFocus
returnFocus
>
@@ -351,13 +353,17 @@ export default function ChatInput({
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
aria-haspopup="menu"
aria-expanded={plusMenuOpen}
aria-controls={plusMenuOpen ? plusMenuId : undefined}
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
@@ -377,6 +383,7 @@ export default function ChatInput({
</button>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
@@ -385,7 +392,7 @@ export default function ChatInput({
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
{t("Mention a page")}
</button>
</Popover.Dropdown>
</Popover>
@@ -17,14 +17,26 @@ import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx";
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") || "";
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
if (node.tagName !== "A") return;
const href = node.getAttribute("href") || "";
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
const m = href.match(PAGE_PATH_RE);
if (m) {
node.setAttribute("href", m[0]);
node.removeAttribute("target");
node.removeAttribute("rel");
return;
}
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
});
@@ -76,7 +76,6 @@
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
}
/* Empty state - Notion AI style centered layout */
.emptyState {
flex: 1;
display: flex;
@@ -1,11 +1,11 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
@@ -23,10 +23,11 @@ export function ApiKeyTable({
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
};
const isExpired = (expiresAt: string | null) => {
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", {
const formatted = date.toLocaleDateString(i18n.language, {
month: "short",
day: "2-digit",
year: "numeric",
@@ -4,12 +4,13 @@ import {
} from "@/ee/billing/queries/billing-query.ts";
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
import classes from "./billing.module.css";
import { format } from "date-fns";
import { formatInterval } from "@/ee/billing/utils.ts";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
export default function BillingDetails() {
const { data: billing } = useBillingQuery();
const { data: plans } = useBillingPlans();
const locale = useDateFnsLocale();
if (!billing || !plans) {
return null;
@@ -75,7 +76,12 @@ export default function BillingDetails() {
: "Renewal date"}
</Text>
<Text fw={700} fz="lg">
{format(billing.periodEndAt, "dd MMM, yyyy")}
{formatLocalized(
billing.periodEndAt,
"dd MMM, yyyy",
"PP",
locale,
)}
</Text>
</div>
</Group>
+1
View File
@@ -19,4 +19,5 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
DOCX_EXPORT: 'export:docx',
} as const;
@@ -1,13 +1,14 @@
import { Badge, Table } from "@mantine/core";
import { format } from "date-fns";
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
export default function LicenseDetails() {
const { data: license, isError } = useLicenseInfo();
const [workspace] = useAtom(workspaceAtom);
const locale = useDateFnsLocale();
if (!license) {
return null;
@@ -50,12 +51,16 @@ export default function LicenseDetails() {
<Table.Tr>
<Table.Th>Issued at</Table.Th>
<Table.Td>{format(license.issuedAt, "dd MMMM, yyyy")}</Table.Td>
<Table.Td>
{formatLocalized(license.issuedAt, "dd MMMM, yyyy", "PPP", locale)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Expires at</Table.Th>
<Table.Td>{format(license.expiresAt, "dd MMMM, yyyy")}</Table.Td>
<Table.Td>
{formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>License ID</Table.Th>
@@ -1,6 +1,7 @@
import { Group, NumberInput, Select, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
import {
ExpirationMode,
PeriodUnit,
@@ -30,7 +31,7 @@ export function addDays(days: number, from?: Date): Date {
function formatShortDate(date: Date): string {
const crossesYear = date.getFullYear() !== new Date().getFullYear();
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(i18n.language, {
month: "short",
day: "numeric",
...(crossesYear && { year: "numeric" }),
@@ -38,7 +39,7 @@ function formatShortDate(date: Date): string {
}
function formatLongDate(date: Date): string {
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(i18n.language, {
month: "long",
day: "numeric",
year: "numeric",
@@ -12,6 +12,7 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
import {
useMarkObsoleteMutation,
usePageVerificationInfoQuery,
@@ -197,11 +198,14 @@ function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) {
{info.expiresAt && (
<Text size="xs" c="dimmed">
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
date: new Date(info.expiresAt).toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
}),
date: new Date(info.expiresAt).toLocaleDateString(
i18n.language,
{
month: "long",
day: "numeric",
year: "numeric",
},
),
})}
</Text>
)}
@@ -13,6 +13,7 @@ import {
IconShieldCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
@@ -127,7 +128,7 @@ export function PageVerificationBadge({
status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined,
i18n.language,
{ month: "long", day: "numeric", year: "numeric" },
),
})
@@ -16,9 +16,10 @@ import {
} from "@/ee/page-verification/types/page-verification.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import type { Locale } from "date-fns";
const MAX_VISIBLE_VERIFIERS = 5;
@@ -48,7 +49,11 @@ function statusBadge(status: VerificationStatus | null, t: (s: string) => string
}
}
function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string {
function verifiedUntilText(
item: IVerificationListItem,
t: (s: string) => string,
locale: Locale,
): string {
if (item.type === "qms") {
if (item.status === "approved") return t("Indefinitely");
return "—";
@@ -60,7 +65,7 @@ function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string
const now = new Date();
if (expires <= now) return t("Expired");
return format(expires, "MMM d, yyyy");
return formatLocalized(expires, "MMM d, yyyy", "PP", locale);
}
function TableSkeleton() {
@@ -98,6 +103,7 @@ export default function VerificationListTable({
isLoading,
}: VerificationListTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
return (
<Table.ScrollContainer minWidth={600}>
@@ -200,7 +206,7 @@ export default function VerificationListTable({
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{verifiedUntilText(item, t)}
{verifiedUntilText(item, t, locale)}
</Text>
</Table.Td>
@@ -1,11 +1,11 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
interface ScimTokenTableProps {
tokens: IScimToken[];
@@ -21,10 +21,11 @@ export function ScimTokenTable({
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
};
return (
@@ -32,6 +32,12 @@
margin-bottom: 0.25em;
}
/* The emoji glyph renders larger than its font-size box; let the transparent
ActionIcon overflow so it isn't clipped on the edges. */
.emojiButton button {
overflow: visible;
}
.titleInput {
font-size: 2.5rem;
font-weight: 700;
@@ -32,6 +32,12 @@ import {
} from "../queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role";
import { useAtomValue } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import classes from "./template-editor.module.css";
@@ -39,6 +45,9 @@ export default function TemplateEditor() {
const { t } = useTranslation();
const { templateId } = useParams<{ templateId: string }>();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
@@ -238,6 +247,10 @@ export default function TemplateEditor() {
</title>
</Helmet>
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
)}
<div className={classes.header}>
<Container size={900} h="100%" px={0}>
<Group justify="space-between" h="100%" wrap="nowrap">
@@ -379,6 +392,13 @@ export default function TemplateEditor() {
)}
</div>
<EditorContent editor={editor} />
{editor && (
<>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} templateMode />
<EditorLinkMenu editor={editor} />
</>
)}
<div style={{ paddingBottom: "20vh" }} />
</Container>
</>
@@ -5,6 +5,7 @@ import {
useQueryClient,
UseQueryResult,
InfiniteData,
keepPreviousData,
} from "@tanstack/react-query";
import { useAtom, useStore } from "jotai";
import {
@@ -35,6 +36,7 @@ export function useGetTemplatesQuery(params?: { spaceId?: string }) {
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
placeholderData: keepPreviousData,
});
}
@@ -38,9 +38,11 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null;
templateMode?: boolean;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props;
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
@@ -232,8 +234,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
))}
</ActionIcon.Group>
<LinkSelector />
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
@@ -246,18 +246,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
</>
)}
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
<LinkSelector />
{!templateMode && (
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
)}
</div>
</BubbleMenu>
);
@@ -1,12 +1,12 @@
import { FC } from "react";
import { useAtomValue } from "jotai";
import type { Editor } from "@tiptap/react";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { useToolbarState } from "./use-toolbar-state";
import { BlockTypeGroup } from "./groups/block-type-group";
import { InlineMarksGroup } from "./groups/inline-marks-group";
import { ColorGroup } from "./groups/color-group";
import { ListsGroup } from "./groups/lists-group";
import { LinkGroup } from "./groups/link-group";
import { AlignmentGroup } from "./groups/alignment-group";
import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
@@ -16,8 +16,17 @@ import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
export const FixedToolbar: FC = () => {
const editor = useAtomValue(pageEditorAtom);
type FixedToolbarProps = {
editor?: Editor | null;
templateMode?: boolean;
};
export const FixedToolbar: FC<FixedToolbarProps> = ({
editor: editorProp,
templateMode = false,
}) => {
const editorFromAtom = useAtomValue(pageEditorAtom);
const editor = editorProp ?? editorFromAtom;
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
@@ -48,14 +57,12 @@ export const FixedToolbar: FC = () => {
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<LinkGroup />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} />
<MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
</div>
@@ -1,6 +0,0 @@
import { FC } from "react";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
export const LinkGroup: FC = () => {
return <LinkSelector />;
};
@@ -17,6 +17,7 @@ import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-act
interface Props {
editor: Editor;
templateMode?: boolean;
}
type UploadFn = (
@@ -60,7 +61,7 @@ function pickFile(
input.click();
}
export const MediaGroup: FC<Props> = ({ editor }) => {
export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
const { t } = useTranslation();
return (
@@ -78,24 +79,30 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPhoto size={16} />}
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
>
{t("Image")}
</Menu.Item>
<Menu.Item
leftSection={<IconMovie size={16} />}
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
>
{t("Video")}
</Menu.Item>
<Menu.Item
leftSection={<IconMusic size={16} />}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
>
{t("Audio")}
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconPhoto size={16} />}
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
>
{t("Image")}
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconMovie size={16} />}
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
>
{t("Video")}
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconMusic size={16} />}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
>
{t("Audio")}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFileTypePdf size={16} />}
onClick={() =>
@@ -104,14 +111,16 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
>
PDF
</Menu.Item>
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={() =>
pickFile(editor, "", true, uploadAttachmentAction, true)
}
>
{t("File attachment")}
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={() =>
pickFile(editor, "", true, uploadAttachmentAction, true)
}
>
{t("File attachment")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
@@ -32,16 +32,17 @@ import { useTranslation } from "react-i18next";
interface Props {
editor: Editor;
templateMode?: boolean;
}
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
const { t } = useTranslation();
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
const { t, i18n } = useTranslation();
const setEmbed = (provider: string) =>
editor.chain().focus().setEmbed({ provider }).run();
const insertDate = () => {
const currentDate = new Date().toLocaleDateString("en-US", {
const currentDate = new Date().toLocaleDateString(i18n.language, {
year: "numeric",
month: "long",
day: "numeric",
@@ -91,14 +92,16 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
>
{t("Subpages")}
</Menu.Item>
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
}
>
{t("Synced block")}
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconRotate2 size={16} />}
onClick={() =>
editor.chain().focus().insertTransclusionSource().run()
}
>
{t("Synced block")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label>{t("Diagrams")}</Menu.Label>
@@ -115,18 +118,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
>
{t("Mermaid diagram")}
</Menu.Item>
<Menu.Item
leftSection={<IconDrawio size={16} />}
onClick={() => editor.chain().focus().setDrawio().run()}
>
Draw.io
</Menu.Item>
<Menu.Item
leftSection={<IconExcalidraw size={16} />}
onClick={() => editor.chain().focus().setExcalidraw().run()}
>
Excalidraw
</Menu.Item>
{!templateMode && (
<Menu.Item
leftSection={<IconDrawio size={16} />}
onClick={() => editor.chain().focus().setDrawio().run()}
>
Draw.io
</Menu.Item>
)}
{!templateMode && (
<Menu.Item
leftSection={<IconExcalidraw size={16} />}
onClick={() => editor.chain().focus().setExcalidraw().run()}
>
Excalidraw
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label>{t("Embeds")}</Menu.Label>
@@ -43,6 +43,7 @@ import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
import { IconColumns4 } from "@/components/icons/icon-columns-4";
import { IconColumns5 } from "@/components/icons/icon-columns-5";
import i18n from "@/i18n.ts";
import {
AirtableIcon,
FigmaIcon,
@@ -459,7 +460,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
searchTerms: ["date", "today"],
icon: IconCalendar,
command: ({ editor, range }: CommandProps) => {
const currentDate = new Date().toLocaleDateString("en-US", {
const currentDate = new Date().toLocaleDateString(i18n.language, {
year: "numeric",
month: "long",
day: "numeric",
@@ -0,0 +1,20 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const CleanStyles = Extension.create({
name: "cleanStyles",
priority: 80,
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("cleanStyles"),
props: {
transformPastedHTML(html) {
return html.replace(/\s+style="[^"]*"/gi, "");
},
},
}),
];
},
});
@@ -3,7 +3,7 @@ import { StarterKit } from "@tiptap/starter-kit";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -112,6 +112,7 @@ import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -383,6 +384,7 @@ export const mainExtensions = [
MarkdownClipboard.configure({
transformPastedText: true,
}),
CleanStyles,
CharacterCount.configure({
wordCounter: (text) => countWords(text),
}),
@@ -435,6 +437,7 @@ const TemplateSlashCommand = Command.configure({
export const templateExtensions = [
...mainExtensions.filter((ext: any) => ext !== SlashCommand),
TemplateSlashCommand,
UndoRedo,
] as any;
export const collabExtensions: CollabExtensions = (provider, user) => [
@@ -14,6 +14,7 @@ import {
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import {
Editor,
@@ -145,6 +146,24 @@ export default function PageEditor({
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
@@ -169,6 +188,7 @@ export default function PageEditor({
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
@@ -318,7 +338,6 @@ export default function PageEditor({
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
updatedAt: new Date(),
});
}
}, 3000);
@@ -103,13 +103,13 @@
margin: 0;
@mixin where-light {
background-color: var(--code-bg, var(--mantine-color-gray-1));
color: var(--mantine-color-pink-7);
background-color: var(--mantine-color-gray-1);
color: var(--mantine-color-text);
}
@mixin where-dark {
background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-pink-7);
background-color: var(--mantine-color-dark-5) !important;
color: var(--mantine-color-text);
}
}
}
@@ -204,10 +204,6 @@
opacity: 1;
}
.ProseMirror table th:has(.tableReadonlySortChevron) {
padding-right: 30px;
}
.tableReadonlySortChevron:hover {
background: light-dark(
rgba(55, 53, 47, 0.16),
@@ -91,7 +91,9 @@ export default function GroupMembersList() {
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions")}
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
@@ -1,15 +1,27 @@
import { format, isThisYear, isToday, isYesterday } from "date-fns";
import { isThisYear, isToday, isYesterday } from "date-fns";
import i18n from "@/i18n.ts";
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
export function formatLabelListDate(date: Date): string {
const locale = getDateFnsLocale();
if (isToday(date)) {
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
return i18n.t("Today, {{time}}", {
time: formatLocalized(date, "h:mma", "p", locale),
});
}
if (isYesterday(date)) {
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
return i18n.t("Yesterday, {{time}}", {
time: formatLocalized(date, "h:mma", "p", locale),
});
}
if (isThisYear(date)) {
return format(date, "MMM dd");
if (locale.code?.startsWith("en")) {
return formatLocalized(date, "MMM dd", "MMM dd", locale);
}
return new Intl.DateTimeFormat(i18n.language, {
month: "short",
day: "numeric",
}).format(date);
}
return format(date, "MMM dd, yyyy");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
}
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useId, useState } from "react";
import {
ActionIcon,
Group,
@@ -31,6 +31,7 @@ import classes from "../notification.module.css";
export function NotificationPopover() {
const { t } = useTranslation();
const titleId = useId();
const [opened, setOpened] = useState(false);
const [tab, setTab] = useState<NotificationTab>("direct");
const [filter, setFilter] = useState<NotificationFilter>("all");
@@ -83,10 +84,11 @@ export function NotificationPopover() {
<Popover.Dropdown
p={0}
aria-labelledby={titleId}
style={{ width: "min(420px, calc(100vw - 24px))" }}
>
<Group justify="space-between" px="md" py="sm">
<Title order={2} fz="sm" fw={600}>
<Title id={titleId} order={2} fz="sm" fw={600}>
{t("Notifications")}
</Title>
<Group gap={4}>
@@ -1,3 +1,4 @@
import i18n from "@/i18n.ts";
import { INotification } from "./types/notification.types";
export function formatRelativeTime(dateStr: string): string {
@@ -8,15 +9,15 @@ export function formatRelativeTime(dateStr: string): string {
const diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "now";
if (diffMin < 1) return i18n.t("now");
if (diffMin < 60) return `${diffMin}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString(undefined, {
return new Intl.DateTimeFormat(i18n.language, {
month: "short",
day: "numeric",
});
}).format(date);
}
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
@@ -16,7 +16,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
import { BacklinksModal } from "./backlinks-modal";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { formattedDate } from "@/lib/time.ts";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
@@ -139,6 +140,7 @@ function StatsSection({
updatedAt: Date | string;
}) {
const { t } = useTranslation();
const lastUpdated = useTimeAgo(updatedAt);
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
@@ -150,10 +152,7 @@ function StatsSection({
label={t("Created")}
value={formattedDate(new Date(createdAt))}
/>
<StatRow
label={t("Last updated")}
value={timeAgo(new Date(updatedAt))}
/>
<StatRow label={t("Last updated")} value={lastUpdated} />
</Stack>
);
}
@@ -132,6 +132,25 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
saveAs(req.data, decodedFileName);
}
export async function exportPageToDocx(data: { pageId: string }): Promise<void> {
const req = await api.post("/docx-export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
let decodedFileName = fileName;
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
}
export async function importPage(file: File, spaceId: string) {
const formData = new FormData();
formData.append("spaceId", spaceId);
@@ -34,6 +34,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css";
export interface NodeMenuProps {
node: SpaceTreeNode;
@@ -123,8 +124,9 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
tabIndex={-1}
onClick={(e) => {
@@ -201,13 +201,13 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
return (
<span
aria-hidden
className={classes.actionIcon}
style={{
width: 20,
height: 20,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-6)",
flexShrink: 0,
}}
>
@@ -220,7 +220,8 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
<ActionIcon
size={20}
variant="subtle"
c="gray"
color="gray"
className={classes.actionIcon}
aria-label={isOpen ? t("Collapse") : t("Expand")}
aria-expanded={isOpen}
tabIndex={-1}
@@ -272,8 +273,9 @@ function CreateNode({
return (
<ActionIcon
variant="transparent"
c="gray"
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
tabIndex={-1}
onClick={(e) => {
@@ -57,6 +57,10 @@
flex-shrink: 0;
}
.actionIcon {
color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-4));
}
.text {
flex: 1;
/* min-width: 0 lets a flex child shrink below its content size required
@@ -98,4 +98,5 @@ export interface IExportPageParams {
export enum ExportFormat {
HTML = "html",
Markdown = "markdown",
Docx = "docx",
}
@@ -17,6 +17,7 @@ import {
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
@@ -175,7 +176,7 @@ export function SearchSpotlightFilters({
{contentTypeOptions.map((option) => (
<Menu.Item
key={option.value}
role="menuitemradio"
component={RadioMenuItem}
aria-checked={contentType === option.value}
onClick={() =>
!option.disabled &&
@@ -7,8 +7,8 @@ import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.ts";
import { format } from "date-fns";
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -20,6 +20,7 @@ export default function ShareList() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSharesQuery({ cursor });
const locale = useDateFnsLocale();
if (!isLoading && data?.items.length === 0) {
return <EmptyState icon={IconWorld} title={t("No shared pages")} />;
@@ -81,7 +82,12 @@ export default function ShareList() {
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{format(new Date(share.createdAt), "MMM dd, yyyy")}
{formatLocalized(
share.createdAt,
"MMM dd, yyyy",
"PP",
locale,
)}
</Text>
</Table.Td>
<Table.Td>
@@ -13,6 +13,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
type SpaceFilterMenuProps = {
value: string | null;
@@ -75,7 +76,7 @@ export function SpaceFilterMenu({
<ScrollArea.Autosize mah={280}>
<Menu.Item
role="menuitemradio"
component={RadioMenuItem}
aria-checked={!value}
onClick={() => onChange(null)}
>
@@ -103,7 +104,7 @@ export function SpaceFilterMenu({
{orderedSpaces.map((space) => (
<Menu.Item
key={space.id}
role="menuitemradio"
component={RadioMenuItem}
aria-checked={value === space.id}
onClick={() => onChange(space.id)}
>
@@ -210,7 +210,9 @@ export default function SpaceMembersList({
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions")}
aria-label={t("Member actions for {{name}}", {
name: member.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
@@ -12,9 +12,14 @@ import useUserRole from "@/hooks/use-user-role.tsx";
interface Props {
userId: string;
name: string;
deactivatedAt: Date | null;
}
export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
export default function MemberActionMenu({
userId,
name,
deactivatedAt,
}: Props) {
const { t } = useTranslation();
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
const deactivateMutation = useDeactivateWorkspaceMemberMutation();
@@ -86,7 +91,7 @@ export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions")}
aria-label={t("Member actions for {{name}}", { name })}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
@@ -111,6 +111,7 @@ export default function WorkspaceMembersTable() {
{isAdmin && (
<MemberActionMenu
userId={user.id}
name={user.name}
deactivatedAt={user.deactivatedAt}
/>
)}
+5 -1
View File
@@ -10,7 +10,11 @@ const api: AxiosInstance = axios.create({
api.interceptors.response.use(
(response) => {
// we need the response headers for these endpoints
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
const exemptEndpoints = [
"/api/pages/export",
"/api/spaces/export",
"/api/docx-export",
];
if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) {
+62
View File
@@ -0,0 +1,62 @@
import { format as dateFnsFormat, type Locale } from "date-fns";
import {
de,
enUS,
es,
fr,
it,
ja,
ko,
nl,
ptBR,
ru,
uk,
zhCN,
} from "date-fns/locale";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
const LOCALE_MAP: Record<string, Locale> = {
"de-DE": de,
"en-US": enUS,
"es-ES": es,
"fr-FR": fr,
"it-IT": it,
"ja-JP": ja,
"ko-KR": ko,
"nl-NL": nl,
"pt-BR": ptBR,
"ru-RU": ru,
"uk-UA": uk,
"zh-CN": zhCN,
};
export function getDateFnsLocale(language?: string): Locale {
const lang = language ?? i18n.language ?? "en-US";
return LOCALE_MAP[lang] ?? LOCALE_MAP[lang.split("-")[0]] ?? enUS;
}
export function useDateFnsLocale(): Locale {
const { i18n: instance } = useTranslation();
return getDateFnsLocale(instance.language);
}
function isEnglishLocale(locale: Locale): boolean {
return locale.code === "en-US" || locale.code?.startsWith("en") === true;
}
/**
* Picks `enUSPattern` for the English locale and `localizedPattern` for every
* other locale. Keeps existing en-US output byte-identical while letting other
* languages use date-fns localized format tokens (P, PP, p, PPp, ).
*/
export function formatLocalized(
date: Date | number | string,
enUSPattern: string,
localizedPattern: string,
locale?: Locale,
): string {
const effective = locale ?? getDateFnsLocale();
const pattern = isEnglishLocale(effective) ? enUSPattern : localizedPattern;
return dateFnsFormat(new Date(date), pattern, { locale: effective });
}
+14 -6
View File
@@ -1,17 +1,25 @@
import { formatDistanceStrict } from "date-fns";
import { format, isToday, isYesterday } from "date-fns";
import { formatDistanceStrict, isToday, isYesterday } from "date-fns";
import i18n from "@/i18n.ts";
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
export function timeAgo(date: Date) {
return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true });
return formatDistanceStrict(new Date(date), new Date(), {
addSuffix: true,
locale: getDateFnsLocale(),
});
}
export function formattedDate(date: Date) {
const locale = getDateFnsLocale();
if (isToday(date)) {
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
return i18n.t("Today, {{time}}", {
time: formatLocalized(date, "h:mma", "p", locale),
});
} else if (isYesterday(date)) {
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
return i18n.t("Yesterday, {{time}}", {
time: formatLocalized(date, "h:mma", "p", locale),
});
} else {
return format(date, "MMM dd, yyyy, h:mma");
return formatLocalized(date, "MMM dd, yyyy, h:mma", "PPp", locale);
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
+39 -23
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.90.0",
"version": "0.90.1",
"description": "",
"author": "",
"private": true,
@@ -30,14 +30,15 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.52",
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/google": "3.0.52",
"@ai-sdk/openai": "3.0.47",
"@ai-sdk/openai-compatible": "2.0.37",
"@aws-sdk/client-s3": "3.1050.0",
"@aws-sdk/lib-storage": "3.1050.0",
"@aws-sdk/s3-request-presigner": "3.1050.0",
"@clickhouse/client": "^1.18.2",
"@docmost/pdf-inspector": "1.9.4",
"@azure/storage-blob": "12.31.0",
"@clickhouse/client": "1.18.2",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
@@ -64,19 +65,19 @@
"@nestjs/websockets": "^11.1.19",
"@node-saml/passport-saml": "^5.1.0",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.134",
"ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0",
"bowser": "^2.14.1",
"bullmq": "^5.76.10",
"cache-manager": "^7.2.8",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"ai": "6.0.134",
"ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0",
"bowser": "2.14.1",
"bullmq": "5.76.10",
"cache-manager": "7.2.8",
"cheerio": "1.2.0",
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"cookie": "1.1.1",
"fast-bm25": "0.0.5",
"fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
"fastify-ip": "2.0.0",
"fs-extra": "11.3.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21",
@@ -113,9 +114,9 @@
"scimmy": "1.3.5",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"tseep": "1.3.1",
"typesense": "^3.0.5",
"undici": "7.24.0",
"ws": "^8.20.1",
@@ -163,7 +164,21 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"happy-dom.+\\.js$": ["babel-jest", { "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }],
"happy-dom.+\\.js$": [
"babel-jest",
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
]
}
],
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
@@ -177,7 +192,8 @@
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
"^src/(.*)$": "<rootDir>/$1"
}
}
}
@@ -165,6 +165,21 @@ export class PersistenceExtension implements Extension {
}
if (page) {
document.broadcastStateless(
JSON.stringify({
type: 'page.updated',
updatedAt: new Date().toISOString(),
lastUpdatedById: context?.user?.id,
lastUpdatedBy: context?.user
? {
id: context.user?.id,
name: context.user?.name,
avatarUrl: context.user?.avatarUrl,
}
: undefined,
}),
);
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
}
+1
View File
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
DOCX_EXPORT: 'export:docx',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -4,6 +4,11 @@ export enum UserRole {
MEMBER = 'member',
}
export enum InviteUserRole {
ADMIN = 'admin', // can have owner permissions but cannot delete workspace
MEMBER = 'member',
}
export enum SpaceRole {
ADMIN = 'admin', // can manage space settings, members, and delete space
WRITER = 'writer', // can read and write pages in space
@@ -310,6 +310,7 @@ export class PageService {
expression: 'position',
direction: 'asc',
orderModifier: (ob) => ob.collate('C').asc(),
cursorExpression: sql`position collate "C"`,
},
{ expression: 'id', direction: 'asc' },
],
@@ -1,320 +0,0 @@
import { Test } from '@nestjs/testing';
import { TransclusionService } from '../transclusion.service';
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { StorageService } from '../../../../integrations/storage/storage.service';
import { PageAccessService } from '../../page-access/page-access.service';
describe('TransclusionService.syncPageTransclusions', () => {
let service: TransclusionService;
let repo: jest.Mocked<PageTransclusionsRepo>;
beforeEach(async () => {
const mockRepo: jest.Mocked<Partial<PageTransclusionsRepo>> = {
findByPageId: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
deleteByPageAndTransclusionIds: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
TransclusionService,
{ provide: PageTransclusionsRepo, useValue: mockRepo },
{ provide: PageTransclusionReferencesRepo, useValue: {} },
{ provide: PageRepo, useValue: {} },
{ provide: PagePermissionRepo, useValue: {} },
{ provide: AttachmentRepo, useValue: {} },
{ provide: StorageService, useValue: {} },
{ provide: PageAccessService, useValue: {} },
],
}).compile();
service = module.get(TransclusionService);
repo = module.get(PageTransclusionsRepo);
});
const pageId = '00000000-0000-0000-0000-000000000001';
const workspaceId = '00000000-0000-0000-0000-000000000099';
it('inserts new transclusions that did not exist before', async () => {
repo.findByPageId.mockResolvedValue([]);
const pm = {
type: 'doc',
content: [
{
type: 'transclusionSource',
attrs: { id: 'a' },
content: [{ type: 'paragraph' }],
},
],
};
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.insert).toHaveBeenCalledWith(
expect.objectContaining({
pageId,
transclusionId: 'a',
}),
undefined,
);
expect(repo.update).not.toHaveBeenCalled();
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
});
it('updates transclusions whose content changed', async () => {
repo.findByPageId.mockResolvedValue([
{
id: 'row1',
pageId,
transclusionId: 'a',
content: { type: 'doc', content: [{ type: 'paragraph' }] },
createdAt: new Date(),
updatedAt: new Date(),
} as any,
]);
const newContent = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] },
],
};
const pm = {
type: 'doc',
content: [
{
type: 'transclusionSource',
attrs: { id: 'a' },
content: newContent.content,
},
],
};
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
expect(repo.update).toHaveBeenCalledWith(
pageId,
'a',
expect.objectContaining({ content: newContent }),
undefined,
);
});
it('skips update when content is unchanged', async () => {
const sameContent = {
type: 'doc',
content: [{ type: 'paragraph' }],
};
repo.findByPageId.mockResolvedValue([
{
id: 'row1',
pageId,
transclusionId: 'a',
content: sameContent,
createdAt: new Date(),
updatedAt: new Date(),
} as any,
]);
const pm = {
type: 'doc',
content: [
{
type: 'transclusionSource',
attrs: { id: 'a' },
content: sameContent.content,
},
],
};
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
expect(repo.update).not.toHaveBeenCalled();
});
it('deletes transclusions that no longer appear in the doc', async () => {
repo.findByPageId.mockResolvedValue([
{
id: 'r',
pageId,
transclusionId: 'gone',
content: { type: 'doc', content: [] },
createdAt: new Date(),
updatedAt: new Date(),
} as any,
]);
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith(
pageId,
['gone'],
undefined,
);
});
it('handles empty doc → noop', async () => {
repo.findByPageId.mockResolvedValue([]);
const result = await service.syncPageTransclusions(pageId, workspaceId, null);
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
expect(repo.insert).not.toHaveBeenCalled();
expect(repo.update).not.toHaveBeenCalled();
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
});
});
describe('TransclusionService.syncPageReferences', () => {
let service: TransclusionService;
let refRepo: jest.Mocked<PageTransclusionReferencesRepo>;
beforeEach(async () => {
const mockTransclusionsRepo: Partial<PageTransclusionsRepo> = {};
const mockRefRepo: jest.Mocked<Partial<PageTransclusionReferencesRepo>> = {
findByReferencePageId: jest.fn(),
insertMany: jest.fn(),
deleteByReferenceAndKeys: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
TransclusionService,
{ provide: PageTransclusionsRepo, useValue: mockTransclusionsRepo },
{ provide: PageTransclusionReferencesRepo, useValue: mockRefRepo },
{ provide: PageRepo, useValue: {} },
{ provide: PagePermissionRepo, useValue: {} },
{ provide: AttachmentRepo, useValue: {} },
{ provide: StorageService, useValue: {} },
{ provide: PageAccessService, useValue: {} },
],
}).compile();
service = module.get(TransclusionService);
refRepo = module.get(PageTransclusionReferencesRepo);
});
const referencePageId = '00000000-0000-0000-0000-000000000001';
const workspaceId = '00000000-0000-0000-0000-000000000099';
it('inserts new loose references, no deletes when none existed', async () => {
refRepo.findByReferencePageId.mockResolvedValue([]);
const pm = {
type: 'doc',
content: [
{
type: 'transclusionReference',
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
},
{
type: 'transclusionReference',
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
},
],
};
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 2, deleted: 0 });
expect(refRepo.insertMany).toHaveBeenCalledWith(
[
{
workspaceId,
referencePageId,
sourcePageId: 'p1',
transclusionId: 'e1',
},
{
workspaceId,
referencePageId,
sourcePageId: 'p2',
transclusionId: 'e2',
},
],
undefined,
);
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
});
it('ignores references nested inside a source (schema-forbidden)', async () => {
refRepo.findByReferencePageId.mockResolvedValue([]);
const pm = {
type: 'doc',
content: [
{
type: 'transclusionSource',
attrs: { id: 's1' },
content: [
{
type: 'transclusionReference',
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
},
],
},
],
};
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, deleted: 0 });
expect(refRepo.insertMany).not.toHaveBeenCalled();
});
it('deletes references that no longer appear', async () => {
refRepo.findByReferencePageId.mockResolvedValue([
{
id: 'r1',
referencePageId,
sourcePageId: 'p1',
transclusionId: 'e1',
createdAt: new Date(),
} as any,
]);
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, deleted: 1 });
expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith(
referencePageId,
[
{
sourcePageId: 'p1',
transclusionId: 'e1',
},
],
undefined,
);
expect(refRepo.insertMany).not.toHaveBeenCalled();
});
it('is a no-op when desired matches existing exactly', async () => {
refRepo.findByReferencePageId.mockResolvedValue([
{
id: 'r',
referencePageId,
sourcePageId: 'p1',
transclusionId: 'e1',
createdAt: new Date(),
} as any,
]);
const pm = {
type: 'doc',
content: [
{
type: 'transclusionReference',
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
},
],
};
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, deleted: 0 });
expect(refRepo.insertMany).not.toHaveBeenCalled();
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
});
});
@@ -6,11 +6,13 @@ import {
} from '@nestjs/common';
import { isDeepStrictEqual } from 'node:util';
import { v7 as uuid7 } from 'uuid';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { StorageService } from '../../../integrations/storage/storage.service';
import {
@@ -36,10 +38,12 @@ export class TransclusionService {
private readonly logger = new Logger(TransclusionService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly storageService: StorageService,
private readonly pageAccessService: PageAccessService,
@@ -213,6 +217,40 @@ export class TransclusionService {
return { inserted: rows.length };
}
/**
* Resolve viewer access for source page IDs supplied by an authenticated
* caller. Restricts candidates to pages the viewer can see at the space
* level before applying page-level restrictions, so a workspace member
* cannot read a sync block from a private space they don't belong to via
* an unrestricted source page.
*/
private async filterViewerAccessiblePageIds(
pageIds: string[],
viewerUserId: string,
workspaceId: string,
): Promise<string[]> {
if (pageIds.length === 0) return [];
const spaceVisible = await this.db
.selectFrom('pages')
.select('id')
.where('id', 'in', pageIds)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(viewerUserId),
)
.execute();
if (spaceVisible.length === 0) return [];
return this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: spaceVisible.map((r) => r.id),
userId: viewerUserId,
});
}
async lookup(
references: Array<{ sourcePageId: string; transclusionId: string }>,
viewerUserId: string,
@@ -224,10 +262,11 @@ export class TransclusionService {
new Set(references.map((r) => r.sourcePageId)),
);
const accessibleSet = new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: candidatePageIds,
userId: viewerUserId,
}),
await this.filterViewerAccessiblePageIds(
candidatePageIds,
viewerUserId,
workspaceId,
),
);
return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
@@ -336,10 +375,11 @@ export class TransclusionService {
new Set([sourcePageId, ...referencePageIds]),
);
const accessibleSet = new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: candidatePageIds,
userId: viewerUserId,
}),
await this.filterViewerAccessiblePageIds(
candidatePageIds,
viewerUserId,
workspaceId,
),
);
const accessibleIds = candidatePageIds.filter((id) =>
@@ -11,7 +11,7 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission';
import { InviteUserRole } from '../../../common/helpers/types/permission';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto {
@@ -32,7 +32,7 @@ export class InviteUserDto {
@IsUUID('all', { each: true })
groupIds: string[];
@IsEnum(UserRole)
@IsEnum(InviteUserRole)
role: string;
}
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
@@ -40,6 +41,7 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { isAdminActingOnOwner } from '../workspace.util';
@Injectable()
export class WorkspaceInvitationService {
@@ -119,6 +121,10 @@ export class WorkspaceInvitationService {
): Promise<void> {
const { emails, role, groupIds } = inviteUserDto;
if (isAdminActingOnOwner(authUser.role, role)) {
throw new ForbiddenException();
}
let invites: WorkspaceInvitation[] = [];
try {
@@ -30,6 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import { isAdminActingOnOwner } from '../workspace.util';
import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -590,8 +591,8 @@ export class WorkspaceService {
// prevent ADMIN from managing OWNER role
if (
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role)
) {
throw new ForbiddenException();
}
@@ -695,7 +696,7 @@ export class WorkspaceService {
throw new BadRequestException('You cannot deactivate yourself');
}
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException(
'You cannot deactivate a user with owner role',
);
@@ -753,7 +754,7 @@ export class WorkspaceService {
throw new BadRequestException('User is not deactivated');
}
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException(
'You cannot activate a user with owner role',
);
@@ -805,7 +806,7 @@ export class WorkspaceService {
throw new BadRequestException('You cannot delete yourself');
}
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException('You cannot delete a user with owner role');
}
@@ -0,0 +1,8 @@
import { UserRole } from '../../common/helpers/types/permission';
export function isAdminActingOnOwner(
authUserRole: string,
targetRole: string,
): boolean {
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
}
@@ -14,12 +14,14 @@ type SortField<DB, TB extends keyof DB, O> =
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
cursorExpression?: ReferenceExpression<DB, TB>;
key?: keyof O & string;
}
| {
expression: ReferenceExpression<DB, TB>;
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
cursorExpression?: ReferenceExpression<DB, TB>;
key: keyof O & string;
};
@@ -202,11 +204,12 @@ export async function executeWithCursorPagination<
const comparison = field.direction === defaultDirection ? '>' : '<';
const value = cursor[field.key as keyof typeof cursor];
const compareExpr = field.cursorExpression ?? field.expression;
const conditions = [eb(field.expression, comparison, value)];
const conditions = [eb(compareExpr, comparison, value)];
if (expression) {
conditions.push(and([eb(field.expression, '=', value), expression]));
conditions.push(and([eb(compareExpr, '=', value), expression]));
}
expression = or(conditions);
@@ -122,6 +122,26 @@ export class EnvironmentService {
return this.configService.get<string>('AWS_S3_URL');
}
getAzureStorageAccountName(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
}
getAzureStorageContainer(): string {
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
}
getAzureStorageAccountKey(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
}
getAzureStorageEndpoint(): string {
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
}
getAzureStorageUrl(): string {
return this.configService.get<string>('AZURE_STORAGE_URL');
}
getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log');
}
@@ -49,7 +49,7 @@ export class EnvironmentVariables {
MAIL_DRIVER: string;
@IsOptional()
@IsIn(['local', 's3'])
@IsIn(['local', 's3', 'azure'])
STORAGE_DRIVER: string;
@IsOptional()
@@ -0,0 +1,192 @@
import { Readable } from 'stream';
import {
AzureStorageConfig,
StorageDriver,
StorageOption,
} from '../interfaces';
import {
BlobSASPermissions,
BlobServiceClient,
BlockBlobClient,
ContainerClient,
generateBlobSASQueryParameters,
SASProtocol,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { Logger } from '@nestjs/common';
import { getMimeType } from '../../../common/helpers';
export class AzureDriver implements StorageDriver {
private readonly config: AzureStorageConfig;
private readonly blobServiceClient: BlobServiceClient;
private readonly containerClient: ContainerClient;
private readonly sharedKeyCredential: StorageSharedKeyCredential;
private readonly accountUrl: string;
constructor(config: AzureStorageConfig) {
this.config = config;
if (!config.accountName) {
throw new Error('AzureDriver: accountName is required');
}
if (!config.container) {
throw new Error('AzureDriver: container is required');
}
if (!config.accountKey) {
throw new Error('AzureDriver: accountKey is required');
}
this.accountUrl =
config.endpoint ??
`https://${config.accountName}.blob.core.windows.net`;
this.sharedKeyCredential = new StorageSharedKeyCredential(
config.accountName,
config.accountKey,
);
this.blobServiceClient = this.createBlobServiceClient();
this.containerClient = this.blobServiceClient.getContainerClient(
config.container,
);
}
private blockBlob(filePath: string): BlockBlobClient {
return this.containerClient.getBlockBlobClient(filePath);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
const stream: Readable = Buffer.isBuffer(file) ? Readable.from(file) : file;
await this.uploadStream(filePath, stream);
}
async uploadStream(
filePath: string,
file: Readable,
options?: { recreateClient?: boolean },
): Promise<void> {
const clientToUse = options?.recreateClient
? this.createBlobServiceClient()
.getContainerClient(this.config.container)
.getBlockBlobClient(filePath)
: this.blockBlob(filePath);
try {
const contentType = getMimeType(filePath);
await clientToUse.uploadStream(file, undefined, undefined, {
blobHTTPHeaders: { blobContentType: contentType },
});
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
}
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
try {
if (!(await this.exists(fromFilePath))) {
return;
}
const sourceUrl = await this.getSignedUrl(fromFilePath, 60);
const dest = this.blockBlob(toFilePath);
await dest.syncCopyFromURL(sourceUrl);
} catch (err) {
throw new Error(`Failed to copy file: ${(err as Error).message}`);
}
}
async read(filePath: string): Promise<Buffer> {
try {
return await this.blockBlob(filePath).downloadToBuffer();
} catch (err) {
throw new Error(
`Failed to read file from Azure: ${(err as Error).message}`,
);
}
}
async readStream(filePath: string): Promise<Readable> {
try {
const response = await this.blockBlob(filePath).download();
return response.readableStreamBody as Readable;
} catch (err) {
throw new Error(
`Failed to read file from Azure: ${(err as Error).message}`,
);
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const count = range.end - range.start + 1;
const response = await this.blockBlob(filePath).download(
range.start,
count,
);
return response.readableStreamBody as Readable;
} catch (err) {
throw new Error(
`Failed to read file from Azure: ${(err as Error).message}`,
);
}
}
async exists(filePath: string): Promise<boolean> {
try {
return await this.blockBlob(filePath).exists();
} catch (err) {
throw new Error(
`Failed to check existence in Azure: ${(err as Error).message}`,
);
}
}
getUrl(filePath: string): string {
const base = this.config.baseUrl ?? this.accountUrl;
return `${base}/${this.config.container}/${filePath}`;
}
async getSignedUrl(filePath: string, expiresIn: number): Promise<string> {
const expiresOn = new Date(Date.now() + expiresIn * 1000);
const sas = generateBlobSASQueryParameters(
{
containerName: this.config.container,
blobName: filePath,
permissions: BlobSASPermissions.parse('r'),
expiresOn,
protocol: SASProtocol.HttpsAndHttp,
},
this.sharedKeyCredential,
).toString();
return `${this.accountUrl}/${this.config.container}/${filePath}?${sas}`;
}
async delete(filePath: string): Promise<void> {
try {
await this.blockBlob(filePath).delete();
} catch (err) {
throw new Error(
`Error deleting file ${filePath} from Azure: ${(err as Error).message}`,
);
}
}
getDriver(): BlobServiceClient {
return this.blobServiceClient;
}
getDriverName(): string {
return StorageOption.AZURE;
}
getConfig(): Record<string, any> {
return this.config;
}
private createBlobServiceClient(): BlobServiceClient {
return new BlobServiceClient(this.accountUrl, this.sharedKeyCredential);
}
}
@@ -1,2 +1,3 @@
export { LocalDriver } from './local.driver';
export { S3Driver } from './s3.driver';
export { AzureDriver } from './azure.driver';
@@ -3,11 +3,13 @@ import { S3ClientConfig } from '@aws-sdk/client-s3';
export enum StorageOption {
LOCAL = 'local',
S3 = 's3',
AZURE = 'azure',
}
export type StorageConfig =
| { driver: StorageOption.LOCAL; config: LocalStorageConfig }
| { driver: StorageOption.S3; config: S3StorageConfig };
| { driver: StorageOption.S3; config: S3StorageConfig }
| { driver: StorageOption.AZURE; config: AzureStorageConfig };
export interface LocalStorageConfig {
storagePath: string;
@@ -20,6 +22,14 @@ export interface S3StorageConfig
baseUrl?: string; // Optional CDN URL for assets
}
export interface AzureStorageConfig {
accountName: string;
container: string;
accountKey: string;
endpoint?: string;
baseUrl?: string;
}
export interface StorageOptions {
disk: StorageConfig;
}
@@ -4,13 +4,14 @@ import {
} from '../constants/storage.constants';
import { EnvironmentService } from '../../environment/environment.service';
import {
AzureStorageConfig,
LocalStorageConfig,
S3StorageConfig,
StorageConfig,
StorageDriver,
StorageOption,
} from '../interfaces';
import { LocalDriver, S3Driver } from '../drivers';
import { AzureDriver, LocalDriver, S3Driver } from '../drivers';
import * as process from 'node:process';
import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
import path from 'path';
@@ -21,6 +22,8 @@ function createStorageDriver(disk: StorageConfig): StorageDriver {
return new LocalDriver(disk.config as LocalStorageConfig);
case StorageOption.S3:
return new S3Driver(disk.config as S3StorageConfig);
case StorageOption.AZURE:
return new AzureDriver(disk.config as AzureStorageConfig);
default:
throw new Error(`Unknown storage driver`);
}
@@ -70,6 +73,18 @@ export const storageDriverConfigProvider = {
return s3Config; }
case StorageOption.AZURE:
return {
driver,
config: {
accountName: environmentService.getAzureStorageAccountName(),
container: environmentService.getAzureStorageContainer(),
accountKey: environmentService.getAzureStorageAccountKey(),
endpoint: environmentService.getAzureStorageEndpoint() || undefined,
baseUrl: environmentService.getAzureStorageUrl() || undefined,
},
};
default:
throw new Error(`Unknown storage driver: ${driver}`);
}
+24 -23
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.90.0",
"version": "0.90.1",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -19,15 +19,15 @@
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@braintree/sanitize-url": "7.1.2",
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@floating-ui/dom": "1.7.3",
"@hocuspocus/provider": "3.4.4",
"@hocuspocus/server": "3.4.4",
"@hocuspocus/transformer": "3.4.4",
"@joplin/turndown": "^4.0.82",
"@joplin/turndown-plugin-gfm": "^1.0.64",
"@joplin/turndown": "4.0.82",
"@joplin/turndown-plugin-gfm": "1.0.64",
"@sindresorhus/slugify": "3.0.0",
"@tiptap/core": "3.20.4",
"@tiptap/extension-audio": "3.20.4",
@@ -58,31 +58,32 @@
"@tiptap/starter-kit": "3.20.4",
"@tiptap/suggestion": "3.20.4",
"@tiptap/y-tiptap": "3.0.2",
"bytes": "^3.1.2",
"cross-env": "^10.1.0",
"date-fns": "^4.1.0",
"bytes": "3.1.2",
"cross-env": "10.1.0",
"date-fns": "4.1.0",
"diff": "8.0.3",
"docx": "9.7.1",
"dompurify": "3.4.1",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
"jszip": "^3.10.1",
"linkifyjs": "^4.3.2",
"fractional-indexing-jittered": "1.0.0",
"highlight.js": "11.11.1",
"image-dimensions": "2.5.0",
"jszip": "3.10.1",
"linkifyjs": "4.3.2",
"marked": "17.0.5",
"ms": "3.0.0-canary.1",
"qrcode": "^1.5.4",
"qrcode": "1.5.4",
"rfc6902": "5.2.0",
"uuid": "^14.0.0",
"y-indexeddb": "^9.0.12",
"uuid": "14.0.0",
"y-indexeddb": "9.0.12",
"y-prosemirror": "1.3.7",
"yjs": "^13.6.30"
},
"devDependencies": {
"@nx/js": "22.6.1",
"@types/bytes": "^3.1.5",
"@types/qrcode": "^1.5.6",
"@types/turndown": "^5.0.6",
"concurrently": "^9.2.1",
"@types/bytes": "3.1.5",
"@types/qrcode": "1.5.6",
"@types/turndown": "5.0.6",
"concurrently": "9.2.1",
"nx": "22.6.1",
"tsx": "^4.21.0"
},
@@ -103,7 +104,7 @@
"glob": "13.0.6",
"ws": "8.20.1",
"dompurify": "3.4.1",
"tmp": "0.2.5",
"tmp": "0.2.6",
"hono": "4.12.18",
"mermaid": "11.15.0",
"nanoid@^3": "3.3.8",
@@ -133,8 +134,8 @@
"axios": "1.16.0",
"langsmith": "0.7.0",
"follow-redirects": "1.16.0",
"protobufjs": "7.5.8",
"ip-address": "10.1.1"
"protobufjs": "7.5.8",
"ip-address": "10.1.1"
},
"neverBuiltDependencies": []
}
+1
View File
@@ -2,6 +2,7 @@
"name": "@docmost/editor-ext",
"homepage": "https://docmost.com",
"private": true,
"sideEffects": false,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch"
+4
View File
@@ -34,3 +34,7 @@ export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
export {
pageNodeToDocxBuffer,
type DocxImageResolver,
} from "./lib/prosemirror-docx";
@@ -0,0 +1,167 @@
# `prosemirror-docx`
[![prosemirror-docx on npm](https://img.shields.io/npm/v/prosemirror-docx.svg)](https://www.npmjs.com/package/prosemirror-docx)
[![prosemirror-docx on GitHub](https://img.shields.io/github/stars/curvenote/prosemirror-docx.svg?style=social)](https://github.com/curvenote/prosemirror-docx)
[
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
![CI](https://github.com/curvenote/prosemirror-docx/workflows/CI/badge.svg)
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
![image](https://user-images.githubusercontent.com/913249/134953610-886047eb-2a21-4929-9a53-9a29d8f6184f.png)
## Overview
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but cant read from `*.docx`), and has most of the basic nodes covered (see below).
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
## Basic usage
```ts
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
import { EditorState } from 'prosemirror-state';
import { writeFileSync } from 'fs'; // Or some other way to write a file
// Set up your prosemirror state/document as you normally do
const state = EditorState.create({ schema: mySchema });
// If there are images, we will need to preload the buffers
const opts = {
getImageBuffer(src: string) {
return anImageBuffer;
},
};
// Create a doc in memory, and then write it to disk
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
await writeDocx(wordDocument).then((buffer) => {
writeFileSync('HelloWorld.docx', buffer);
});
```
### Advanced usage
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
```ts
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
return {
numbering: {
config: state.numbering,
},
fonts: [], // embed fonts,
styles: {
paragraphStyles,
default: {
heading1: paragraphStyles[1],
},
},
};
});
```
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
## Extended usage
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
```ts
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
const nodeSerializer = {
...defaultNodes,
my_paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
};
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
```
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
```ts
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
import { EditorState } from 'prosemirror-state';
import { writeFileSync } from 'fs';
const state = EditorState.create({ schema: mySchema });
export const docxSerializer = new DocxSerializerAsync(
{
...defaultAsyncNodes,
async image(state, node) {
const { src } = node.attrs;
await state.image(src, 70, 'center', undefined, 'png');
state.closeBlock(node);
},
},
defaultMarks,
);
// If there are images, we will need to preload the buffers
const opts = {
async getImageBuffer(src: string) {
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
return new Uint8Array(arrayBuffer);
},
};
// Create a doc in memory, and then write it to disk
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
await writeDocx(wordDocument).then((buffer) => {
writeFileSync('HelloWorld.docx', buffer);
});
```
## Supported Nodes
- text
- paragraph
- heading (levels)
- TODO: Support numbering of headings
- blockquote
- code_block
- TODO: No styles supported
- horizontal_rule
- hard_break
- ordered_list
- unordered_list
- list_item
- image
- math
- equations (numbered & unnumbered)
- tables
Planned:
- Internal References (e.g. see Table 1)
## Supported Marks
- em
- strong
- link
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
- code
- subscript
- superscript
- strikethrough
- underline
- smallcaps
- allcaps
## Resources
- [Prosemirror Docs](https://prosemirror.net/docs/)
- [docx](https://docx.js.org/)
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
@@ -0,0 +1,24 @@
// MIT - https://github.com/curvenote/prosemirror-docx/
export type { SectionConfig, SerializationState } from './types';
export type {
MarkSerializer,
NodeSerializer,
NodeSerializerAsync,
Options,
OptionsAsync,
} from './serializer';
export {
DocxSerializerStateAsync,
DocxSerializerAsync,
DocxSerializerState,
DocxSerializer,
MAX_IMAGE_WIDTH,
} from './serializer';
export {
defaultAsyncNodes,
defaultMarks,
pageNodeToDocxBuffer,
type DocxImageResolver,
} from './schema';
export { writeDocx, createDocFromState, buildDoc } from './utils';
@@ -0,0 +1,47 @@
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
import { INumbering } from './types';
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
return {
alignment: AlignmentType.START,
style: {
paragraph: {
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
},
},
};
}
const numbered = Array(3)
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
.flat()
.map((format, level) => ({
level,
format,
text: `%${level + 1}.`,
...basicIndentStyle((level + 1) / 2),
}));
const bullets = Array(3)
.fill(['●', '○', '■'])
.flat()
.map((text, level) => ({
level,
format: LevelFormat.BULLET,
text,
...basicIndentStyle((level + 1) / 2),
}));
const styles = {
numbered,
bullets,
};
export type NumberingStyles = keyof typeof styles;
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
return {
reference,
levels: styles[style],
};
}
@@ -0,0 +1,250 @@
import { HeadingLevel, ShadingType } from 'docx';
import { Node } from 'prosemirror-model';
import {
DocxSerializerAsync,
MarkSerializer,
NodeSerializerAsync,
OptionsAsync,
} from './serializer';
import { writeDocx } from './utils';
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
// (named colors, hsl, etc.) so the caller omits the color rather than letting
// docx throw "Invalid hex value".
function toDocxColor(input?: string): string | undefined {
if (!input) return undefined;
const value = input.trim().toLowerCase();
const hex = value.startsWith('#') ? value.slice(1) : value;
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
if (/^[0-9a-f]{3}$/.test(hex)) {
return hex
.split('')
.map((ch) => ch + ch)
.join('');
}
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) {
const channel = (n: string) =>
Math.max(0, Math.min(255, parseInt(n, 10)))
.toString(16)
.padStart(2, '0');
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
}
return undefined;
}
// Images and diagrams embed via the image resolver; the URL (with its file
// extension) is passed through so docx can infer the image type.
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
const src = node.attrs?.src || node.attrs?.attachmentId;
if (src) {
try {
await state.image(src, 100);
} catch {
// Unrenderable/missing image: skip rather than fail the whole export.
}
}
state.closeBlock(node);
};
// Non-embeddable media render as a labelled line.
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
const label =
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
state.text(label);
state.closeBlock(node);
};
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
const label = node.attrs?.src || node.attrs?.url || 'embed';
state.text(label);
state.closeBlock(node);
};
export const defaultAsyncNodes: NodeSerializerAsync = {
text(state, node) {
state.text(node.text ?? '');
},
async paragraph(state, node) {
await state.renderInline(node);
state.closeBlock(node);
},
async heading(state, node) {
await state.renderInline(node);
const heading = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
][(node.attrs.level ?? 1) - 1];
state.closeBlock(node, { heading });
},
async blockquote(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async codeBlock(state, node) {
await state.renderContent(node);
state.closeBlock(node);
},
horizontalRule(state, node) {
state.closeBlock(node, { thematicBreak: true });
state.closeBlock(node);
},
hardBreak(state) {
state.addRunOptions({ break: 1 });
},
async bulletList(state, node) {
await state.renderList(node, 'bullets');
},
async orderedList(state, node) {
await state.renderList(node, 'numbered');
},
async listItem(state, node) {
await state.renderListItem(node);
},
async taskList(state, node) {
await state.renderList(node, 'bullets');
},
async taskItem(state, node) {
if (state.currentNumbering) {
state.addParagraphOptions({ numbering: state.currentNumbering });
}
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
await state.renderContent(node);
},
async table(state, node) {
await state.table(node);
},
// Docmost stores LaTeX in attrs.text.
mathInline(state, node) {
state.math(node.attrs?.text ?? '', { inline: true });
},
mathBlock(state, node) {
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
state.closeBlock(node);
},
image: renderImage,
drawio: renderImage,
excalidraw: renderImage,
video: renderFileLine,
audio: renderFileLine,
pdf: renderFileLine,
attachment: renderFileLine,
embed: renderEmbedLine,
youtube: renderEmbedLine,
async callout(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async details(state, node) {
await state.renderContent(node);
},
async detailsSummary(state, node) {
await state.renderInline(node);
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
},
async detailsContent(state, node) {
await state.renderContent(node);
},
async columns(state, node) {
await state.renderContent(node);
},
async column(state, node) {
await state.renderContent(node);
},
async transclusionSource(state, node) {
await state.renderContent(node);
},
mention(state, node) {
state.text(`@${node.attrs?.label ?? ''}`);
},
status(state, node) {
state.text(`[${node.attrs?.text ?? ''}]`);
},
pageBreak(state, node) {
state.closeBlock(node, { pageBreakBefore: true });
},
// No usable static export representation: skip without failing.
subpages() {},
transclusionReference() {},
};
export const defaultMarks: MarkSerializer = {
bold() {
return { bold: true };
},
italic() {
return { italics: true };
},
strike() {
return { strike: true };
},
underline() {
return { underline: {} };
},
code() {
return {
font: { name: 'Monospace' },
color: '000000',
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
};
},
superscript() {
return { superScript: true };
},
subscript() {
return { subScript: true };
},
link() {
// Handled specifically in the serializer; Word treats links as nodes.
return {};
},
highlight(_state, _node, mark) {
const fill = toDocxColor(mark.attrs?.color);
return fill
? { shading: { type: ShadingType.CLEAR, fill } }
: { highlight: 'yellow' };
},
// @tiptap/extension-color stores the color on the textStyle mark.
textStyle(_state, _node, mark) {
const color = toDocxColor(mark.attrs?.color);
return color ? { color } : {};
},
// Comments are editor-only; drop the annotation in the export.
comment() {
return {};
},
};
export async function pageNodeToDocxBuffer(
doc: Node,
getImageBuffer: DocxImageResolver,
): Promise<Buffer> {
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
const wordDoc = await serializer.serializeAsync(
doc,
{ getImageBuffer },
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
// has no heading color, so override the default heading run colors to the
// normal text color. Sizes/italics mirror docx's own defaults so only the
// color changes.
() =>
({
styles: {
default: {
heading1: { run: { color: '000000', size: 32 } },
heading2: { run: { color: '000000', size: 26 } },
heading3: { run: { color: '000000', size: 24 } },
heading4: { run: { color: '000000', italics: true } },
heading5: { run: { color: '000000' } },
heading6: { run: { color: '000000' } },
},
},
}) as any,
);
return writeDocx(wordDoc);
}
@@ -0,0 +1,925 @@
import { Node, Mark } from 'prosemirror-model';
import {
IParagraphOptions,
IRunOptions,
Paragraph,
TextRun,
ExternalHyperlink,
ParagraphChild,
MathRun,
Math,
TabStopType,
TabStopPosition,
SequentialIdentifier,
Bookmark,
ImageRun,
AlignmentType,
Table,
TableRow,
TableCell,
ITableCellOptions,
InternalHyperlink,
SimpleField,
FootnoteReferenceRun,
IImageOptions,
Document,
ITableOptions,
ITableRowOptions,
IPropertiesOptions,
} from 'docx';
import { imageDimensionsFromData } from 'image-dimensions';
import { createNumbering, NumberingStyles } from './numbering';
import { buildDoc, createShortId } from './utils';
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
// This is duplicated from @curvenote/schema
export type AlignOptions = 'left' | 'center' | 'right';
export type NodeSerializer = Record<
string,
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
>;
export type NodeSerializerAsync = Record<
string,
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
>;
export type MarkSerializer = Record<
string,
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
>;
export type Options = {
getImageBuffer: (src: string) => Uint8Array;
sections?: SectionConfig[];
};
export type OptionsAsync = {
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
sections?: SectionConfig[];
};
export type IMathOpts = {
inline?: boolean;
id?: string | null;
numbered?: boolean;
};
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
export const MAX_IMAGE_WIDTH = 600;
function createReferenceBookmark(
id: string,
kind: 'Equation' | 'Figure' | 'Table',
before?: string,
after?: string,
) {
const textBefore = before ? [new TextRun(before)] : [];
const textAfter = after ? [new TextRun(after)] : [];
return new Bookmark({
id,
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
});
}
export class DocxSerializerState {
nodes: NodeSerializer;
options: Options;
marks: MarkSerializer;
children: (Paragraph | Table)[];
sections: Array<{
config: SectionConfig;
children: (Paragraph | Table)[];
}>;
currentSectionIndex = 0;
numbering: INumbering[];
footnotes: IFootnotes = {};
nextRunOpts?: IRunOptions;
current: ParagraphChild[] = [];
currentLink?: { link: string; children: IRunOptions[] };
// Optionally add options
nextParentParagraphOpts?: IParagraphOptions;
currentNumbering?: { reference: string; level: number };
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? ({} as Options);
this.children = [];
this.numbering = [];
// Initialize sections
if (options.sections && options.sections.length > 0) {
this.sections = options.sections.map((config) => ({
config,
children: [],
}));
this.children = this.sections[0].children;
} else {
this.sections = [];
}
}
renderContent(parent: Node, opts?: IParagraphOptions) {
parent.forEach((node, _, i) => {
if (opts) this.addParagraphOptions(opts);
this.render(node, parent, i);
});
}
render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
this.nodes[node.type.name](this, node, parent, index);
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {
return marks
.map((mark) => {
return this.marks[mark.type.name]?.(this, node, mark);
})
.reduce((a, b) => ({ ...a, ...b }), {});
}
renderInline(parent: Node) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
const closeLink = () => {
if (!currentLink) return;
const hyperlink = new ExternalHyperlink({
link: currentLink.link,
// child: this.current[0],
children: this.current,
});
this.current = [...currentLink.stack, hyperlink];
currentLink = undefined;
};
const openLink = (href: string) => {
const sameLink = href === currentLink?.link;
this.addRunOptions({ style: 'Hyperlink' });
// TODO: https://github.com/dolanmiu/docx/issues/1119
// Remove the if statement here and oneLink!
const oneLink = true;
if (!oneLink) {
closeLink();
} else {
if (currentLink && sameLink) return;
if (currentLink && !sameLink) {
// Close previous, and open a new one
closeLink();
}
}
currentLink = {
link: href,
stack: this.current,
};
this.current = [];
};
const progress = (node: Node, offset: number, index: number) => {
const links = node.marks.filter((m) => m.type.name === 'link');
const hasLink = links.length > 0;
if (hasLink) {
openLink(links[0].attrs.href);
} else if (!hasLink && currentLink) {
closeLink();
}
if (node.isText) {
this.text(node.text, this.renderMarks(node, [...node.marks]));
} else {
this.render(node, parent, index);
}
};
parent.forEach(progress);
// Must call close at the end of everything, just in case
closeLink();
}
renderList(node: Node, style: NumberingStyles) {
if (!this.currentNumbering) {
const nextId = createShortId();
this.numbering.push(createNumbering(nextId, style));
this.currentNumbering = { reference: nextId, level: 0 };
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level + 1 };
}
this.renderContent(node);
if (this.currentNumbering.level === 0) {
delete this.currentNumbering;
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level - 1 };
}
}
// This is a pass through to the paragraphs, etc. underneath they will close the block
renderListItem(node: Node) {
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
this.renderContent(node);
}
addParagraphOptions(opts: IParagraphOptions) {
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
}
addRunOptions(opts: IRunOptions) {
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
}
text(text: string | null | undefined, opts?: IRunOptions) {
if (!text) return;
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
delete this.nextRunOpts;
}
math(latex: string, opts: IMathOpts = { inline: true }) {
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = opts.id ?? createShortId();
this.current = [
new TextRun('\t'),
new Math({
children: [new MathRun(latex)],
}),
new TextRun('\t('),
createReferenceBookmark(id, 'Equation'),
new TextRun(')'),
];
this.addParagraphOptions({
tabStops: [
{
type: TabStopType.CENTER,
position: TabStopPosition.MAX / 2,
},
{
type: TabStopType.RIGHT,
position: TabStopPosition.MAX,
},
],
});
}
// not sure what this actually is, seems to be close for 8.5x11
maxImageWidth = MAX_IMAGE_WIDTH;
image(
src: string,
widthPercent = 70,
align: AlignOptions = 'center',
imageRunOpts?: IImageOptions,
imageType?: ImageType,
) {
const buffer = this.options.getImageBuffer(src);
const dimensions = imageDimensionsFromData(buffer);
/* If the image is not a valid image, don't add it */
if (!dimensions) return;
const aspect = dimensions.height / dimensions.width;
const width = this.maxImageWidth * (widthPercent / 100);
let it;
try {
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
} catch (e) {
it = 'png';
}
this.current.push(
new ImageRun({
data: buffer,
...imageRunOpts,
type: it,
transformation: {
...(imageRunOpts?.transformation || {}),
width,
height: width * aspect,
},
}),
);
let alignment: string;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment as any,
});
}
table(
node: Node,
opts: {
getCellOptions?: (cell: Node) => ITableCellOptions;
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
tableOptions?: Omit<ITableOptions, 'rows'>;
} = {},
) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
node.content.forEach((row) => {
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
row.content.forEach((cell) => {
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
});
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
row.content.forEach((cell) => {
this.children = [];
this.renderContent(cell);
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
cells.push(
new TableCell({
...tableCellOpts,
...(getCellOptions?.(cell) || {}),
}),
);
});
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
});
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
actualChildren.push(table);
// If there are multiple tables, this seperates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
$footnoteCounter = 0;
footnote(node: Node) {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
this.renderInline(node);
this.footnotes[this.$footnoteCounter] = {
children: [new Paragraph({ children: this.current })],
};
this.current = current;
this.nextRunOpts = nextRunOpts;
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
}
closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({
children: this.current,
...this.nextParentParagraphOpts,
...props,
});
this.current = [];
delete this.nextParentParagraphOpts;
this.children.push(paragraph);
}
/**
* Move to the next section. If no more sections are available,
* this will be ignored (content continues in current section).
*/
nextSection() {
if (this.currentSectionIndex < this.sections.length - 1) {
this.currentSectionIndex += 1;
this.children = this.sections[this.currentSectionIndex].children;
}
}
/**
* Update the current section's configuration
*/
setSectionConfig(config: Partial<SectionConfig>) {
this.sections[this.currentSectionIndex].config = {
...this.sections[this.currentSectionIndex].config,
...config,
};
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config: SectionConfig = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex(): number {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig(): SectionConfig {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState(): SerializationState {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id: string, before?: string, after?: string) {
const children: ParagraphChild[] = [];
if (before) children.push(new TextRun(before));
children.push(new SimpleField(`REF ${id} \\h`));
if (after) children.push(new TextRun(after));
const ref = new InternalHyperlink({ anchor: id, children });
this.current.push(ref);
}
}
export class DocxSerializer {
nodes: NodeSerializer;
marks: MarkSerializer;
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
this.nodes = nodes;
this.marks = marks;
}
serialize(
content: Node,
options: Options,
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
): Document {
const state = new DocxSerializerState(this.nodes, this.marks, options);
state.renderContent(content);
return buildDoc(state, getDocumentOptions?.(state));
}
}
export class DocxSerializerStateAsync {
nodes: NodeSerializerAsync;
options: OptionsAsync;
marks: MarkSerializer;
children: (Paragraph | Table)[];
sections: Array<{
config: SectionConfig;
children: (Paragraph | Table)[];
}>;
currentSectionIndex = 0;
numbering: INumbering[];
footnotes: IFootnotes = {};
nextRunOpts?: IRunOptions;
current: ParagraphChild[] = [];
currentLink?: { link: string; children: IRunOptions[] };
// Optionally add options
nextParentParagraphOpts?: IParagraphOptions;
currentNumbering?: { reference: string; level: number };
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? ({} as OptionsAsync);
this.children = [];
this.numbering = [];
// Initialize sections
if (options.sections && options.sections.length > 0) {
this.sections = options.sections.map((config) => ({
config,
children: [],
}));
this.children = this.sections[0].children;
} else {
this.sections = [];
}
}
async renderContent(parent: Node, opts?: IParagraphOptions) {
for (let i = 0; i < parent.childCount; i += 1) {
const node = parent.child(i);
if (opts) this.addParagraphOptions(opts);
// eslint-disable-next-line no-await-in-loop
await this.render(node, parent, i);
}
}
async render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {
return marks
.map((mark) => {
return this.marks[mark.type.name]?.(this, node, mark);
})
.reduce((a, b) => ({ ...a, ...b }), {});
}
async renderInline(parent: Node) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
const closeLink = () => {
if (!currentLink) return;
const hyperlink = new ExternalHyperlink({
link: currentLink.link,
// child: this.current[0],
children: this.current,
});
this.current = [...currentLink.stack, hyperlink];
currentLink = undefined;
};
const openLink = (href: string) => {
const sameLink = href === currentLink?.link;
this.addRunOptions({ style: 'Hyperlink' });
// TODO: https://github.com/dolanmiu/docx/issues/1119
// Remove the if statement here and oneLink!
const oneLink = true;
if (!oneLink) {
closeLink();
} else {
if (currentLink && sameLink) return;
if (currentLink && !sameLink) {
// Close previous, and open a new one
closeLink();
}
}
currentLink = {
link: href,
stack: this.current,
};
this.current = [];
};
const progress = async (node: Node, offset: number, index: number) => {
const links = node.marks.filter((m) => m.type.name === 'link');
const hasLink = links.length > 0;
if (hasLink) {
openLink(links[0].attrs.href);
} else if (!hasLink && currentLink) {
closeLink();
}
if (node.isText) {
this.text(node.text, this.renderMarks(node, [...node.marks]));
} else {
await this.render(node, parent, index);
}
};
// Process nodes sequentially to maintain order
for (let i = 0; i < parent.childCount; i += 1) {
// eslint-disable-next-line no-await-in-loop
await progress(parent.child(i), 0, i);
}
// Must call close at the end of everything, just in case
closeLink();
}
async renderList(node: Node, style: NumberingStyles) {
if (!this.currentNumbering) {
const nextId = createShortId();
this.numbering.push(createNumbering(nextId, style));
this.currentNumbering = { reference: nextId, level: 0 };
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level + 1 };
}
await this.renderContent(node);
if (this.currentNumbering.level === 0) {
delete this.currentNumbering;
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level - 1 };
}
}
// This is a pass through to the paragraphs, etc. underneath they will close the block
async renderListItem(node: Node) {
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
await this.renderContent(node);
}
addParagraphOptions(opts: IParagraphOptions) {
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
}
addRunOptions(opts: IRunOptions) {
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
}
text(text: string | null | undefined, opts?: IRunOptions) {
if (!text) return;
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
delete this.nextRunOpts;
}
math(latex: string, opts: IMathOpts = { inline: true }) {
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = opts.id ?? createShortId();
this.current = [
new TextRun('\t'),
new Math({
children: [new MathRun(latex)],
}),
new TextRun('\t('),
createReferenceBookmark(id, 'Equation'),
new TextRun(')'),
];
this.addParagraphOptions({
tabStops: [
{
type: TabStopType.CENTER,
position: TabStopPosition.MAX / 2,
},
{
type: TabStopType.RIGHT,
position: TabStopPosition.MAX,
},
],
});
}
// not sure what this actually is, seems to be close for 8.5x11
maxImageWidth = MAX_IMAGE_WIDTH;
async image(
src: string,
widthPercent = 70,
align: AlignOptions = 'center',
imageRunOpts?: IImageOptions,
imageType?: ImageType,
) {
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
const dimensions = imageDimensionsFromData(buffer);
/* If the image is not a valid image, don't add it */
if (!dimensions) return;
const aspect = dimensions.height / dimensions.width;
const width = this.maxImageWidth * (widthPercent / 100);
let it;
try {
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
} catch (e) {
it = 'png';
}
this.current.push(
new ImageRun({
data: buffer,
...imageRunOpts,
type: it,
transformation: {
...(imageRunOpts?.transformation || {}),
width,
height: width * aspect,
},
}),
);
let alignment: string;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment as any,
});
}
async table(
node: Node,
opts: {
getCellOptions?: (cell: Node) => ITableCellOptions;
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
tableOptions?: Omit<ITableOptions, 'rows'>;
} = {},
) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
const row = node.content.child(rowIndex);
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
// Check if all cells in the row are headers
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
}
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
// Iterate through cells and ensure order
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
this.children = [];
// eslint-disable-next-line no-await-in-loop
await this.renderContent(cell); // Ensure order
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
cells.push(
new TableCell({
...tableCellOpts,
...(getCellOptions?.(cell) || {}),
}),
);
}
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
}
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
actualChildren.push(table);
// If there are multiple tables, this separates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
$footnoteCounter = 0;
async footnote(node: Node) {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
await this.renderInline(node);
this.footnotes[this.$footnoteCounter] = {
children: [new Paragraph({ children: this.current })],
};
this.current = current;
this.nextRunOpts = nextRunOpts;
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
}
closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({
children: this.current,
...this.nextParentParagraphOpts,
...props,
});
this.current = [];
delete this.nextParentParagraphOpts;
this.children.push(paragraph);
}
/**
* Move to the next section. If no more sections are available,
* this will be ignored (content continues in current section).
*/
nextSection() {
if (this.currentSectionIndex < this.sections.length - 1) {
this.currentSectionIndex += 1;
this.children = this.sections[this.currentSectionIndex].children;
}
}
/**
* Update the current section's configuration
*/
setSectionConfig(config: Partial<SectionConfig>) {
this.sections[this.currentSectionIndex].config = {
...this.sections[this.currentSectionIndex].config,
...config,
};
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config: SectionConfig = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex(): number {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig(): SectionConfig {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState(): SerializationState {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id: string, before?: string, after?: string) {
const children: ParagraphChild[] = [];
if (before) children.push(new TextRun(before));
children.push(new SimpleField(`REF ${id} \\h`));
if (after) children.push(new TextRun(after));
const ref = new InternalHyperlink({ anchor: id, children });
this.current.push(ref);
}
}
export class DocxSerializerAsync {
nodes: NodeSerializerAsync;
marks: MarkSerializer;
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
this.nodes = nodes;
this.marks = marks;
}
async serializeAsync(
content: Node,
options: OptionsAsync,
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
) {
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
await state.renderContent(content);
return buildDoc(state, getDocumentOptions?.(state));
}
}
@@ -0,0 +1,34 @@
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
export type Mutable<T> = {
-readonly [k in keyof T]: T[k];
};
export type IFootnotes = Mutable<
Readonly<
Record<
string,
{
readonly children: readonly Paragraph[];
}
>
>
>;
export type INumbering = INumberingOptions['config'][0];
export interface SectionConfig {
properties?: ISectionOptions['properties'];
headers?: ISectionOptions['headers'];
footers?: ISectionOptions['footers'];
}
export interface SerializationState {
numbering: INumberingOptions['config'];
sections?: Array<{
config: SectionConfig;
children: ISectionOptions['children'];
}>;
children?: ISectionOptions['children'];
footnotes?: IFootnotes;
}
@@ -0,0 +1,91 @@
import {
Document,
INumberingOptions,
IPropertiesOptions,
ISectionOptions,
Packer,
SectionType,
} from 'docx';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { IFootnotes, SerializationState } from './types';
export function createShortId() {
return Math.random().toString(36).slice(2, 11);
}
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
let sections = state?.sections?.length
? state.sections.map((section) => ({
properties: section.config.properties || {
type: SectionType.CONTINUOUS,
},
headers: section.config.headers,
footers: section.config.footers,
children: section.children,
}))
: undefined;
if (!sections) {
sections = [
{
headers: undefined,
footers: undefined,
properties: {
type: SectionType.CONTINUOUS,
},
children: state?.children || [],
},
];
}
const doc = new Document({
footnotes: state.footnotes,
numbering: {
config: state.numbering,
},
sections,
...(opts || {}),
});
return doc;
}
/**
* @deprecated - use `buildDoc` instead
* Creates a docx document from the given state.
* */
export function createDocFromState(state: {
numbering: INumberingOptions['config'];
children: ISectionOptions['children'];
footnotes?: IFootnotes;
}) {
return buildDoc({
numbering: state.numbering,
sections: [
{
config: {},
children: state.children,
},
],
footnotes: state.footnotes,
});
}
export async function writeDocx(
doc: Document,
/**
* @deprecated use `.then()` or `await` instead
*/
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
) {
const buffer = await Packer.toBuffer(doc);
await write?.(buffer);
return buffer;
}
export function getLatexFromNode(node: ProsemirrorNode): string {
let math = '';
node.forEach((child) => {
if (child.isText) math += child.text;
// TODO: improve this as we may have other things in the future
});
return math;
}
+282 -58
View File
@@ -10,7 +10,7 @@ overrides:
glob: 13.0.6
ws: 8.20.1
dompurify: 3.4.1
tmp: 0.2.5
tmp: 0.2.6
hono: 4.12.18
mermaid: 11.15.0
nanoid@^3: 3.3.8
@@ -53,7 +53,7 @@ importers:
.:
dependencies:
'@braintree/sanitize-url':
specifier: ^7.1.2
specifier: 7.1.2
version: 7.1.2
'@casl/ability':
specifier: 6.8.0
@@ -62,7 +62,7 @@ importers:
specifier: workspace:*
version: link:packages/editor-ext
'@floating-ui/dom':
specifier: ^1.7.3
specifier: 1.7.3
version: 1.7.3
'@hocuspocus/provider':
specifier: 3.4.4
@@ -74,10 +74,10 @@ importers:
specifier: 3.4.4
version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
'@joplin/turndown':
specifier: ^4.0.82
specifier: 4.0.82
version: 4.0.82
'@joplin/turndown-plugin-gfm':
specifier: ^1.0.64
specifier: 1.0.64
version: 1.0.64
'@sindresorhus/slugify':
specifier: 3.0.0
@@ -170,34 +170,37 @@ importers:
specifier: 3.0.2
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
bytes:
specifier: ^3.1.2
specifier: 3.1.2
version: 3.1.2
cross-env:
specifier: ^10.1.0
specifier: 10.1.0
version: 10.1.0
date-fns:
specifier: ^4.1.0
specifier: 4.1.0
version: 4.1.0
diff:
specifier: 8.0.3
version: 8.0.3
docx:
specifier: 9.7.1
version: 9.7.1
dompurify:
specifier: 3.4.1
version: 3.4.1
fractional-indexing-jittered:
specifier: ^1.0.0
specifier: 1.0.0
version: 1.0.0
highlight.js:
specifier: ^11.11.1
specifier: 11.11.1
version: 11.11.1
image-dimensions:
specifier: ^2.5.0
specifier: 2.5.0
version: 2.5.0
jszip:
specifier: ^3.10.1
specifier: 3.10.1
version: 3.10.1
linkifyjs:
specifier: ^4.3.2
specifier: 4.3.2
version: 4.3.2
marked:
specifier: 17.0.5
@@ -206,16 +209,16 @@ importers:
specifier: 3.0.0-canary.1
version: 3.0.0-canary.1
qrcode:
specifier: ^1.5.4
specifier: 1.5.4
version: 1.5.4
rfc6902:
specifier: 5.2.0
version: 5.2.0
uuid:
specifier: ^14.0.0
specifier: 14.0.0
version: 14.0.0
y-indexeddb:
specifier: ^9.0.12
specifier: 9.0.12
version: 9.0.12(yjs@13.6.30)
y-prosemirror:
specifier: 1.3.7
@@ -228,16 +231,16 @@ importers:
specifier: 22.6.1
version: 22.6.1(@babel/traverse@7.28.5)(nx@22.6.1)
'@types/bytes':
specifier: ^3.1.5
specifier: 3.1.5
version: 3.1.5
'@types/qrcode':
specifier: ^1.5.6
specifier: 1.5.6
version: 1.5.6
'@types/turndown':
specifier: ^5.0.6
specifier: 5.0.6
version: 5.0.6
concurrently:
specifier: ^9.2.1
specifier: 9.2.1
version: 9.2.1
nx:
specifier: 22.6.1
@@ -342,8 +345,8 @@ importers:
specifier: 0.4.0
version: 0.4.0(jotai@2.18.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.12)(react@18.3.1))(optics-ts@2.4.1)
js-cookie:
specifier: 3.0.5
version: 3.0.5
specifier: 3.0.7
version: 3.0.7
jwt-decode:
specifier: 4.0.0
version: 4.0.0
@@ -484,13 +487,13 @@ importers:
apps/server:
dependencies:
'@ai-sdk/google':
specifier: ^3.0.52
specifier: 3.0.52
version: 3.0.52(zod@4.3.6)
'@ai-sdk/openai':
specifier: ^3.0.47
specifier: 3.0.47
version: 3.0.47(zod@4.3.6)
'@ai-sdk/openai-compatible':
specifier: ^2.0.37
specifier: 2.0.37
version: 2.0.37(zod@4.3.6)
'@aws-sdk/client-s3':
specifier: 3.1050.0
@@ -501,12 +504,15 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: 3.1050.0
version: 3.1050.0
'@azure/storage-blob':
specifier: 12.31.0
version: 12.31.0
'@clickhouse/client':
specifier: ^1.18.2
specifier: 1.18.2
version: 1.18.2
'@docmost/pdf-inspector':
specifier: 1.9.4
version: 1.9.4
specifier: 1.9.6
version: 1.9.6
'@fastify/cookie':
specifier: ^11.0.2
version: 11.0.2
@@ -586,43 +592,43 @@ importers:
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.4)
ai:
specifier: ^6.0.134
specifier: 6.0.134
version: 6.0.134(zod@4.3.6)
ai-sdk-ollama:
specifier: ^3.8.1
specifier: 3.8.1
version: 3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6)
bcrypt:
specifier: ^6.0.0
specifier: 6.0.0
version: 6.0.0
bowser:
specifier: ^2.14.1
specifier: 2.14.1
version: 2.14.1
bullmq:
specifier: ^5.76.10
specifier: 5.76.10
version: 5.76.10
cache-manager:
specifier: ^7.2.8
specifier: 7.2.8
version: 7.2.8
cheerio:
specifier: ^1.2.0
specifier: 1.2.0
version: 1.2.0
class-transformer:
specifier: ^0.5.1
specifier: 0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
specifier: 0.15.1
version: 0.15.1
cookie:
specifier: ^1.1.1
specifier: 1.1.1
version: 1.1.1
fast-bm25:
specifier: 0.0.5
version: 0.0.5(typescript@5.9.3)
fastify-ip:
specifier: ^2.0.0
specifier: 2.0.0
version: 2.0.0
fs-extra:
specifier: ^11.3.4
specifier: 11.3.4
version: 11.3.4
happy-dom:
specifier: 20.8.9
@@ -733,13 +739,13 @@ importers:
specifier: ^17.7.0
version: 17.7.0
tlds:
specifier: ^1.261.0
specifier: 1.261.0
version: 1.261.0
tmp-promise:
specifier: ^3.0.3
specifier: 3.0.3
version: 3.0.3
tseep:
specifier: ^1.3.1
specifier: 1.3.1
version: 1.3.1
typesense:
specifier: ^3.0.5
@@ -1114,6 +1120,61 @@ packages:
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
engines: {node: '>=18.0.0'}
'@azure/abort-controller@2.1.2':
resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==}
engines: {node: '>=18.0.0'}
'@azure/core-auth@1.10.1':
resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==}
engines: {node: '>=20.0.0'}
'@azure/core-client@1.10.1':
resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==}
engines: {node: '>=20.0.0'}
'@azure/core-http-compat@2.4.0':
resolution: {integrity: sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@azure/core-client': ^1.10.0
'@azure/core-rest-pipeline': ^1.22.0
'@azure/core-lro@2.7.2':
resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==}
engines: {node: '>=18.0.0'}
'@azure/core-paging@1.6.2':
resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==}
engines: {node: '>=18.0.0'}
'@azure/core-rest-pipeline@1.23.0':
resolution: {integrity: sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==}
engines: {node: '>=20.0.0'}
'@azure/core-tracing@1.3.1':
resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==}
engines: {node: '>=20.0.0'}
'@azure/core-util@1.13.1':
resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
engines: {node: '>=20.0.0'}
'@azure/core-xml@1.5.1':
resolution: {integrity: sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==}
engines: {node: '>=20.0.0'}
'@azure/logger@1.3.0':
resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==}
engines: {node: '>=20.0.0'}
'@azure/storage-blob@12.31.0':
resolution: {integrity: sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==}
engines: {node: '>=20.0.0'}
'@azure/storage-common@12.3.0':
resolution: {integrity: sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==}
engines: {node: '>=20.0.0'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -1842,8 +1903,8 @@ packages:
resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==}
engines: {node: '>=18'}
'@docmost/pdf-inspector@1.9.4':
resolution: {integrity: sha512-G5DNyDtLNxybTXWakqi7PuOEuSb/A2ZjDlv2WCkOkiHszPeILdrC+G0a4e4UP10yxvzuLfb23pJ5jy8fUSYZPw==}
'@docmost/pdf-inspector@1.9.6':
resolution: {integrity: sha512-8k8N8Mwu9xbpRC1jLcz4sFv88ev2oBnW56a/2WLbrOBkfXzyZV2Tml5PikUwEWT4cUXfYfk2dGnJpWQYgCESCQ==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -4965,6 +5026,10 @@ packages:
resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typespec/ts-http-runtime@0.3.5':
resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==}
engines: {node: '>=20.0.0'}
'@ucast/core@1.10.2':
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
@@ -6244,6 +6309,10 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
docx@9.7.1:
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
engines: {node: '>=10'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -6915,6 +6984,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hashery@1.4.0:
resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==}
engines: {node: '>=20'}
@@ -7482,9 +7554,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
@@ -8049,6 +8121,9 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -9595,8 +9670,8 @@ packages:
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
tmp@0.2.6:
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
engines: {node: '>=14.14'}
tmpl@1.0.5:
@@ -10202,6 +10277,10 @@ packages:
xml-encryption@3.1.0:
resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
xml-js@1.6.11:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -10214,6 +10293,9 @@ packages:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
xmlbuilder@10.1.1:
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
engines: {node: '>=4.0'}
@@ -10854,6 +10936,119 @@ snapshots:
'@aws/lambda-invoke-store@0.2.3': {}
'@azure/abort-controller@2.1.2':
dependencies:
tslib: 2.8.1
'@azure/core-auth@1.10.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-util': 1.13.1
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-client@1.10.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-rest-pipeline': 1.23.0
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/logger': 1.3.0
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-http-compat@2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-client': 1.10.1
'@azure/core-rest-pipeline': 1.23.0
'@azure/core-lro@2.7.2':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-util': 1.13.1
'@azure/logger': 1.3.0
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-paging@1.6.2':
dependencies:
tslib: 2.8.1
'@azure/core-rest-pipeline@1.23.0':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/logger': 1.3.0
'@typespec/ts-http-runtime': 0.3.5
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-tracing@1.3.1':
dependencies:
tslib: 2.8.1
'@azure/core-util@1.13.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@typespec/ts-http-runtime': 0.3.5
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-xml@1.5.1':
dependencies:
fast-xml-parser: 5.7.3
tslib: 2.8.1
'@azure/logger@1.3.0':
dependencies:
'@typespec/ts-http-runtime': 0.3.5
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/storage-blob@12.31.0':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-client': 1.10.1
'@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)
'@azure/core-lro': 2.7.2
'@azure/core-paging': 1.6.2
'@azure/core-rest-pipeline': 1.23.0
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/core-xml': 1.5.1
'@azure/logger': 1.3.0
'@azure/storage-common': 12.3.0(@azure/core-client@1.10.1)
events: 3.3.0
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/storage-common@12.3.0(@azure/core-client@1.10.1)':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)
'@azure/core-rest-pipeline': 1.23.0
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/logger': 1.3.0
events: 3.3.0
tslib: 2.8.1
transitivePeerDependencies:
- '@azure/core-client'
- supports-color
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -11723,7 +11918,7 @@ snapshots:
'@csstools/css-tokenizer@3.0.3': {}
'@docmost/pdf-inspector@1.9.4': {}
'@docmost/pdf-inspector@1.9.6': {}
'@emnapi/core@1.8.1':
dependencies:
@@ -14274,7 +14469,7 @@ snapshots:
'@tiptap/extension-bubble-menu@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/dom': 1.7.3
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
optional: true
@@ -15106,6 +15301,14 @@ snapshots:
'@typescript-eslint/types': 8.57.1
eslint-visitor-keys: 5.0.1
'@typespec/ts-http-runtime@0.3.5':
dependencies:
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ucast/core@1.10.2': {}
'@ucast/js@3.0.4':
@@ -16437,6 +16640,15 @@ snapshots:
dependencies:
esutils: 2.0.3
docx@9.7.1:
dependencies:
'@types/node': 25.5.0
hash.js: 1.1.7
jszip: 3.10.1
nanoid: 5.1.7
xml: 1.0.1
xml-js: 1.6.11
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
@@ -17358,6 +17570,11 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hashery@1.4.0:
dependencies:
hookified: 1.15.1
@@ -18103,7 +18320,7 @@ snapshots:
joycon@3.1.1: {}
js-cookie@3.0.5: {}
js-cookie@3.0.7: {}
js-tiktoken@1.0.21:
dependencies:
@@ -18638,6 +18855,8 @@ snapshots:
min-indent@1.0.1: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.6
@@ -18809,7 +19028,7 @@ snapshots:
semver: 7.7.4
string-width: 4.2.3
tar-stream: 2.2.0
tmp: 0.2.5
tmp: 0.2.6
tree-kill: 1.2.2
tsconfig-paths: 4.2.0
tslib: 2.8.1
@@ -19980,8 +20199,7 @@ snapshots:
sax@1.4.1: {}
sax@1.6.0:
optional: true
sax@1.6.0: {}
saxes@6.0.0:
dependencies:
@@ -20432,9 +20650,9 @@ snapshots:
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.5
tmp: 0.2.6
tmp@0.2.5: {}
tmp@0.2.6: {}
tmpl@1.0.5: {}
@@ -21040,6 +21258,10 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
xml-js@1.6.11:
dependencies:
sax: 1.6.0
xml-name-validator@5.0.0: {}
xml-naming@0.1.0: {}
@@ -21049,6 +21271,8 @@ snapshots:
sax: 1.4.1
xmlbuilder: 11.0.1
xml@1.0.1: {}
xmlbuilder@10.1.1: {}
xmlbuilder@11.0.1: {}