mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb6c15a552 | |||
| 7c41c1360b | |||
| f3a1b47ccf | |||
| af234465f0 | |||
| 5a1aeed989 | |||
| 6ea4ce72ec | |||
| 8041d9c3bd | |||
| 516d14fe27 | |||
| 70268a73df | |||
| 148be1025f | |||
| 2a17ac1908 | |||
| a70a67235d | |||
| ed5bb8f8d9 | |||
| a7731d9963 | |||
| 6f5e0b70bc | |||
| 856467fa0c | |||
| 280ec17f63 | |||
| 84b48167cb | |||
| c6f90b7647 | |||
| 42865b64d6 | |||
| e5b5cbaab7 | |||
| 463398e2c7 | |||
| 98c9af53c4 | |||
| f0864b5876 | |||
| c89535426b | |||
| 58c4a486f7 | |||
| d5462a92c8 | |||
| 7a90a909b3 | |||
| 189ad30138 | |||
| feb412b1fb | |||
| d551a1a10b | |||
| 2a3ea1254c | |||
| ddfd1b70e5 | |||
| a9b18ccf14 | |||
| 6d3b35ef6c | |||
| c7e96da95a | |||
| 3270ba7fa6 | |||
| fcff256586 | |||
| 0cfe0fc05b | |||
| 67b3e175ee | |||
| d3235250a8 |
+2
-1
@@ -14,7 +14,8 @@
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 10000
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
|
||||
|
||||
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
|
||||
const activeCollection = stores.collections.active;
|
||||
return `${t("Collection")} · ${activeCollection?.name}`;
|
||||
|
||||
+11
-10
@@ -73,15 +73,13 @@ function EditableTitle(
|
||||
return;
|
||||
}
|
||||
|
||||
if (document) {
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
@@ -127,7 +125,10 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
|
||||
<span
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
@@ -234,7 +234,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ type Props = {
|
||||
options: TFilterOption[];
|
||||
selectedKeys: (string | null | undefined)[];
|
||||
defaultLabel?: string;
|
||||
selectedPrefix?: string;
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
@@ -35,7 +34,6 @@ const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
defaultLabel = "Filter options",
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
@@ -54,9 +52,7 @@ const FilterOptions = ({
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selectedLabel = selectedItems.length
|
||||
? selectedItems
|
||||
.map((selected) => `${selectedPrefix} ${selected.label}`)
|
||||
.join(", ")
|
||||
? selectedItems.map((selected) => selected.label).join(", ")
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
@@ -70,7 +66,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.icon}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
@@ -163,10 +159,16 @@ const FilterOptions = ({
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton {...props} className={className} neutral disclosure>
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
@@ -193,7 +195,7 @@ const FilterOptions = ({
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -231,6 +233,7 @@ const SearchInput = styled(Input)`
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("menuBackground")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
@@ -267,15 +270,9 @@ export const StyledButton = styled(Button)`
|
||||
}
|
||||
|
||||
${Inner} {
|
||||
line-height: 24px;
|
||||
line-height: 28px;
|
||||
min-height: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
|
||||
@@ -33,6 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
small?: boolean;
|
||||
/** Whether to enable keyboard navigation */
|
||||
keyboardNavigation?: boolean;
|
||||
ellipsis?: boolean;
|
||||
};
|
||||
|
||||
const ListItem = (
|
||||
@@ -45,6 +46,7 @@ const ListItem = (
|
||||
border,
|
||||
to,
|
||||
keyboardNavigation,
|
||||
ellipsis,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -83,7 +85,9 @@ const ListItem = (
|
||||
column={!compact}
|
||||
$selected={selected}
|
||||
>
|
||||
<Heading $small={small}>{title}</Heading>
|
||||
<Heading $small={small} $ellipsis={ellipsis}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subtitle && (
|
||||
<Subtitle $small={small} $selected={selected}>
|
||||
{subtitle}
|
||||
@@ -105,7 +109,7 @@ const ListItem = (
|
||||
$border={border}
|
||||
$small={small}
|
||||
activeStyle={{
|
||||
background: theme.accent,
|
||||
background: theme.sidebarActiveBackground,
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
@@ -208,10 +212,10 @@ const Image = styled(Flex)`
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Heading = styled.p<{ $small?: boolean }>`
|
||||
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
|
||||
font-size: ${(props) => (props.$small ? 14 : 16)}px;
|
||||
font-weight: 500;
|
||||
${ellipsis()}
|
||||
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
|
||||
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
|
||||
margin: 0;
|
||||
`;
|
||||
@@ -219,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean }>`
|
||||
const Content = styled(Flex)<{ $selected: boolean }>`
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
margin: 0;
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
color: ${s("textTertiary")};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
@@ -234,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(ListItem);
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn: () => void) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
import { useLocaleTime } from "~/hooks/useLocaleTime";
|
||||
|
||||
export type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -29,59 +12,12 @@ export type Props = {
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
const LocaleTime: React.FC<Props> = ({
|
||||
addSuffix,
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
}: Props) => {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current?.();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(Date.parse(dateTime));
|
||||
const locale = dateLocale(userLocale);
|
||||
const relativeContent = dateToRelative(date, {
|
||||
addSuffix,
|
||||
locale,
|
||||
shorten,
|
||||
});
|
||||
|
||||
const tooltipContent = formatDate(date, formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(date, formatLocale, {
|
||||
locale,
|
||||
});
|
||||
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
|
||||
const { tooltipContent, content } = useLocaleTime(rest);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
<time dateTime={rest.dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -21,7 +22,6 @@ import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -12,6 +12,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, PlusIcon } from "outline-icons";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -12,7 +12,11 @@ import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { DocumentsSection, UserSection } from "~/actions/sections";
|
||||
import {
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -40,7 +44,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users } = useStores();
|
||||
const { auth, documents, users, collections } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
@@ -49,8 +53,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const { loading, request } = useRequest(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
}, [search, documents, users])
|
||||
);
|
||||
|
||||
@@ -119,6 +125,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
} as MentionItem)
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
collections
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(collection) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: collection.icon ? (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
color={collection.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon />
|
||||
),
|
||||
title: collection.name,
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
label: collection.name,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
@@ -146,7 +180,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
if (
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!documentId) {
|
||||
|
||||
@@ -20,8 +20,9 @@ import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IconType, MentionType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
@@ -166,6 +167,51 @@ export default class PasteHandler extends Extension {
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else if (isCollectionUrl(text)) {
|
||||
const slug = parseCollectionSlug(text);
|
||||
|
||||
if (slug) {
|
||||
stores.collections
|
||||
.fetch(slug)
|
||||
.then((collection) => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (collection) {
|
||||
if (state.schema.nodes.mention) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state.schema.nodes.mention.create({
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
label: collection.name,
|
||||
id: v4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const hasEmoji =
|
||||
determineIconType(collection.icon) ===
|
||||
IconType.Emoji;
|
||||
|
||||
const title = `${
|
||||
hasEmoji ? collection.icon + " " : ""
|
||||
}${collection.name}`;
|
||||
|
||||
this.insertLink(`${collection.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.insertLink(text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn: () => void) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
dateTime: string;
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
export const useLocaleTime = ({
|
||||
addSuffix,
|
||||
dateTime,
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
}: Props) => {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current?.();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(Date.parse(dateTime));
|
||||
const locale = dateLocale(userLocale);
|
||||
const relativeContent = dateToRelative(date, {
|
||||
addSuffix,
|
||||
locale,
|
||||
shorten,
|
||||
});
|
||||
|
||||
const tooltipContent = formatDate(date, formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(date, formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
tooltipContent,
|
||||
};
|
||||
};
|
||||
@@ -92,6 +92,11 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
archivedBy?: User;
|
||||
|
||||
@computed
|
||||
get searchContent(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/** Returns whether the collection is empty, or undefined if not loaded. */
|
||||
@computed
|
||||
get isEmpty(): boolean | undefined {
|
||||
|
||||
@@ -188,9 +188,10 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
@observable
|
||||
collaboratorIds: string[];
|
||||
|
||||
@observable
|
||||
@Relation(() => User)
|
||||
createdBy: User | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
@observable
|
||||
updatedBy: User | undefined;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class FileOperation extends Model {
|
||||
static modelName = "FileOperation";
|
||||
@@ -27,6 +28,7 @@ class FileOperation extends Model {
|
||||
|
||||
format: FileOperationFormat;
|
||||
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
|
||||
@computed
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isRTL } from "@shared/utils/rtl";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Revision extends Model {
|
||||
@@ -20,6 +21,7 @@ class Revision extends Model {
|
||||
title: string;
|
||||
|
||||
/** An optional name for the revision */
|
||||
@Field
|
||||
name: string | null;
|
||||
|
||||
/** Prosemirror data of the content when revision was created */
|
||||
|
||||
@@ -174,6 +174,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
if (template instanceof Document) {
|
||||
this.props.document.templateId = template.id;
|
||||
this.props.document.fullWidth = template.fullWidth;
|
||||
}
|
||||
|
||||
if (!this.title) {
|
||||
|
||||
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
|
||||
items: [
|
||||
{
|
||||
shortcut: "@",
|
||||
label: t("Mention user or document"),
|
||||
label: t("Mention users and more"),
|
||||
},
|
||||
{
|
||||
shortcut: ":",
|
||||
|
||||
@@ -8,7 +8,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { hover, hideScrollbars } from "@shared/styles";
|
||||
import { hideScrollbars } from "@shared/styles";
|
||||
import {
|
||||
DateFilter as TDateFilter,
|
||||
StatusFilter as TStatusFilter,
|
||||
@@ -60,10 +60,10 @@ function Search(props: Props) {
|
||||
routeMatch.params.term ?? params.get("query") ?? ""
|
||||
).trim();
|
||||
const query = decodedQuery !== "" ? decodedQuery : undefined;
|
||||
const collectionId = params.get("collectionId") ?? undefined;
|
||||
const userId = params.get("userId") ?? undefined;
|
||||
const collectionId = params.get("collectionId") ?? "";
|
||||
const userId = params.get("userId") ?? "";
|
||||
const documentId = params.get("documentId") ?? undefined;
|
||||
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
|
||||
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? "";
|
||||
const statusFilter = params.getAll("statusFilter")?.length
|
||||
? (params.getAll("statusFilter") as TStatusFilter[])
|
||||
: [TStatusFilter.Published, TStatusFilter.Draft];
|
||||
@@ -375,27 +375,24 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
||||
|
||||
const Filters = styled(Flex)`
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
height: 28px;
|
||||
gap: 8px;
|
||||
|
||||
${hideScrollbars()}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 0;
|
||||
`};
|
||||
|
||||
&: ${hover} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchTitlesFilter = styled(Switch)`
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
margin-top: 2px;
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
@@ -21,13 +21,13 @@ function CollectionFilter(props: Props) {
|
||||
const collectionOptions = collections.orderedData.map((collection) => ({
|
||||
key: collection.id,
|
||||
label: collection.name,
|
||||
icon: <CollectionIcon collection={collection} size={18} />,
|
||||
icon: <CollectionIcon collection={collection} size={24} />,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any collection"),
|
||||
icon: <SVGCollectionIcon size={18} />,
|
||||
icon: <SVGCollectionIcon size={24} />,
|
||||
},
|
||||
...collectionOptions,
|
||||
];
|
||||
@@ -39,7 +39,6 @@ function CollectionFilter(props: Props) {
|
||||
selectedKeys={[collectionId]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any collection")}
|
||||
selectedPrefix={`${t("Collection")}:`}
|
||||
showFilter
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any time"),
|
||||
label: t("All time"),
|
||||
},
|
||||
{
|
||||
key: "day",
|
||||
|
||||
@@ -19,7 +19,7 @@ export function DocumentFilter(props: Props) {
|
||||
<div>
|
||||
<Tooltip content={t("Remove document filter")}>
|
||||
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
|
||||
{props.document.title}
|
||||
{props.document.titleWithDefault}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,9 @@ const RemoveButton = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
&:focus,
|
||||
&:${hover} {
|
||||
opacity: 1;
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
@@ -61,17 +63,11 @@ const RecentSearch = styled(Link)`
|
||||
justify-content: space-between;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
padding: 1px 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
font-size: 14px;
|
||||
|
||||
&:before {
|
||||
content: "·";
|
||||
color: ${s("textTertiary")};
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
}
|
||||
margin: 0 -8px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -59,7 +59,7 @@ const Heading = styled.h2`
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: ${s("textSecondary")};
|
||||
margin-bottom: 0;
|
||||
margin: 12px 0 0;
|
||||
`;
|
||||
|
||||
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
||||
|
||||
@@ -25,13 +25,13 @@ function UserFilter(props: Props) {
|
||||
const userOptions = users.all.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.name,
|
||||
icon: <Avatar model={user} size={AvatarSize.Small} />,
|
||||
icon: <StyledAvatar model={user} size={AvatarSize.Small} />,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any author"),
|
||||
icon: <NoAuthor size={20} />,
|
||||
icon: <UserIcon size={20} />,
|
||||
},
|
||||
...userOptions,
|
||||
];
|
||||
@@ -43,7 +43,6 @@ function UserFilter(props: Props) {
|
||||
selectedKeys={[userId]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any author")}
|
||||
selectedPrefix={`${t("Author")}:`}
|
||||
fetchQuery={users.fetchPage}
|
||||
fetchQueryOptions={fetchQueryOptions}
|
||||
showFilter
|
||||
@@ -51,8 +50,8 @@ function UserFilter(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const NoAuthor = styled(UserIcon)`
|
||||
margin-left: -2px;
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin: 2px;
|
||||
`;
|
||||
|
||||
export default observer(UserFilter);
|
||||
|
||||
@@ -69,7 +69,9 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
*/
|
||||
@computed
|
||||
get nonPrivate(): Collection[] {
|
||||
return this.all.filter((collection) => !collection.isPrivate);
|
||||
return this.all.filter(
|
||||
(collection) => collection.isActive && !collection.isPrivate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
export default class RevisionsStore extends Store<Revision> {
|
||||
actions = [RPCAction.List, RPCAction.Info];
|
||||
actions = [RPCAction.List, RPCAction.Update, RPCAction.Info];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Revision);
|
||||
|
||||
+19
-18
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.750.0",
|
||||
"@aws-sdk/lib-storage": "3.750.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.750.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.750.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.750.0",
|
||||
"@aws-sdk/client-s3": "3.758.0",
|
||||
"@aws-sdk/lib-storage": "3.758.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.758.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.758.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.758.0",
|
||||
"@babel/core": "^7.26.9",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
@@ -61,8 +61,8 @@
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
"@bull-board/koa": "^4.12.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.7.10",
|
||||
"@css-inline/css-inline-wasm": "^0.14.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.3",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.11.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^3.58.0",
|
||||
"dd-trace": "^5.40.0",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"email-providers": "^1.14.0",
|
||||
@@ -184,9 +184,9 @@
|
||||
"prosemirror-model": "^1.24.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.37.1",
|
||||
"prosemirror-view": "^1.38.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.1",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -225,7 +225,7 @@
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.7.1",
|
||||
"stoppable": "^1.1.0",
|
||||
@@ -299,8 +299,8 @@
|
||||
"@types/quoted-printable": "^1.0.2",
|
||||
"@types/randomstring": "^1.3.0",
|
||||
"@types/react": "^17.0.34",
|
||||
"@types/react-avatar-editor": "^13.0.3",
|
||||
"@types/react-color": "^3.0.12",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
@@ -331,7 +331,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.37.102",
|
||||
"discord-api-types": "^0.37.119",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.8.0",
|
||||
@@ -344,7 +344,7 @@
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^7.9.0",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"jest-cli": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
@@ -355,7 +355,7 @@
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.1",
|
||||
"terser": "^5.37.0",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
@@ -368,7 +368,8 @@
|
||||
"node-fetch": "^2.7.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.82.0"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import isEqual from "fast-deep-equal";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { schema, serializer } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -45,8 +43,6 @@ export default async function documentCollaborativeUpdater({
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
|
||||
const node = Node.fromJSON(schema, content);
|
||||
const text = serializer.serialize(node, undefined);
|
||||
const isUnchanged = isEqual(document.content, content);
|
||||
const lastModifiedById =
|
||||
sessionCollaboratorIds[sessionCollaboratorIds.length - 1] ??
|
||||
@@ -72,7 +68,6 @@ export default async function documentCollaborativeUpdater({
|
||||
|
||||
await document.update(
|
||||
{
|
||||
text,
|
||||
content,
|
||||
state: Buffer.from(state),
|
||||
lastModifiedById,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Optional } from "utility-types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = Optional<
|
||||
@@ -81,53 +82,58 @@ export default async function documentCreator({
|
||||
}
|
||||
}
|
||||
|
||||
const document = await Document.create(
|
||||
{
|
||||
id,
|
||||
urlId,
|
||||
parentDocumentId,
|
||||
editorVersion,
|
||||
collectionId,
|
||||
teamId: user.teamId,
|
||||
createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
lastModifiedById: user.id,
|
||||
createdById: user.id,
|
||||
template,
|
||||
templateId,
|
||||
publishedAt,
|
||||
importId,
|
||||
sourceMetadata,
|
||||
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
||||
icon: templateDocument ? templateDocument.icon : icon,
|
||||
color: templateDocument ? templateDocument.color : color,
|
||||
title:
|
||||
title ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.title
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: ""),
|
||||
text:
|
||||
text ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.text
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
|
||||
: ""),
|
||||
content: templateDocument
|
||||
? ProsemirrorHelper.replaceTemplateVariables(
|
||||
await DocumentHelper.toJSON(templateDocument),
|
||||
user
|
||||
)
|
||||
: content,
|
||||
state,
|
||||
},
|
||||
{
|
||||
silent: !!createdAt,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
const titleWithReplacements =
|
||||
title ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.title
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: "");
|
||||
|
||||
const contentWithReplacements = text
|
||||
? ProsemirrorHelper.toProsemirror(text).toJSON()
|
||||
: templateDocument
|
||||
? template
|
||||
? templateDocument.content
|
||||
: SharedProsemirrorHelper.replaceTemplateVariables(
|
||||
await DocumentHelper.toJSON(templateDocument),
|
||||
user
|
||||
)
|
||||
: content;
|
||||
|
||||
const document = Document.build({
|
||||
id,
|
||||
urlId,
|
||||
parentDocumentId,
|
||||
editorVersion,
|
||||
collectionId,
|
||||
teamId: user.teamId,
|
||||
createdAt,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
lastModifiedById: user.id,
|
||||
createdById: user.id,
|
||||
template,
|
||||
templateId,
|
||||
publishedAt,
|
||||
importId,
|
||||
sourceMetadata,
|
||||
fullWidth: fullWidth ?? templateDocument?.fullWidth,
|
||||
icon: icon ?? templateDocument?.icon,
|
||||
color: color ?? templateDocument?.color,
|
||||
title: titleWithReplacements,
|
||||
content: contentWithReplacements,
|
||||
state,
|
||||
});
|
||||
|
||||
document.text = DocumentHelper.toMarkdown(document, {
|
||||
includeTitle: false,
|
||||
});
|
||||
|
||||
await document.save({
|
||||
silent: !!createdAt,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.create",
|
||||
|
||||
@@ -52,7 +52,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(document),
|
||||
["comment"]
|
||||
),
|
||||
text: document.text,
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
@@ -86,7 +85,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(childDocument),
|
||||
["comment"]
|
||||
),
|
||||
text: childDocument.text,
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
|
||||
@@ -122,11 +122,11 @@ export class Mailer {
|
||||
sendMail = async (data: SendMailOptions): Promise<void> => {
|
||||
const { transporter } = this;
|
||||
|
||||
if (!transporter) {
|
||||
Logger.info(
|
||||
if (env.isDevelopment) {
|
||||
Logger.debug(
|
||||
"email",
|
||||
[
|
||||
`Attempted to send email but no transport configured.`,
|
||||
`Sending email:`,
|
||||
``,
|
||||
`--------------`,
|
||||
`From: ${data.from.address}`,
|
||||
@@ -138,6 +138,9 @@ export class Mailer {
|
||||
data.text,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
if (!transporter) {
|
||||
Logger.warn("No mail transport available");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import { Document, UserMembership } from "@server/models";
|
||||
import { Document, GroupMembership, UserMembership } from "@server/models";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
@@ -11,13 +11,14 @@ import Heading from "./components/Heading";
|
||||
type InputProps = EmailProps & {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
membershipId?: string;
|
||||
actorName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
document: Document;
|
||||
membership: UserMembership;
|
||||
membership: UserMembership | GroupMembership;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
@@ -33,18 +34,20 @@ export default class DocumentSharedEmail extends BaseEmail<
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected async beforeSend({ documentId, userId }: InputProps) {
|
||||
protected async beforeSend({ documentId, membershipId }: InputProps) {
|
||||
if (!membershipId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = await Document.unscoped().findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const membership = await UserMembership.findOne({
|
||||
where: {
|
||||
documentId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
const membership =
|
||||
(await UserMembership.findByPk(membershipId)) ??
|
||||
(await GroupMembership.findByPk(membershipId));
|
||||
|
||||
if (!membership) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("notifications", "membershipId", {
|
||||
type: Sequelize.UUID,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn("notifications", "membershipId");
|
||||
},
|
||||
};
|
||||
@@ -830,7 +830,9 @@ class Document extends ArchivableModel<
|
||||
}
|
||||
|
||||
this.content = revision.content;
|
||||
this.text = revision.text;
|
||||
this.text = DocumentHelper.toMarkdown(revision, {
|
||||
includeTitle: false,
|
||||
});
|
||||
this.title = revision.title;
|
||||
this.icon = revision.icon;
|
||||
this.color = revision.color;
|
||||
|
||||
@@ -177,6 +177,10 @@ class Notification extends Model<
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.UUID)
|
||||
membershipId: string;
|
||||
|
||||
@AfterCreate
|
||||
static async createEvent(
|
||||
model: Notification,
|
||||
@@ -191,6 +195,7 @@ class Notification extends Model<
|
||||
documentId: model.documentId,
|
||||
collectionId: model.collectionId,
|
||||
actorId: model.actorId,
|
||||
membershipId: model.membershipId,
|
||||
};
|
||||
|
||||
if (options.transaction) {
|
||||
|
||||
@@ -16,6 +16,5 @@ describe("#findLatest", () => {
|
||||
await Revision.createFromDocument(document);
|
||||
const revision = await Revision.findLatest(document.id);
|
||||
expect(revision?.title).toBe("Changed 2");
|
||||
expect(revision?.text).toBe("Content");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,8 +69,9 @@ class Revision extends IdModel<
|
||||
/**
|
||||
* The content of the revision as Markdown.
|
||||
*
|
||||
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
|
||||
* This column will be removed in a future migration.
|
||||
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if
|
||||
* exporting lossy markdown. This column will be removed in a future migration
|
||||
* and is no longer being written.
|
||||
*/
|
||||
@Column(DataType.TEXT)
|
||||
text: string;
|
||||
@@ -134,7 +135,6 @@ class Revision extends IdModel<
|
||||
static buildFromDocument(document: Document) {
|
||||
return this.build({
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
content: document.content,
|
||||
|
||||
@@ -147,10 +147,15 @@ export class DocumentHelper {
|
||||
* Returns the document as Markdown. This is a lossy conversion and should only be used for export.
|
||||
*
|
||||
* @param document The document or revision to convert
|
||||
* @param options Options for the conversion
|
||||
* @returns The document title and content as a Markdown string
|
||||
*/
|
||||
static toMarkdown(
|
||||
document: Document | Revision | Collection | ProsemirrorData
|
||||
document: Document | Revision | Collection | ProsemirrorData,
|
||||
options?: {
|
||||
/** Whether to include the document title (default: true) */
|
||||
includeTitle?: boolean;
|
||||
}
|
||||
) {
|
||||
const text = serializer
|
||||
.serialize(DocumentHelper.toProsemirror(document))
|
||||
@@ -165,7 +170,10 @@ export class DocumentHelper {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (document instanceof Document || document instanceof Revision) {
|
||||
if (
|
||||
(document instanceof Document || document instanceof Revision) &&
|
||||
options?.includeTitle !== false
|
||||
) {
|
||||
const iconType = determineIconType(document.icon);
|
||||
|
||||
const title = `${iconType === IconType.Emoji ? document.icon + " " : ""}${
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { buildProseMirrorDoc } from "@server/test/factories";
|
||||
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
describe("ProseMirrorHelper", () => {
|
||||
describe("ProsemirrorHelper", () => {
|
||||
describe("getNodeForMentionEmail", () => {
|
||||
it("should return the paragraph node", () => {
|
||||
const mentionAttrs: MentionAttrs = {
|
||||
|
||||
@@ -118,10 +118,13 @@ export class ProsemirrorHelper {
|
||||
/**
|
||||
* Converts a plain object into a Prosemirror Node.
|
||||
*
|
||||
* @param data The object to parse
|
||||
* @param data The ProsemirrorData object or string to parse.
|
||||
* @returns The content as a Prosemirror Node
|
||||
*/
|
||||
static toProsemirror(data: ProsemirrorData) {
|
||||
static toProsemirror(data: ProsemirrorData | string) {
|
||||
if (typeof data === "string") {
|
||||
return parser.parse(data);
|
||||
}
|
||||
return Node.fromJSON(schema, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -861,6 +861,51 @@ describe("SearchHelper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#searchCollectionsForUser", () => {
|
||||
test("should return search results from collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection1 = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
name: "Test Collection",
|
||||
});
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
name: "Other Collection",
|
||||
});
|
||||
|
||||
const results = await SearchHelper.searchCollectionsForUser(user, {
|
||||
query: "test",
|
||||
});
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].id).toBe(collection1.id);
|
||||
});
|
||||
|
||||
test("should return all collections when no query provided", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection1 = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
name: "Alpha",
|
||||
});
|
||||
const collection2 = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
name: "Beta",
|
||||
});
|
||||
|
||||
const results = await SearchHelper.searchCollectionsForUser(user);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].id).toBe(collection1.id);
|
||||
expect(results[1].id).toBe(collection2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webSearchQuery", () => {
|
||||
test("should correctly sanitize query", () => {
|
||||
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
|
||||
|
||||
@@ -203,6 +203,35 @@ export default class SearchHelper {
|
||||
});
|
||||
}
|
||||
|
||||
public static async searchCollectionsForUser(
|
||||
user: User,
|
||||
options: SearchOptions = {}
|
||||
): Promise<Collection[]> {
|
||||
const { limit = 15, offset = 0, query } = options;
|
||||
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
return Collection.findAll({
|
||||
where: {
|
||||
[Op.and]: query
|
||||
? {
|
||||
[Op.or]: [
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
],
|
||||
}
|
||||
: {},
|
||||
id: collectionIds,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
order: [["name", "ASC"]],
|
||||
replacements: { query: `%${query}%` },
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
public static async searchForUser(
|
||||
user: User,
|
||||
options: SearchOptions = {}
|
||||
|
||||
@@ -42,7 +42,7 @@ async function presentDocument(
|
||||
|
||||
const text =
|
||||
!asData || options?.includeText
|
||||
? document.text || DocumentHelper.toMarkdown(data)
|
||||
? DocumentHelper.toMarkdown(data, { includeTitle: false })
|
||||
: undefined;
|
||||
|
||||
const res: Record<string, any> = {
|
||||
|
||||
@@ -6,21 +6,17 @@ import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = [
|
||||
"documents.add_user",
|
||||
"documents.remove_user",
|
||||
"documents.add_group",
|
||||
"documents.remove_group",
|
||||
];
|
||||
|
||||
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
|
||||
switch (event.name) {
|
||||
case "documents.add_user":
|
||||
case "documents.remove_user": {
|
||||
await DocumentSubscriptionTask.schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
case "documents.add_group":
|
||||
case "documents.remove_group":
|
||||
return this.handleGroup(event);
|
||||
|
||||
@@ -29,11 +25,6 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
}
|
||||
|
||||
private async handleGroup(event: DocumentGroupEvent) {
|
||||
const userEventName: DocumentUserEvent["name"] =
|
||||
event.name === "documents.add_group"
|
||||
? "documents.add_user"
|
||||
: "documents.remove_user";
|
||||
|
||||
await GroupUser.findAllInBatches<GroupUser>(
|
||||
{
|
||||
where: {
|
||||
@@ -49,7 +40,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionTask.schedule({
|
||||
...event,
|
||||
name: userEventName,
|
||||
name: "documents.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ export default class EmailsProcessor extends BaseProcessor {
|
||||
to: notification.user.email,
|
||||
userId: notification.userId,
|
||||
documentId: notification.documentId,
|
||||
membershipId: notification.membershipId,
|
||||
teamUrl: notification.team.url,
|
||||
actorName: notification.actor.name,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import isEqual from "fast-deep-equal";
|
||||
import revisionCreator from "@server/commands/revisionCreator";
|
||||
import { Revision, Document, User } from "@server/models";
|
||||
import { DocumentEvent, RevisionEvent, Event } from "@server/types";
|
||||
import DocumentUpdateTextTask from "../tasks/DocumentUpdateTextTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class RevisionsProcessor extends BaseProcessor {
|
||||
@@ -36,6 +37,8 @@ export default class RevisionsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await DocumentUpdateTextTask.schedule(event);
|
||||
|
||||
const user = await User.findByPk(event.actorId, {
|
||||
paranoid: false,
|
||||
rejectOnEmpty: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { CommentEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class CommentCreatedNotificationsTask extends BaseTask<CommentEvent> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { CommentEvent, CommentUpdateEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Op } from "sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { GroupUser, UserMembership } from "@server/models";
|
||||
import { GroupUser } from "@server/models";
|
||||
import { DocumentGroupEvent } from "@server/types";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
|
||||
@@ -20,26 +19,9 @@ export default class DocumentAddGroupNotificationsTask extends BaseTask<Document
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map(async (groupUser) => {
|
||||
const userMembership = await UserMembership.findOne({
|
||||
where: {
|
||||
userId: groupUser.userId,
|
||||
documentId: event.documentId,
|
||||
},
|
||||
});
|
||||
if (userMembership) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Suppressing notification for user ${groupUser.userId} as they are already a member of the document`,
|
||||
{
|
||||
documentId: event.documentId,
|
||||
userId: groupUser.userId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await DocumentAddUserNotificationsTask.schedule({
|
||||
...event,
|
||||
modelId: event.data.membershipId,
|
||||
userId: groupUser.userId,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,27 +1,65 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { DocumentPermission, NotificationEventType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Notification, User } from "@server/models";
|
||||
import { DocumentUserEvent } from "@server/types";
|
||||
import { isElevatedPermission } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class DocumentAddUserNotificationsTask extends BaseTask<DocumentUserEvent> {
|
||||
public async perform(event: DocumentUserEvent) {
|
||||
const recipient = await User.findByPk(event.userId);
|
||||
if (!recipient) {
|
||||
const permission = event.changes?.attributes.permission as
|
||||
| DocumentPermission
|
||||
| undefined;
|
||||
|
||||
if (!permission) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`permission not available in the DocumentAddUserNotificationsTask event`,
|
||||
{
|
||||
name: event.name,
|
||||
modelId: event.modelId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = await User.findByPk(event.userId);
|
||||
if (
|
||||
!recipient.isSuspended &&
|
||||
recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
|
||||
!recipient ||
|
||||
recipient.isSuspended ||
|
||||
!recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.AddUserToDocument,
|
||||
userId: event.userId,
|
||||
actorId: event.actorId,
|
||||
teamId: event.teamId,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isElevated = await isElevatedPermission({
|
||||
userId: recipient.id,
|
||||
documentId: event.documentId,
|
||||
permission,
|
||||
skipMembershipId: event.modelId,
|
||||
});
|
||||
|
||||
if (!isElevated) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Suppressing notification for user ${event.userId} as the new permission does not elevate user's permission to the document`,
|
||||
{
|
||||
documentId: event.documentId,
|
||||
userId: event.userId,
|
||||
permission,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Notification.create({
|
||||
event: NotificationEventType.AddUserToDocument,
|
||||
userId: event.userId,
|
||||
actorId: event.actorId,
|
||||
teamId: event.teamId,
|
||||
documentId: event.documentId,
|
||||
membershipId: event.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
public get options() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Document, Notification, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { DocumentEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class DocumentPublishedNotificationsTask extends BaseTask<DocumentEvent> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { Subscription, User } from "@server/models";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Subscription, User } from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { DocumentUserEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
@@ -10,44 +11,29 @@ import BaseTask from "./BaseTask";
|
||||
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
|
||||
public async perform(event: DocumentUserEvent) {
|
||||
const user = await User.findByPk(event.userId);
|
||||
if (!user) {
|
||||
|
||||
if (!user || event.name !== "documents.remove_user") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "documents.add_user":
|
||||
return this.addUser(event, user);
|
||||
|
||||
case "documents.remove_user":
|
||||
return this.removeUser(event, user);
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private async addUser(event: DocumentUserEvent, user: User) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await subscriptionCreator({
|
||||
ctx: createContext({
|
||||
user,
|
||||
authType: event.authType,
|
||||
ip: event.ip,
|
||||
transaction,
|
||||
}),
|
||||
documentId: event.documentId,
|
||||
event: SubscriptionType.Document,
|
||||
resubscribe: false,
|
||||
});
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
private async removeUser(event: DocumentUserEvent, user: User) {
|
||||
if (can(user, "read", document)) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Skip unsubscribing user ${user.id} as they have permission to the document ${event.documentId} through other means`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const subscription = await Subscription.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { schema, serializer } from "@server/editor";
|
||||
import { Document } from "@server/models";
|
||||
import { DocumentEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
export default class DocumentUpdateTextTask extends BaseTask<DocumentEvent> {
|
||||
public async perform(event: DocumentEvent) {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document?.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = Node.fromJSON(schema, document.content);
|
||||
document.text = serializer.serialize(node);
|
||||
await document.save({ silent: true });
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Document, Revision, Notification, User, View } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { RevisionEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionEvent> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@shared/types";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { createContext } from "@server/context";
|
||||
import { parser } from "@server/editor";
|
||||
import {
|
||||
Document,
|
||||
View,
|
||||
@@ -3257,21 +3258,26 @@ describe("#documents.restore", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const previousText = revision.text;
|
||||
const previous = revision.content;
|
||||
const revisionId = revision.id;
|
||||
|
||||
// update the document contents
|
||||
document.text = "UPDATED";
|
||||
document.content = parser.parse("updated")?.toJSON();
|
||||
await document.save();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
revisionId,
|
||||
},
|
||||
headers: {
|
||||
"x-api-version": 3,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.text).toEqual(previousText);
|
||||
expect(body.data.data).toEqual(previous);
|
||||
});
|
||||
|
||||
it("should not allow restoring a revision in another document", async () => {
|
||||
|
||||
@@ -11,7 +11,10 @@ export default function apiResponse() {
|
||||
typeof ctx.body === "object" &&
|
||||
!(ctx.body instanceof Readable) &&
|
||||
!(ctx.body instanceof stream.Readable) &&
|
||||
!(ctx.body instanceof Buffer)
|
||||
!(ctx.body instanceof Buffer) &&
|
||||
// JSZip returns a wrapped stream instance that is not a true readable stream
|
||||
// and not exported from the module either, so we must identify it like so.
|
||||
!(ctx.body && "_readableState" in ctx.body)
|
||||
) {
|
||||
ctx.body = {
|
||||
...ctx.body,
|
||||
|
||||
@@ -64,6 +64,44 @@ describe("#revisions.update", () => {
|
||||
expect(body.data.name).toEqual("new name");
|
||||
});
|
||||
|
||||
it("should allow setting name to null", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: null,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBeNull();
|
||||
});
|
||||
|
||||
it("should not allow setting name to empty string", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow an admin to update a document revision", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -33,7 +33,8 @@ export const RevisionsUpdateSchema = BaseSchema.extend({
|
||||
name: z
|
||||
.string()
|
||||
.min(RevisionValidation.minNameLength)
|
||||
.max(RevisionValidation.maxNameLength),
|
||||
.max(RevisionValidation.maxNameLength)
|
||||
.or(z.null()),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ router.post(
|
||||
const { offset, limit } = ctx.state.pagination;
|
||||
const actor = ctx.state.auth.user;
|
||||
|
||||
const [documents, users] = await Promise.all([
|
||||
const [documents, users, collections] = await Promise.all([
|
||||
SearchHelper.searchTitlesForUser(actor, {
|
||||
query,
|
||||
offset,
|
||||
@@ -53,6 +53,7 @@ router.post(
|
||||
offset,
|
||||
limit,
|
||||
}),
|
||||
SearchHelper.searchCollectionsForUser(actor, { query, offset, limit }),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
@@ -67,6 +68,7 @@ router.post(
|
||||
includeDetails: !!can(actor, "readDetails", user),
|
||||
})
|
||||
),
|
||||
collections,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Blob } from "buffer";
|
||||
import { Readable } from "stream";
|
||||
import { PresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import FileHelper from "@shared/editor/lib/FileHelper";
|
||||
import { isBase64Url, isInternalUrl } from "@shared/utils/urls";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -239,14 +240,14 @@ export default abstract class BaseStorage {
|
||||
* @returns The content disposition
|
||||
*/
|
||||
public getContentDisposition(contentType?: string) {
|
||||
if (contentType && this.safeInlineContentTypes.includes(contentType)) {
|
||||
return "inline";
|
||||
if (!contentType) {
|
||||
return "attachment";
|
||||
}
|
||||
|
||||
if (
|
||||
contentType &&
|
||||
this.safeInlineContentPrefixes.some((prefix) =>
|
||||
contentType.startsWith(prefix)
|
||||
)
|
||||
FileHelper.isAudio(contentType) ||
|
||||
FileHelper.isVideo(contentType) ||
|
||||
this.safeInlineContentTypes.includes(contentType)
|
||||
) {
|
||||
return "inline";
|
||||
}
|
||||
@@ -255,8 +256,8 @@ export default abstract class BaseStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of content types considered safe to display inline in the browser. Note that
|
||||
* SVGs are purposefully not included here as they can contain JavaScript.
|
||||
* A list of content types considered safe to display inline in the browser.
|
||||
* Note that SVGs are purposefully not included here as they can contain JS.
|
||||
*/
|
||||
protected safeInlineContentTypes = [
|
||||
"application/pdf",
|
||||
@@ -265,9 +266,4 @@ export default abstract class BaseStorage {
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
/**
|
||||
* A list of content type prefixes considered safe to display inline in the browser.
|
||||
*/
|
||||
protected safeInlineContentPrefixes = ["video/", "audio/"];
|
||||
}
|
||||
|
||||
@@ -294,11 +294,14 @@ export async function buildCollection(
|
||||
overrides.archivedById = overrides.userId;
|
||||
}
|
||||
|
||||
if (overrides.permission === undefined) {
|
||||
overrides.permission = CollectionPermission.ReadWrite;
|
||||
}
|
||||
|
||||
return Collection.create({
|
||||
name: faker.lorem.words(2),
|
||||
description: faker.lorem.words(4),
|
||||
createdById: overrides.userId,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,6 +464,7 @@ export type NotificationEvent = BaseEvent<Notification> & {
|
||||
commentId?: string;
|
||||
documentId?: string;
|
||||
collectionId?: string;
|
||||
membershipId?: string;
|
||||
};
|
||||
|
||||
export type Event =
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import { GroupMembership, UserMembership } from "@server/models";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildGroup,
|
||||
buildGroupUser,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { getDocumentPermission, isElevatedPermission } from "./permissions";
|
||||
|
||||
describe("permissions", () => {
|
||||
describe("isElevatedPermission", () => {
|
||||
it("should return false when user has higher permission through collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
|
||||
const isElevated = await isElevatedPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
permission: DocumentPermission.Read,
|
||||
});
|
||||
|
||||
expect(isElevated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user has higher permission through document", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup();
|
||||
await Promise.all([
|
||||
await buildGroupUser({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
}),
|
||||
await GroupMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isElevated = await isElevatedPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
permission: DocumentPermission.Read,
|
||||
});
|
||||
|
||||
expect(isElevated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when user has the same permission", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup();
|
||||
await Promise.all([
|
||||
await buildGroupUser({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
}),
|
||||
await GroupMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isElevated = await isElevatedPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
});
|
||||
|
||||
expect(isElevated).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when user has lower permission", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup();
|
||||
await Promise.all([
|
||||
await buildGroupUser({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
}),
|
||||
await GroupMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isElevated = await isElevatedPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
permission: DocumentPermission.Admin,
|
||||
});
|
||||
|
||||
expect(isElevated).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user does not have access", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const isElevated = await isElevatedPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
permission: DocumentPermission.Admin,
|
||||
});
|
||||
|
||||
expect(isElevated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentPermission", () => {
|
||||
it("should return the highest provided permission through collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
|
||||
const permission = await getDocumentPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
expect(permission).toEqual(DocumentPermission.ReadWrite);
|
||||
});
|
||||
|
||||
it("should return the highest provided permission through document", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup();
|
||||
await Promise.all([
|
||||
await buildGroupUser({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
}),
|
||||
await GroupMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
}),
|
||||
]);
|
||||
|
||||
const permission = await getDocumentPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
expect(permission).toEqual(DocumentPermission.ReadWrite);
|
||||
});
|
||||
|
||||
it("should return the highest provided permission with skipped membership", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup();
|
||||
const [, , groupMembership] = await Promise.all([
|
||||
await buildGroupUser({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
}),
|
||||
await GroupMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: document.id,
|
||||
groupId: group.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
}),
|
||||
]);
|
||||
|
||||
const permission = await getDocumentPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
skipMembershipId: groupMembership.id,
|
||||
});
|
||||
|
||||
expect(permission).toEqual(DocumentPermission.Read);
|
||||
});
|
||||
|
||||
it("should return undefined when user does not have access", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const permission = await getDocumentPermission({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
expect(permission).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import compact from "lodash/compact";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import {
|
||||
Document,
|
||||
Group,
|
||||
GroupMembership,
|
||||
User,
|
||||
UserMembership,
|
||||
} from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
|
||||
// Higher value takes precedence
|
||||
export const CollectionPermissionPriority = {
|
||||
[CollectionPermission.Admin]: 2,
|
||||
[CollectionPermission.ReadWrite]: 1,
|
||||
[CollectionPermission.Read]: 0,
|
||||
} satisfies Record<CollectionPermission, number>;
|
||||
|
||||
// Higher value takes precedence
|
||||
export const DocumentPermissionPriority = {
|
||||
[DocumentPermission.Admin]: 2,
|
||||
[DocumentPermission.ReadWrite]: 1,
|
||||
[DocumentPermission.Read]: 0,
|
||||
} satisfies Record<DocumentPermission, number>;
|
||||
|
||||
/**
|
||||
* Check if the given user can access a document
|
||||
*
|
||||
* @param user - The user to check
|
||||
* @param documentId - The document to check
|
||||
* @returns Boolean whether the user can access the document
|
||||
*/
|
||||
export const canUserAccessDocument = async (user: User, documentId: string) => {
|
||||
try {
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the user's access to a document is being elevated with the new permission.
|
||||
*
|
||||
* @param {Object} params Input parameters.
|
||||
* @param {string} params.userId The user to check.
|
||||
* @param {string} params.documentId The document to check.
|
||||
* @param {DocumentPermission} params.permission The new permission given to the user.
|
||||
* @param {string} params.skipMembershipId The membership to skip when comparing the existing permissions.
|
||||
* @returns {boolean} Whether the user has a higher access level
|
||||
*/
|
||||
export const isElevatedPermission = async ({
|
||||
userId,
|
||||
documentId,
|
||||
permission,
|
||||
skipMembershipId,
|
||||
}: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
permission: DocumentPermission;
|
||||
skipMembershipId?: string;
|
||||
}) => {
|
||||
const existingPermission = await getDocumentPermission({
|
||||
userId,
|
||||
documentId,
|
||||
skipMembershipId,
|
||||
});
|
||||
|
||||
if (!existingPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
DocumentPermissionPriority[existingPermission] <
|
||||
DocumentPermissionPriority[permission]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the user's permission to a document.
|
||||
*
|
||||
* @param {Object} params Input parameters.
|
||||
* @param {string} params.userId The user to check.
|
||||
* @param {string} params.documentId The document to check.
|
||||
* @param {string} params.skipMembershipId The membership to skip when comparing the existing permissions.
|
||||
* @returns {DocumentPermission | undefined} Highest permission, if it exists.
|
||||
*/
|
||||
export const getDocumentPermission = async ({
|
||||
userId,
|
||||
documentId,
|
||||
skipMembershipId,
|
||||
}: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
skipMembershipId?: string;
|
||||
}): Promise<DocumentPermission | undefined> => {
|
||||
const document = await Document.scope({
|
||||
method: ["withCollectionPermissions", userId],
|
||||
}).findOne({ where: { id: documentId } });
|
||||
|
||||
const permissions: DocumentPermission[] = [];
|
||||
|
||||
const collection = document?.collection;
|
||||
if (collection) {
|
||||
const collectionPermissions = orderBy(
|
||||
compact([
|
||||
collection.permission,
|
||||
...compact(
|
||||
collection.memberships?.map(
|
||||
(m) => m.permission as CollectionPermission
|
||||
)
|
||||
),
|
||||
...compact(
|
||||
collection.groupMemberships?.map(
|
||||
(m) => m.permission as CollectionPermission
|
||||
)
|
||||
),
|
||||
]),
|
||||
(permission) => CollectionPermissionPriority[permission],
|
||||
"desc"
|
||||
);
|
||||
|
||||
if (collectionPermissions[0]) {
|
||||
permissions.push(
|
||||
collectionPermissions[0] === CollectionPermission.Read
|
||||
? DocumentPermission.Read
|
||||
: DocumentPermission.ReadWrite
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userMembershipWhere: WhereOptions<UserMembership> = {
|
||||
userId,
|
||||
documentId,
|
||||
};
|
||||
const groupMembershipWhere: WhereOptions<GroupMembership> = {
|
||||
documentId,
|
||||
};
|
||||
|
||||
if (skipMembershipId) {
|
||||
userMembershipWhere.id = { [Op.ne]: skipMembershipId };
|
||||
groupMembershipWhere.id = { [Op.ne]: skipMembershipId };
|
||||
}
|
||||
|
||||
const [userMemberships, groupMemberships] = await Promise.all([
|
||||
UserMembership.findAll({
|
||||
where: userMembershipWhere,
|
||||
}),
|
||||
GroupMembership.findAll({
|
||||
where: groupMembershipWhere,
|
||||
include: [
|
||||
{
|
||||
model: Group.filterByMember(userId),
|
||||
as: "group",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
permissions.push(
|
||||
...userMemberships.map((m) => m.permission as DocumentPermission),
|
||||
...groupMemberships.map((m) => m.permission as DocumentPermission)
|
||||
);
|
||||
|
||||
const orderedPermissions = orderBy(
|
||||
permissions,
|
||||
(permission) => DocumentPermissionPriority[permission],
|
||||
"desc"
|
||||
);
|
||||
|
||||
return orderedPermissions[0];
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Document, User } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
|
||||
/**
|
||||
* Check if the given user can access a document
|
||||
*
|
||||
* @param user - The user to check
|
||||
* @param documentId - The document to check
|
||||
* @returns Boolean whether the user can access the document
|
||||
*/
|
||||
export const canUserAccessDocument = async (user: User, documentId: string) => {
|
||||
try {
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -58,11 +58,11 @@ const insertFiles = async function (
|
||||
const filesToUpload = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const isImage =
|
||||
FileHelper.isImage(file) &&
|
||||
FileHelper.isImage(file.type) &&
|
||||
!options.isAttachment &&
|
||||
!!schema.nodes.image;
|
||||
const isVideo =
|
||||
FileHelper.isVideo(file) &&
|
||||
FileHelper.isVideo(file.type) &&
|
||||
!options.isAttachment &&
|
||||
!!schema.nodes.video;
|
||||
const getDimensions = isImage
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
deleteColumn,
|
||||
} from "prosemirror-tables";
|
||||
import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper";
|
||||
import { CSVHelper } from "../../utils/csv";
|
||||
import { chainTransactions } from "../lib/chainTransactions";
|
||||
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
||||
import { TableLayout } from "../types";
|
||||
@@ -137,7 +138,7 @@ export function exportTable({
|
||||
}
|
||||
|
||||
// Avoid cell content being interpreted as formulas by adding a leading single quote
|
||||
value = value.trimStart().replace(/^([+\-=@])/, "'$1");
|
||||
value = CSVHelper.sanitizeValue(value);
|
||||
|
||||
return `"${value}"`;
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, EmailIcon } from "outline-icons";
|
||||
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -67,3 +67,36 @@ export const MentionDocument = observer(function MentionDocument_(
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionCollection = observer(function MentionCollection_(
|
||||
props: ComponentProps
|
||||
) {
|
||||
const { isSelected, node } = props;
|
||||
const { collections } = useStores();
|
||||
const collection = collections.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
void collections.fetch(modelId);
|
||||
}
|
||||
}, [modelId, collections]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
to={collection?.path ?? `/collection/${node.attrs.modelId}`}
|
||||
>
|
||||
{collection?.icon ? (
|
||||
<Icon value={collection?.icon} color={collection?.color} size={18} />
|
||||
) : (
|
||||
<CollectionIcon size={18} />
|
||||
)}
|
||||
{collection?.title || node.attrs.label}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -589,7 +589,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
title: "Tldraw",
|
||||
keywords: "draw schematics diagrams",
|
||||
regexMatch: [
|
||||
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvo]+/(.*)"),
|
||||
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvopf]+/(.*)"),
|
||||
],
|
||||
transformMatch: (matches: RegExpMatchArray) => matches[0],
|
||||
icon: <Img src="/images/tldraw.png" alt="Tldraw" />,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import FileHelper from "./FileHelper";
|
||||
|
||||
describe("FileHelper", () => {
|
||||
it("isImage", () => {
|
||||
expect(FileHelper.isImage("image/png")).toBe(true);
|
||||
expect(FileHelper.isImage("image/jpeg")).toBe(true);
|
||||
expect(FileHelper.isImage("image/webp")).toBe(true);
|
||||
expect(FileHelper.isImage("image/gif")).toBe(true);
|
||||
expect(FileHelper.isImage("image/bmp")).toBe(true);
|
||||
expect(FileHelper.isImage("image/avif")).toBe(true);
|
||||
expect(FileHelper.isImage("image/heif-sequence")).toBe(true);
|
||||
expect(FileHelper.isImage("image/svg+xml")).toBe(true);
|
||||
expect(FileHelper.isImage("text/plain")).toBe(false);
|
||||
expect(FileHelper.isImage("application/json")).toBe(false);
|
||||
});
|
||||
|
||||
it("isVideo", () => {
|
||||
expect(FileHelper.isVideo("video/mp4")).toBe(true);
|
||||
expect(FileHelper.isVideo("video/webm")).toBe(true);
|
||||
expect(FileHelper.isVideo("video/x-msvideo")).toBe(true);
|
||||
expect(FileHelper.isVideo("video/vnd.dlna.mpeg-tts")).toBe(true);
|
||||
expect(FileHelper.isVideo("text/plain")).toBe(false);
|
||||
expect(FileHelper.isVideo("application/json")).toBe(false);
|
||||
});
|
||||
|
||||
it("isAudio", () => {
|
||||
expect(FileHelper.isAudio("audio/mpeg")).toBe(true);
|
||||
expect(FileHelper.isAudio("audio/wav")).toBe(true);
|
||||
expect(FileHelper.isAudio("audio/vnd.dolby.heaac.1")).toBe(true);
|
||||
expect(FileHelper.isAudio("audio/vnd.lucent.voice")).toBe(true);
|
||||
expect(FileHelper.isAudio("text/plain")).toBe(false);
|
||||
expect(FileHelper.isAudio("application/json")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,21 +4,31 @@ export default class FileHelper {
|
||||
/**
|
||||
* Checks if a file is an image.
|
||||
*
|
||||
* @param file The file to check
|
||||
* @param contentType The content type of the file
|
||||
* @returns True if the file is an image
|
||||
*/
|
||||
static isImage(file: File) {
|
||||
return file.type.startsWith("image/");
|
||||
static isImage(contentType: string) {
|
||||
return /^image\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is a video.
|
||||
*
|
||||
* @param file The file to check
|
||||
* @param contentType The content type of the file
|
||||
* @returns True if the file is an video
|
||||
*/
|
||||
static isVideo(file: File) {
|
||||
return file.type.startsWith("video/");
|
||||
static isVideo(contentType: string) {
|
||||
return /^video\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is an audio file.
|
||||
*
|
||||
* @param contentType The content type of the file
|
||||
* @returns True if the file is an audio file
|
||||
*/
|
||||
static isAudio(contentType: string) {
|
||||
return /^audio\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,8 +21,8 @@ export default class Code extends Mark {
|
||||
|
||||
get schema(): MarkSpec {
|
||||
return {
|
||||
excludes: "mention link placeholder highlight em strong",
|
||||
parseDOM: [{ tag: "code.inline", preserveWhitespace: true }],
|
||||
excludes: "mention placeholder highlight em strong",
|
||||
parseDOM: [{ tag: "code", preserveWhitespace: true }],
|
||||
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default class CheckboxItem extends Node {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
content: "paragraph block*",
|
||||
content: "block+",
|
||||
defining: true,
|
||||
draggable: true,
|
||||
parseDOM: [
|
||||
|
||||
@@ -173,8 +173,6 @@ export default class CodeFence extends Node {
|
||||
defining: true,
|
||||
draggable: false,
|
||||
parseDOM: [
|
||||
{ tag: "code" },
|
||||
{ tag: "pre", preserveWhitespace: "full" },
|
||||
{
|
||||
tag: ".code-block",
|
||||
preserveWhitespace: "full",
|
||||
@@ -184,6 +182,18 @@ export default class CodeFence extends Node {
|
||||
language: dom.dataset.language,
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: "code",
|
||||
preserveWhitespace: "full",
|
||||
getAttrs: (dom) => {
|
||||
// Only parse code blocks that contain newlines for code fences,
|
||||
// otherwise the code mark rule will be applied.
|
||||
if (!dom.textContent?.includes("\n")) {
|
||||
return false;
|
||||
}
|
||||
return { language: dom.dataset.language };
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
"div",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default class ListItem extends Node {
|
||||
|
||||
get schema(): NodeSpec {
|
||||
return {
|
||||
content: "paragraph block*",
|
||||
content: "block+",
|
||||
defining: true,
|
||||
draggable: true,
|
||||
parseDOM: [{ tag: "li" }],
|
||||
|
||||
@@ -16,7 +16,11 @@ import { Primitive } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import { MentionType } from "../../types";
|
||||
import { MentionDocument, MentionUser } from "../components/Mentions";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import mentionRule from "../rules/mention";
|
||||
import { ComponentProps } from "../types";
|
||||
@@ -76,7 +80,9 @@ export default class Mention extends Node {
|
||||
href:
|
||||
node.attrs.type === MentionType.User
|
||||
? undefined
|
||||
: `${env.URL}/doc/${node.attrs.modelId}`,
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
: `${env.URL}/collection/${node.attrs.modelId}`,
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorid": node.attrs.actorId,
|
||||
@@ -97,6 +103,8 @@ export default class Mention extends Node {
|
||||
return <MentionUser {...props} />;
|
||||
case MentionType.Document:
|
||||
return <MentionDocument {...props} />;
|
||||
case MentionType.Collection:
|
||||
return <MentionCollection {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -145,10 +153,23 @@ export default class Mention extends Node {
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === this.name &&
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
(selection.node.attrs.type === MentionType.Document ||
|
||||
selection.node.attrs.type === MentionType.Collection)
|
||||
) {
|
||||
const { modelId } = selection.node.attrs;
|
||||
this.editor.props.onClickLink?.(`/doc/${modelId}`);
|
||||
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: selection.node.attrs.type === MentionType.Collection
|
||||
? "collection"
|
||||
: undefined;
|
||||
|
||||
if (!linkType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Vložený kód",
|
||||
"Inline LaTeX": "Vložený LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Přihlásit se",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Vytvořili jste před {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} vytvořil před {{ timeAgo }}",
|
||||
"Uploading": "Nahrávání"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Inline code",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Sign In",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Uploading"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Inline-Code",
|
||||
"Inline LaTeX": "Inline-LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Anmelden",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Du hast vor {{ timeAgo }} erstellt",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} erstellte vor {{ timeAgo }}",
|
||||
"Uploading": "Wird hochgeladen"
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@
|
||||
"Update role": "Update role",
|
||||
"Delete user": "Delete user",
|
||||
"Collection": "Collection",
|
||||
"Collections": "Collections",
|
||||
"Debug": "Debug",
|
||||
"Document": "Document",
|
||||
"Documents": "Documents",
|
||||
@@ -368,7 +369,6 @@
|
||||
"Archived collections": "Archived collections",
|
||||
"New doc": "New doc",
|
||||
"Empty": "Empty",
|
||||
"Collections": "Collections",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "Expand",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
||||
@@ -770,7 +770,7 @@
|
||||
"Inline code": "Inline code",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Sign In",
|
||||
@@ -815,16 +815,16 @@
|
||||
"Or": "Or",
|
||||
"Already have an account? Go to <1>login</1>.": "Already have an account? Go to <1>login</1>.",
|
||||
"Any collection": "Any collection",
|
||||
"Any time": "Any time",
|
||||
"All time": "All time",
|
||||
"Past day": "Past day",
|
||||
"Past week": "Past week",
|
||||
"Past month": "Past month",
|
||||
"Past year": "Past year",
|
||||
"Any time": "Any time",
|
||||
"Remove document filter": "Remove document filter",
|
||||
"Any status": "Any status",
|
||||
"Remove search": "Remove search",
|
||||
"Any author": "Any author",
|
||||
"Author": "Author",
|
||||
"We were unable to find the page you’re looking for.": "We were unable to find the page you’re looking for.",
|
||||
"Search titles only": "Search titles only",
|
||||
"Something went wrong": "Something went wrong",
|
||||
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Código en línea",
|
||||
"Inline LaTeX": "Línea de LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Iniciar sesión",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Tú lo creaste {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} lo creó {{ timeAgo }}",
|
||||
"Uploading": "Subiendo"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "کد درون خطی",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "ورود",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "در حال بارگذاری"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Ligne de Code",
|
||||
"Inline LaTeX": "LaTeX en ligne",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Se connecter",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Créé par vous il y a {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "Créé par {{ user }} il y a {{ timeAgo }}",
|
||||
"Uploading": "Transfert en cours"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Inline code",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Sign In",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Uploading"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Beágyazott kód",
|
||||
"Inline LaTeX": "Beágyazott LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Bejelentkezés",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Uploading"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Kode inline",
|
||||
"Inline LaTeX": "LaTeX Inline",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Masuk",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Mengunggah"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Codice inline",
|
||||
"Inline LaTeX": "LaTeX in linea",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Accedi",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Caricamento"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "インラインコード",
|
||||
"Inline LaTeX": "インライン LaTeX",
|
||||
"Triggers": "トリガー",
|
||||
"Mention user or document": "ユーザーまたはドキュメントにメンション",
|
||||
"Mention users and more": "ユーザーまたはドキュメントにメンション",
|
||||
"Emoji": "絵文字",
|
||||
"Insert block": "ブロックの挿入",
|
||||
"Sign In": "ログイン",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "あなたが {{ timeAgo }} に作成しました",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} が {{ timeAgo }} に作成しました",
|
||||
"Uploading": "アップロード中"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "인라인 코드",
|
||||
"Inline LaTeX": "인라인 LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "로그인",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "{{ timeAgo }} 전에 내가 생성함",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} 이(가) {{ timeAgo }} 전에 생성",
|
||||
"Uploading": "업로드 중"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Innebygd kode",
|
||||
"Inline LaTeX": "Innebygd LaTeX",
|
||||
"Triggers": "Utløsere",
|
||||
"Mention user or document": "Nevn bruker eller dokument",
|
||||
"Mention users and more": "Nevn bruker eller dokument",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Sett inn blokk",
|
||||
"Sign In": "Logg inn",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Du opprettet {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} opprettet {{ timeAgo }}",
|
||||
"Uploading": "Laster opp"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Inline code",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Voeg blok in",
|
||||
"Sign In": "Aanmelden",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "{{ timeAgo }} door jou aangemaakt",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ timeAgo }} door {{ user }} aangemaakt",
|
||||
"Uploading": "Bezig met uploaden"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Kod w linii",
|
||||
"Inline LaTeX": "LaTeX w linii",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Zaloguj się",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Utworzyłeś {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} utworzył {{ timeAgo }}",
|
||||
"Uploading": "Wysyłanie"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Código embutido",
|
||||
"Inline LaTeX": "LaTeX em linha",
|
||||
"Triggers": "Gatilhos",
|
||||
"Mention user or document": "Mencionar usuário ou documento",
|
||||
"Mention users and more": "Mencionar usuário ou documento",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Inserir bloco",
|
||||
"Sign In": "Entrar",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Você criou {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} criou {{ timeAgo }}",
|
||||
"Uploading": "Enviando"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Código em linha",
|
||||
"Inline LaTeX": "LaTeX Embutido",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Iniciar Sessão",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Criou à {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} criado à {{ timeAgo }}",
|
||||
"Uploading": "A carregar"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Inline-kod",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Logga in",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Du skapade {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} skapade {{ timeAgo }}",
|
||||
"Uploading": "Laddar upp"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Inline code",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Sign In",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Uploading"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Satır içi kod",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Kayıt ol",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Yükleniyor"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Вбудований код",
|
||||
"Inline LaTeX": "Вбудований LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Увійти",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "Ви створили {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} створив {{ timeAgo }}",
|
||||
"Uploading": "Завантажується"
|
||||
}
|
||||
}
|
||||
@@ -769,7 +769,7 @@
|
||||
"Inline code": "Mã nội tuyến",
|
||||
"Inline LaTeX": "LaTeX nội tuyến",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Đăng nhập",
|
||||
@@ -1143,4 +1143,4 @@
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Đang tải lên"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user