Compare commits

..

17 Commits

Author SHA1 Message Date
Philipinho b97bbba77d fix: remove redundant breadcrumb from destination modal 2026-05-28 16:13:25 +01:00
Philipinho d5db7fea2d fix flickers 2026-05-28 16:08:34 +01:00
Philipinho 0b14c529d8 fix page updated time object 2026-05-28 16:05:06 +01:00
Philipinho c71e9ee919 fix clipped emoji in templates editor 2026-05-28 15:52:47 +01:00
Philipinho 2cac7d6fce date localization 2026-05-28 15:38:34 +01:00
Philipinho 5a6b9503a7 support fixed toolbar in templates editor 2026-05-27 18:08:44 +01:00
Philipinho cb3b409a5a fix page position collation 2026-05-27 13:27:31 +01:00
Philipinho 41aaeddac4 util 2026-05-27 10:43:43 +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
Philipinho ed0501a864 fix passing wrong object 2026-05-20 19:09:22 +01:00
Philipinho aa0c37bd68 sync 2026-05-20 18:41:23 +01:00
Philip Okugbe a5858bc470 fix: update packages (#2221) 2026-05-20 18:30:15 +01:00
53 changed files with 911 additions and 501 deletions
+6 -1
View File
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public" DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379
# options: local | s3 # options: local | s3 | azure
STORAGE_DRIVER=local STORAGE_DRIVER=local
# S3 driver config # S3 driver config
@@ -21,6 +21,11 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT= AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE= AWS_S3_FORCE_PATH_STYLE=
# Azure Blob Storage driver config
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
AZURE_STORAGE_CONTAINER=
# default: 50mb # default: 50mb
FILE_UPLOAD_SIZE_LIMIT= FILE_UPLOAD_SIZE_LIMIT=
@@ -424,6 +424,7 @@
"Names do not match": "Names do not match", "Names do not match": "Names do not match",
"Today, {{time}}": "Today, {{time}}", "Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}", "Yesterday, {{time}}": "Yesterday, {{time}}",
"now": "now",
"Space created successfully": "Space created successfully", "Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully", "Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully", "Space deleted successfully": "Space deleted successfully",
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; 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 { useTranslation } from "react-i18next";
import { DestinationPicker } from "./destination-picker"; import { DestinationPicker } from "./destination-picker";
import { import {
@@ -52,7 +52,9 @@ export function DestinationPickerModal({
searchSpacesOnly={searchSpacesOnly} searchSpacesOnly={searchSpacesOnly}
/> />
<Group justify="flex-end" mt="md"> <Divider my="md" />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>
{t("Close")} {t("Close")}
</Button> </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 { .emptyState {
padding: 12px; padding: 12px;
text-align: center; text-align: center;
@@ -221,14 +221,6 @@ export function DestinationPicker({
)) ))
)} )}
</ScrollArea> </ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</> </>
); );
} }
@@ -17,14 +17,26 @@ import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css"; import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
const chatSanitizer = DOMPurify(); const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => { chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") { if (node.tagName !== "A") return;
const href = node.getAttribute("href") || ""; const href = node.getAttribute("href") || "";
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank"); // Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
node.setAttribute("rel", "noopener noreferrer"); // 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");
} }
}); });
@@ -1,11 +1,11 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react"; import React from "react";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
interface ApiKeyTableProps { interface ApiKeyTableProps {
apiKeys: IApiKey[]; apiKeys: IApiKey[];
@@ -23,10 +23,11 @@ export function ApiKeyTable({
onRevoke, onRevoke,
}: ApiKeyTableProps) { }: ApiKeyTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const locale = useDateFnsLocale();
const formatDate = (date: Date | string | null) => { const formatDate = (date: Date | string | null) => {
if (!date) return t("Never"); 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) => { const isExpired = (expiresAt: string | null) => {
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
onClose, onClose,
onSuccess, onSuccess,
}: CreateApiKeyModalProps) { }: CreateApiKeyModalProps) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30"); const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation(); const createApiKeyMutation = useCreateApiKeyMutation();
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
const getExpirationLabel = (days: number) => { const getExpirationLabel = (days: number) => {
const date = new Date(); const date = new Date();
date.setDate(date.getDate() + days); date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", { const formatted = date.toLocaleDateString(i18n.language, {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
@@ -4,12 +4,13 @@ import {
} from "@/ee/billing/queries/billing-query.ts"; } from "@/ee/billing/queries/billing-query.ts";
import { Group, Text, SimpleGrid, Paper } from "@mantine/core"; import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
import classes from "./billing.module.css"; import classes from "./billing.module.css";
import { format } from "date-fns";
import { formatInterval } from "@/ee/billing/utils.ts"; import { formatInterval } from "@/ee/billing/utils.ts";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
export default function BillingDetails() { export default function BillingDetails() {
const { data: billing } = useBillingQuery(); const { data: billing } = useBillingQuery();
const { data: plans } = useBillingPlans(); const { data: plans } = useBillingPlans();
const locale = useDateFnsLocale();
if (!billing || !plans) { if (!billing || !plans) {
return null; return null;
@@ -75,7 +76,12 @@ export default function BillingDetails() {
: "Renewal date"} : "Renewal date"}
</Text> </Text>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg">
{format(billing.periodEndAt, "dd MMM, yyyy")} {formatLocalized(
billing.periodEndAt,
"dd MMM, yyyy",
"PP",
locale,
)}
</Text> </Text>
</div> </div>
</Group> </Group>
@@ -1,13 +1,14 @@
import { Badge, Table } from "@mantine/core"; import { Badge, Table } from "@mantine/core";
import { format } from "date-fns";
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts"; import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
import { isLicenseExpired } from "@/ee/licence/license.utils.ts"; import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
export default function LicenseDetails() { export default function LicenseDetails() {
const { data: license, isError } = useLicenseInfo(); const { data: license, isError } = useLicenseInfo();
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const locale = useDateFnsLocale();
if (!license) { if (!license) {
return null; return null;
@@ -50,12 +51,16 @@ export default function LicenseDetails() {
<Table.Tr> <Table.Tr>
<Table.Th>Issued at</Table.Th> <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.Tr> <Table.Tr>
<Table.Th>Expires at</Table.Th> <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.Tr> <Table.Tr>
<Table.Th>License ID</Table.Th> <Table.Th>License ID</Table.Th>
@@ -1,6 +1,7 @@
import { Group, NumberInput, Select, Text } from "@mantine/core"; import { Group, NumberInput, Select, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates"; import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
import { import {
ExpirationMode, ExpirationMode,
PeriodUnit, PeriodUnit,
@@ -30,7 +31,7 @@ export function addDays(days: number, from?: Date): Date {
function formatShortDate(date: Date): string { function formatShortDate(date: Date): string {
const crossesYear = date.getFullYear() !== new Date().getFullYear(); const crossesYear = date.getFullYear() !== new Date().getFullYear();
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(i18n.language, {
month: "short", month: "short",
day: "numeric", day: "numeric",
...(crossesYear && { year: "numeric" }), ...(crossesYear && { year: "numeric" }),
@@ -38,7 +39,7 @@ function formatShortDate(date: Date): string {
} }
function formatLongDate(date: Date): string { function formatLongDate(date: Date): string {
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(i18n.language, {
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
@@ -12,6 +12,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
import { import {
useMarkObsoleteMutation, useMarkObsoleteMutation,
usePageVerificationInfoQuery, usePageVerificationInfoQuery,
@@ -197,11 +198,14 @@ function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) {
{info.expiresAt && ( {info.expiresAt && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", { {t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
date: new Date(info.expiresAt).toLocaleDateString(undefined, { date: new Date(info.expiresAt).toLocaleDateString(
month: "long", i18n.language,
day: "numeric", {
year: "numeric", month: "long",
}), day: "numeric",
year: "numeric",
},
),
})} })}
</Text> </Text>
)} )}
@@ -13,6 +13,7 @@ import {
IconShieldCheck, IconShieldCheck,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "@/i18n.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query"; import { usePageQuery } from "@/features/page/queries/page-query";
@@ -127,7 +128,7 @@ export function PageVerificationBadge({
status === "verified" && verificationInfo?.expiresAt status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", { ? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString( date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined, i18n.language,
{ month: "long", day: "numeric", year: "numeric" }, { month: "long", day: "numeric", year: "numeric" },
), ),
}) })
@@ -16,9 +16,10 @@ import {
} from "@/ee/page-verification/types/page-verification.types"; } from "@/ee/page-verification/types/page-verification.types";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { buildPageUrl } from "@/features/page/page.utils"; import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
import rowClasses from "@/components/ui/clickable-table-row.module.css"; 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; 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.type === "qms") {
if (item.status === "approved") return t("Indefinitely"); if (item.status === "approved") return t("Indefinitely");
return "—"; return "—";
@@ -60,7 +65,7 @@ function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string
const now = new Date(); const now = new Date();
if (expires <= now) return t("Expired"); if (expires <= now) return t("Expired");
return format(expires, "MMM d, yyyy"); return formatLocalized(expires, "MMM d, yyyy", "PP", locale);
} }
function TableSkeleton() { function TableSkeleton() {
@@ -98,6 +103,7 @@ export default function VerificationListTable({
isLoading, isLoading,
}: VerificationListTableProps) { }: VerificationListTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const locale = useDateFnsLocale();
return ( return (
<Table.ScrollContainer minWidth={600}> <Table.ScrollContainer minWidth={600}>
@@ -200,7 +206,7 @@ export default function VerificationListTable({
<Table.Td> <Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}> <Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{verifiedUntilText(item, t)} {verifiedUntilText(item, t, locale)}
</Text> </Text>
</Table.Td> </Table.Td>
@@ -1,11 +1,11 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react"; import React from "react";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types"; import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
interface ScimTokenTableProps { interface ScimTokenTableProps {
tokens: IScimToken[]; tokens: IScimToken[];
@@ -21,10 +21,11 @@ export function ScimTokenTable({
onRevoke, onRevoke,
}: ScimTokenTableProps) { }: ScimTokenTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const locale = useDateFnsLocale();
const formatDate = (date: Date | string | null) => { const formatDate = (date: Date | string | null) => {
if (!date) return t("Never"); if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy"); return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
}; };
return ( return (
@@ -32,6 +32,12 @@
margin-bottom: 0.25em; 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 { .titleInput {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
@@ -32,6 +32,12 @@ import {
} from "../queries/template-query"; } from "../queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role"; 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"; import classes from "./template-editor.module.css";
@@ -39,6 +45,9 @@ export default function TemplateEditor() {
const { t } = useTranslation(); const { t } = useTranslation();
const { templateId } = useParams<{ templateId: string }>(); const { templateId } = useParams<{ templateId: string }>();
const { isAdmin: isWorkspaceAdmin } = useUserRole(); const { isAdmin: isWorkspaceAdmin } = useUserRole();
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || ""); const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
const { data: spaces } = useGetSpacesQuery({ limit: 100 }); const { data: spaces } = useGetSpacesQuery({ limit: 100 });
@@ -238,6 +247,10 @@ export default function TemplateEditor() {
</title> </title>
</Helmet> </Helmet>
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
)}
<div className={classes.header}> <div className={classes.header}>
<Container size={900} h="100%" px={0}> <Container size={900} h="100%" px={0}>
<Group justify="space-between" h="100%" wrap="nowrap"> <Group justify="space-between" h="100%" wrap="nowrap">
@@ -379,6 +392,13 @@ export default function TemplateEditor() {
)} )}
</div> </div>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{editor && (
<>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} templateMode />
<EditorLinkMenu editor={editor} />
</>
)}
<div style={{ paddingBottom: "20vh" }} /> <div style={{ paddingBottom: "20vh" }} />
</Container> </Container>
</> </>
@@ -5,6 +5,7 @@ import {
useQueryClient, useQueryClient,
UseQueryResult, UseQueryResult,
InfiniteData, InfiniteData,
keepPreviousData,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useAtom, useStore } from "jotai"; import { useAtom, useStore } from "jotai";
import { import {
@@ -35,6 +36,7 @@ export function useGetTemplatesQuery(params?: { spaceId?: string }) {
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
placeholderData: keepPreviousData,
}); });
} }
@@ -38,9 +38,11 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & { type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null; editor: Editor | null;
templateMode?: boolean;
}; };
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
@@ -232,8 +234,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
))} ))}
</ActionIcon.Group> </ActionIcon.Group>
<LinkSelector />
<ColorSelector <ColorSelector
editor={props.editor} editor={props.editor}
isOpen={isColorSelectorOpen} isOpen={isColorSelectorOpen}
@@ -246,18 +246,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
</> </>
)} )}
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}> <LinkSelector />
<ActionIcon
variant="default" {!templateMode && (
size="lg" <Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
radius="6px" <ActionIcon
aria-label={t(commentItem.name)} variant="default"
style={{ border: "none" }} size="lg"
onClick={commentItem.command} radius="6px"
> aria-label={t(commentItem.name)}
<IconMessage size={16} stroke={2} /> style={{ border: "none" }}
</ActionIcon> onClick={commentItem.command}
</Tooltip> >
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
)}
</div> </div>
</BubbleMenu> </BubbleMenu>
); );
@@ -1,12 +1,12 @@
import { FC } from "react"; import { FC } from "react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type { Editor } from "@tiptap/react";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { useToolbarState } from "./use-toolbar-state"; import { useToolbarState } from "./use-toolbar-state";
import { BlockTypeGroup } from "./groups/block-type-group"; import { BlockTypeGroup } from "./groups/block-type-group";
import { InlineMarksGroup } from "./groups/inline-marks-group"; import { InlineMarksGroup } from "./groups/inline-marks-group";
import { ColorGroup } from "./groups/color-group"; import { ColorGroup } from "./groups/color-group";
import { ListsGroup } from "./groups/lists-group"; import { ListsGroup } from "./groups/lists-group";
import { LinkGroup } from "./groups/link-group";
import { AlignmentGroup } from "./groups/alignment-group"; import { AlignmentGroup } from "./groups/alignment-group";
import { MediaGroup } from "./groups/media-group"; import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-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 { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css"; import classes from "./fixed-toolbar.module.css";
export const FixedToolbar: FC = () => { type FixedToolbarProps = {
const editor = useAtomValue(pageEditorAtom); 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 state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
@@ -48,14 +57,12 @@ export const FixedToolbar: FC = () => {
<div className={classes.divider} /> <div className={classes.divider} />
<ListsGroup editor={editor} state={state} /> <ListsGroup editor={editor} state={state} />
<div className={classes.divider} /> <div className={classes.divider} />
<LinkGroup />
<div className={classes.divider} />
<AlignmentGroup editor={editor} /> <AlignmentGroup editor={editor} />
<div className={classes.divider} /> <div className={classes.divider} />
<MediaGroup editor={editor} /> <MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} /> <div className={classes.divider} />
<QuickInsertsGroup editor={editor} /> <QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} /> <MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} /> <div className={classes.divider} />
<HistoryGroup editor={editor} state={state} /> <HistoryGroup editor={editor} state={state} />
</div> </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 { interface Props {
editor: Editor; editor: Editor;
templateMode?: boolean;
} }
type UploadFn = ( type UploadFn = (
@@ -60,7 +61,7 @@ function pickFile(
input.click(); input.click();
} }
export const MediaGroup: FC<Props> = ({ editor }) => { export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -78,24 +79,30 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
</Tooltip> </Tooltip>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item {!templateMode && (
leftSection={<IconPhoto size={16} />} <Menu.Item
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)} leftSection={<IconPhoto size={16} />}
> onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
{t("Image")} >
</Menu.Item> {t("Image")}
<Menu.Item </Menu.Item>
leftSection={<IconMovie size={16} />} )}
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)} {!templateMode && (
> <Menu.Item
{t("Video")} leftSection={<IconMovie size={16} />}
</Menu.Item> onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
<Menu.Item >
leftSection={<IconMusic size={16} />} {t("Video")}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)} </Menu.Item>
> )}
{t("Audio")} {!templateMode && (
</Menu.Item> <Menu.Item
leftSection={<IconMusic size={16} />}
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
>
{t("Audio")}
</Menu.Item>
)}
<Menu.Item <Menu.Item
leftSection={<IconFileTypePdf size={16} />} leftSection={<IconFileTypePdf size={16} />}
onClick={() => onClick={() =>
@@ -104,14 +111,16 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
> >
PDF PDF
</Menu.Item> </Menu.Item>
<Menu.Item {!templateMode && (
leftSection={<IconPaperclip size={16} />} <Menu.Item
onClick={() => leftSection={<IconPaperclip size={16} />}
pickFile(editor, "", true, uploadAttachmentAction, true) onClick={() =>
} pickFile(editor, "", true, uploadAttachmentAction, true)
> }
{t("File attachment")} >
</Menu.Item> {t("File attachment")}
</Menu.Item>
)}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -32,16 +32,17 @@ import { useTranslation } from "react-i18next";
interface Props { interface Props {
editor: Editor; editor: Editor;
templateMode?: boolean;
} }
export const MoreInsertsGroup: FC<Props> = ({ editor }) => { export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const setEmbed = (provider: string) => const setEmbed = (provider: string) =>
editor.chain().focus().setEmbed({ provider }).run(); editor.chain().focus().setEmbed({ provider }).run();
const insertDate = () => { const insertDate = () => {
const currentDate = new Date().toLocaleDateString("en-US", { const currentDate = new Date().toLocaleDateString(i18n.language, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
@@ -91,14 +92,16 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
> >
{t("Subpages")} {t("Subpages")}
</Menu.Item> </Menu.Item>
<Menu.Item {!templateMode && (
leftSection={<IconRotate2 size={16} />} <Menu.Item
onClick={() => leftSection={<IconRotate2 size={16} />}
editor.chain().focus().insertTransclusionSource().run() onClick={() =>
} editor.chain().focus().insertTransclusionSource().run()
> }
{t("Synced block")} >
</Menu.Item> {t("Synced block")}
</Menu.Item>
)}
<Menu.Divider /> <Menu.Divider />
<Menu.Label>{t("Diagrams")}</Menu.Label> <Menu.Label>{t("Diagrams")}</Menu.Label>
@@ -115,18 +118,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
> >
{t("Mermaid diagram")} {t("Mermaid diagram")}
</Menu.Item> </Menu.Item>
<Menu.Item {!templateMode && (
leftSection={<IconDrawio size={16} />} <Menu.Item
onClick={() => editor.chain().focus().setDrawio().run()} leftSection={<IconDrawio size={16} />}
> onClick={() => editor.chain().focus().setDrawio().run()}
Draw.io >
</Menu.Item> Draw.io
<Menu.Item </Menu.Item>
leftSection={<IconExcalidraw size={16} />} )}
onClick={() => editor.chain().focus().setExcalidraw().run()} {!templateMode && (
> <Menu.Item
Excalidraw leftSection={<IconExcalidraw size={16} />}
</Menu.Item> onClick={() => editor.chain().focus().setExcalidraw().run()}
>
Excalidraw
</Menu.Item>
)}
<Menu.Divider /> <Menu.Divider />
<Menu.Label>{t("Embeds")}</Menu.Label> <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 IconDrawio from "@/components/icons/icon-drawio";
import { IconColumns4 } from "@/components/icons/icon-columns-4"; import { IconColumns4 } from "@/components/icons/icon-columns-4";
import { IconColumns5 } from "@/components/icons/icon-columns-5"; import { IconColumns5 } from "@/components/icons/icon-columns-5";
import i18n from "@/i18n.ts";
import { import {
AirtableIcon, AirtableIcon,
FigmaIcon, FigmaIcon,
@@ -459,7 +460,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
searchTerms: ["date", "today"], searchTerms: ["date", "today"],
icon: IconCalendar, icon: IconCalendar,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
const currentDate = new Date().toLocaleDateString("en-US", { const currentDate = new Date().toLocaleDateString(i18n.language, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", 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 { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; 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 { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
@@ -112,6 +112,7 @@ import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts"; import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts"; import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -383,6 +384,7 @@ export const mainExtensions = [
MarkdownClipboard.configure({ MarkdownClipboard.configure({
transformPastedText: true, transformPastedText: true,
}), }),
CleanStyles,
CharacterCount.configure({ CharacterCount.configure({
wordCounter: (text) => countWords(text), wordCounter: (text) => countWords(text),
}), }),
@@ -435,6 +437,7 @@ const TemplateSlashCommand = Command.configure({
export const templateExtensions = [ export const templateExtensions = [
...mainExtensions.filter((ext: any) => ext !== SlashCommand), ...mainExtensions.filter((ext: any) => ext !== SlashCommand),
TemplateSlashCommand, TemplateSlashCommand,
UndoRedo,
] as any; ] as any;
export const collabExtensions: CollabExtensions = (provider, user) => [ export const collabExtensions: CollabExtensions = (provider, user) => [
@@ -14,6 +14,7 @@ import {
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket, HocuspocusProviderWebsocket,
onSyncedParameters, onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import {
Editor, Editor,
@@ -145,6 +146,24 @@ export default function PageEditor({
const onSyncedHandler = (event: onSyncedParameters) => { const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state); 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 onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token); const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000; const now = Date.now().valueOf() / 1000;
@@ -169,6 +188,7 @@ export default function PageEditor({
onAuthenticationFailed: onAuthenticationFailedHandler, onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler, onStatus: onStatusHandler,
onSynced: onSyncedHandler, onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
}); });
local.on("synced", onLocalSyncedHandler); local.on("synced", onLocalSyncedHandler);
@@ -318,7 +338,6 @@ export default function PageEditor({
queryClient.setQueryData(["pages", slugId], { queryClient.setQueryData(["pages", slugId], {
...pageData, ...pageData,
content: newContent, content: newContent,
updatedAt: new Date(),
}); });
} }
}, 3000); }, 3000);
@@ -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 i18n from "@/i18n.ts";
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
export function formatLabelListDate(date: Date): string { export function formatLabelListDate(date: Date): string {
const locale = getDateFnsLocale();
if (isToday(date)) { 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)) { 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)) { 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,3 +1,4 @@
import i18n from "@/i18n.ts";
import { INotification } from "./types/notification.types"; import { INotification } from "./types/notification.types";
export function formatRelativeTime(dateStr: string): string { 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 diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_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 (diffMin < 60) return `${diffMin}m`;
if (diffHours < 24) return `${diffHours}h`; if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`; if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString(undefined, { return new Intl.DateTimeFormat(i18n.language, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); }).format(date);
} }
type TimeGroup = "today" | "yesterday" | "this_week" | "older"; 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 { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts"; import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
import { BacklinksModal } from "./backlinks-modal"; 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 { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { LabelsSection } from "@/features/label/components/labels-section.tsx"; import { LabelsSection } from "@/features/label/components/labels-section.tsx";
@@ -139,6 +140,7 @@ function StatsSection({
updatedAt: Date | string; updatedAt: Date | string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const lastUpdated = useTimeAgo(updatedAt);
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Text size="xs" fw={500} c="dimmed"> <Text size="xs" fw={500} c="dimmed">
@@ -150,10 +152,7 @@ function StatsSection({
label={t("Created")} label={t("Created")}
value={formattedDate(new Date(createdAt))} value={formattedDate(new Date(createdAt))}
/> />
<StatRow <StatRow label={t("Last updated")} value={lastUpdated} />
label={t("Last updated")}
value={timeAgo(new Date(updatedAt))}
/>
</Stack> </Stack>
); );
} }
@@ -7,8 +7,8 @@ import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.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 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 { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib"; import { getPageIcon } from "@/lib";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -20,6 +20,7 @@ export default function ShareList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate(); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSharesQuery({ cursor }); const { data, isLoading } = useGetSharesQuery({ cursor });
const locale = useDateFnsLocale();
if (!isLoading && data?.items.length === 0) { if (!isLoading && data?.items.length === 0) {
return <EmptyState icon={IconWorld} title={t("No shared pages")} />; return <EmptyState icon={IconWorld} title={t("No shared pages")} />;
@@ -81,7 +82,12 @@ export default function ShareList() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}> <Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{format(new Date(share.createdAt), "MMM dd, yyyy")} {formatLocalized(
share.createdAt,
"MMM dd, yyyy",
"PP",
locale,
)}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
+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 { formatDistanceStrict, isToday, isYesterday } from "date-fns";
import { format, isToday, isYesterday } from "date-fns";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
export function timeAgo(date: Date) { 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) { export function formattedDate(date: Date) {
const locale = getDateFnsLocale();
if (isToday(date)) { 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)) { } 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 { } else {
return format(date, "MMM dd, yyyy, h:mma"); return formatLocalized(date, "MMM dd, yyyy, h:mma", "PPp", locale);
} }
} }
+17 -2
View File
@@ -36,8 +36,9 @@
"@aws-sdk/client-s3": "3.1050.0", "@aws-sdk/client-s3": "3.1050.0",
"@aws-sdk/lib-storage": "3.1050.0", "@aws-sdk/lib-storage": "3.1050.0",
"@aws-sdk/s3-request-presigner": "3.1050.0", "@aws-sdk/s3-request-presigner": "3.1050.0",
"@azure/storage-blob": "12.31.0",
"@clickhouse/client": "^1.18.2", "@clickhouse/client": "^1.18.2",
"@docmost/pdf-inspector": "1.9.4", "@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
@@ -163,7 +164,21 @@
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "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" "^.+\\.(t|j)s$": "ts-jest"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
@@ -165,6 +165,21 @@ export class PersistenceExtension implements Extension {
} }
if (page) { 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); await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
} }
@@ -4,6 +4,11 @@ export enum UserRole {
MEMBER = 'member', MEMBER = 'member',
} }
export enum InviteUserRole {
ADMIN = 'admin', // can have owner permissions but cannot delete workspace
MEMBER = 'member',
}
export enum SpaceRole { export enum SpaceRole {
ADMIN = 'admin', // can manage space settings, members, and delete space ADMIN = 'admin', // can manage space settings, members, and delete space
WRITER = 'writer', // can read and write pages in space WRITER = 'writer', // can read and write pages in space
@@ -310,6 +310,7 @@ export class PageService {
expression: 'position', expression: 'position',
direction: 'asc', direction: 'asc',
orderModifier: (ob) => ob.collate('C').asc(), orderModifier: (ob) => ob.collate('C').asc(),
cursorExpression: sql`position collate "C"`,
}, },
{ expression: 'id', direction: 'asc' }, { expression: 'id', direction: 'asc' },
], ],
@@ -481,7 +482,7 @@ export class PageService {
); );
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove, pageIds: pageIdsToMove,
workspaceId: rootPage.workspaceId, workspaceId: rootPage.workspaceId,
}); });
} }
@@ -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'; } from '@nestjs/common';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { v7 as uuid7 } from 'uuid'; 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 { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo'; import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.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 { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { StorageService } from '../../../integrations/storage/storage.service'; import { StorageService } from '../../../integrations/storage/storage.service';
import { import {
@@ -36,10 +38,12 @@ export class TransclusionService {
private readonly logger = new Logger(TransclusionService.name); private readonly logger = new Logger(TransclusionService.name);
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageTransclusionsRepo: PageTransclusionsRepo, private readonly pageTransclusionsRepo: PageTransclusionsRepo,
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo, private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo, private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
private readonly storageService: StorageService, private readonly storageService: StorageService,
private readonly pageAccessService: PageAccessService, private readonly pageAccessService: PageAccessService,
@@ -213,6 +217,40 @@ export class TransclusionService {
return { inserted: rows.length }; 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( async lookup(
references: Array<{ sourcePageId: string; transclusionId: string }>, references: Array<{ sourcePageId: string; transclusionId: string }>,
viewerUserId: string, viewerUserId: string,
@@ -224,10 +262,11 @@ export class TransclusionService {
new Set(references.map((r) => r.sourcePageId)), new Set(references.map((r) => r.sourcePageId)),
); );
const accessibleSet = new Set( const accessibleSet = new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({ await this.filterViewerAccessiblePageIds(
pageIds: candidatePageIds, candidatePageIds,
userId: viewerUserId, viewerUserId,
}), workspaceId,
),
); );
return this.lookupWithAccessSet(references, accessibleSet, workspaceId); return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
@@ -336,10 +375,11 @@ export class TransclusionService {
new Set([sourcePageId, ...referencePageIds]), new Set([sourcePageId, ...referencePageIds]),
); );
const accessibleSet = new Set( const accessibleSet = new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({ await this.filterViewerAccessiblePageIds(
pageIds: candidatePageIds, candidatePageIds,
userId: viewerUserId, viewerUserId,
}), workspaceId,
),
); );
const accessibleIds = candidatePageIds.filter((id) => const accessibleIds = candidatePageIds.filter((id) =>
@@ -11,7 +11,7 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } 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'; import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto { export class InviteUserDto {
@@ -32,7 +32,7 @@ export class InviteUserDto {
@IsUUID('all', { each: true }) @IsUUID('all', { each: true })
groupIds: string[]; groupIds: string[];
@IsEnum(UserRole) @IsEnum(InviteUserRole)
role: string; role: string;
} }
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Inject, Inject,
Injectable, Injectable,
Logger, Logger,
@@ -40,6 +41,7 @@ import {
AUDIT_SERVICE, AUDIT_SERVICE,
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { isAdminActingOnOwner } from '../workspace.util';
@Injectable() @Injectable()
export class WorkspaceInvitationService { export class WorkspaceInvitationService {
@@ -119,6 +121,10 @@ export class WorkspaceInvitationService {
): Promise<void> { ): Promise<void> {
const { emails, role, groupIds } = inviteUserDto; const { emails, role, groupIds } = inviteUserDto;
if (isAdminActingOnOwner(authUser.role, role)) {
throw new ForbiddenException();
}
let invites: WorkspaceInvitation[] = []; let invites: WorkspaceInvitation[] = [];
try { try {
@@ -30,6 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import { isAdminActingOnOwner } from '../workspace.util';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -590,8 +591,8 @@ export class WorkspaceService {
// prevent ADMIN from managing OWNER role // prevent ADMIN from managing OWNER role
if ( if (
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) || isAdminActingOnOwner(authUser.role, newRole) ||
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) isAdminActingOnOwner(authUser.role, user.role)
) { ) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@@ -695,7 +696,7 @@ export class WorkspaceService {
throw new BadRequestException('You cannot deactivate yourself'); 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( throw new BadRequestException(
'You cannot deactivate a user with owner role', 'You cannot deactivate a user with owner role',
); );
@@ -753,7 +754,7 @@ export class WorkspaceService {
throw new BadRequestException('User is not deactivated'); 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( throw new BadRequestException(
'You cannot activate a user with owner role', 'You cannot activate a user with owner role',
); );
@@ -805,7 +806,7 @@ export class WorkspaceService {
throw new BadRequestException('You cannot delete yourself'); 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'); 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}`); | (StringReference<DB, TB> & `${string}.${keyof O & string}`);
direction: OrderByDirection; direction: OrderByDirection;
orderModifier?: OrderByModifiers; orderModifier?: OrderByModifiers;
cursorExpression?: ReferenceExpression<DB, TB>;
key?: keyof O & string; key?: keyof O & string;
} }
| { | {
expression: ReferenceExpression<DB, TB>; expression: ReferenceExpression<DB, TB>;
direction: OrderByDirection; direction: OrderByDirection;
orderModifier?: OrderByModifiers; orderModifier?: OrderByModifiers;
cursorExpression?: ReferenceExpression<DB, TB>;
key: keyof O & string; key: keyof O & string;
}; };
@@ -202,11 +204,12 @@ export async function executeWithCursorPagination<
const comparison = field.direction === defaultDirection ? '>' : '<'; const comparison = field.direction === defaultDirection ? '>' : '<';
const value = cursor[field.key as keyof typeof cursor]; 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) { if (expression) {
conditions.push(and([eb(field.expression, '=', value), expression])); conditions.push(and([eb(compareExpr, '=', value), expression]));
} }
expression = or(conditions); expression = or(conditions);
@@ -122,6 +122,26 @@ export class EnvironmentService {
return this.configService.get<string>('AWS_S3_URL'); 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 { getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log'); return this.configService.get<string>('MAIL_DRIVER', 'log');
} }
@@ -49,7 +49,7 @@ export class EnvironmentVariables {
MAIL_DRIVER: string; MAIL_DRIVER: string;
@IsOptional() @IsOptional()
@IsIn(['local', 's3']) @IsIn(['local', 's3', 'azure'])
STORAGE_DRIVER: string; STORAGE_DRIVER: string;
@IsOptional() @IsOptional()
@@ -66,8 +66,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed') @OnWorkerEvent('failed')
async onFailed(job: Job) { async onFailed(job: Job) {
const fileTaskId = job.data?.fileTaskId;
this.logger.error( this.logger.error(
`Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`, fileTaskId
? `Error processing ${job.name} job. File Task ID: ${fileTaskId}. Reason: ${job.failedReason}`
: `Error processing ${job.name} job. Reason: ${job.failedReason}`,
); );
if (job.name === QueueJob.IMPORT_TASK) { if (job.name === QueueJob.IMPORT_TASK) {
@@ -79,8 +82,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('completed') @OnWorkerEvent('completed')
async onCompleted(job: Job) { async onCompleted(job: Job) {
const fileTaskId = job.data?.fileTaskId;
this.logger.log( this.logger.log(
`Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`, fileTaskId
? `Completed ${job.name} job for File task ID ${fileTaskId}`
: `Completed ${job.name} job`,
); );
if (job.name === QueueJob.IMPORT_TASK) { if (job.name === QueueJob.IMPORT_TASK) {
@@ -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 { LocalDriver } from './local.driver';
export { S3Driver } from './s3.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 { export enum StorageOption {
LOCAL = 'local', LOCAL = 'local',
S3 = 's3', S3 = 's3',
AZURE = 'azure',
} }
export type StorageConfig = export type StorageConfig =
| { driver: StorageOption.LOCAL; config: LocalStorageConfig } | { driver: StorageOption.LOCAL; config: LocalStorageConfig }
| { driver: StorageOption.S3; config: S3StorageConfig }; | { driver: StorageOption.S3; config: S3StorageConfig }
| { driver: StorageOption.AZURE; config: AzureStorageConfig };
export interface LocalStorageConfig { export interface LocalStorageConfig {
storagePath: string; storagePath: string;
@@ -20,6 +22,14 @@ export interface S3StorageConfig
baseUrl?: string; // Optional CDN URL for assets baseUrl?: string; // Optional CDN URL for assets
} }
export interface AzureStorageConfig {
accountName: string;
container: string;
accountKey: string;
endpoint?: string;
baseUrl?: string;
}
export interface StorageOptions { export interface StorageOptions {
disk: StorageConfig; disk: StorageConfig;
} }
@@ -4,13 +4,14 @@ import {
} from '../constants/storage.constants'; } from '../constants/storage.constants';
import { EnvironmentService } from '../../environment/environment.service'; import { EnvironmentService } from '../../environment/environment.service';
import { import {
AzureStorageConfig,
LocalStorageConfig, LocalStorageConfig,
S3StorageConfig, S3StorageConfig,
StorageConfig, StorageConfig,
StorageDriver, StorageDriver,
StorageOption, StorageOption,
} from '../interfaces'; } from '../interfaces';
import { LocalDriver, S3Driver } from '../drivers'; import { AzureDriver, LocalDriver, S3Driver } from '../drivers';
import * as process from 'node:process'; import * as process from 'node:process';
import { LOCAL_STORAGE_PATH } from '../../../common/helpers'; import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
import path from 'path'; import path from 'path';
@@ -21,6 +22,8 @@ function createStorageDriver(disk: StorageConfig): StorageDriver {
return new LocalDriver(disk.config as LocalStorageConfig); return new LocalDriver(disk.config as LocalStorageConfig);
case StorageOption.S3: case StorageOption.S3:
return new S3Driver(disk.config as S3StorageConfig); return new S3Driver(disk.config as S3StorageConfig);
case StorageOption.AZURE:
return new AzureDriver(disk.config as AzureStorageConfig);
default: default:
throw new Error(`Unknown storage driver`); throw new Error(`Unknown storage driver`);
} }
@@ -70,6 +73,18 @@ export const storageDriverConfigProvider = {
return s3Config; } 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: default:
throw new Error(`Unknown storage driver: ${driver}`); throw new Error(`Unknown storage driver: ${driver}`);
} }
+188 -5
View File
@@ -501,12 +501,15 @@ importers:
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: 3.1050.0 specifier: 3.1050.0
version: 3.1050.0 version: 3.1050.0
'@azure/storage-blob':
specifier: 12.31.0
version: 12.31.0
'@clickhouse/client': '@clickhouse/client':
specifier: ^1.18.2 specifier: ^1.18.2
version: 1.18.2 version: 1.18.2
'@docmost/pdf-inspector': '@docmost/pdf-inspector':
specifier: 1.9.4 specifier: 1.9.6
version: 1.9.4 version: 1.9.6
'@fastify/cookie': '@fastify/cookie':
specifier: ^11.0.2 specifier: ^11.0.2
version: 11.0.2 version: 11.0.2
@@ -1114,6 +1117,61 @@ packages:
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
engines: {node: '>=18.0.0'} 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': '@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1842,8 +1900,8 @@ packages:
resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@docmost/pdf-inspector@1.9.4': '@docmost/pdf-inspector@1.9.6':
resolution: {integrity: sha512-G5DNyDtLNxybTXWakqi7PuOEuSb/A2ZjDlv2WCkOkiHszPeILdrC+G0a4e4UP10yxvzuLfb23pJ5jy8fUSYZPw==} resolution: {integrity: sha512-8k8N8Mwu9xbpRC1jLcz4sFv88ev2oBnW56a/2WLbrOBkfXzyZV2Tml5PikUwEWT4cUXfYfk2dGnJpWQYgCESCQ==}
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -4965,6 +5023,10 @@ packages:
resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@ucast/core@1.10.2':
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==} resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
@@ -10854,6 +10916,119 @@ snapshots:
'@aws/lambda-invoke-store@0.2.3': {} '@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': '@babel/code-frame@7.27.1':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@@ -11723,7 +11898,7 @@ snapshots:
'@csstools/css-tokenizer@3.0.3': {} '@csstools/css-tokenizer@3.0.3': {}
'@docmost/pdf-inspector@1.9.4': {} '@docmost/pdf-inspector@1.9.6': {}
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
dependencies: dependencies:
@@ -15106,6 +15281,14 @@ snapshots:
'@typescript-eslint/types': 8.57.1 '@typescript-eslint/types': 8.57.1
eslint-visitor-keys: 5.0.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/core@1.10.2': {}
'@ucast/js@3.0.4': '@ucast/js@3.0.4':