Compare commits

...

11 Commits

Author SHA1 Message Date
Philipinho 3b4a02e94a feat: give workspace owners global space management permission 2025-09-19 22:00:09 +01:00
Philipinho 3c4cab0d2a v0.23.2 2025-09-18 18:00:28 +01:00
Philipinho 4de25a8b94 invalidate queries on space deletion 2025-09-18 15:52:53 +01:00
Philipinho cf5bbb10df fix import html processing 2025-09-18 15:34:13 +01:00
Philipinho ac17521717 sync 2025-09-18 13:24:16 +01:00
Philip Okugbe 9ac180f719 fix: enhance page import (#1570)
* change import process

* fix processor

* fix page name in notion import

* preserve confluence table bg color

* sync
2025-09-17 23:50:27 +01:00
Philipinho 46669fea56 (cloud) disable page sharing in trial mode 2025-09-17 23:36:13 +01:00
Pleasure1234 fe6ecdf1f1 fix: update combobox props in SpaceSelect component (#1564)
Added 'keepMounted: false' and 'dropdownPadding: 0' to comboboxProps for improved dropdown behavior and appearance in the SpaceSelect sidebar component.
2025-09-17 13:36:12 +01:00
Philipinho 04ae1d7270 Allow lastColumnResizable in table 2025-09-15 22:34:29 +01:00
Philip Okugbe 1280f96f37 feat: implement space and workspace icons (#1558)
* feat: implement space and workspace icons
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Add Sharp package for server-side image resizing and optimization
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Support removing icons

* add workspace logo support
- add upload loader
- add white background to transparent image
- other fixes and enhancements

* dark mode

* fixes

* cleanup
2025-09-15 21:11:37 +01:00
Philipinho 61d1cf88a7 fix: reset file inputs after import 2025-09-15 12:52:31 +01:00
62 changed files with 1509 additions and 375 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.23.1",
"version": "0.23.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -527,5 +527,11 @@
"Delete SSO provider": "Delete SSO provider",
"Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?",
"Action": "Action",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration"
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully"
}
@@ -0,0 +1,165 @@
import React, { useRef } from "react";
import { Menu, Box, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconTrash, IconUpload } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { notifications } from "@mantine/notifications";
interface AvatarUploaderProps {
currentImageUrl?: string | null;
fallbackName?: string;
radius?: string | number;
size?: string | number;
variant?: string;
type: AvatarIconType;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
isLoading?: boolean;
disabled?: boolean;
}
export default function AvatarUploader({
currentImageUrl,
fallbackName,
radius,
variant,
size,
type,
onUpload,
onRemove,
isLoading = false,
disabled = false,
}: AvatarUploaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file || disabled) {
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
notifications.show({
message: t("Image exceeds 10MB limit."),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
await onUpload(file);
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to upload image"),
color: "red",
});
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
console.error("File input ref is null!");
}
};
const handleRemove = async () => {
if (disabled) return;
try {
await onRemove();
notifications.show({
message: t("Image removed successfully"),
});
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to remove image"),
color: "red",
});
}
};
return (
<Box>
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
style={{ display: "none" }}
/>
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
<Menu.Target>
<Box style={{ position: "relative", display: "inline-block" }}>
<CustomAvatar
component="button"
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
radius={radius}
variant={variant}
type={type}
/>
{isLoading && (
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
}}
>
<Loader size="sm" />
</Box>
)}
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUpload size={16} />}
disabled={isLoading || disabled}
onClick={handleUploadClick}
>
{t("Upload image")}
</Menu.Item>
{currentImageUrl && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={handleRemove}
disabled={isLoading || disabled}
>
{t("Remove image")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}
@@ -1,8 +1,8 @@
import {
Group,
Menu,
UnstyledButton,
Text,
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import {
@@ -10,7 +10,6 @@ import {
IconBrush,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDeviceDesktop,
IconLogout,
IconMoon,
@@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function TopMenu() {
const { t } = useTranslation();
@@ -50,6 +50,7 @@ export default function TopMenu() {
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}
@@ -1,6 +1,7 @@
import React from "react";
import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl: string;
@@ -11,13 +12,15 @@ interface CustomAvatarProps {
variant?: string;
style?: any;
component?: any;
type?: AvatarIconType;
mt?: string | number;
}
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl);
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
return (
<Avatar
@@ -0,0 +1,64 @@
import api from "@/lib/api-client";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
return await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.AVATAR);
}
export async function uploadSpaceIcon(
file: File,
spaceId: string,
): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
}
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
}
async function removeIcon(
type: AvatarIconType,
spaceId?: string,
): Promise<void> {
const payload: { spaceId?: string; type: string } = { type };
if (spaceId) {
payload.spaceId = spaceId;
}
await api.post("/attachments/remove-icon", payload);
}
export async function removeAvatar(): Promise<void> {
await removeIcon(AvatarIconType.AVATAR);
}
export async function removeSpaceIcon(spaceId: string): Promise<void> {
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
}
export async function removeWorkspaceIcon(): Promise<void> {
await removeIcon(AvatarIconType.WORKSPACE_ICON);
}
@@ -0,0 +1,9 @@
export {
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
uploadWorkspaceIcon,
removeAvatar,
removeSpaceIcon,
removeWorkspaceIcon,
} from "./attachment-service.ts";
@@ -0,0 +1,29 @@
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
WORKSPACE_ICON = "workspace-icon",
}
export enum AttachmentType {
AVATAR = "avatar",
WORKSPACE_ICON = "workspace-icon",
SPACE_ICON = "space-icon",
FILE = "file",
}
@@ -17,7 +17,7 @@ import {
EventExit,
EventSave,
} from "react-drawio";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
@@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
@@ -165,7 +165,7 @@ export const mainExtensions = [
}),
CustomTable.configure({
resizable: true,
lastColumnResizable: false,
lastColumnResizable: true,
allowTableNodeSelection: true,
}),
TableRow,
@@ -94,7 +94,12 @@
hr {
border: none;
border-top: 1px solid #ced4da;
@mixin light {
border-top: 1px solid var(--mantine-color-gray-4);
}
@mixin dark {
border-top: 1px solid var(--mantine-color-dark-4);
}
&:hover {
cursor: pointer;
@@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
@@ -84,6 +84,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit();
const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => {
@@ -116,6 +122,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
});
setFileTaskId(importTask.id);
// Reset file input after successful upload
if (source === "notion" && notionFileRef.current) {
notionFileRef.current();
} else if (source === "confluence" && confluenceFileRef.current) {
confluenceFileRef.current();
} else if (source === "generic" && zipFileRef.current) {
zipFileRef.current();
}
} catch (err) {
console.log("Failed to upload import file", err);
notifications.update({
@@ -243,6 +258,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree);
}
// Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current();
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -272,7 +291,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return (
<>
<SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept=".md" multiple>
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
{(props) => (
<Button
justify="start"
@@ -285,7 +304,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)}
</FileButton>
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
{(props) => (
<Button
justify="start"
@@ -301,6 +320,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip"
resetRef={notionFileRef}
>
{(props) => (
<Button
@@ -316,6 +336,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "confluence")}
accept="application/zip"
resetRef={confluenceFileRef}
>
{(props) => (
<Tooltip
@@ -352,6 +373,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "generic")}
accept="application/zip"
resetRef={zipFileRef}
>
{(props) => (
<Group justify="center">
@@ -9,10 +9,11 @@ import {
SidebarPagesParams,
} from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data);
@@ -10,7 +10,7 @@ import {
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
import {
useCreateShareMutation,
@@ -18,23 +18,27 @@ import {
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query.ts";
import { Link, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps {
readOnly: boolean;
}
export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const { isTrial } = useTrial();
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
@@ -61,7 +65,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
@@ -92,26 +96,29 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
});
};
const shareLink = useMemo(() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
), [publicLink]);
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
return (
<Popover width={350} position="bottom" withArrow shadow="md">
@@ -135,7 +142,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
</Button>
</Popover.Target>
<Popover.Dropdown style={{ userSelect: "none" }}>
{isDescendantShared ? (
{isCloud() && isTrial ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center" mb="sm">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button
size="xs"
onClick={() => navigate("/settings/billing")}
fullWidth
>
{t("Upgrade Plan")}
</Button>
</>
) : isDescendantShared ? (
<>
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
@@ -1,10 +1,10 @@
import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React, {useMemo} from "react";
import React from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx";
import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
@@ -39,16 +39,18 @@ export default function SpaceSettingsModal({
xOffset={0}
mah={400}
>
<Modal.Overlay/>
<Modal.Content style={{overflow: "hidden"}}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title>
<Text fw={500} lineClamp={1}>{space?.name}</Text>
<Text fw={500} lineClamp={1}>
{space?.name}
</Text>
</Modal.Title>
<Modal.CloseButton/>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<div style={{height: rem(600)}}>
<div style={{ height: rem(600) }}>
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
@@ -60,13 +62,15 @@ export default function SpaceSettingsModal({
</Tabs.List>
<Tabs.Panel value="general">
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
<ScrollArea h={550} scrollbarSize={4} pr={8}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="members">
@@ -74,7 +78,7 @@ export default function SpaceSettingsModal({
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id}/>}
) && <AddSpaceMembersModal spaceId={space?.id} />}
</Group>
<SpaceMembersList
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface SpaceSelectProps {
onChange: (value: ISpace) => void;
@@ -16,7 +18,14 @@ interface SpaceSelectProps {
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm" wrap="nowrap">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<CustomAvatar
name={option.label}
avatarUrl={option?.["icon"]}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<div>
<Text size="sm" lineClamp={1}>
{option.label}
@@ -50,6 +59,7 @@ export function SpaceSelect({
return {
label: space.name,
value: space.slug,
icon: space.logo,
};
});
@@ -76,12 +86,11 @@ export function SpaceSelect({
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
// duct tape
onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom" }}
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened}
/>
);
@@ -74,7 +74,11 @@ export function SpaceSidebar() {
marginBottom: 3,
}}
>
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
<SwitchSpace
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
</div>
<div className={classes.section}>
@@ -1,17 +1,25 @@
import classes from './switch-space.module.css';
import { useNavigate } from 'react-router-dom';
import { SpaceSelect } from './space-select';
import { getSpaceUrl } from '@/lib/config';
import { Avatar, Button, Popover, Text } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
import classes from "./switch-space.module.css";
import { useNavigate } from "react-router-dom";
import { SpaceSelect } from "./space-select";
import { getSpaceUrl } from "@/lib/config";
import { Button, Popover, Text } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import React from "react";
interface SwitchSpaceProps {
spaceName: string;
spaceSlug: string;
spaceIcon?: string;
}
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
export function SwitchSpace({
spaceName,
spaceSlug,
spaceIcon,
}: SwitchSpaceProps) {
const navigate = useNavigate();
const [opened, { close, open, toggle }] = useDisclosure(false);
@@ -40,11 +48,13 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
color="gray"
onClick={open}
>
<Avatar
size={20}
<CustomAvatar
name={spaceName}
avatarUrl={spaceIcon}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
name={spaceName}
size={20}
/>
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
{spaceName}
@@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={space => handleSelect(space.slug)}
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
/>
@@ -1,11 +1,23 @@
import React from 'react';
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
import React, { useState } from "react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
import { Button, Divider, Text } from "@mantine/core";
import DeleteSpaceModal from "./delete-space-modal";
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadSpaceIcon,
removeSpaceIcon,
} from "@/features/attachments/services/attachment-service.ts";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { queryClient } from "@/main.tsx";
import {
ResponsiveSettingsContent,
ResponsiveSettingsControl,
ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx";
interface SpaceDetailsProps {
spaceId: string;
@@ -13,9 +25,40 @@ interface SpaceDetailsProps {
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading } = useSpaceQuery(spaceId);
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false);
const handleIconUpload = async (file: File) => {
setIsIconUploading(true);
try {
await uploadSpaceIcon(file, spaceId);
await refetch();
await queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
} catch (err) {
// skip
} finally {
setIsIconUploading(false);
}
};
const handleIconRemove = async () => {
setIsIconUploading(true);
try {
await removeSpaceIcon(spaceId);
await refetch();
await queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
} catch (err) {
// skip
} finally {
setIsIconUploading(false);
}
};
return (
<>
@@ -24,38 +67,56 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<Text my="md" fw={600}>
{t("Details")}
</Text>
<div style={{ marginBottom: "20px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={space.logo}
fallbackName={space.name}
size={"60px"}
variant="filled"
type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isIconUploading}
disabled={readOnly}
/>
</div>
<EditSpaceForm space={space} readOnly={readOnly} />
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("Export space")}</Text>
<Text size="sm" c="dimmed">
{t("Export all pages and attachments in this space.")}
</Text>
</div>
<Button onClick={openExportModal}>
{t("Export")}
</Button>
</Group>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Button onClick={openExportModal}>{t("Export")}</Button>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("Delete space")}</Text>
<Text size="sm" c="dimmed">
{t("Delete this space with all its pages and data.")}
</Text>
</div>
<DeleteSpaceModal space={space} />
</Group>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<DeleteSpaceModal space={space} />
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<ExportModal
type="space"
@@ -7,7 +7,7 @@
}
.cardSection {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.title {
@@ -1,5 +1,5 @@
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React, { useEffect } from 'react';
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React from "react";
import {
prefetchSpace,
useGetSpacesQuery,
@@ -10,6 +10,8 @@ import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceGrid() {
const { t } = useTranslation();
@@ -27,8 +29,10 @@ export default function SpaceGrid() {
withBorder
>
<Card.Section className={classes.cardSection} h={40}></Card.Section>
<Avatar
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size="md"
@@ -54,7 +58,7 @@ export default function SpaceGrid() {
</Group>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
{data?.items && data.items.length > 9 && (
<Group justify="flex-end" mt="lg">
<Button
@@ -1,4 +1,4 @@
import { Table, Group, Text, Avatar } from "@mantine/core";
import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
@@ -6,11 +6,22 @@ import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { UserRole } from "@/lib/types.ts";
import { useIsEEOnly } from "@/hooks/use-is-cloud-ee.tsx";
export default function SpaceList() {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const { data, isLoading } = useGetSpacesQuery({ page });
const [user] = useAtom(userAtom);
const isEEOnly = useIsEEOnly();
const { data, isLoading } = useGetSpacesQuery({
page,
...(isEEOnly && user.role === UserRole.OWNER && { includeAllSpaces: true }),
});
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@@ -39,8 +50,10 @@ export default function SpaceList() {
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Avatar
<CustomAvatar
color="initials"
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
variant="filled"
name={space.name}
/>
@@ -6,13 +6,12 @@ import {
Box,
Space,
Menu,
Avatar,
Anchor,
} from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { getSpaceUrl } from "@/lib/config";
@@ -22,6 +21,8 @@ import Paginate from "@/components/common/paginate";
import NoTableResults from "@/components/common/no-table-results";
import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface AllSpacesListProps {
spaces: any[];
@@ -87,11 +88,13 @@ export default function AllSpacesList({
className={classes.spaceLink}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
>
<Avatar
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
name={space.name}
size={40}
size="md"
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
@@ -152,13 +152,36 @@ export function useDeleteSpaceMutation() {
});
}
const spaces = queryClient.getQueryData(["spaces"]) as any;
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
exact: true,
});
// Invalidate recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
queryClient.invalidateQueries({
queryKey: ["recent-changes", variables.id],
});
}
// Update spaces list cache
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) {
spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id,
);
queryClient.setQueryData(["spaces"], spaces);
}
}*/
// Invalidate all spaces queries to refresh lists
queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
@@ -8,7 +8,6 @@ import {
ISpaceMember,
} from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver";
export async function getSpaces(
@@ -9,7 +9,7 @@ export interface ISpace {
id: string;
name: string;
description: string;
icon: string;
logo?: string;
slug: string;
hostname: string;
creatorId: string;
@@ -74,4 +74,4 @@ export interface IExportSpaceParams {
spaceId: string;
format: ExportFormat;
includeAttachments?: boolean;
}
}
@@ -1,55 +1,58 @@
import { focusAtom } from "jotai-optics";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
currentUserAtom,
userAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { FileButton, Tooltip } from "@mantine/core";
import { uploadAvatar } from "@/features/user/services/user-service.ts";
import { useTranslation } from "react-i18next";
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadUserAvatar,
removeAvatar,
} from "@/features/attachments/services/attachment-service.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function AccountAvatar() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
const [file, setFile] = useState<File | null>(null);
const handleFileChange = async (selectedFile: File) => {
if (!selectedFile) {
return;
}
setFile(selectedFile);
const handleUpload = async (selectedFile: File) => {
setIsLoading(true);
try {
setIsLoading(true);
const avatar = await uploadAvatar(selectedFile);
setUser((prev) => ({ ...prev, avatarUrl: avatar.fileName }));
const avatar = await uploadUserAvatar(selectedFile);
if (currentUser?.user) {
setUser({ ...currentUser.user, avatarUrl: avatar.fileName });
}
} catch (err) {
console.log(err);
// skip
} finally {
setIsLoading(false);
}
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatar();
if (currentUser?.user) {
setUser({ ...currentUser.user, avatarUrl: null });
}
} catch (err) {
// skip
} finally {
setIsLoading(false);
}
};
return (
<>
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
{(props) => (
<Tooltip label={t("Change photo")} position="bottom">
<CustomAvatar
{...props}
component="button"
size="60px"
avatarUrl={currentUser?.user.avatarUrl}
name={currentUser?.user.name}
style={{ cursor: "pointer" }}
/>
</Tooltip>
)}
</FileButton>
</>
<AvatarUploader
currentImageUrl={currentUser?.user.avatarUrl}
fallbackName={currentUser?.user.name}
size="60px"
type={AvatarIconType.AVATAR}
onUpload={handleUpload}
onRemove={handleRemove}
isLoading={isLoading}
/>
);
}
@@ -10,16 +10,3 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
const req = await api.post<IUser>("/users/update", data);
return req.data as IUser;
}
export async function uploadAvatar(file: File): Promise<any> {
const formData = new FormData();
formData.append("type", "avatar");
formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req;
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadWorkspaceIcon,
removeWorkspaceIcon,
} from "@/features/attachments/services/attachment-service.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceIcon() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const handleIconUpload = async (file: File) => {
setIsLoading(true);
try {
const result = await uploadWorkspaceIcon(file);
if (workspace) {
setWorkspace({ ...workspace, logo: result.fileName });
}
} catch (error) {
//
} finally {
setIsLoading(false);
}
};
const handleIconRemove = async () => {
setIsLoading(true);
try {
await removeWorkspaceIcon();
if (workspace) {
setWorkspace({ ...workspace, logo: null });
}
} catch (error) {
//
} finally {
setIsLoading(false);
}
};
return (
<div style={{ marginBottom: "24px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={workspace?.logo}
fallbackName={workspace?.name}
type={AvatarIconType.WORKSPACE_ICON}
size="60px"
radius="sm"
variant="filled"
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isLoading}
disabled={!isAdmin}
/>
</div>
);
}
@@ -109,15 +109,3 @@ export async function getAppVersion(): Promise<IVersion> {
return req.data;
}
export async function uploadLogo(file: File) {
const formData = new FormData();
formData.append("type", "workspace-logo");
formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req.data;
}
+10 -1
View File
@@ -1,7 +1,16 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import usePlan from "@/ee/hooks/use-plan";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
};
export const useIsEEOnly = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || !!hasLicenseKey;
};
+6 -2
View File
@@ -1,5 +1,6 @@
import bytes from "bytes";
import { castToBoolean } from "@/lib/utils.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
declare global {
interface Window {
@@ -41,11 +42,14 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function getAvatarUrl(avatarUrl: string) {
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
) {
if (!avatarUrl) return null;
if (avatarUrl?.startsWith("http")) return avatarUrl;
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
return getBackendUrl() + `/attachments/img/${type}/` + encodeURI(avatarUrl);
}
export function getSpaceUrl(spaceSlug: string) {
+1 -17
View File
@@ -2,6 +2,7 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
includeAllSpaces?: boolean;
}
export enum UserRole {
@@ -36,20 +37,3 @@ export type IPagination<T> = {
items: T[];
meta: IPaginationMeta;
};
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
@@ -1,5 +1,6 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import { useTranslation } from "react-i18next";
import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -14,6 +15,7 @@ export default function WorkspaceSettings() {
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("General")} />
<WorkspaceIcon />
<WorkspaceNameForm />
{isCloud() && (
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.23.1",
"version": "0.23.2",
"description": "",
"author": "",
"private": true,
@@ -84,7 +84,8 @@
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "^1.0.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.1",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
@@ -10,7 +10,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
@@ -63,7 +63,10 @@ export class AuthenticationExtension implements Extension {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole) {
// if role not found but user is a workspace owner, grant them readonly permission
if (!userSpaceRole && user.role === UserRole.OWNER) {
data.connection.readOnly = true;
} else if (!userSpaceRole) {
this.logger.warn(`User not authorized to access page: ${pageId}`);
throw new UnauthorizedException();
}
+3 -1
View File
@@ -72,7 +72,9 @@ export function extractDateFromUuid7(uuid7: string) {
}
export function sanitizeFileName(fileName: string): string {
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
const sanitizedFilename = sanitize(fileName)
.replace(/ /g, '_')
.replace(/#/g, '_');
return sanitizedFilename.slice(0, 255);
}
@@ -1,12 +1,12 @@
export enum AttachmentType {
Avatar = 'avatar',
WorkspaceLogo = 'workspace-logo',
SpaceLogo = 'space-logo',
WorkspaceIcon = 'workspace-icon',
SpaceIcon = 'space-icon',
File = 'file',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '5MB';
export const MAX_AVATAR_SIZE = '10MB';
export const inlineFileExtensions = [
'.jpg',
@@ -1,5 +1,6 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
@@ -51,6 +52,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
@Controller()
export class AttachmentController {
@@ -302,7 +304,7 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
if (attachmentType === AttachmentType.WorkspaceIcon) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
@@ -314,7 +316,7 @@ export class AttachmentController {
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (attachmentType === AttachmentType.SpaceIcon) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
@@ -372,8 +374,59 @@ export class AttachmentController {
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
// this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/remove-icon')
async removeIcon(
@Body() dto: RemoveIconDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const { type, spaceId } = dto;
// remove current user avatar
if (type === AttachmentType.Avatar) {
await this.attachmentService.removeUserAvatar(user);
return;
}
// remove space icon
if (type === AttachmentType.SpaceIcon) {
if (!spaceId) {
throw new BadRequestException(
'spaceId is required to change space icons',
);
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
await this.attachmentService.removeSpaceIcon(spaceId, workspace.id);
return;
}
// remove workspace icon
if (type === AttachmentType.WorkspaceIcon) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
await this.attachmentService.removeWorkspaceIcon(workspace);
return;
}
}
}
@@ -1,8 +1,8 @@
import { MultipartFile } from '@fastify/multipart';
import { randomBytes } from 'crypto';
import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
export interface PreparedFile {
buffer: Buffer;
@@ -22,10 +22,8 @@ export async function prepareFile(
}
try {
const rand = randomBytes(8).toString('hex');
const buffer = await file.toBuffer();
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase();
@@ -58,9 +56,9 @@ export function getAttachmentFolderPath(
switch (type) {
case AttachmentType.Avatar:
return `${workspaceId}/avatars`;
case AttachmentType.WorkspaceLogo:
return `${workspaceId}/workspace-logo`;
case AttachmentType.SpaceLogo:
case AttachmentType.WorkspaceIcon:
return `${workspaceId}/workspace-logos`;
case AttachmentType.SpaceIcon:
return `${workspaceId}/space-logos`;
case AttachmentType.File:
return `${workspaceId}/files`;
@@ -70,3 +68,51 @@ export function getAttachmentFolderPath(
}
export const validAttachmentTypes = Object.values(AttachmentType);
export async function compressAndResizeIcon(
buffer: Buffer,
attachmentType?: AttachmentType,
): Promise<Buffer> {
try {
let sharpInstance = sharp(buffer);
const metadata = await sharpInstance.metadata();
const targetWidth = 300;
const targetHeight = 300;
// Only resize if image is larger than target dimensions
if (metadata.width > targetWidth || metadata.height > targetHeight) {
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Handle based on original format
if (metadata.format === 'png') {
// Only flatten avatars to remove transparency
if (attachmentType === AttachmentType.Avatar) {
sharpInstance = sharpInstance.flatten({
background: { r: 255, g: 255, b: 255 },
});
}
return await sharpInstance
.png({
quality: 85,
compressionLevel: 6,
})
.toBuffer();
} else {
return await sharpInstance
.jpeg({
quality: 85,
progressive: true,
mozjpeg: true,
})
.toBuffer();
}
} catch (err) {
throw err;
}
}
@@ -0,0 +1,17 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
AttachmentType.Avatar,
AttachmentType.SpaceIcon,
AttachmentType.WorkspaceIcon,
])
@IsNotEmpty()
type: AttachmentType;
@IsOptional()
@IsUUID()
spaceId: string;
}
@@ -1,3 +0,0 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class AvatarUploadDto {}
@@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class GetFileDto {
@IsString()
@IsNotEmpty()
attachmentId: string;
}
@@ -1,20 +0,0 @@
import {
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UploadFileDto {
@IsString()
@IsNotEmpty()
attachmentType: string;
@IsOptional()
@IsUUID()
pageId: string;
@IsDefined()
file: any;
}
@@ -7,6 +7,7 @@ import {
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
compressAndResizeIcon,
getAttachmentFolderPath,
PreparedFile,
prepareFile,
@@ -16,7 +17,7 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { AttachmentType, validImageExtensions } from '../attachment.constants';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Attachment } from '@docmost/db/types/entity.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -132,8 +133,8 @@ export class AttachmentService {
filePromise: Promise<MultipartFile>,
type:
| AttachmentType.Avatar
| AttachmentType.WorkspaceLogo
| AttachmentType.SpaceLogo,
| AttachmentType.WorkspaceIcon
| AttachmentType.SpaceIcon,
userId: string,
workspaceId: string,
spaceId?: string,
@@ -141,6 +142,9 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
@@ -174,7 +178,7 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.WorkspaceLogo) {
} else if (type === AttachmentType.WorkspaceIcon) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
});
@@ -186,7 +190,7 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.SpaceLogo && spaceId) {
} else if (type === AttachmentType.SpaceIcon && spaceId) {
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
trx,
});
@@ -205,7 +209,6 @@ export class AttachmentService {
});
} catch (err) {
// delete uploaded file on db update failure
this.logger.error('Image upload error:', err);
await this.deleteRedundantFile(filePath);
throw new BadRequestException('Failed to upload image');
}
@@ -389,4 +392,40 @@ export class AttachmentService {
}
}
async removeUserAvatar(user: User) {
if (user.avatarUrl && !user.avatarUrl.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.Avatar, user.workspaceId)}/${user.avatarUrl}`;
await this.deleteRedundantFile(filePath);
}
await this.userRepo.updateUser(
{ avatarUrl: null },
user.id,
user.workspaceId,
);
}
async removeSpaceIcon(spaceId: string, workspaceId: string) {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (space.logo && !space.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.SpaceIcon, workspaceId)}/${space.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.spaceRepo.updateSpace({ logo: null }, spaceId, workspaceId);
}
async removeWorkspaceIcon(workspace: Workspace) {
if (workspace.logo && !workspace.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.WorkspaceIcon, workspace.id)}/${workspace.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.workspaceRepo.updateWorkspace({ logo: null }, workspace.id);
}
}
@@ -4,7 +4,7 @@ import {
createMongoAbility,
MongoAbility,
} from '@casl/ability';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { SpaceRole, UserRole } from '../../../common/helpers/types/permission';
import { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
@@ -25,13 +25,17 @@ export default class SpaceAbilityFactory {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
return buildWorkspaceOwnerAbility();
}
switch (userSpaceRole) {
case SpaceRole.ADMIN:
return buildSpaceAdminAbility();
case SpaceRole.WRITER:
return buildSpaceWriterAbility();
return buildSpaceWriterAbility(user.role);
case SpaceRole.READER:
return buildSpaceReaderAbility();
return buildSpaceReaderAbility(user.role);
default:
throw new NotFoundException('Space permissions not found');
}
@@ -49,23 +53,50 @@ function buildSpaceAdminAbility() {
return build();
}
function buildSpaceWriterAbility() {
function buildSpaceWriterAbility(workspaceRole?: string) {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
if (workspaceRole === UserRole.OWNER) {
// Workspace owners get manage permissions even with writer space role
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
} else {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
}
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
function buildSpaceReaderAbility() {
function buildSpaceReaderAbility(workspaceRole?: string) {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
if (workspaceRole === UserRole.OWNER) {
// Workspace owners get manage permissions even with reader space role
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
} else {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
}
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
}
function buildWorkspaceOwnerAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
@@ -279,4 +279,14 @@ export class SpaceMemberService {
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
}
async getAllWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getAllWorkspaceSpaces(
workspaceId,
pagination,
);
}
}
+15 -1
View File
@@ -34,6 +34,7 @@ import {
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { CreateSpaceDto } from './dto/create-space.dto';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
@UseGuards(JwtAuthGuard)
@Controller('spaces')
@@ -52,7 +53,17 @@ export class SpaceController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (pagination.includeAllSpaces) {
if (user.role !== UserRole.OWNER) {
throw new ForbiddenException('Only workspace owners view all spaces');
}
return this.spaceMemberService.getAllWorkspaceSpaces(
workspace.id,
pagination,
);
}
return this.spaceMemberService.getUserSpaces(user.id, pagination);
}
@@ -82,7 +93,10 @@ export class SpaceController {
space.id,
);
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
let userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
userSpaceRole = SpaceRole.READER;
}
const membership = {
userId: user.id,
@@ -1,4 +1,5 @@
import {
IsBoolean,
IsNumber,
IsOptional,
IsPositive,
@@ -23,4 +24,9 @@ export class PaginationOptions {
@IsOptional()
@IsString()
query: string;
//for space endpoint workspace owners
@IsOptional()
@IsBoolean()
includeAllSpaces?: boolean;
}
@@ -263,4 +263,37 @@ export class SpaceMemberRepo {
return result;
}
async getAllWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
) {
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
}
@@ -47,15 +47,23 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
await this.handleFailedJob(job);
}
@OnWorkerEvent('stalled')
async onStalled(job: Job) {
this.logger.error(
`Job ${job.name} stalled. . Import Task ID: ${job.data.fileTaskId}.. Job ID: ${job.id}`,
@OnWorkerEvent('completed')
async onCompleted(job: Job) {
this.logger.log(
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
);
// Set failedReason for stalled jobs since it's not automatically set
job.failedReason = 'Job stalled and was marked as failed';
await this.handleFailedJob(job);
try {
const fileTask = await this.fileTaskService.getFileTask(
job.data.fileTaskId,
);
if (fileTask) {
await this.storageService.delete(fileTask.filePath);
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
}
} catch (err) {
this.logger.error(`Failed to delete imported zip file:`, err);
}
}
private async handleFailedJob(job: Job) {
@@ -78,25 +86,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
@OnWorkerEvent('completed')
async onCompleted(job: Job) {
this.logger.log(
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
);
try {
const fileTask = await this.fileTaskService.getFileTask(
job.data.fileTaskId,
);
if (fileTask) {
await this.storageService.delete(fileTask.filePath);
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
}
} catch (err) {
this.logger.error(`Failed to delete imported zip file:`, err);
}
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
@@ -24,6 +24,7 @@ import { formatImportHtml } from '../utils/import-formatter';
import {
buildAttachmentCandidates,
collectMarkdownAndHtmlFiles,
stripNotionID,
} from '../utils/import.utils';
import { executeTx } from '@docmost/db/utils';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
@@ -159,17 +160,12 @@ export class FileImportTaskService {
.split(path.sep)
.join('/'); // normalize to forward-slashes
const ext = path.extname(relPath).toLowerCase();
let content = await fs.readFile(absPath, 'utf-8');
if (ext.toLowerCase() === '.md') {
content = await markdownToHtml(content);
}
pagesMap.set(relPath, {
id: v7(),
slugId: generateSlugId(),
name: path.basename(relPath, ext),
content,
name: stripNotionID(path.basename(relPath, ext)),
content: '',
parentPageId: null,
fileExtension: ext,
filePath: relPath,
@@ -254,71 +250,160 @@ export class FileImportTaskService {
});
});
const pageResults = await Promise.all(
Array.from(pagesMap.values()).map(async (page) => {
const htmlContent =
await this.importAttachmentService.processAttachments({
html: page.content,
pageRelativePath: page.filePath,
extractDir,
pageId: page.id,
fileTask,
attachmentCandidates,
});
// Group pages by level (topological sort for parent-child relationships)
const pagesByLevel = new Map<number, Array<[string, ImportPageNode]>>();
const pageLevel = new Map<string, number>();
const { html, backlinks, pageIcon } = await formatImportHtml({
html: htmlContent,
currentFilePath: page.filePath,
filePathToPageMetaMap: filePathToPageMetaMap,
creatorId: fileTask.creatorId,
sourcePageId: page.id,
workspaceId: fileTask.workspaceId,
});
// Calculate levels using BFS
const calculateLevels = () => {
const queue: Array<{ filePath: string; level: number }> = [];
const pmState = getProsemirrorContent(
await this.importService.processHTML(html),
// Start with root pages (no parent)
for (const [filePath, page] of pagesMap.entries()) {
if (!page.parentPageId) {
queue.push({ filePath, level: 0 });
pageLevel.set(filePath, 0);
}
}
// BFS to assign levels
while (queue.length > 0) {
const { filePath, level } = queue.shift()!;
const currentPage = pagesMap.get(filePath)!;
// Find children of current page
for (const [childFilePath, childPage] of pagesMap.entries()) {
if (
childPage.parentPageId === currentPage.id &&
!pageLevel.has(childFilePath)
) {
pageLevel.set(childFilePath, level + 1);
queue.push({ filePath: childFilePath, level: level + 1 });
}
}
}
// Group pages by level
for (const [filePath, page] of pagesMap.entries()) {
const level = pageLevel.get(filePath) || 0;
if (!pagesByLevel.has(level)) {
pagesByLevel.set(level, []);
}
pagesByLevel.get(level)!.push([filePath, page]);
}
};
calculateLevels();
if (pagesMap.size < 1) return;
// Process pages level by level sequentially to respect foreign key constraints
const allBacklinks: any[] = [];
const validPageIds = new Set<string>();
let totalPagesProcessed = 0;
// Sort levels to process in order
const sortedLevels = Array.from(pagesByLevel.keys()).sort((a, b) => a - b);
try {
await executeTx(this.db, async (trx) => {
// Process pages level by level sequentially within the transaction
for (const level of sortedLevels) {
const levelPages = pagesByLevel.get(level)!;
for (const [filePath, page] of levelPages) {
const absPath = path.join(extractDir, filePath);
let content = await fs.readFile(absPath, 'utf-8');
if (page.fileExtension.toLowerCase() === '.md') {
content = await markdownToHtml(content);
}
const htmlContent =
await this.importAttachmentService.processAttachments({
html: content,
pageRelativePath: page.filePath,
extractDir,
pageId: page.id,
fileTask,
attachmentCandidates,
});
const { html, backlinks, pageIcon } = await formatImportHtml({
html: htmlContent,
currentFilePath: page.filePath,
filePathToPageMetaMap: filePathToPageMetaMap,
creatorId: fileTask.creatorId,
sourcePageId: page.id,
workspaceId: fileTask.workspaceId,
});
const pmState = getProsemirrorContent(
await this.importService.processHTML(html),
);
const { title, prosemirrorJson } =
this.importService.extractTitleAndRemoveHeading(pmState);
const insertablePage: InsertablePage = {
id: page.id,
slugId: page.slugId,
title: title || page.name,
icon: pageIcon || null,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson),
position: page.position!,
spaceId: fileTask.spaceId,
workspaceId: fileTask.workspaceId,
creatorId: fileTask.creatorId,
lastUpdatedById: fileTask.creatorId,
parentPageId: page.parentPageId,
};
await trx.insertInto('pages').values(insertablePage).execute();
// Track valid page IDs and collect backlinks
validPageIds.add(insertablePage.id);
allBacklinks.push(...backlinks);
totalPagesProcessed++;
// Log progress periodically
if (totalPagesProcessed % 50 === 0) {
this.logger.debug(`Processed ${totalPagesProcessed} pages...`);
}
}
}
const filteredBacklinks = allBacklinks.filter(
({ sourcePageId, targetPageId }) =>
validPageIds.has(sourcePageId) && validPageIds.has(targetPageId),
);
const { title, prosemirrorJson } =
this.importService.extractTitleAndRemoveHeading(pmState);
// Insert backlinks in batches
if (filteredBacklinks.length > 0) {
const BACKLINK_BATCH_SIZE = 100;
for (
let i = 0;
i < filteredBacklinks.length;
i += BACKLINK_BATCH_SIZE
) {
const backlinkChunk = filteredBacklinks.slice(
i,
Math.min(i + BACKLINK_BATCH_SIZE, filteredBacklinks.length),
);
await this.backlinkRepo.insertBacklink(backlinkChunk, trx);
}
}
const insertablePage: InsertablePage = {
id: page.id,
slugId: page.slugId,
title: title || page.name,
icon: pageIcon || null,
content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson),
position: page.position!,
spaceId: fileTask.spaceId,
workspaceId: fileTask.workspaceId,
creatorId: fileTask.creatorId,
lastUpdatedById: fileTask.creatorId,
parentPageId: page.parentPageId,
};
return { insertablePage, backlinks };
}),
);
const insertablePages = pageResults.map((r) => r.insertablePage);
const insertableBacklinks = pageResults.flatMap((r) => r.backlinks);
if (insertablePages.length < 1) return;
const validPageIds = new Set(insertablePages.map((row) => row.id));
const filteredBacklinks = insertableBacklinks.filter(
({ sourcePageId, targetPageId }) =>
validPageIds.has(sourcePageId) && validPageIds.has(targetPageId),
);
await executeTx(this.db, async (trx) => {
await trx.insertInto('pages').values(insertablePages).execute();
if (filteredBacklinks.length > 0) {
await this.backlinkRepo.insertBacklink(filteredBacklinks, trx);
}
});
this.logger.log(
`Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
);
});
} catch (error) {
this.logger.error('Failed to import files:', error);
throw new Error(`File import failed: ${error?.['message']}`);
}
}
async getFileTask(fileTaskId: string) {
@@ -35,7 +35,7 @@ interface DrawioPair {
@Injectable()
export class ImportAttachmentService {
private readonly logger = new Logger(ImportAttachmentService.name);
private readonly CONCURRENT_UPLOADS = 1;
private readonly CONCURRENT_UPLOADS = 3;
private readonly MAX_RETRIES = 2;
private readonly RETRY_DELAY = 2000;
@@ -222,17 +222,40 @@ export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
}
export function unwrapFromParagraph($: CheerioAPI, $node: Cheerio<any>) {
// find the nearest <p> or <a> ancestor
let $wrapper = $node.closest('p, a');
// Keep track of processed wrappers to avoid infinite loops
const processedWrappers = new Set<any>();
let $wrapper = $node.closest('p, a');
while ($wrapper.length) {
// if the wrapper has only our node inside, replace it entirely
if ($wrapper.contents().length === 1) {
const wrapperElement = $wrapper.get(0);
// If we've already processed this wrapper, break to avoid infinite loop
if (processedWrappers.has(wrapperElement)) {
break;
}
processedWrappers.add(wrapperElement);
// Check if the wrapper contains only whitespace and our target node
const hasOnlyTargetNode =
$wrapper.contents().filter((_, el) => {
const $el = $(el);
// Skip whitespace-only text nodes. NodeType 3 = text node
if (el.nodeType === 3 && !$el.text().trim()) {
return false;
}
// Return true if this is not our target node
return !$el.is($node) && !$node.is($el);
}).length === 0;
if (hasOnlyTargetNode) {
// Replace the wrapper entirely with our node
$wrapper.replaceWith($node);
} else {
// otherwise just move the node to before the wrapper
// Move the node to before the wrapper, preserving other content
$wrapper.before($node);
}
// look again for any new wrapper around $node
$wrapper = $node.closest('p, a');
}
@@ -64,3 +64,9 @@ export async function collectMarkdownAndHtmlFiles(
await walk(dir);
return results;
}
export function stripNotionID(fileName: string): string {
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
return fileName.replace(notionIdPattern, '').trim();
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.23.1",
"version": "0.23.2",
"private": true,
"scripts": {
"build": "nx run-many -t build",
+12 -6
View File
@@ -2,33 +2,39 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
parseHTML: (element) =>
element.style.backgroundColor ||
element.getAttribute("data-background-color") ||
null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor,
"data-background-color": attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
parseHTML: (element) =>
element.getAttribute("data-background-color-name") || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
"data-background-color-name":
attributes.backgroundColorName.toLowerCase(),
};
},
},
+13 -7
View File
@@ -2,36 +2,42 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
parseHTML: (element) =>
element.style.backgroundColor ||
element.getAttribute("data-background-color") ||
null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor,
"data-background-color": attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
parseHTML: (element) =>
element.getAttribute("data-background-color-name") || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
"data-background-color-name":
attributes.backgroundColorName.toLowerCase(),
};
},
},
};
},
});
});
+288 -1
View File
@@ -589,8 +589,11 @@ importers:
specifier: ^7.8.2
version: 7.8.2
sanitize-filename-ts:
specifier: ^1.0.2
specifier: 1.0.2
version: 1.0.2
sharp:
specifier: 0.34.3
version: 0.34.3
socket.io:
specifier: ^4.8.1
version: 4.8.1
@@ -1781,6 +1784,9 @@ packages:
'@emnapi/runtime@1.2.0':
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
'@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
'@emnapi/wasi-threads@1.0.1':
resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==}
@@ -2283,6 +2289,128 @@ packages:
'@iconify/utils@3.0.1':
resolution: {integrity: sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==}
'@img/sharp-darwin-arm64@0.34.3':
resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.3':
resolution: {integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.0':
resolution: {integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.0':
resolution: {integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.0':
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.0':
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.3':
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.3':
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.3':
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.34.3':
resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.3':
resolution: {integrity: sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.3':
resolution: {integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.3':
resolution: {integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@inquirer/checkbox@4.1.2':
resolution: {integrity: sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==}
engines: {node: '>=18'}
@@ -5265,10 +5393,17 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
columnify@1.6.0:
resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
engines: {node: '>=8.0.0'}
@@ -5733,6 +5868,10 @@ packages:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@@ -6595,6 +6734,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-async-function@2.0.0:
resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==}
engines: {node: '>= 0.4'}
@@ -8783,6 +8925,10 @@ packages:
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
sharp@0.34.3:
resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -8814,6 +8960,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -11664,6 +11813,11 @@ snapshots:
dependencies:
tslib: 2.8.1
'@emnapi/runtime@1.5.0':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.0.1':
dependencies:
tslib: 2.8.1
@@ -12124,6 +12278,92 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@img/sharp-darwin-arm64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.0
optional: true
'@img/sharp-darwin-x64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.0
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.0':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.0':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.0':
optional: true
'@img/sharp-libvips-linux-arm@1.2.0':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.0':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.0':
optional: true
'@img/sharp-libvips-linux-x64@1.2.0':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
optional: true
'@img/sharp-linux-arm64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.0
optional: true
'@img/sharp-linux-arm@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.0
optional: true
'@img/sharp-linux-ppc64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.0
optional: true
'@img/sharp-linux-s390x@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.0
optional: true
'@img/sharp-linux-x64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.0
optional: true
'@img/sharp-linuxmusl-arm64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.0
optional: true
'@img/sharp-linuxmusl-x64@0.34.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.0
optional: true
'@img/sharp-wasm32@0.34.3':
dependencies:
'@emnapi/runtime': 1.5.0
optional: true
'@img/sharp-win32-arm64@0.34.3':
optional: true
'@img/sharp-win32-ia32@0.34.3':
optional: true
'@img/sharp-win32-x64@0.34.3':
optional: true
'@inquirer/checkbox@4.1.2(@types/node@22.13.4)':
dependencies:
'@inquirer/core': 10.1.7(@types/node@22.13.4)
@@ -15487,8 +15727,18 @@ snapshots:
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
color-support@1.1.3: {}
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
columnify@1.6.0:
dependencies:
strip-ansi: 6.0.1
@@ -15957,6 +16207,8 @@ snapshots:
detect-libc@2.0.3: {}
detect-libc@2.0.4: {}
detect-newline@3.1.0: {}
detect-node-es@1.1.0: {}
@@ -17053,6 +17305,8 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.4: {}
is-async-function@2.0.0:
dependencies:
has-tostringtag: 1.0.2
@@ -19622,6 +19876,35 @@ snapshots:
shallowequal@1.1.0: {}
sharp@0.34.3:
dependencies:
color: 4.2.3
detect-libc: 2.0.4
semver: 7.7.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.3
'@img/sharp-darwin-x64': 0.34.3
'@img/sharp-libvips-darwin-arm64': 1.2.0
'@img/sharp-libvips-darwin-x64': 1.2.0
'@img/sharp-libvips-linux-arm': 1.2.0
'@img/sharp-libvips-linux-arm64': 1.2.0
'@img/sharp-libvips-linux-ppc64': 1.2.0
'@img/sharp-libvips-linux-s390x': 1.2.0
'@img/sharp-libvips-linux-x64': 1.2.0
'@img/sharp-libvips-linuxmusl-arm64': 1.2.0
'@img/sharp-libvips-linuxmusl-x64': 1.2.0
'@img/sharp-linux-arm': 0.34.3
'@img/sharp-linux-arm64': 0.34.3
'@img/sharp-linux-ppc64': 0.34.3
'@img/sharp-linux-s390x': 0.34.3
'@img/sharp-linux-x64': 0.34.3
'@img/sharp-linuxmusl-arm64': 0.34.3
'@img/sharp-linuxmusl-x64': 0.34.3
'@img/sharp-wasm32': 0.34.3
'@img/sharp-win32-arm64': 0.34.3
'@img/sharp-win32-ia32': 0.34.3
'@img/sharp-win32-x64': 0.34.3
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -19649,6 +19932,10 @@ snapshots:
signal-exit@4.1.0: {}
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
sisteransi@1.0.5: {}
slash@3.0.0: {}