mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 409313639d | |||
| 78ad61c9fb | |||
| 2d9de26041 | |||
| 0a9bd39aac | |||
| f614f3dd3f | |||
| 7f818c7329 | |||
| 27d116c8e2 | |||
| 7e962d36e6 | |||
| f09450e7ea | |||
| 05b9c69da8 | |||
| ac55ad55dd | |||
| 8c11b6cfc8 | |||
| d858289159 | |||
| 52d420bd98 | |||
| 386eebb117 | |||
| d0993c3393 | |||
| 54d17503bf | |||
| 0de2a3dc98 | |||
| 73ac18bbde | |||
| 18dcef8ce4 | |||
| 7458228df0 | |||
| 7c93f8a039 | |||
| d6a126d974 | |||
| 779fb1d568 | |||
| a0ce14f2a2 | |||
| 091abf0b9d | |||
| 342c42194e | |||
| 8383a0ee1e | |||
| 19a696942e | |||
| f1a5e95f77 | |||
| 99fedfa354 | |||
| 9da73202c7 | |||
| 30db7bc554 | |||
| b40eaf4184 | |||
| 3aff344501 | |||
| 0f812d70c1 | |||
| 125e9c2e0b | |||
| 95402b4b52 | |||
| d01e3ad09c | |||
| edb6d44bdc |
@@ -26,3 +26,6 @@ updates:
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: build
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.86.1
|
||||
Licensed Work: Outline 0.87.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-08-09
|
||||
Change Date: 2029-08-31
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { toast } from "sonner";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
@@ -50,16 +49,6 @@ export const resolveCommentFactory = ({
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
@@ -82,16 +71,6 @@ export const unresolveCommentFactory = ({
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,9 +11,15 @@ class DocumentContext {
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
/** The ID of the currently focused comment, or null if no comment is focused */
|
||||
@observable
|
||||
focusedCommentId: string | null = null;
|
||||
|
||||
/** Whether the editor has been initialized */
|
||||
@observable
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
/** The headings in the document */
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@@ -39,6 +45,11 @@ class DocumentContext {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
setFocusedCommentId = (commentId: string | null) => {
|
||||
this.focusedCommentId = commentId;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -25,6 +25,10 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -50,6 +54,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
@@ -78,87 +83,109 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
groupMemberships.getByDocumentId(document.id)
|
||||
);
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
const actionContext = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
context={actionContext}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const ContextMenu = observer(
|
||||
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant={"context"}>
|
||||
<Menu onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
|
||||
<MenuContent
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
DropdownMenu as DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -23,7 +20,7 @@ import {
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { toMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
@@ -66,7 +63,7 @@ export const DropdownMenu = observer(
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
@@ -126,24 +123,26 @@ export const DropdownMenu = observer(
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
<MenuProvider variant={"dropdown"}>
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import {
|
||||
DropdownMenuButton,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuIconWrapper,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuDisclosure,
|
||||
SelectedIconWrapper,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
MenuGroup,
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
@@ -39,15 +29,15 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<DropdownMenuButton
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -61,7 +51,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<DropdownMenuInternalLink
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -72,7 +62,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<DropdownMenuExternalLink
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -85,33 +75,33 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toDropdownMenuItems(item.items);
|
||||
const submenuItems = toMenuItems(item.items);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownSubMenuTrigger
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toDropdownMenuItems(item.items);
|
||||
const groupItems = toMenuItems(item.items);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuGroup
|
||||
<MenuGroup
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
@@ -120,7 +110,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -149,15 +139,15 @@ export function toMobileMenuItems(
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
@@ -167,31 +157,31 @@ export function toMobileMenuItems(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</Components.MenuButton>
|
||||
);
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
<Components.MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
<Components.MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
@@ -201,8 +191,8 @@ export function toMobileMenuItems(
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
@@ -217,7 +207,7 @@ export function toMobileMenuItems(
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
@@ -225,9 +215,9 @@ export function toMobileMenuItems(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuButton>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,14 +234,14 @@ export function toMobileMenuItems(
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||
<Components.MenuHeader>{item.title}</Components.MenuHeader>
|
||||
{groupItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuExternalLink,
|
||||
MenuHeader,
|
||||
MenuInternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
SelectedIconWrapper,
|
||||
} from "./components/Menu";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger ref={ref} {...rest} asChild>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sideOffset={4}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
|
||||
|
||||
const DropdownSubMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
DropdownSubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
|
||||
<MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuSubTrigger>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownSubMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
{...rest}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
type DropdownMenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
>;
|
||||
|
||||
const DropdownMenuGroup = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
|
||||
DropdownMenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { label, items, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group ref={ref} {...rest}>
|
||||
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
||||
{items}
|
||||
</DropdownMenuPrimitive.Group>
|
||||
);
|
||||
});
|
||||
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
|
||||
|
||||
type BaseDropdownItemProps = {
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DropdownMenuButtonProps = BaseDropdownItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuButton = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuButtonProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const button = (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
DropdownMenuButton.displayName = "DropdownMenuButton";
|
||||
|
||||
type DropdownMenuInternalLinkProps = BaseDropdownItemProps & {
|
||||
to: LocationDescriptor;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuInternalLink = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuInternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
DropdownMenuInternalLink.displayName = "DropdownMenuInternalLink";
|
||||
|
||||
type DropdownMenuExternalLinkProps = BaseDropdownItemProps & {
|
||||
href: string;
|
||||
target?: string;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuExternalLink = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuExternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuExternalLink href={href} target={target} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
DropdownMenuExternalLink.displayName = "DropdownMenuExternalLink";
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>((props, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} {...props} asChild>
|
||||
<MenuSeparator />
|
||||
</DropdownMenuPrimitive.Separator>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label ref={ref} {...props} asChild>
|
||||
<MenuHeader>{children}</MenuHeader>
|
||||
</DropdownMenuPrimitive.Label>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
/** Styled components */
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
z-index: ${depths.menu};
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
|
||||
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms ease-out;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuButton,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuTrigger,
|
||||
DropdownSubMenuContent,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { useCsrfToken } from "~/hooks/useCsrfToken";
|
||||
|
||||
/**
|
||||
* Form component that automatically includes a CSRF token as a hidden input field.
|
||||
*/
|
||||
export const Form = ({
|
||||
children,
|
||||
...props
|
||||
}: React.FormHTMLAttributes<HTMLFormElement>) => {
|
||||
const token = useCsrfToken();
|
||||
|
||||
return (
|
||||
<form {...props}>
|
||||
{token && <input type="hidden" name={CSRF.fieldName} value={token} />}
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
|
||||
const MenuContext = createContext<{
|
||||
variant: MenuVariant;
|
||||
}>({
|
||||
variant: "dropdown",
|
||||
});
|
||||
|
||||
export function MenuProvider({
|
||||
variant,
|
||||
children,
|
||||
}: {
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
export const useMenuContext = () => useContext(MenuContext);
|
||||
@@ -0,0 +1,435 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as Components from "../components/Menu";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { useMenuContext } from "./MenuContext";
|
||||
|
||||
type MenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
|
||||
|
||||
const Menu = ({ children, ...rest }: MenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Root =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Root
|
||||
: ContextMenuPrimitive.Root;
|
||||
|
||||
return <Root {...rest}>{children}</Root>;
|
||||
};
|
||||
|
||||
type SubMenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Sub
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>;
|
||||
|
||||
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Sub =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Sub
|
||||
: ContextMenuPrimitive.Sub;
|
||||
|
||||
return <Sub {...rest}>{children}</Sub>;
|
||||
};
|
||||
|
||||
type TriggerProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger>;
|
||||
|
||||
const MenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
|
||||
TriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Trigger
|
||||
: ContextMenuPrimitive.Trigger;
|
||||
|
||||
return (
|
||||
<Trigger ref={ref} {...rest} asChild>
|
||||
{children}
|
||||
</Trigger>
|
||||
);
|
||||
});
|
||||
MenuTrigger.displayName = "MenuTrigger";
|
||||
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
: ContextMenuPrimitive.Portal;
|
||||
|
||||
const Content =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Content
|
||||
: ContextMenuPrimitive.Content;
|
||||
|
||||
const offsetProp =
|
||||
variant === "dropdown" ? { sideOffset: 4 } : { alignOffset: 4 };
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-available-height"
|
||||
: "--radix-context-menu-content-available-height",
|
||||
transformOriginVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-transform-origin"
|
||||
: "--radix-context-menu-content-transform-origin",
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
MenuContent.displayName = "MenuContent";
|
||||
|
||||
type SubMenuTriggerProps = BaseItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>;
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.SubTrigger
|
||||
: ContextMenuPrimitive.SubTrigger;
|
||||
|
||||
return (
|
||||
<Trigger ref={ref} {...rest} asChild>
|
||||
<Components.MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuSubTrigger>
|
||||
</Trigger>
|
||||
);
|
||||
});
|
||||
SubMenuTrigger.displayName = "SubMenuTrigger";
|
||||
|
||||
type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>;
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
: ContextMenuPrimitive.Portal;
|
||||
|
||||
const Content =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.SubContent
|
||||
: ContextMenuPrimitive.SubContent;
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-available-height"
|
||||
: "--radix-context-menu-content-available-height",
|
||||
transformOriginVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-transform-origin"
|
||||
: "--radix-context-menu-content-transform-origin",
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
SubMenuContent.displayName = "SubMenuContent";
|
||||
|
||||
type MenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
>;
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, items, ...rest } = props;
|
||||
|
||||
const Group =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Group
|
||||
: ContextMenuPrimitive.Group;
|
||||
|
||||
return (
|
||||
<Group ref={ref} {...rest}>
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{items}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type MenuButtonProps = BaseItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
MenuButton.displayName = "MenuButton";
|
||||
|
||||
type MenuInternalLinkProps = BaseItemProps & {
|
||||
to: LocationDescriptor;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuInternalLink = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuInternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
return (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
MenuInternalLink.displayName = "MenuInternalLink";
|
||||
|
||||
type MenuExternalLinkProps = BaseItemProps & {
|
||||
href: string;
|
||||
target?: string;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuExternalLink = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuExternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
return (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuExternalLink
|
||||
href={href}
|
||||
target={target}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
MenuExternalLink.displayName = "MenuExternalLink";
|
||||
|
||||
type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>;
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
MenuSeparatorProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Separator =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Separator
|
||||
: ContextMenuPrimitive.Separator;
|
||||
|
||||
return (
|
||||
<Separator ref={ref} {...props} asChild>
|
||||
<Components.MenuSeparator />
|
||||
</Separator>
|
||||
);
|
||||
});
|
||||
MenuSeparator.displayName = "MenuSeparator";
|
||||
|
||||
type MenuLabelProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>;
|
||||
|
||||
const MenuLabel = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Label>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
MenuLabelProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Label =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Label
|
||||
: ContextMenuPrimitive.Label;
|
||||
|
||||
return (
|
||||
<Label ref={ref} {...rest} asChild>
|
||||
<Components.MenuHeader>{children}</Components.MenuHeader>
|
||||
</Label>
|
||||
);
|
||||
});
|
||||
MenuLabel.displayName = "MenuLabel";
|
||||
|
||||
export {
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuButton,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuSeparator,
|
||||
MenuGroup,
|
||||
MenuLabel,
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { ellipsis } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type BaseMenuItemProps = {
|
||||
disabled?: boolean;
|
||||
@@ -135,3 +137,31 @@ export const SelectedIconWrapper = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuContent = styled(Scrollable)<{
|
||||
maxHeightVar: string;
|
||||
transformOriginVar: string;
|
||||
}>`
|
||||
z-index: ${depths.menu};
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`};
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
|
||||
transform-origin: ${({ transformOriginVar }) => `var(${transformOriginVar})`};
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms ease-out;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -44,9 +44,7 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
.map((node) =>
|
||||
ProsemirrorHelper.toPlainText(node, this.editor.schema)
|
||||
)
|
||||
.map((node) => ProsemirrorHelper.toPlainText(node))
|
||||
.join("");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@ import Extension, {
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
@@ -627,8 +626,7 @@ export class Editor extends React.PureComponent<
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () =>
|
||||
ProsemirrorHelper.getHeadings(this.view.state.doc, this.schema);
|
||||
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Return the images in the current editor.
|
||||
@@ -721,9 +719,8 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = getTextSerializers(this.schema);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
return textBetween(doc, 0, doc.content.size);
|
||||
};
|
||||
|
||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
/**
|
||||
* React hook for accessing CSRF tokens in components
|
||||
*
|
||||
* @returns The CSRF token string or null if not found
|
||||
*/
|
||||
export function useCsrfToken() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateToken = () => {
|
||||
const currentToken = getCookie(CSRF.cookieName);
|
||||
|
||||
setToken(currentToken);
|
||||
};
|
||||
|
||||
// Initial load
|
||||
updateToken();
|
||||
|
||||
// Listen for cookie changes (when navigating or refreshing)
|
||||
const interval = setInterval(updateToken, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
restoreDocument,
|
||||
unsubscribeDocument,
|
||||
subscribeDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
editDocument,
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
deleteDocument,
|
||||
leaveDocument,
|
||||
permanentlyDeleteDocument,
|
||||
} from "~/actions/definitions/documents";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import useMobile from "./useMobile";
|
||||
import Document from "~/models/Document";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the actions are generated */
|
||||
document: Document;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
document,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
const document = documents.get(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "./useStores";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export default function useFocusedComment() {
|
||||
/**
|
||||
* Custom hook to retrieve the currently focused comment in a document.
|
||||
* It checks both the document context and the query string for the comment ID.
|
||||
* If a comment is focused, it returns the comment itself or the parent thread if it exists
|
||||
*/
|
||||
export function useFocusedComment() {
|
||||
const { comments } = useStores();
|
||||
const location = useLocation<{ commentId?: string }>();
|
||||
const context = useDocumentContext();
|
||||
const query = useQuery();
|
||||
const focusedCommentId = location.state?.commentId || query.get("commentId");
|
||||
const focusedCommentId = context.focusedCommentId || query.get("commentId");
|
||||
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
|
||||
const history = useHistory();
|
||||
|
||||
// Move the query string into context
|
||||
useEffect(() => {
|
||||
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
|
||||
context.setFocusedCommentId(focusedCommentId);
|
||||
}
|
||||
}, [focusedCommentId, context]);
|
||||
|
||||
// Clear query string from location
|
||||
useEffect(() => {
|
||||
if (focusedCommentId) {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
|
||||
if (params.get("commentId") === focusedCommentId) {
|
||||
params.delete("commentId");
|
||||
history.replace({
|
||||
pathname: history.location.pathname,
|
||||
search: params.toString(),
|
||||
state: history.location.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [focusedCommentId, history]);
|
||||
|
||||
return comment?.parentCommentId
|
||||
? comments.get(comment.parentCommentId)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
import usePrevious from "./usePrevious";
|
||||
|
||||
type Options = {
|
||||
/* Whether to listen and react to changes in the value from other tabs */
|
||||
@@ -41,6 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
defaultValue: T,
|
||||
options?: Options
|
||||
): [T, (value: T) => void] {
|
||||
const previousKey = usePrevious(key);
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
@@ -65,6 +67,13 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
// Sync state when key changes
|
||||
useEffect(() => {
|
||||
if (previousKey !== key) {
|
||||
setStoredValue(Storage.get(key) ?? defaultValue);
|
||||
}
|
||||
}, [previousKey, key, defaultValue]);
|
||||
|
||||
// Listen to the key changing in other tabs so we can keep UI in sync
|
||||
useEventListener("storage", (event: StorageEvent) => {
|
||||
if (options?.listen !== false && event.key === key && event.newValue) {
|
||||
|
||||
+15
-103
@@ -1,6 +1,5 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -11,49 +10,14 @@ import Document from "~/models/Document";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
createTemplateFromDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
moveDocument,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
downloadDocument,
|
||||
importDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
archiveDocument,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
printDocument,
|
||||
openDocumentComments,
|
||||
createDocumentFromTemplate,
|
||||
createNestedDocument,
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
leaveDocument,
|
||||
moveTemplate,
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
editDocument,
|
||||
applyTemplateFactory,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { MenuSeparator } from "~/components/primitives/components/Menu";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
@@ -95,7 +59,13 @@ function DocumentMenu({
|
||||
const isMobile = useMobile();
|
||||
const can = usePolicy(document);
|
||||
|
||||
const { subscriptions, pins } = useStores();
|
||||
const { userMemberships, groupMemberships, subscriptions, pins } =
|
||||
useStores();
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
groupMemberships.getByDocumentId(document.id)
|
||||
);
|
||||
|
||||
const {
|
||||
loading: auxDataLoading,
|
||||
@@ -155,78 +125,18 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
const rootAction = useDocumentMenuAction({
|
||||
document,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
|
||||
@@ -280,6 +190,7 @@ function DocumentMenu({
|
||||
}, [
|
||||
t,
|
||||
can.update,
|
||||
can.updateInsights,
|
||||
document.embedsDisabled,
|
||||
document.fullWidth,
|
||||
document.insightsEnabled,
|
||||
@@ -288,6 +199,7 @@ function DocumentMenu({
|
||||
showToggleEmbeds,
|
||||
handleEmbedsToggle,
|
||||
handleFullWidthToggle,
|
||||
handleInsightsToggle,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,8 @@ import { isPast } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import User from "./User";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
@@ -25,6 +27,10 @@ class ApiKey extends ParanoidModel {
|
||||
@observable
|
||||
lastActiveAt?: string;
|
||||
|
||||
/** The user who this API key belongs to. */
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
|
||||
/** The user ID that the API key belongs to. */
|
||||
userId: string;
|
||||
|
||||
|
||||
@@ -723,8 +723,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = ProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, this.data),
|
||||
schema
|
||||
Node.fromJSON(schema, this.data)
|
||||
);
|
||||
return text;
|
||||
};
|
||||
|
||||
@@ -75,8 +75,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const id = params.id || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const collection: Collection | null | undefined = collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import queryString from "query-string";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { CommentSortType } from "~/types";
|
||||
|
||||
const CommentSortMenu = () => {
|
||||
type Props = {
|
||||
/** Callback when the sort type changes */
|
||||
onChange?: (sortType: CommentSortType | "resolved") => void;
|
||||
/** Whether resolved comments are being viewed */
|
||||
viewingResolved?: boolean;
|
||||
};
|
||||
|
||||
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
|
||||
const preferredSortType = user.getPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument
|
||||
@@ -25,42 +24,23 @@ const CommentSortMenu = () => {
|
||||
? CommentSortType.OrderInDocument
|
||||
: CommentSortType.MostRecent;
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(val: string) => {
|
||||
if (val === "resolved") {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
return;
|
||||
(val: CommentSortType | "resolved") => {
|
||||
if (val !== "resolved") {
|
||||
if (val !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
val === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
}
|
||||
|
||||
const sortType = val as CommentSortType;
|
||||
if (sortType !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
sortType === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
onChange?.(val);
|
||||
},
|
||||
[history, location, sidebarContext, user, preferredSortType]
|
||||
[user, onChange, preferredSortType]
|
||||
);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -17,7 +16,6 @@ import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -51,14 +49,11 @@ function CommentThread({
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const { editor } = useDocumentContext();
|
||||
const { editor, setFocusedCommentId } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
const user = useCurrentUser();
|
||||
|
||||
@@ -102,14 +97,7 @@ function CommentThread({
|
||||
!(event.target as HTMLElement).classList.contains("comment") &&
|
||||
event.defaultPrevented === false
|
||||
) {
|
||||
history.replace({
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: undefined,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,15 +106,7 @@ function CommentThread({
|
||||
}, [editor, thread.id]);
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: thread.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: {
|
||||
commentId: thread.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(thread.id);
|
||||
};
|
||||
|
||||
const handleClickExpand = (ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
|
||||
/**
|
||||
* Hook to calculate if we should display a timestamp on a comment
|
||||
@@ -111,6 +112,7 @@ function CommentThreadItem({
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
}: Props) {
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const [data, setData] = React.useState(comment.data);
|
||||
@@ -154,6 +156,9 @@ function CommentThreadItem({
|
||||
const handleUpdate = React.useCallback(
|
||||
(attrs: { resolved: boolean }) => {
|
||||
onUpdate?.(comment.id, attrs);
|
||||
if ("resolved" in attrs) {
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
},
|
||||
[comment.id, onUpdate]
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useFocusedComment } from "~/hooks/useFocusedComment";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -31,11 +31,13 @@ function Comments() {
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const params = useQuery();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
|
||||
const query = useQuery();
|
||||
const [viewingResolved, setViewingResolved] = useState(
|
||||
query.get("resolved") !== null || focusedComment?.isResolved || false
|
||||
);
|
||||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevThreadCount = useRef(0);
|
||||
const isAtBottom = useRef(true);
|
||||
@@ -43,6 +45,13 @@ function Comments() {
|
||||
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
|
||||
// Account for the resolved status of the comment changing
|
||||
useEffect(() => {
|
||||
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
|
||||
setViewingResolved(focusedComment.isResolved);
|
||||
}
|
||||
}, [focusedComment, viewingResolved]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document?.id}-new`,
|
||||
undefined
|
||||
@@ -57,7 +66,6 @@ function Comments() {
|
||||
}
|
||||
: { type: CommentSortType.MostRecent };
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const threads = !document
|
||||
? []
|
||||
: viewingResolved
|
||||
@@ -124,7 +132,12 @@ function Comments() {
|
||||
title={
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu />
|
||||
<CommentSortMenu
|
||||
viewingResolved={viewingResolved}
|
||||
onChange={(val) => {
|
||||
setViewingResolved(val === "resolved");
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
|
||||
@@ -67,9 +67,7 @@ function DataLoader({ match, children }: Props) {
|
||||
const { revisionId, documentSlug } = match.params;
|
||||
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
|
||||
if (document) {
|
||||
setDocument(document);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useFocusedComment } from "~/hooks/useFocusedComment";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -59,11 +59,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const titleRef = React.useRef<RefHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const focusedComment = useFocusedComment();
|
||||
const { ui, comments } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const params = useQuery();
|
||||
const {
|
||||
@@ -95,18 +95,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
(focusedComment.isResolved && !viewingResolved) ||
|
||||
(!focusedComment.isResolved && viewingResolved)
|
||||
) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: focusedComment.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(focusedComment.id);
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
|
||||
}, [focusedComment, ui, document.id, params]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
@@ -127,16 +120,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[focusAtStart, ref]
|
||||
);
|
||||
|
||||
const handleClickComment = React.useCallback(
|
||||
(commentId: string) => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[history, sidebarContext]
|
||||
);
|
||||
|
||||
// Create a Comment model in local store when a comment mark is created, this
|
||||
// acts as a local draft before submission.
|
||||
const handleDraftComment = React.useCallback(
|
||||
@@ -156,13 +139,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
);
|
||||
comment.id = commentId;
|
||||
comments.add(comment);
|
||||
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
setFocusedCommentId(commentId);
|
||||
},
|
||||
[comments, user?.id, props.id, history, sidebarContext]
|
||||
[comments, user?.id, props.id]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
@@ -258,7 +237,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
userId={user?.id}
|
||||
focusedCommentId={focusedComment?.id}
|
||||
onClickCommentMark={
|
||||
commentingEnabled && can.comment ? handleClickComment : undefined
|
||||
commentingEnabled && can.comment ? setFocusedCommentId : undefined
|
||||
}
|
||||
onCreateCommentMark={
|
||||
commentingEnabled && can.comment ? handleDraftComment : undefined
|
||||
|
||||
@@ -166,7 +166,7 @@ function DocumentHeader({
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ function History() {
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
|
||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { ConnectHeader } from "./components/ConnectHeader";
|
||||
import { TeamSwitcher } from "./components/TeamSwitcher";
|
||||
import { Form } from "~/components/primitives/Form";
|
||||
|
||||
export default function OAuthAuthorize() {
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
@@ -203,7 +204,7 @@ function Authorize() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form
|
||||
<Form
|
||||
method="POST"
|
||||
action="/oauth/authorize"
|
||||
style={{ width: "100%" }}
|
||||
@@ -236,7 +237,7 @@ function Authorize() {
|
||||
{t("Authorize")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Form>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
|
||||
@@ -12,15 +12,17 @@ import { detectLanguage } from "~/utils/language";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Background } from "./Background";
|
||||
import { Centered } from "./Centered";
|
||||
import { Form } from "~/components/primitives/Form";
|
||||
|
||||
const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered
|
||||
as="form"
|
||||
as={Form}
|
||||
action="/api/installation.create"
|
||||
method="POST"
|
||||
gap={12}
|
||||
|
||||
@@ -32,7 +32,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
|
||||
{apiKey.userId === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}{" "}
|
||||
: t(`by {{ name }}`, { name: apiKey.user.name })}{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
@@ -186,7 +185,7 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
get(id: string): Collection | undefined {
|
||||
get(id: string = ""): Collection | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((collection) => id.endsWith(collection.urlId))
|
||||
@@ -242,10 +241,6 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
return this.orderedData.map((collection) => collection.asNavigationNode);
|
||||
}
|
||||
|
||||
getByUrl(url: string): Collection | null | undefined {
|
||||
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||
}
|
||||
|
||||
async delete(collection: Collection) {
|
||||
await super.delete(collection);
|
||||
await this.rootStore.documents.fetchRecentlyUpdated();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import compact from "lodash/compact";
|
||||
import filter from "lodash/filter";
|
||||
import find from "lodash/find";
|
||||
import omitBy from "lodash/omitBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
@@ -460,7 +459,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@action
|
||||
prefetchDocument = async (id: string) => {
|
||||
if (!this.data.get(id) && !this.getByUrl(id)) {
|
||||
if (!this.get(id)) {
|
||||
return this.fetch(id, {
|
||||
prefetch: true,
|
||||
});
|
||||
@@ -746,12 +745,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
return subscription?.delete();
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | undefined =>
|
||||
find(
|
||||
this.orderedData,
|
||||
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
|
||||
);
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return document.collectionId
|
||||
? this.rootStore.collections.get(document.collectionId)
|
||||
|
||||
+24
-1
@@ -2,7 +2,7 @@ import retry from "fetch-retry";
|
||||
import trim from "lodash/trim";
|
||||
import queryString from "query-string";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { JSONObject } from "@shared/types";
|
||||
import { JSONObject, Scope } from "@shared/types";
|
||||
import stores from "~/stores";
|
||||
import Logger from "./Logger";
|
||||
import download from "./download";
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
UnprocessableEntityError,
|
||||
UpdateRequiredError,
|
||||
} from "./errors";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
import { CSRF } from "@shared/constants";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
|
||||
type Options = {
|
||||
baseUrl?: string;
|
||||
@@ -105,6 +108,20 @@ class ApiClient {
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
// Add CSRF token to headers for mutating requests
|
||||
const isModifyingRequest = ["POST", "PUT", "PATCH", "DELETE"].includes(
|
||||
method
|
||||
);
|
||||
const canAccessWithReadOnly = AuthenticationHelper.canAccess(path, [
|
||||
Scope.Read,
|
||||
]);
|
||||
if (isModifyingRequest && !canAccessWithReadOnly) {
|
||||
const csrfToken = getCookie(CSRF.cookieName);
|
||||
if (csrfToken) {
|
||||
headerOptions[CSRF.headerName] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
// for multipart forms or other non JSON requests fetch
|
||||
// populates the Content-Type without needing to explicitly
|
||||
// set it.
|
||||
@@ -213,6 +230,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
if (error.error === "csrf_error") {
|
||||
throw new AuthorizationError(
|
||||
"CSRF token invalid, please try reloading."
|
||||
);
|
||||
}
|
||||
|
||||
throw new AuthorizationError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function settingsPath(...args: string[]): string {
|
||||
|
||||
export function commentPath(document: Document, comment: Comment): string {
|
||||
return `${documentPath(document)}?commentId=${comment.id}${
|
||||
comment.isResolved ? "&resolved=" : ""
|
||||
comment.isResolved ? "&resolved=1" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
// TypeScript files
|
||||
"**/*.[tj]s?(x)": [
|
||||
(f) => `prettier --write ${f.join(" ")}`,
|
||||
(f) => (f.length > 20 ? `yarn lint` : `oxlint ${f.join(" ")}`),
|
||||
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
|
||||
() => `yarn build:i18n`,
|
||||
() => "git add shared/i18n/locales/en_US/translation.json",
|
||||
],
|
||||
|
||||
+32
-31
@@ -51,32 +51,32 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.864.0",
|
||||
"@aws-sdk/lib-storage": "3.864.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.864.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.864.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.864.0",
|
||||
"@babel/core": "^7.27.7",
|
||||
"@aws-sdk/client-s3": "3.873.0",
|
||||
"@aws-sdk/lib-storage": "3.873.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.873.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.873.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.873.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.1",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.12.0",
|
||||
"@css-inline/css-inline-wasm": "^0.14.3",
|
||||
"@css-inline/css-inline-wasm": "^0.17.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.3",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-redis": "1.1.2",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
@@ -84,22 +84,23 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@linear/sdk": "^39.0.0",
|
||||
"@linear/sdk": "^39.2.1",
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@octokit/auth-app": "^6.1.4",
|
||||
"@octokit/webhooks": "^13.9.1",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.2",
|
||||
"@sentry/node": "^7.120.4",
|
||||
"@sentry/react": "^7.120.4",
|
||||
@@ -123,11 +124,11 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie": "^0.7.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.37.0",
|
||||
"core-js": "^3.45.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.62.0",
|
||||
"dd-trace": "^5.63.0",
|
||||
"diff": "^5.2.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
@@ -170,7 +171,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^3.0.0",
|
||||
"mermaid": "11.9.0",
|
||||
"mermaid": "11.10.1",
|
||||
"mime-types": "^3.0.1",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
@@ -255,17 +256,17 @@
|
||||
"styled-normalize": "^8.1.1",
|
||||
"throng": "^5.0.0",
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.4",
|
||||
"tmp": "^0.2.5",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ukkonen": "^2.1.0",
|
||||
"ukkonen": "^2.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.15.15",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -276,7 +277,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.0",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
@@ -351,7 +352,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.119",
|
||||
"discord-api-types": "^0.38.20",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
@@ -365,7 +366,7 @@
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.1.3",
|
||||
"rollup-plugin-webpack-stats": "2.1.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.9.2",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
@@ -381,6 +382,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ type ParsePageOutput = ImportTaskOutput[number] & {
|
||||
};
|
||||
|
||||
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
|
||||
private skippableErrorMessages = [
|
||||
"Database retrievals do not support linked databases",
|
||||
"does not contain any data sources accessible by this API bot", // error msg for linked database views
|
||||
];
|
||||
|
||||
/**
|
||||
* Process the Notion import task.
|
||||
* This fetches data from Notion and converts it to task output.
|
||||
@@ -138,8 +143,8 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
if (
|
||||
error.code === APIErrorCode.ObjectNotFound ||
|
||||
error.code === APIErrorCode.Unauthorized ||
|
||||
error.message.includes(
|
||||
"Database retrievals do not support linked databases"
|
||||
this.skippableErrorMessages.some((errorMsg) =>
|
||||
error.message.includes(errorMsg)
|
||||
)
|
||||
) {
|
||||
Logger.warn(
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ export class Environment {
|
||||
/**
|
||||
* The url of the database.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsUrl({
|
||||
require_tld: false,
|
||||
allow_underscores: true,
|
||||
@@ -91,7 +91,7 @@ export class Environment {
|
||||
"DATABASE_USER",
|
||||
"DATABASE_PASSWORD",
|
||||
])
|
||||
public DATABASE_URL = environment.DATABASE_URL ?? "";
|
||||
public DATABASE_URL = this.toOptionalString(environment.DATABASE_URL);
|
||||
|
||||
/**
|
||||
* Database host for individual component configuration.
|
||||
|
||||
@@ -32,6 +32,12 @@ export function AuthorizationError(message = "Authorization error") {
|
||||
});
|
||||
}
|
||||
|
||||
export function CSRFError(message = "Authorization error") {
|
||||
return httpErrors(403, message, {
|
||||
id: "csrf_error",
|
||||
});
|
||||
}
|
||||
|
||||
export function RateLimitExceededError(
|
||||
message = "Rate limit exceeded for this operation"
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Next } from "koa";
|
||||
import { Scope } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import { AppContext } from "@server/types";
|
||||
import {
|
||||
generateRawToken,
|
||||
bundleToken,
|
||||
unbundleToken,
|
||||
} from "@server/utils/csrf";
|
||||
import { getCookieDomain } from "@shared/utils/domains";
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { CSRFError } from "@server/errors";
|
||||
|
||||
/**
|
||||
* Middleware that generates and attaches CSRF tokens for safe methods
|
||||
*/
|
||||
export function attachCSRFToken() {
|
||||
return async function attachCSRFTokenMiddleware(ctx: AppContext, next: Next) {
|
||||
// Only attach tokens for safe methods that don't mutate state
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(ctx.method)) {
|
||||
const raw = generateRawToken(16);
|
||||
const bundled = bundleToken(raw, env.SECRET_KEY);
|
||||
|
||||
// Set cookie that JavaScript can read (not HttpOnly)
|
||||
ctx.cookies.set(CSRF.cookieName, bundled, {
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
domain: getCookieDomain(ctx.request.hostname, env.isCloudHosted),
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that verifies CSRF tokens for mutating requests
|
||||
*/
|
||||
export function verifyCSRFToken() {
|
||||
/**
|
||||
* Determines if a request requires CSRF protection
|
||||
*/
|
||||
const shouldProtectRequest = (ctx: AppContext): boolean => {
|
||||
// Skip if not a potentially mutating method
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(ctx.method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If not using cookie-based auth, skip CSRF protection
|
||||
if (!ctx.cookies.get("accessToken")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For API routes, use AuthenticationHelper to determine if the operation is read-only
|
||||
if (ctx.originalUrl.startsWith("/api/")) {
|
||||
const canAccessWithReadOnly = AuthenticationHelper.canAccess(ctx.path, [
|
||||
Scope.Read,
|
||||
]);
|
||||
|
||||
// If it can be accessed with read-only scope, it doesn't need CSRF protection
|
||||
if (canAccessWithReadOnly) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Protect all other mutating requests
|
||||
return true;
|
||||
};
|
||||
|
||||
return async function verifyCSRFTokenMiddleware(ctx: AppContext, next: Next) {
|
||||
if (!shouldProtectRequest(ctx)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token from cookie
|
||||
const cookieVal = ctx.cookies.get(CSRF.cookieName);
|
||||
if (!cookieVal) {
|
||||
throw CSRFError("CSRF token missing from cookie");
|
||||
}
|
||||
|
||||
// Get token from header or form field depending on type
|
||||
// Access the already-parsed body from koa-body middleware
|
||||
const inputVal =
|
||||
ctx.get(CSRF.headerName) || ctx.request.body?.[CSRF.fieldName];
|
||||
|
||||
if (!inputVal) {
|
||||
throw CSRFError("CSRF token missing from request");
|
||||
}
|
||||
|
||||
// Verify both tokens are valid HMAC-signed tokens
|
||||
const { valid: cookieValid } = unbundleToken(cookieVal, env.SECRET_KEY);
|
||||
const { valid: inputValid } = unbundleToken(inputVal, env.SECRET_KEY);
|
||||
|
||||
if (!cookieValid || !inputValid) {
|
||||
throw CSRFError("CSRF token invalid or malformed");
|
||||
}
|
||||
|
||||
// Verify tokens match (double-submit check)
|
||||
if (cookieVal !== inputVal) {
|
||||
throw CSRFError("CSRF token mismatch");
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create a partial unique index that only applies when revokedAt is NULL
|
||||
// This ensures that only non-revoked shares have the unique constraint
|
||||
await queryInterface.sequelize.query(
|
||||
`CREATE UNIQUE INDEX CONCURRENTLY "shares_urlId_teamId_not_revoked_uk"
|
||||
ON "shares" ("urlId", "teamId")
|
||||
WHERE "revokedAt" IS NULL;`
|
||||
);
|
||||
|
||||
// Remove the existing unique constraint
|
||||
await queryInterface.removeConstraint("shares", "shares_urlId_teamId_uk");
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Restore the original unique constraint
|
||||
await queryInterface.addConstraint("shares", {
|
||||
fields: ["urlId", "teamId"],
|
||||
type: "unique",
|
||||
name: "shares_urlId_teamId_uk",
|
||||
});
|
||||
|
||||
// Remove the partial unique index
|
||||
await queryInterface.sequelize.query(
|
||||
`DROP INDEX CONCURRENTLY IF EXISTS "shares_urlId_teamId_not_revoked_uk";`
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("authentications", "expiresAt", {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("authentications", "expiresAt");
|
||||
},
|
||||
};
|
||||
@@ -21,7 +21,7 @@ import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
import Fix from "./decorators/Fix";
|
||||
import AuthenticationHelper from "./helpers/AuthenticationHelper";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@Table({ tableName: "apiKeys", modelName: "apiKey" })
|
||||
|
||||
@@ -136,7 +136,7 @@ class Comment extends ParanoidModel<
|
||||
*/
|
||||
public toPlainText() {
|
||||
const node = Node.fromJSON(schema, this.data);
|
||||
return ProsemirrorHelper.toPlainText(node, schema);
|
||||
return ProsemirrorHelper.toPlainText(node);
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@@ -33,6 +33,9 @@ class IntegrationAuthentication extends IdModel<
|
||||
@Encrypted
|
||||
refreshToken: string;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
expiresAt: Date | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
/* oxlint-disable @typescript-eslint/no-var-requires */
|
||||
import find from "lodash/find";
|
||||
import { Scope } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import Team from "@server/models/Team";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
|
||||
export default class AuthenticationHelper {
|
||||
/**
|
||||
* The mapping of method names to their scopes, anything not listed here
|
||||
* defaults to `Scope.Write`.
|
||||
*
|
||||
* - `documents.create` -> `Scope.Create`
|
||||
* - `documents.list` -> `Scope.Read`
|
||||
* - `documents.info` -> `Scope.Read`
|
||||
*/
|
||||
private static methodToScope = {
|
||||
create: Scope.Create,
|
||||
list: Scope.Read,
|
||||
info: Scope.Read,
|
||||
search: Scope.Read,
|
||||
documents: Scope.Read,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the enabled authentication provider configurations for the current
|
||||
* installation.
|
||||
@@ -69,45 +52,4 @@ export default class AuthenticationHelper {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given path can be accessed with any of the scopes. We
|
||||
* support scopes in the formats of:
|
||||
*
|
||||
* - `/api/namespace.method`
|
||||
* - `namespace:scope`
|
||||
* - `scope`
|
||||
*
|
||||
* @param path The path to check
|
||||
* @param scopes The scopes to check
|
||||
* @returns True if the path can be accessed
|
||||
*/
|
||||
public static canAccess = (path: string, scopes: string[]) => {
|
||||
// strip any query string, this is never used as part of scope matching
|
||||
path = path.split("?")[0];
|
||||
|
||||
const resource = path.split("/").pop() ?? "";
|
||||
const [namespace, method] = resource.split(".");
|
||||
|
||||
return scopes.some((scope) => {
|
||||
const [scopeNamespace, scopeMethod] = scope.match(/[:\.]/g)
|
||||
? scope.replace("/api/", "").split(/[:\.]/g)
|
||||
: ["*", scope];
|
||||
const isRouteScope = scope.startsWith("/api/");
|
||||
|
||||
if (isRouteScope) {
|
||||
return (
|
||||
(namespace === scopeNamespace || scopeNamespace === "*") &&
|
||||
(method === scopeMethod || scopeMethod === "*")
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(namespace === scopeNamespace || scopeNamespace === "*") &&
|
||||
(scopeMethod === Scope.Write ||
|
||||
this.methodToScope[method as keyof typeof this.methodToScope] ===
|
||||
scopeMethod)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import ukkonen from "ukkonen";
|
||||
import { updateYFragment, yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { IconType, ProsemirrorData } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
@@ -141,8 +140,7 @@ export class DocumentHelper {
|
||||
*/
|
||||
static toPlainText(document: Document | Revision | ProsemirrorData) {
|
||||
const node = DocumentHelper.toProsemirror(document);
|
||||
|
||||
return textBetween(node, 0, node.content.size, this.textSerializers);
|
||||
return textBetween(node, 0, node.content.size);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -523,6 +521,4 @@ export class DocumentHelper {
|
||||
const distance = ukkonen(first, second, threshold + 1);
|
||||
return distance > threshold;
|
||||
}
|
||||
|
||||
private static textSerializers = getTextSerializers(schema);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import User from "@server/models/User";
|
||||
import ParanoidModel from "@server/models/base/ParanoidModel";
|
||||
import { SkipChangeset } from "@server/models/decorators/Changeset";
|
||||
import Fix from "@server/models/decorators/Fix";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import { hash } from "@server/utils/crypto";
|
||||
import OAuthClient from "./OAuthClient";
|
||||
|
||||
|
||||
@@ -25,10 +25,7 @@ export default function TextLength({
|
||||
let text;
|
||||
|
||||
try {
|
||||
text = ProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, value),
|
||||
schema
|
||||
);
|
||||
text = ProsemirrorHelper.toPlainText(Node.fromJSON(schema, value));
|
||||
} catch (_err) {
|
||||
throw new Error("Invalid data");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import ApiKey from "@server/models/ApiKey";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default function presentApiKey(apiKey: ApiKey) {
|
||||
return {
|
||||
id: apiKey.id,
|
||||
user: presentUser(apiKey.user),
|
||||
userId: apiKey.userId,
|
||||
name: apiKey.name,
|
||||
scope: apiKey.scope,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createQueue } from "@server/queues/queue";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
export const globalEventQueue = createQueue("globalEvents", {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000,
|
||||
delay: Second.ms,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,18 +13,18 @@ export const processorEventQueue = createQueue("processorEvents", {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 10 * 1000,
|
||||
delay: 10 * Second.ms,
|
||||
},
|
||||
});
|
||||
|
||||
export const websocketQueue = createQueue("websockets", {
|
||||
timeout: 10 * 1000,
|
||||
timeout: 10 * Second.ms,
|
||||
});
|
||||
|
||||
export const taskQueue = createQueue("tasks", {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 10 * 1000,
|
||||
delay: 10 * Second.ms,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
userId: string;
|
||||
sourceMetadata: Pick<Required<SourceMetadata>, "fileName" | "mimeType">;
|
||||
publish?: boolean;
|
||||
collectionId?: string;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string | null;
|
||||
ip: string;
|
||||
key: string;
|
||||
|
||||
@@ -72,6 +72,35 @@ export default class ExportJSONTask extends ExportTask {
|
||||
attachments: {},
|
||||
};
|
||||
|
||||
async function addAttachments(attachments: Attachment[]) {
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
zip.file(
|
||||
attachment.key,
|
||||
new Promise<Buffer>((resolve) => {
|
||||
attachment.buffer.then(resolve).catch((err) => {
|
||||
Logger.warn(`Failed to read attachment from storage`, {
|
||||
attachmentId: attachment.id,
|
||||
teamId: attachment.teamId,
|
||||
error: err.message,
|
||||
});
|
||||
resolve(Buffer.from(""));
|
||||
});
|
||||
}),
|
||||
{
|
||||
date: attachment.updatedAt,
|
||||
createFolders: true,
|
||||
}
|
||||
);
|
||||
|
||||
output.attachments[attachment.id] = {
|
||||
...omit(presentAttachment(attachment), "url"),
|
||||
key: attachment.key,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function addDocumentTree(nodes: NavigationNode[]) {
|
||||
for (const node of nodes) {
|
||||
const document = await Document.findByPk(node.id, {
|
||||
@@ -82,7 +111,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachments = includeAttachments
|
||||
const documentAttachments = includeAttachments
|
||||
? await Attachment.findAll({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
@@ -93,32 +122,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
})
|
||||
: [];
|
||||
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
zip.file(
|
||||
attachment.key,
|
||||
new Promise<Buffer>((resolve) => {
|
||||
attachment.buffer.then(resolve).catch((err) => {
|
||||
Logger.warn(`Failed to read attachment from storage`, {
|
||||
attachmentId: attachment.id,
|
||||
teamId: attachment.teamId,
|
||||
error: err.message,
|
||||
});
|
||||
resolve(Buffer.from(""));
|
||||
});
|
||||
}),
|
||||
{
|
||||
date: attachment.updatedAt,
|
||||
createFolders: true,
|
||||
}
|
||||
);
|
||||
|
||||
output.attachments[attachment.id] = {
|
||||
...omit(presentAttachment(attachment), "url"),
|
||||
key: attachment.key,
|
||||
};
|
||||
})
|
||||
);
|
||||
await addAttachments(documentAttachments);
|
||||
|
||||
output.documents[document.id] = {
|
||||
id: document.id,
|
||||
@@ -146,6 +150,19 @@ export default class ExportJSONTask extends ExportTask {
|
||||
}
|
||||
}
|
||||
|
||||
const collectionAttachments = includeAttachments
|
||||
? await Attachment.findAll({
|
||||
where: {
|
||||
teamId: collection.teamId,
|
||||
id: ProsemirrorHelper.parseAttachmentIds(
|
||||
DocumentHelper.toProsemirror(collection)
|
||||
),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
await addAttachments(collectionAttachments);
|
||||
|
||||
if (collection.documentStructure) {
|
||||
await addDocumentTree(collection.documentStructure);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ router.post(
|
||||
scope: scope?.map((s) => (s.startsWith("/api/") ? s : `/api/${s}`)),
|
||||
});
|
||||
|
||||
apiKey.user = user;
|
||||
|
||||
ctx.body = {
|
||||
data: presentApiKey(apiKey),
|
||||
};
|
||||
|
||||
@@ -135,6 +135,7 @@ router.post(
|
||||
});
|
||||
|
||||
const presignedPost = await FileStorage.getPresignedPost(
|
||||
ctx,
|
||||
key,
|
||||
acl,
|
||||
maxUploadSize,
|
||||
|
||||
@@ -3291,7 +3291,7 @@ describe("#documents.restore", () => {
|
||||
});
|
||||
|
||||
describe("#documents.import", () => {
|
||||
it("should require collectionId", async () => {
|
||||
it("should require collectionId or parentDocumentId", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.import", {
|
||||
body: {
|
||||
@@ -3300,7 +3300,9 @@ describe("#documents.import", () => {
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("collectionId: Required");
|
||||
expect(body.message).toEqual(
|
||||
"body: one of collectionId or parentDocumentId is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should error if no file is passed", async () => {
|
||||
|
||||
@@ -1584,17 +1584,20 @@ router.post(
|
||||
const file = ctx.input.file;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "createDocument", collection);
|
||||
let parentDocument;
|
||||
if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "createDocument", collection);
|
||||
}
|
||||
|
||||
let parentDocument: Document | null = null;
|
||||
|
||||
if (parentDocumentId) {
|
||||
parentDocument = await Document.findByPk(parentDocumentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", parentDocument);
|
||||
authorize(user, "createChildDocument", parentDocument);
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(file.filepath);
|
||||
@@ -1624,7 +1627,7 @@ router.post(
|
||||
mimeType,
|
||||
},
|
||||
userId: user.id,
|
||||
collectionId,
|
||||
collectionId: collectionId ?? parentDocument?.collectionId, // collectionId will be null when parent document is shared to the user.
|
||||
parentDocumentId,
|
||||
publish,
|
||||
ip: ctx.request.ip,
|
||||
|
||||
@@ -311,16 +311,23 @@ export const DocumentsUnpublishSchema = BaseSchema.extend({
|
||||
export type DocumentsUnpublishReq = z.infer<typeof DocumentsUnpublishSchema>;
|
||||
|
||||
export const DocumentsImportSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Whether to publish the imported docs. String as this is always multipart/form-data */
|
||||
publish: z.preprocess((val) => val === "true", z.boolean()).optional(),
|
||||
body: z
|
||||
.object({
|
||||
/** Whether to publish the imported docs. String as this is always multipart/form-data */
|
||||
publish: z.preprocess((val) => val === "true", z.boolean()).optional(),
|
||||
|
||||
/** Import docs to this collection */
|
||||
collectionId: z.string().uuid(),
|
||||
/** Import docs to this collection */
|
||||
collectionId: z.string().uuid().nullish(),
|
||||
|
||||
/** Import under this parent doc */
|
||||
parentDocumentId: z.string().uuid().nullish(),
|
||||
}),
|
||||
/** Import under this parent doc */
|
||||
parentDocumentId: z.string().uuid().nullish(),
|
||||
})
|
||||
.refine(
|
||||
(req) => !(isEmpty(req.collectionId) && isEmpty(req.parentDocumentId)),
|
||||
{
|
||||
message: "one of collectionId or parentDocumentId is required",
|
||||
}
|
||||
),
|
||||
file: z.custom<formidable.File>(),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import env from "@server/env";
|
||||
import { NotFoundError } from "@server/errors";
|
||||
import coalesceBody from "@server/middlewares/coaleseBody";
|
||||
import requestTracer from "@server/middlewares/requestTracer";
|
||||
import { verifyCSRFToken } from "@server/middlewares/csrf";
|
||||
import { AppState, AppContext } from "@server/types";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import apiKeys from "./apiKeys";
|
||||
@@ -67,6 +68,7 @@ api.use(requestTracer());
|
||||
api.use(apiResponse());
|
||||
api.use(apiErrorHandler());
|
||||
api.use(editor());
|
||||
api.use(verifyCSRFToken());
|
||||
|
||||
// Register plugin API routes before others to allow for overrides
|
||||
PluginManager.getHooks(Hook.API).forEach((hook) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import coalesceBody from "@server/middlewares/coaleseBody";
|
||||
import { Collection, Team, View } from "@server/models";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import { AppState, AppContext, APIContext } from "@server/types";
|
||||
import { verifyCSRFToken } from "@server/middlewares/csrf";
|
||||
|
||||
const app = new Koa<AppState, AppContext>();
|
||||
const router = new Router();
|
||||
@@ -77,6 +78,7 @@ router.get("/redirect", authMiddleware(), async (ctx: APIContext) => {
|
||||
|
||||
app.use(bodyParser());
|
||||
app.use(coalesceBody());
|
||||
app.use(verifyCSRFToken());
|
||||
app.use(router.routes());
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { OAuthInterface } from "@server/utils/oauth/OAuthInterface";
|
||||
import oauthErrorHandler from "./middlewares/oauthErrorHandler";
|
||||
import * as T from "./schema";
|
||||
import { verifyCSRFToken } from "@server/middlewares/csrf";
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
@@ -127,6 +128,7 @@ router.post(
|
||||
app.use(requestTracer());
|
||||
app.use(oauthErrorHandler());
|
||||
app.use(bodyParser());
|
||||
app.use(verifyCSRFToken());
|
||||
app.use(router.routes());
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -13,6 +13,7 @@ import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Metrics from "@server/logging/Metrics";
|
||||
import csp from "@server/middlewares/csp";
|
||||
import { attachCSRFToken } from "@server/middlewares/csrf";
|
||||
import ShutdownHelper, { ShutdownOrder } from "@server/utils/ShutdownHelper";
|
||||
import { initI18n } from "@server/utils/i18n";
|
||||
import routes from "../routes";
|
||||
@@ -45,6 +46,7 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
}
|
||||
|
||||
app.use(compress());
|
||||
app.use(attachCSRFToken());
|
||||
|
||||
// Monitor server connections
|
||||
if (server) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isBase64Url, isInternalUrl } from "@shared/utils/urls";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import fetch, { chromeUserAgent, RequestInit } from "@server/utils/fetch";
|
||||
import { AppContext } from "@server/types";
|
||||
|
||||
export default abstract class BaseStorage {
|
||||
/** The default number of seconds until a signed URL expires. */
|
||||
@@ -15,6 +16,7 @@ export default abstract class BaseStorage {
|
||||
/**
|
||||
* Returns a presigned post for uploading files to the storage provider.
|
||||
*
|
||||
* @param ctx The request context
|
||||
* @param key The path to store the file at
|
||||
* @param acl The ACL to use
|
||||
* @param maxUploadSize The maximum upload size in bytes
|
||||
@@ -22,6 +24,7 @@ export default abstract class BaseStorage {
|
||||
* @returns The presigned post object to use on the client (TODO: Abstract away from S3)
|
||||
*/
|
||||
public abstract getPresignedPost(
|
||||
ctx: AppContext,
|
||||
key: string,
|
||||
acl: string,
|
||||
maxUploadSize: number,
|
||||
|
||||
@@ -11,9 +11,12 @@ import env from "@server/env";
|
||||
import { InternalError, ValidationError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import BaseStorage from "./BaseStorage";
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { AppContext } from "@server/types";
|
||||
|
||||
export default class LocalStorage extends BaseStorage {
|
||||
public async getPresignedPost(
|
||||
ctx: AppContext,
|
||||
key: string,
|
||||
acl: string,
|
||||
maxUploadSize: number,
|
||||
@@ -26,6 +29,7 @@ export default class LocalStorage extends BaseStorage {
|
||||
acl,
|
||||
maxUploadSize: String(maxUploadSize),
|
||||
contentType,
|
||||
[CSRF.fieldName]: ctx.cookies.get(CSRF.cookieName) || "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import tmp from "tmp";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import BaseStorage from "./BaseStorage";
|
||||
import { AppContext } from "@server/types";
|
||||
|
||||
export default class S3Storage extends BaseStorage {
|
||||
constructor() {
|
||||
@@ -34,6 +35,7 @@ export default class S3Storage extends BaseStorage {
|
||||
}
|
||||
|
||||
public async getPresignedPost(
|
||||
_ctx: AppContext,
|
||||
key: string,
|
||||
acl: string,
|
||||
maxUploadSize: number,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { randomBytes, createHmac } from "crypto";
|
||||
import { safeEqual } from "./crypto";
|
||||
|
||||
/**
|
||||
* Generates cryptographically secure random bytes
|
||||
*
|
||||
* @param size The number of bytes to generate
|
||||
* @returns A buffer containing random bytes
|
||||
*/
|
||||
export const generateRawToken = (size: number): Buffer => randomBytes(size);
|
||||
|
||||
/**
|
||||
* Creates an HMAC-SHA256 signature for a token
|
||||
*
|
||||
* @param token The token to sign
|
||||
* @param secret The secret key for signing
|
||||
* @returns The HMAC signature as a hex string
|
||||
*/
|
||||
export const signToken = (token: Buffer, secret: string): string =>
|
||||
createHmac("sha256", secret).update(token).digest("hex");
|
||||
|
||||
/**
|
||||
* Bundles a token with its HMAC signature
|
||||
*
|
||||
* @param token The raw token
|
||||
* @param secret The secret key for signing
|
||||
* @returns A string containing the token and signature separated by a dot
|
||||
*/
|
||||
export const bundleToken = (token: Buffer, secret: string): string => {
|
||||
const sig = signToken(token, secret);
|
||||
return `${token.toString("hex")}.${sig}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unbundles and verifies a token with its HMAC signature
|
||||
*
|
||||
* @param bundled The bundled token string
|
||||
* @param secret The secret key for verification
|
||||
* @returns An object indicating validity and the raw token if valid
|
||||
*/
|
||||
export const unbundleToken = (
|
||||
bundled: string,
|
||||
secret: string
|
||||
): { valid: boolean; raw?: Buffer } => {
|
||||
const [hex, sig] = bundled.split(".");
|
||||
if (!hex || !sig) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const token = Buffer.from(hex, "hex");
|
||||
const expected = signToken(token, secret);
|
||||
|
||||
const valid = safeEqual(sig, expected);
|
||||
return { valid, raw: valid ? token : undefined };
|
||||
};
|
||||
@@ -15,6 +15,12 @@ export const Pagination = {
|
||||
sidebarLimit: 10,
|
||||
};
|
||||
|
||||
export const CSRF = {
|
||||
cookieName: "csrfToken",
|
||||
headerName: "x-csrf-token",
|
||||
fieldName: "_csrf",
|
||||
};
|
||||
|
||||
export const TeamPreferenceDefaults: TeamPreferences = {
|
||||
[TeamPreference.SeamlessEdit]: true,
|
||||
[TeamPreference.ViewersCanExport]: true,
|
||||
|
||||
@@ -143,7 +143,7 @@ export function exportTable({
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => {
|
||||
let value = ProsemirrorHelper.toPlainText(cell, state.schema);
|
||||
let value = ProsemirrorHelper.toPlainText(cell);
|
||||
|
||||
// Escape double quotes by doubling them
|
||||
if (value.includes('"')) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { PlainTextSerializer } from "../types";
|
||||
|
||||
/**
|
||||
* Returns the text content between two positions.
|
||||
@@ -7,25 +6,22 @@ import { PlainTextSerializer } from "../types";
|
||||
* @param doc The Prosemirror document to use
|
||||
* @param from A start point
|
||||
* @param to An end point
|
||||
* @param plainTextSerializers A map of node names to PlainTextSerializers which convert a node to plain text
|
||||
* @returns A string of plain text
|
||||
*/
|
||||
export default function textBetween(
|
||||
doc: ProseMirrorNode,
|
||||
from: number,
|
||||
to: number,
|
||||
plainTextSerializers: Record<string, PlainTextSerializer | undefined>
|
||||
to: number
|
||||
): string {
|
||||
let text = "";
|
||||
let first = true;
|
||||
const blockSeparator = "\n";
|
||||
|
||||
doc.nodesBetween(from, to, (node, pos) => {
|
||||
const toPlainText = plainTextSerializers[node.type.name];
|
||||
let nodeText = "";
|
||||
|
||||
if (toPlainText) {
|
||||
nodeText += toPlainText(node);
|
||||
if (node.type.spec.leafText) {
|
||||
nodeText += node.type.spec.leafText(node);
|
||||
} else if (node.isText) {
|
||||
nodeText += node.textBetween(
|
||||
Math.max(from, pos) - pos,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Schema } from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* Generate a map of text serializers for a given schema
|
||||
* @param schema
|
||||
* @returns Text serializers
|
||||
*/
|
||||
export function getTextSerializers(schema: Schema) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default class Attachment extends Node {
|
||||
},
|
||||
String(node.attrs.title),
|
||||
],
|
||||
toPlainText: (node) => node.attrs.title,
|
||||
leafText: (node) => node.attrs.title,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export default class Embed extends Node {
|
||||
];
|
||||
}
|
||||
},
|
||||
toPlainText: (node) => node.attrs.href,
|
||||
leafText: (node) => node.attrs.href,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export default class Emoji extends Extension {
|
||||
getEmojiFromName(name),
|
||||
];
|
||||
},
|
||||
toPlainText: (node) => getEmojiFromName(node.attrs["data-name"]),
|
||||
leafText: (node) => getEmojiFromName(node.attrs["data-name"]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export default class HardBreak extends Node {
|
||||
selectable: false,
|
||||
parseDOM: [{ tag: "br" }],
|
||||
toDOM: () => ["br"],
|
||||
toPlainText: () => "\n",
|
||||
leafText: () => "\n",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ export default class Image extends SimpleImage {
|
||||
...children,
|
||||
];
|
||||
},
|
||||
toPlainText: (node) =>
|
||||
leafText: (node) =>
|
||||
node.attrs.alt ? `(image: ${node.attrs.alt})` : "(image)",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export default class Mention extends Node {
|
||||
},
|
||||
toPlainText(node),
|
||||
],
|
||||
toPlainText,
|
||||
leafText: toPlainText,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import deleteEmptyFirstParagraph from "../commands/deleteEmptyFirstParagraph";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import Node from "./Node";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
export default class Paragraph extends Node {
|
||||
get name() {
|
||||
@@ -13,7 +14,23 @@ export default class Paragraph extends Node {
|
||||
return {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [{ tag: "p" }],
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "p",
|
||||
getAttrs: (dom) => {
|
||||
if (!(dom instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We must suppress image captions from being parsed as a separate paragraph.
|
||||
if (dom.classList.contains(EditorStyleHelper.imageCaption)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: () => ["p", { dir: "auto" }, 0],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class Video extends Node {
|
||||
String(node.attrs.title),
|
||||
],
|
||||
],
|
||||
toPlainText: (node) => node.attrs.title,
|
||||
leafText: (node) => node.attrs.title,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const EDITOR_VERSION = "15.0.0";
|
||||
const EDITOR_VERSION = "16.0.0";
|
||||
|
||||
export default EDITOR_VERSION;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Scope } from "../types";
|
||||
|
||||
export default class AuthenticationHelper {
|
||||
/**
|
||||
* The mapping of method names to their scopes, anything not listed here
|
||||
* defaults to `Scope.Write`.
|
||||
*
|
||||
* - `documents.create` -> `Scope.Create`
|
||||
* - `documents.list` -> `Scope.Read`
|
||||
* - `documents.info` -> `Scope.Read`
|
||||
*/
|
||||
private static methodToScope = {
|
||||
create: Scope.Create,
|
||||
config: Scope.Read,
|
||||
list: Scope.Read,
|
||||
info: Scope.Read,
|
||||
search: Scope.Read,
|
||||
documents: Scope.Read,
|
||||
export: Scope.Read,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether the given path can be accessed with any of the scopes. We
|
||||
* support scopes in the formats of:
|
||||
*
|
||||
* - `/api/namespace.method`
|
||||
* - `namespace:scope`
|
||||
* - `scope`
|
||||
*
|
||||
* @param path The path to check
|
||||
* @param scopes The scopes to check
|
||||
* @returns True if the path can be accessed
|
||||
*/
|
||||
public static canAccess = (path: string, scopes: string[]) => {
|
||||
// strip any query string, this is never used as part of scope matching
|
||||
path = path.split("?")[0];
|
||||
|
||||
const resource = path.split("/").pop() ?? "";
|
||||
const [namespace, method] = resource.split(".");
|
||||
|
||||
return scopes.some((scope) => {
|
||||
const [scopeNamespace, scopeMethod] = scope.match(/[:\.]/g)
|
||||
? scope.replace("/api/", "").split(/[:\.]/g)
|
||||
: ["*", scope];
|
||||
const isRouteScope = scope.startsWith("/api/");
|
||||
|
||||
if (isRouteScope) {
|
||||
return (
|
||||
(namespace === scopeNamespace || scopeNamespace === "*") &&
|
||||
(method === scopeMethod || scopeMethod === "*")
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(namespace === scopeNamespace || scopeNamespace === "*") &&
|
||||
(scopeMethod === Scope.Write ||
|
||||
this.methodToScope[method as keyof typeof this.methodToScope] ===
|
||||
scopeMethod)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -260,7 +260,7 @@
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Entschuldigung, ein Fehler ist aufgetreten{{notified}}. Bitte versuche die Seite neu zu laden, es könnte inzwischen wieder funktionieren.",
|
||||
"our engineers have been notified": "unsere Entwickler wurden benachrichtigt",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Cache leeren + neu laden",
|
||||
"Show detail": "Details anzeigen",
|
||||
"{{userName}} archived": "{{userName}} archivierte",
|
||||
"{{userName}} restored": "{{userName}} stellte wieder her",
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"No results found": "No results found",
|
||||
"Document options": "Document options",
|
||||
"New": "New",
|
||||
"Only visible to you": "Only visible to you",
|
||||
"Draft": "Draft",
|
||||
@@ -541,6 +542,7 @@
|
||||
"Video": "Video",
|
||||
"None": "None",
|
||||
"Delete embed": "Delete embed",
|
||||
"Rename": "Rename",
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Unsubscribed from collection": "Unsubscribed from collection",
|
||||
@@ -562,12 +564,10 @@
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Rename": "Rename",
|
||||
"Collection menu": "Collection menu",
|
||||
"Comment options": "Comment options",
|
||||
"Enable viewer insights": "Enable viewer insights",
|
||||
"Enable embeds": "Enable embeds",
|
||||
"Document options": "Document options",
|
||||
"File": "File",
|
||||
"Group member options": "Group member options",
|
||||
"Group members": "Group members",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"New API key": "Nueva clave API",
|
||||
"Delete": "Eliminar",
|
||||
"Revoke": "Revocar",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke API key": "Revocar clave de la API",
|
||||
"Revoke token": "Revocar token",
|
||||
"Open collection": "Abrir colección",
|
||||
"New collection": "Nueva colección",
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Copiar ID",
|
||||
"Clear IndexedDB cache": "Borrar caché de IndexedDB",
|
||||
"IndexedDB cache cleared": "Caché de IndexedDB borrado",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "Borrar almacenamiento local",
|
||||
"Local storage cleared": "Almacenamiento local borrado",
|
||||
"Toggle debug logging": "Activar/Desactivar registro de depuración",
|
||||
"Debug logging enabled": "Registro de depuración activado",
|
||||
"Debug logging disabled": "Registro de depuración desactivado",
|
||||
@@ -108,8 +108,8 @@
|
||||
"Leave document": "Abandonar documento",
|
||||
"You have left the shared document": "Has abandonado el documento compartido",
|
||||
"Could not leave document": "No se pudo abandonar el documento",
|
||||
"Apply template": "Apply template",
|
||||
"Disconnect analytics": "Disconnect analytics",
|
||||
"Apply template": "Aplicar plantilla",
|
||||
"Disconnect analytics": "Desconectar analítica",
|
||||
"Home": "Inicio",
|
||||
"Drafts": "Borradores",
|
||||
"Search": "Buscar",
|
||||
@@ -133,7 +133,7 @@
|
||||
"Archive all notifications": "Archivar todas las notificaciones",
|
||||
"New App": "Nueva aplicación",
|
||||
"New Application": "Nueva solicitud",
|
||||
"This version of the document was deleted": "This version of the document was deleted",
|
||||
"This version of the document was deleted": "Esta versión del documento ha sido eliminada",
|
||||
"Link copied": "Enlace copiado",
|
||||
"Dark": "Oscuro",
|
||||
"Light": "Claro",
|
||||
@@ -142,7 +142,7 @@
|
||||
"Change theme": "Cambiar tema",
|
||||
"Change theme to": "Cambiar tema a",
|
||||
"Share link copied": "Enlace para compartir copiado",
|
||||
"Go to collection": "Go to collection",
|
||||
"Go to collection": "Ir a la colección",
|
||||
"Go to document": "Ir al documento",
|
||||
"Revoke link": "Revocar enlace",
|
||||
"Share link revoked": "Enlace para compartir revocado",
|
||||
@@ -182,7 +182,7 @@
|
||||
"Public document sharing": "Compartir documentos públicamente",
|
||||
"Allow documents within this collection to be shared publicly on the internet.": "Permitir que los documentos de esta colección sean compartidos públicamente en Internet.",
|
||||
"Commenting": "Comentando",
|
||||
"Allow commenting on documents within this collection.": "Allow commenting on documents within this collection.",
|
||||
"Allow commenting on documents within this collection.": "Permitir comentarios sobre documentos dentro de esta colección.",
|
||||
"Saving": "Guardando",
|
||||
"Save": "Guardar",
|
||||
"Creating": "Creando",
|
||||
@@ -211,8 +211,8 @@
|
||||
"Install now": "Instalar ahora",
|
||||
"Disconnect": "Desconectar",
|
||||
"Disconnecting": "Desconectando",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "¿Estás seguro de que quieres desconectar la integración <em>{{ service }}</em>?",
|
||||
"This will stop sending analytics events to the configured instance.": "Esto dejará de enviar eventos analíticos a la instancia configurada.",
|
||||
"Deleted Collection": "Colección Eliminada",
|
||||
"Untitled": "Sin título",
|
||||
"Unpin": "Desfijar",
|
||||
@@ -257,10 +257,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Lo sentimos, parte de la aplicación no pudo ser cargada. Esto puede deberse a que se actualizó desde que abriste la pestaña o debido a una solicitud de red fallida. Por favor intenta recargar la página.",
|
||||
"Reload": "Recargar",
|
||||
"Something Unexpected Happened": "Ha ocurrido algo inesperado",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "Se ha producido un error varias veces recientemente. Si continúa, por favor intenta limpiar la caché o probar con un navegador diferente.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Lo sentimos, se ha producido un error irrecuperable{{notified}}. Intenta recargar la página, pudo haber sido un error temporal.",
|
||||
"our engineers have been notified": "nuestros ingenieros han sido notificados",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Limpiar caché + recargar",
|
||||
"Show detail": "Mostrar detalle",
|
||||
"{{userName}} archived": "{{userName}} ha archivado",
|
||||
"{{userName}} restored": "{{userName}} ha restaurado",
|
||||
@@ -281,8 +281,8 @@
|
||||
"You will receive an email when it's complete.": "Recibirás un correo electrónico cuando esté completado.",
|
||||
"Include attachments": "Incluir archivos adjuntos",
|
||||
"Including uploaded images and files in the exported data": "Incluyendo imágenes y archivos subidos en los datos exportados",
|
||||
"{{count}} more user": "{{count}} more user",
|
||||
"{{count}} more user_plural": "{{count}} more users",
|
||||
"{{count}} more user": "{{count}} usuario más",
|
||||
"{{count}} more user_plural": "{{count}} usuarios más",
|
||||
"Filter": "Filtro",
|
||||
"No results": "No hay resultados",
|
||||
"{{authorName}} created <3></3>": "{{authorName}} ha creado <3></3>",
|
||||
@@ -336,7 +336,7 @@
|
||||
"Reaction picker": "Selector de reacción",
|
||||
"Could not load reactions": "No se pudo cargar las reacciones",
|
||||
"Reaction": "Reacción",
|
||||
"Revision deleted": "Revision deleted",
|
||||
"Revision deleted": "Revisión eliminada",
|
||||
"Current version": "Versión actual",
|
||||
"{{userName}} edited": "{{userName}} ha editado",
|
||||
"Results": "Resultados",
|
||||
@@ -354,7 +354,7 @@
|
||||
"Publish to internet": "Publicar en Internet",
|
||||
"Search engine indexing": "Indexación del motor de búsqueda",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Desactivar esta opción para desalentar que los motores de búsqueda indexen la página",
|
||||
"Show last modified": "Show last modified",
|
||||
"Show last modified": "Mostrar última modificación",
|
||||
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"Invite": "Invitar",
|
||||
@@ -442,7 +442,7 @@
|
||||
"New email": "Nuevo correo electrónico",
|
||||
"Email can't be empty": "El correo electrónico no puede estar vacío",
|
||||
"Your import completed": "Tu importación se completó",
|
||||
"Sorry, invalid embed link": "Sorry, invalid embed link",
|
||||
"Sorry, invalid embed link": "Enlace incrustado inválido",
|
||||
"Previous match": "Coincidencia anterior",
|
||||
"Next match": "Coincidencia siguiente",
|
||||
"Find and replace": "Buscar y reemplazar",
|
||||
@@ -479,7 +479,7 @@
|
||||
"Create a new child doc": "Crear un nuevo documento hijo",
|
||||
"Delete table": "Eliminar tabla",
|
||||
"Delete file": "Eliminar el archivo",
|
||||
"Width x Height": "Width x Height",
|
||||
"Width x Height": "Anchura x Altura",
|
||||
"Download file": "Descargar el archivo",
|
||||
"Replace file": "Reemplazar el archivo",
|
||||
"Delete image": "Eliminar imagen",
|
||||
@@ -525,8 +525,8 @@
|
||||
"Toggle header": "Mostrar/Ocultar cabecera",
|
||||
"Math inline (LaTeX)": "Fórmula matemática en línea (LaTeX)",
|
||||
"Math block (LaTeX)": "Bloque de matemáticas (LaTeX)",
|
||||
"Merge cells": "Merge cells",
|
||||
"Split cell": "Split cell",
|
||||
"Merge cells": "Combinar celdas",
|
||||
"Split cell": "Dividir la celda",
|
||||
"Tip": "Sugerencia",
|
||||
"Tip notice": "Aviso de sugerencia",
|
||||
"Warning": "Atención",
|
||||
@@ -540,7 +540,7 @@
|
||||
"Outdent": "Reducir sangría",
|
||||
"Video": "Video",
|
||||
"None": "Nada",
|
||||
"Delete embed": "Delete embed",
|
||||
"Delete embed": "Eliminar incrustado",
|
||||
"Could not import file": "No se pudo importar el archivo",
|
||||
"Unsubscribed from document": "Desuscrito del documento",
|
||||
"Unsubscribed from collection": "Dado de baja de la colección",
|
||||
@@ -556,7 +556,7 @@
|
||||
"Import": "Importar",
|
||||
"Install": "Instalar",
|
||||
"Integrations": "Integraciones",
|
||||
"API key": "API key",
|
||||
"API key": "Clave API",
|
||||
"Show path to document": "Mostrar ruta al documento",
|
||||
"Sort in sidebar": "Ordenar en barra lateral",
|
||||
"A-Z sort": "Ordenado A-Z",
|
||||
@@ -568,7 +568,7 @@
|
||||
"Enable viewer insights": "Habilitar estadísticas",
|
||||
"Enable embeds": "Habilitar embeds",
|
||||
"Document options": "Opciones del documento",
|
||||
"File": "File",
|
||||
"File": "Archivo",
|
||||
"Group member options": "Opciones de los miembros del grupo",
|
||||
"Group members": "Miembros del grupo",
|
||||
"Edit group": "Editar grupo",
|
||||
@@ -581,7 +581,7 @@
|
||||
"Save in workspace": "Guardar en el espacio de trabajo",
|
||||
"Revoke {{ appName }}": "Revocar {{ appName }}",
|
||||
"Revoking": "Revocando",
|
||||
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
|
||||
"Are you sure you want to revoke access?": "¿Seguro que quieres revocar el acceso?",
|
||||
"Delete app": "Eliminar aplicación",
|
||||
"Revision options": "Opciones de revisión",
|
||||
"Share options": "Opciones de compartir",
|
||||
@@ -605,7 +605,7 @@
|
||||
"mentioned you in": "te mencionó en",
|
||||
"left a comment on": "dejó un comentario en",
|
||||
"resolved a comment on": "resolvió un comentario en",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"reacted {{ emoji }} to your comment on": "reaccionó {{ emoji }} a tu comentario el",
|
||||
"shared": "compartido",
|
||||
"invited you to": "te invitó a",
|
||||
"Choose a date": "Elige una fecha",
|
||||
@@ -690,17 +690,17 @@
|
||||
"Created": "Creado",
|
||||
"Imported from {{ source }}": "Importado desde {{ source }}",
|
||||
"Stats": "Estadísticas",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} minute read": "{{ number }} minuto de lectura",
|
||||
"{{ number }} words": "{{ number }} palabra",
|
||||
"{{ number }} words_plural": "{{ number }} palabras",
|
||||
"{{ number }} characters": "{{ number }} carácter",
|
||||
"{{ number }} characters_plural": "{{ number }} caracteres",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "Ningún texto seleccionado",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"{{ number }} words selected": "{{ number }} palabra seleccionada",
|
||||
"{{ number }} words selected_plural": "{{ number }} palabras seleccionadas",
|
||||
"{{ number }} characters selected": "{{ number }} carácter seleccionado",
|
||||
"{{ number }} characters selected_plural": "{{ number }} caracteres seleccionados",
|
||||
"Contributors": "Colaboradores",
|
||||
"Creator": "Creador",
|
||||
"Last edited": "Última edición",
|
||||
@@ -716,7 +716,7 @@
|
||||
"Observing {{ userName }}": "Observando {{ userName }}",
|
||||
"Backlinks": "Enlaces de retroceso",
|
||||
"Close": "Cerrar",
|
||||
"This document is large which may affect performance": "This document is large which may affect performance",
|
||||
"This document is large which may affect performance": "Este documento es grande y puede afectar al rendimiento",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "¿Estás seguro de que quieres eliminar la plantilla <em>{{ documentTitle }}</em>?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "¿Estás seguro? Eliminar el documento <em>{{ documentTitle }}</em> eliminará todo su historial</em>.",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "¿Estás seguro? Eliminar el documento <em>{{ documentTitle }}</em> eliminará todo su historial y <em>{{ any }} documento anidado</em>.",
|
||||
@@ -792,8 +792,8 @@
|
||||
"Underline": "Subrayar",
|
||||
"Undo": "Deshacer",
|
||||
"Redo": "Rehacer",
|
||||
"Move block up": "Move block up",
|
||||
"Move block down": "Move block down",
|
||||
"Move block up": "Subir bloque",
|
||||
"Move block down": "Bajar bloque",
|
||||
"Lists": "Listas",
|
||||
"Toggle task list item": "Alternar elemento de lista de tareas",
|
||||
"Tab": "Tabulador",
|
||||
@@ -824,7 +824,7 @@
|
||||
"To continue, enter your workspace’s subdomain.": "Para continuar, introduce el subdominio de tu espacio de trabajo.",
|
||||
"subdomain": "subdominio",
|
||||
"Continue": "Continuar",
|
||||
"Sorry, the code you entered is invalid or has expired.": "Sorry, the code you entered is invalid or has expired.",
|
||||
"Sorry, the code you entered is invalid or has expired.": "Lo sentimos, el código introducido no es válido o ha caducado.",
|
||||
"The domain associated with your email address has not been allowed for this workspace.": "El dominio asociado con tu dirección de correo electrónico no está permitido para este espacio de trabajo.",
|
||||
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "No se pudo iniciar sesión. Por favor, ve a la URL personalizada de tu espacio de trabajo e intenta iniciar sesión de nuevo.<1></1>Si has sido invitado a un espacio de trabajo, encontrarás un enlace a este en el correo electrónico de invitación.",
|
||||
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Lo sentimos, no se puede crear una nueva cuenta con una dirección de correo electrónico personal de Gmail.<1></1>Por favor, utilice una cuenta de Google Workspaces en su lugar.",
|
||||
@@ -843,11 +843,11 @@
|
||||
"Sorry, an unknown error occurred.": "Lo sentimos, se ha producido un error desconocido.",
|
||||
"Choose a workspace": "Elegir un espacio de trabajo",
|
||||
"Choose an {{ appName }} workspace or login to continue connecting this app": "Choose an {{ appName }} workspace or login to continue connecting this app",
|
||||
"Create workspace": "Create workspace",
|
||||
"Create workspace": "Crear espacio de trabajo",
|
||||
"Setup your workspace by providing a name and details for admin login. You can change these later.": "Setup your workspace by providing a name and details for admin login. You can change these later.",
|
||||
"Workspace name": "Nombre del espacio de trabajo",
|
||||
"Admin name": "Admin name",
|
||||
"Admin email": "Admin email",
|
||||
"Admin name": "Nombre del administrador",
|
||||
"Admin email": "Correo del administrador",
|
||||
"Login": "Iniciar sesión",
|
||||
"Error": "Error",
|
||||
"Failed to load configuration.": "No se pudo cargar la configuración.",
|
||||
@@ -866,19 +866,19 @@
|
||||
"You signed in with {{ authProviderName }} last time.": "Iniciaste sesión con {{ authProviderName }} la última vez.",
|
||||
"Or": "O",
|
||||
"Already have an account? Go to <1>login</1>.": "¿Ya tienes una cuenta? Ve a <1>iniciar sesión</1>.",
|
||||
"An error occurred": "An error occurred",
|
||||
"An error occurred": "Ha ocurrido un error",
|
||||
"The OAuth client could not be found, please check the provided client ID": "The OAuth client could not be found, please check the provided client ID",
|
||||
"The OAuth client could not be loaded, please check the redirect URI is valid": "The OAuth client could not be loaded, please check the redirect URI is valid",
|
||||
"Required OAuth parameters are missing": "Required OAuth parameters are missing",
|
||||
"Authorize": " Ocurrió un error",
|
||||
"{{ appName }} wants to access {{ teamName }}": "",
|
||||
"By <em>{{ developerName }}</em>": "By <em>{{ developerName }}</em>",
|
||||
"By <em>{{ developerName }}</em>": "Por <em>{{ developerName }}</em>",
|
||||
"{{ appName }} will be able to access your account and perform the following actions": "{{ appName }} will be able to access your account and perform the following actions",
|
||||
"read": "leer",
|
||||
"write": "escribir",
|
||||
"read and write": "leer y escribir",
|
||||
"API keys": "Claves API",
|
||||
"attachments": "attachments",
|
||||
"attachments": "adjuntos",
|
||||
"collections": "colecciones",
|
||||
"comments": "comentarios",
|
||||
"documents": "documentos",
|
||||
@@ -916,21 +916,21 @@
|
||||
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "Las claves API se pueden usar para autenticarse con la API y controlar de forma programática los datos de tu espacio de trabajo. Para más detalles, consulta la <em>documentación para desarrolladores</em>.",
|
||||
"Application published": "Aplicación publicada",
|
||||
"Application updated": "Aplicación actualizada",
|
||||
"Client secret rotated": "Client secret rotated",
|
||||
"Rotate secret": "Rotate secret",
|
||||
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.",
|
||||
"Displayed to users when authorizing": "Displayed to users when authorizing",
|
||||
"Client secret rotated": "Secreto de cliente rotado",
|
||||
"Rotate secret": "Rotar secreto",
|
||||
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "Rotar el secreto del cliente invalidará el secreto actual. Asegúrate de actualizar cualquier aplicación que utilice estas credenciales.",
|
||||
"Displayed to users when authorizing": "Mostrado a los usuarios mientras se autorizan",
|
||||
"Developer information shown to users when authorizing": "Developer information shown to users when authorizing",
|
||||
"Developer name": "Developer name",
|
||||
"Developer URL": "Developer URL",
|
||||
"Developer name": "Nombre del desarrollador",
|
||||
"Developer URL": "URL de desarollo",
|
||||
"Allow users from other workspaces to authorize this app": "Allow users from other workspaces to authorize this app",
|
||||
"Credentials": "Credenciales",
|
||||
"OAuth client ID": "OAuth client ID",
|
||||
"OAuth client ID": "ID de cliente OAuth",
|
||||
"The public identifier for this app": "El identificador público de esta aplicación",
|
||||
"OAuth client secret": "OAuth client secret",
|
||||
"Store this value securely, do not expose it publicly": "Store this value securely, do not expose it publicly",
|
||||
"Where users are redirected after authorizing this app": "Where users are redirected after authorizing this app",
|
||||
"Authorization URL": "Authorization URL",
|
||||
"OAuth client secret": "Secreto de cliente OAuth",
|
||||
"Store this value securely, do not expose it publicly": "Guarda este valor de forma segura, no lo expongas públicamente",
|
||||
"Where users are redirected after authorizing this app": "Donde se redirige a los usuarios después de autorizar esta aplicación",
|
||||
"Authorization URL": "URL de autorización",
|
||||
"Where users are redirected to authorize this app": "Where users are redirected to authorize this app",
|
||||
"Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>.": "Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>.",
|
||||
"by {{ name }}": "por {{ name }}",
|
||||
@@ -984,7 +984,7 @@
|
||||
"No people matching your search": "No hay personas que coincidan con tu búsqueda",
|
||||
"No people left to add": "No quedan personas para agregar",
|
||||
"Date created": "Fecha de creación",
|
||||
"Crop Image": "Crop Image",
|
||||
"Crop Image": "Recortar imagen",
|
||||
"Crop image": "Recortar imagen",
|
||||
"How does this work?": "¿Cómo funciona esto?",
|
||||
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Puedes importar un archivo zip que se exportó previamente desde la opción JSON en otra instancia. En {{ appName }}, abre <em>Exportar</em> en la barra lateral de configuración y haz clic en <em>Exportar datos</em>.",
|
||||
@@ -998,14 +998,14 @@
|
||||
"{{ count }} document imported_plural": "{{ count }} documentos importados",
|
||||
"You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Puedes importar un archivo zip que se exportó previamente desde la opción Markdown en otra instancia de Outline. En Outline, abre <em>Exportar</em> en la barra lateral de configuración y haz clic en <em>Exportar datos</em>.",
|
||||
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Arrastra y suelta el archivo zip de la opción de exportación Markdown de {{appName}}, o haz clic para cargarlo",
|
||||
"Configure": "Configure",
|
||||
"Configure": "Configurar",
|
||||
"Connect": "Conectar",
|
||||
"Last active": "Última vez activo",
|
||||
"Role": "Rol",
|
||||
"Guest": "Invitado",
|
||||
"Never used": "Never used",
|
||||
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
|
||||
"Title": "Title",
|
||||
"Never used": "Nunca usado",
|
||||
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "¿Estás seguro de que quieres eliminar la aplicación {{ appName }} ? Esto no se puede deshacer.",
|
||||
"Title": "Título",
|
||||
"Shared by": "Compartido por",
|
||||
"Date shared": "Fecha en que se compartió",
|
||||
"Last accessed": "Ultimo acceso",
|
||||
@@ -1026,8 +1026,8 @@
|
||||
"Display": "Aspecto",
|
||||
"The logo is displayed at the top left of the application.": "El logo se muestra en la parte superior izquierda de la aplicación.",
|
||||
"The workspace name, usually the same as your company name.": "Nombre del espacio de trabajo, usualmente igual al nombre de tu empresa.",
|
||||
"Description": "Description",
|
||||
"A short description of your workspace.": "A short description of your workspace.",
|
||||
"Description": "Descripción",
|
||||
"A short description of your workspace.": "Una breve descripción de tu espacio de trabajo.",
|
||||
"Theme": "Tema",
|
||||
"Customize the interface look and feel.": "Personaliza la apariencia del sitio.",
|
||||
"Reset theme": "Restablecer tema",
|
||||
@@ -1063,7 +1063,7 @@
|
||||
"Enterprise": "Enterprise",
|
||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Transfiere rápidamente tus documentos, páginas y archivos existentes desde otras herramientas y servicios a {{appName}}. También puedes arrastrar y soltar cualquier archivo HTML, Markdown y de texto directamente a Colecciones en la aplicación.",
|
||||
"Recent imports": "Importaciones recientes",
|
||||
"Configure a variety of integrations with third-party services.": "Configure a variety of integrations with third-party services.",
|
||||
"Configure a variety of integrations with third-party services.": "Configurar una variedad de integraciones con servicios de terceros.",
|
||||
"Could not load members": "No se han podido cargar miembros",
|
||||
"Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Todos los que han iniciado sesión en {{appName}} aparecen aquí. Es posible que haya otros usuarios que tengan acceso a través de {{signinMethods}} pero que aún no hayan iniciado sesión.",
|
||||
"Receive a notification whenever a new document is published": "Recibir notificaciones cuando se publique un nuevo documento",
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"Mentioned": "Mencionado",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Recibir una notificación cuando alguien te mencione en un documento o comentario",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Recibir una notificación cuando se resuelva un hilo de comentarios en el que participó",
|
||||
"Reaction added": "Reaction added",
|
||||
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",
|
||||
"Reaction added": "Reacción añadida",
|
||||
"Receive a notification when someone reacts to your comment": "Recibir una notificación cuando alguien reaccione a tu comentario",
|
||||
"Collection created": "Colección creada",
|
||||
"Receive a notification whenever a new collection is created": "Recibir notificaciones cuando se cree una nueva colección",
|
||||
"Invite accepted": "Invitación aceptada",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"New API key": "Nouvelle clé d'API",
|
||||
"Delete": "Supprimer",
|
||||
"Revoke": "Supprimer",
|
||||
"Revoke": "Révoquer",
|
||||
"Revoke API key": "Révoquer la clé API",
|
||||
"Revoke token": "Supprimer le jeton",
|
||||
"Revoke token": "Révoquer le jeton",
|
||||
"Open collection": "Ouvrir la collection",
|
||||
"New collection": "Nouvelle collection",
|
||||
"Create a collection": "Créer une collection",
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Copier l'identifiant",
|
||||
"Clear IndexedDB cache": "Vider le cache IndexedDB",
|
||||
"IndexedDB cache cleared": "Cache IndexedDB vidé",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "Nettoyer le stockage local",
|
||||
"Local storage cleared": "Stockage local vidé",
|
||||
"Toggle debug logging": "Activer la journalisation de débogage",
|
||||
"Debug logging enabled": "Journal de débogage activé",
|
||||
"Debug logging disabled": "Journal de débogage désactivé",
|
||||
@@ -109,7 +109,7 @@
|
||||
"You have left the shared document": "Vous avez quitté le document partagé",
|
||||
"Could not leave document": "Impossible de quitter le document",
|
||||
"Apply template": "Appliquer le modèle",
|
||||
"Disconnect analytics": "Disconnect analytics",
|
||||
"Disconnect analytics": "Déconnecter les statistiques",
|
||||
"Home": "Accueil",
|
||||
"Drafts": "Brouillons",
|
||||
"Search": "Recherche",
|
||||
@@ -142,7 +142,7 @@
|
||||
"Change theme": "Changer le thème",
|
||||
"Change theme to": "Changer vers le thème",
|
||||
"Share link copied": "Lien de partage copié",
|
||||
"Go to collection": "Go to collection",
|
||||
"Go to collection": "Aller à la collection",
|
||||
"Go to document": "Accéder au document",
|
||||
"Revoke link": "Révoquer le lien",
|
||||
"Share link revoked": "Lien de partage révoqué",
|
||||
@@ -211,8 +211,8 @@
|
||||
"Install now": "Installer maintenant",
|
||||
"Disconnect": "Se déconnecter",
|
||||
"Disconnecting": "Déconnexion en cours",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Êtes-vous sûr de vouloir déconnecter l'intégration <em>{{ service }}</em>?",
|
||||
"This will stop sending analytics events to the configured instance.": "Ceci arrêtera d'envoyer des événements de statistiques à l'instance configurée.",
|
||||
"Deleted Collection": "Collection supprimée",
|
||||
"Untitled": "Sans titre",
|
||||
"Unpin": "Désépingler",
|
||||
@@ -257,10 +257,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Désolé, le chargement d'une partie de l'application a échoué. C'est probablement dû au fait qu'elle a été mise à jour depuis que vous avez ouvert l'onglet, ou en raison d'une requête réseau échouée. Essayez d'actualiser la page.",
|
||||
"Reload": "Actualiser",
|
||||
"Something Unexpected Happened": "Quelque chose d'inattendu s'est produit",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "Une erreur s'est produite plusieurs fois récemment. Si elle persiste, essayez de vider le cache ou d'utiliser un autre navigateur.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Désolé, une erreur irrécupérable s'est produite{{notified}}. Essayez d'actualiser la page, il s'agit peut-être d'un problème temporaire.",
|
||||
"our engineers have been notified": "nos ingénieurs ont été notifiés",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Vider le cache et recharger",
|
||||
"Show detail": "Afficher détail",
|
||||
"{{userName}} archived": "{{userName}} a archivé",
|
||||
"{{userName}} restored": "{{userName}} a restauré",
|
||||
@@ -356,7 +356,7 @@
|
||||
"Disable this setting to discourage search engines from indexing the page": "Désactivez ce paramètre pour empêcher les moteurs de recherche d'indexer la page.",
|
||||
"Show last modified": "Afficher les dernières modifications",
|
||||
"Display the last modified timestamp on the shared page": "Afficher le dernier horodatage modifié sur la page partagée",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "Tous les documents de cette collection seront partagés sur le web, y compris les nouveaux documents ajoutés plus tard",
|
||||
"Invite": "Inviter",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} été ajouté à la collection",
|
||||
"{{ count }} people added to the collection": "{{ count }} personnes ajoutées à la collection",
|
||||
@@ -389,8 +389,8 @@
|
||||
"Active <1></1> ago": "Actif il y a <1></1>",
|
||||
"Never signed in": "Jamais connecté",
|
||||
"Leave": "Quitter",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "N'importe qui avec le lien peut y accéder car la collection, <2>{sharedParent.sourceTitle}</2>, est partagée",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "N'importe qui avec le lien peut y accéder car le document parent, <2>{sharedParent.sourceTitle}</2>, est partagé",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Les documents imbriqués ne sont pas partagés sur le web. Activer/désactiver le partage pour activer l'accès, ce sera le comportement par défaut dans le futur",
|
||||
"{{ userName }} was added to the document": "{{ userName }} a été ajouté au document",
|
||||
"{{ count }} people added to the document": "{{ count }} personnes ajoutées au document",
|
||||
@@ -442,7 +442,7 @@
|
||||
"New email": "Nouveau courriel",
|
||||
"Email can't be empty": "L'adresse électronique ne peut pas être vide",
|
||||
"Your import completed": "Votre importation est terminée",
|
||||
"Sorry, invalid embed link": "Sorry, invalid embed link",
|
||||
"Sorry, invalid embed link": "Désolé, lien d'intégration invalide",
|
||||
"Previous match": "Occurence précédente",
|
||||
"Next match": "Prochaine occurence",
|
||||
"Find and replace": "Rechercher et remplacer",
|
||||
@@ -540,7 +540,7 @@
|
||||
"Outdent": "Désindenter",
|
||||
"Video": "Vidéo",
|
||||
"None": "Abc",
|
||||
"Delete embed": "Delete embed",
|
||||
"Delete embed": "Supprimer l'intégration",
|
||||
"Could not import file": "Le fichier n'a pas pu être importé",
|
||||
"Unsubscribed from document": "Se désabonner du document",
|
||||
"Unsubscribed from collection": "Désabonné de la collection",
|
||||
@@ -605,7 +605,7 @@
|
||||
"mentioned you in": "vous a mentionné dans",
|
||||
"left a comment on": "a laissé un commentaire sur",
|
||||
"resolved a comment on": "a résolu un commentaire sur",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"reacted {{ emoji }} to your comment on": "a réagi {{ emoji }} à votre commentaire sur",
|
||||
"shared": "partagé",
|
||||
"invited you to": "vous a invité à",
|
||||
"Choose a date": "Choisissez une date",
|
||||
@@ -690,17 +690,17 @@
|
||||
"Created": "Créé",
|
||||
"Imported from {{ source }}": "Importé depuis {{ source }}",
|
||||
"Stats": "Statistiques",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} minute read": "Durée de lecture de {{ number }} minutes",
|
||||
"{{ number }} words": "{{ number }} mot",
|
||||
"{{ number }} words_plural": "{{ number }} mots",
|
||||
"{{ number }} characters": "{{ number }} caractère",
|
||||
"{{ number }} characters_plural": "{{ number }} caractères",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "Aucun texte sélectionné",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"{{ number }} words selected": "{{ number }} mot sélectionné",
|
||||
"{{ number }} words selected_plural": "{{ number }} mots sélectionnés",
|
||||
"{{ number }} characters selected": "{{ number }} caractère sélectionné",
|
||||
"{{ number }} characters selected_plural": "{{ number }} caractères sélectionnés",
|
||||
"Contributors": "Contributeurs",
|
||||
"Creator": "Créateur",
|
||||
"Last edited": "Dernière modification",
|
||||
@@ -858,7 +858,7 @@
|
||||
"Choose workspace": "Choisir un espace de travail",
|
||||
"This login method requires choosing your workspace to continue": "Cette méthode de connexion nécessite de choisir votre espace de travail pour continuer",
|
||||
"Check your email": "Vérifiez votre messagerie",
|
||||
"Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>": "Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>",
|
||||
"Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>": "Entrez le code de connexion envoyé à l'e-mail <em>{{ emailLinkSentTo }}</em>",
|
||||
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.": "Si cet utilisateur existe, un lien de connexion automatique lui a été envoyé sur <em>{{ emailLinkSentTo }}</em>.",
|
||||
"Back to login": "Retour à la page d'identification",
|
||||
"Get started by choosing a sign-in method for your new workspace below…": "Commencez par choisir ci-dessous une méthode de connexion pour votre nouvel espace de travail…",
|
||||
@@ -1005,7 +1005,7 @@
|
||||
"Guest": "Invité",
|
||||
"Never used": "Jamais utilisé",
|
||||
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Êtes-vous sûr de vouloir supprimer l'application {{ appName }} ? Cette action est irréversible.",
|
||||
"Title": "Title",
|
||||
"Title": "Titre",
|
||||
"Shared by": "Partagé par",
|
||||
"Date shared": "Date du partage",
|
||||
"Last accessed": "Dernier accès",
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"Mentioned": "Mentionné",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Recevez une notification lorsque quelqu'un vous mentionne dans un document ou commentaire",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Recevoir une notification lorsqu'un sujet de commentaire auquel vous avez participé est résolu",
|
||||
"Reaction added": "Reaction added",
|
||||
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",
|
||||
"Reaction added": "Réaction ajoutée",
|
||||
"Receive a notification when someone reacts to your comment": "Recevoir une notification lorsque quelqu'un réagit à votre commentaire",
|
||||
"Collection created": "Collection créée",
|
||||
"Receive a notification whenever a new collection is created": "Recevez une notification à chaque fois qu'une nouvelle collection est créée",
|
||||
"Invite accepted": "Invitation acceptée",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"New API key": "",
|
||||
"Delete": "מחיקה",
|
||||
"Revoke": "Revoke",
|
||||
"Revoke": "בטל",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke token": "Revoke token",
|
||||
"Revoke token": "בטל אסימון",
|
||||
"Open collection": "עריכת אוסף",
|
||||
"New collection": "אוסף חדש",
|
||||
"Create a collection": "יצירת אוסף",
|
||||
@@ -20,7 +20,7 @@
|
||||
"Unsubscribe": "ביטול מנוי",
|
||||
"Unsubscribed from document notifications": "הוסר מהתראות המסמך",
|
||||
"Archive": "ארכיון",
|
||||
"Archive collection": "Archive collection",
|
||||
"Archive collection": "חיפוש בתוך האוסף",
|
||||
"Collection archived": "Collection archived",
|
||||
"Archiving": "Archiving",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
|
||||
@@ -117,7 +117,7 @@
|
||||
"Settings": "הגדרות",
|
||||
"Profile": "פרופיל",
|
||||
"Templates": "תבניות",
|
||||
"Notification settings": "Notification settings",
|
||||
"Notification settings": "הגדרות התראות",
|
||||
"Notifications": "התראות",
|
||||
"Preferences": "העדפות",
|
||||
"Documentation": "תיעוד",
|
||||
@@ -162,15 +162,15 @@
|
||||
"Debug": "Debug",
|
||||
"Document": "Document",
|
||||
"Documents": "Documents",
|
||||
"Recently viewed": "Recently viewed",
|
||||
"Recently viewed": "נצפה לאחרונה",
|
||||
"Revision": "Revision",
|
||||
"Navigation": "Navigation",
|
||||
"Notification": "Notification",
|
||||
"Groups": "Groups",
|
||||
"People": "People",
|
||||
"Navigation": "סרגל ניווט",
|
||||
"Notification": "התראה",
|
||||
"Groups": "קבוצות",
|
||||
"People": "משתתפים",
|
||||
"Share": "שיתוף",
|
||||
"Workspace": "Workspace",
|
||||
"Recent searches": "Recent searches",
|
||||
"Workspace": "סביבות עבודה",
|
||||
"Recent searches": "חיפושים אחרונים",
|
||||
"currently editing": "currently editing",
|
||||
"currently viewing": "currently viewing",
|
||||
"previously edited": "previously edited",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"New API key": "Új API-kulcs",
|
||||
"Delete": "Törlés",
|
||||
"Revoke": "Visszavonás",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke API key": "API kulcs visszavonása",
|
||||
"Revoke token": "Token visszavonása",
|
||||
"Open collection": "Gyűjtemény megnyitása",
|
||||
"New collection": "Új gyűjtemény",
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Azonosító másolása",
|
||||
"Clear IndexedDB cache": "IndexedDB gyorsítótárának törlése",
|
||||
"IndexedDB cache cleared": "Az IndexedDB gyorsítótára törölve",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "Lokális tárhely törlése",
|
||||
"Local storage cleared": "Lokális tárhely törölve",
|
||||
"Toggle debug logging": "Hibakeresési naplózás be- vagy kikapcsolása",
|
||||
"Debug logging enabled": "Hibakeresési naplózás engedélyezve",
|
||||
"Debug logging disabled": "Hibakeresési naplózás letiltva",
|
||||
@@ -55,7 +55,7 @@
|
||||
"Publish document": "Dokumentum közzététele",
|
||||
"Unpublish": "Közzététel visszavonása",
|
||||
"Unpublished {{ documentName }}": "A(z) {{ documentName }} közzététele visszavonva",
|
||||
"Subscription inherited from collection": "Subscription inherited from collection",
|
||||
"Subscription inherited from collection": "A feliratkozás a gyűjteményből öröklődött",
|
||||
"Share this document": "Dokumentum megosztása",
|
||||
"HTML": "HTML",
|
||||
"PDF": "PDF",
|
||||
@@ -88,28 +88,28 @@
|
||||
"Create template": "Sablon létrehozása",
|
||||
"Open random document": "Véletlenszerű dokumentum megnyítása",
|
||||
"Search documents for \"{{searchQuery}}\"": "\"{{searchQuery}}\" keresése a dokumentumokban",
|
||||
"Move to workspace": "Move to workspace",
|
||||
"Move to workspace": "Áthelyezés a munkaterületre",
|
||||
"Move": "Áthelyezés",
|
||||
"Move to collection": "Mozgatás a kollekcióba",
|
||||
"Move {{ documentType }}": "{{ documentType }} áthelyezése",
|
||||
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
|
||||
"Are you sure you want to archive this document?": "Biztos vagy benne, hogy archiválni szeretnéd ezt a dokumentumot?",
|
||||
"Document archived": "Dokumentum archiválva",
|
||||
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
|
||||
"Archiving this document will remove it from the collection and search results.": "A dokumentum archiválása el fogja távolítani a gyűjteményből és a keresési eredményekből.",
|
||||
"{{ documentName }} restored": "{{ documentName}} visszaállította",
|
||||
"Choose a collection": "Gyűjtemény kiválasztása",
|
||||
"Delete {{ documentName }}": "{{ documentName }} törlése",
|
||||
"Permanently delete": "Végleges törlés",
|
||||
"Permanently delete {{ documentName }}": "{{ documentName }} végleges törlése",
|
||||
"Empty trash": "Lomtár ürítése",
|
||||
"Permanently delete documents in trash": "Permanently delete documents in trash",
|
||||
"Permanently delete documents in trash": "A kukában lévő dokumentumok végleges törlése",
|
||||
"Comments": "Hozzászólások",
|
||||
"History": "Előzmények",
|
||||
"Insights": "Részletek",
|
||||
"Leave document": "Dokumentum mentése",
|
||||
"You have left the shared document": "You have left the shared document",
|
||||
"You have left the shared document": "Elhagytad a megosztott dokumentumot",
|
||||
"Could not leave document": "Nem lehet menteni a dokumentumot",
|
||||
"Apply template": "Apply template",
|
||||
"Disconnect analytics": "Disconnect analytics",
|
||||
"Apply template": "Sablon használata",
|
||||
"Disconnect analytics": "Analitika leválasztása",
|
||||
"Home": "Kezdőlap",
|
||||
"Drafts": "Piszkozatok",
|
||||
"Search": "Keresés",
|
||||
@@ -131,9 +131,9 @@
|
||||
"Log out": "Kijelentkezés",
|
||||
"Mark notifications as read": "Értesítések olvasottnak jelölése",
|
||||
"Archive all notifications": "Összes értesítés archiválása",
|
||||
"New App": "New App",
|
||||
"New Application": "New Application",
|
||||
"This version of the document was deleted": "This version of the document was deleted",
|
||||
"New App": "Új alkalmazás",
|
||||
"New Application": "Új alkalmazás",
|
||||
"This version of the document was deleted": "A dokumetnum ezen verziója törölve lett",
|
||||
"Link copied": "Hivatkozás másolva",
|
||||
"Dark": "Sötét",
|
||||
"Light": "Világos",
|
||||
@@ -142,7 +142,7 @@
|
||||
"Change theme": "Téma váltása",
|
||||
"Change theme to": "Téma váltása erre:",
|
||||
"Share link copied": "Megosztási hivatkozás másolva",
|
||||
"Go to collection": "Go to collection",
|
||||
"Go to collection": "Irány a gyűjteményhez",
|
||||
"Go to document": "Tovább a dokumentumhoz",
|
||||
"Revoke link": "Hivatkozás visszavonása",
|
||||
"Share link revoked": "Hivatkozás megosztás visszavonva",
|
||||
@@ -150,7 +150,7 @@
|
||||
"Select a workspace": "Válasszon munkaterületet",
|
||||
"New workspace": "Új munkaterület",
|
||||
"Create a workspace": "Munkaterület létrehozása",
|
||||
"Login to workspace": "Login to workspace",
|
||||
"Login to workspace": "Bejelentkezés a munkaterületbe",
|
||||
"Invite people": "Mások meghívása",
|
||||
"Invite to workspace": "Meghívása a munkaterületbe",
|
||||
"Promote to {{ role }}": "Előléptetés {{ role }} szerepbe",
|
||||
@@ -178,11 +178,11 @@
|
||||
"Viewers": "Megtekintők",
|
||||
"Collections are used to group documents and choose permissions": "A gyűjtemények dokumentuok csoportosítására és jogosultságok beállítására szolgálnak",
|
||||
"Name": "Név",
|
||||
"The default access for workspace members, you can share with more users or groups later.": "The default access for workspace members, you can share with more users or groups later.",
|
||||
"The default access for workspace members, you can share with more users or groups later.": "Az alapértelmezett hozzáférés a munkaterület tagjainak, a későbbiekben további felhasználókkal és csoportokkal is megoszthatod.",
|
||||
"Public document sharing": "Nyilvános dokumentum megosztás",
|
||||
"Allow documents within this collection to be shared publicly on the internet.": "Gyűjteményhez tartozó dokumentumok inteneten való publikus megosztásának engedélyezése.",
|
||||
"Commenting": "Commenting",
|
||||
"Allow commenting on documents within this collection.": "Allow commenting on documents within this collection.",
|
||||
"Commenting": "Hozzászólás",
|
||||
"Allow commenting on documents within this collection.": "Hozzászólások engedélyezése ezen gyűjteményhez tartozó dokumentumokon",
|
||||
"Saving": "Mentés",
|
||||
"Save": "Mentés",
|
||||
"Creating": "Létrehozás",
|
||||
@@ -197,31 +197,31 @@
|
||||
"Are you sure you want to permanently delete this entire comment thread?": "Biztos benne, hogy véglegesen törölni szeretné az egész hozzászólás szálat?",
|
||||
"Are you sure you want to permanently delete this comment?": "Biztos benne, hogy véglegesen törölni szeretné a hozzászólást?",
|
||||
"Confirm": "Megerősítés",
|
||||
"manage access": "manage access",
|
||||
"view and edit access": "view and edit access",
|
||||
"view only access": "view only access",
|
||||
"manage access": "hozzáférés kezelése",
|
||||
"view and edit access": "hozzáférés megtekintése és szerkesztése",
|
||||
"view only access": "csak megtekinthető",
|
||||
"no access": "nincs hozzáférés",
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "Nincs jogosultságod, hogy {{ documentName }} nevű dokumentumot a {{ collectionName }}be helyezd",
|
||||
"Move document": "Dokumentum áthelyezése",
|
||||
"Moving": "Áthelyezés",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "A {{ title }} nevű dokumentum áthelyezése a {{ newCollectionName }} gyűjteménybe megváltoztatja a munkaterület tagjainak hozzáférését: {{ prevPermission }} -> {{ newPermission }}",
|
||||
"Submenu": "Almenü",
|
||||
"Collections could not be loaded, please reload the app": "A gyűjtemények nem tölthetők be, kérem töltse újra a programot",
|
||||
"Start view": "Kezdje el",
|
||||
"Install now": "Telepítés most",
|
||||
"Disconnect": "Disconnect",
|
||||
"Disconnecting": "Disconnecting",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
"Disconnect": "Megszakít",
|
||||
"Disconnecting": "Kapcsolat megszakítása",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Biztos vagy benne, hogy le akarod választani a {{ service }} integrációt?",
|
||||
"This will stop sending analytics events to the configured instance.": "Ez megállítja az analitikai események küldését a beállított fogadónak.",
|
||||
"Deleted Collection": "Törölt gyűjtemény",
|
||||
"Untitled": "Névtelen",
|
||||
"Unpin": "Feloldás",
|
||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"{{ minutes }}m read": "{{ minutes }} perccel ezelőtt olvasta",
|
||||
"Select a location to copy": "Válassz ki egy helyet a másoláshoz",
|
||||
"Document copied": "Dokumentum átmásolva",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Couldn’t copy the document, try again?": "A dokumentum másolása sikertelen volt. Újrapróbálod?",
|
||||
"Include nested documents": "Beágyazott dokumentumokkal együtt",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Copy to <em>{{ location }}</em>": "Másolás a {{ location }} helyre",
|
||||
"Search collections & documents": "Keresés a gyűteményekben és dokumentumokban",
|
||||
"No results found": "Nincs találat",
|
||||
"New": "Új",
|
||||
@@ -257,22 +257,22 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Elnézést, az alkalmazás egy részének betöltése sikertelen. Lehet, hogy frissült, mióta megnyitotta a fület, vagy hálózati kapcsolati hiba történt. Kérem próbálja újratölteni.",
|
||||
"Reload": "Újratöltés",
|
||||
"Something Unexpected Happened": "Valami váratlan történt",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "Nemrég több hiba is történt. Amennyiben ez nem változik, próbáld meg törölni a cache-t vagy használj egy másik böngészőt.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Elnézést, egy helyrehozhatatlan hiba történt {{notified}}. Kérem próbálja újratölteni a lapot, lehet, hogy csak átmeneti a probléma.",
|
||||
"our engineers have been notified": "a mérnökeinket éretsítettük",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Cache törlése és újratöltés",
|
||||
"Show detail": "Részletek mutatása",
|
||||
"{{userName}} archived": "{{userName}} archiválta",
|
||||
"{{userName}} restored": "{{userName}} visszaállította",
|
||||
"{{userName}} deleted": "{{userName}} törölve",
|
||||
"{{userName}} added {{addedUserName}}": "{{userName}} added {{addedUserName}}",
|
||||
"{{userName}} removed {{removedUserName}}": "{{userName}} removed {{removedUserName}}",
|
||||
"{{userName}} added {{addedUserName}}": "{{addedUserName}} hozzáadva {{userName}} által",
|
||||
"{{userName}} removed {{removedUserName}}": "{{removedUserName}} eltávolítva {{userName}} által",
|
||||
"{{userName}} moved from trash": "{{userName}} lomtárból áthelyezve",
|
||||
"{{userName}} published": "{{userName}} publikálta",
|
||||
"{{userName}} unpublished": "{{userName}} visszavonta",
|
||||
"{{userName}} moved": "{{userName}} áthelyezte",
|
||||
"Export started": "Az exportálás elindult",
|
||||
"Your file will be available in {{ location }} soon": "Your file will be available in {{ location }} soon",
|
||||
"Your file will be available in {{ location }} soon": "A fájl hamarosan elérhető lesz a {{ location }} útvonalon",
|
||||
"View": "Megtekintés",
|
||||
"A ZIP file containing the images, and documents in the Markdown format.": "Egy ZIP állomány, melyben a képek és Markdown formátumú dokumenumok találhatóak.",
|
||||
"A ZIP file containing the images, and documents as HTML files.": "Egy ZIP állomány, melyben a képek és HTML formátumú dokumenumok találhatóak.",
|
||||
@@ -321,63 +321,63 @@
|
||||
"Unknown": "Ismeretlen",
|
||||
"Mark all as read": "Összes megjelölése olvasottként",
|
||||
"You're all caught up": "Minden hozzászólást elolvasott",
|
||||
"Icon": "Icon",
|
||||
"My App": "My App",
|
||||
"Tagline": "Tagline",
|
||||
"A short description": "A short description",
|
||||
"Callback URLs": "Callback URLs",
|
||||
"Icon": "Ikon",
|
||||
"My App": "Az én alkalmazásom",
|
||||
"Tagline": "Jelmondat",
|
||||
"A short description": "Rövid leírás",
|
||||
"Callback URLs": "Visszahívási URL",
|
||||
"Published": "Közzétéve",
|
||||
"Allow this app to be installed by other workspaces": "Allow this app to be installed by other workspaces",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Allow this app to be installed by other workspaces": "Az alkalmazás letöltésének engedélyezése a munkaterület tagjainak",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} a következőt reagálta: {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} és {{ secondUsername }} a következőt reagálták: {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} és {{ count }} másik személy a következőt reagálták: {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} és {{ count }} másik személy a következőt reagálták: {{ emoji }}",
|
||||
"Add reaction": "Reagálás hozzáadása",
|
||||
"Reaction picker": "Reagálásválasztó",
|
||||
"Could not load reactions": "Nem sikerült betölteni a reagálásokat",
|
||||
"Reaction": "Reagálás",
|
||||
"Revision deleted": "Revision deleted",
|
||||
"Revision deleted": "A módosítás törölve lett",
|
||||
"Current version": "Jelenlegi verzió",
|
||||
"{{userName}} edited": "{{userName}} módosította",
|
||||
"Results": "Eredmények",
|
||||
"No results for {{query}}": "Nincs találat: {{query}}",
|
||||
"Manage": "Kezelés",
|
||||
"All members": "Összes tag",
|
||||
"Everyone in the workspace": "Everyone in the workspace",
|
||||
"Everyone in the workspace": "Mindenki a munkaterületen",
|
||||
"{{ count }} member": "{{ count }} tag",
|
||||
"{{ count }} member_plural": "{{ count }} tag",
|
||||
"Only lowercase letters, digits and dashes allowed": "Csak kisbetűk, számok és kötőjelek használhatók",
|
||||
"Sorry, this link has already been used": "Elnézést, ez a hivatkozás már használatban van",
|
||||
"Public link copied to clipboard": "Public link copied to clipboard",
|
||||
"Public link copied to clipboard": "A nyilvános link a vágólapra lett másolva",
|
||||
"Web": "Web",
|
||||
"Allow anyone with the link to access": "Allow anyone with the link to access",
|
||||
"Allow anyone with the link to access": "A link birtokában bárki hozzáférhet",
|
||||
"Publish to internet": "Publikálás interneten",
|
||||
"Search engine indexing": "Keresőmotor indexelése",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Kapcsold ki ezt a beállítást, hogy megakadályozzd a keresőmotorokat az oldal indexelésében.",
|
||||
"Show last modified": "Show last modified",
|
||||
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"Display the last modified timestamp on the shared page": "Az utolsó módosítás idejének megjelenítése a megosztott oldalon",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "A gyűjteményben található összes dokumentum meg lesz osztva a weben, a jövőben hozzáadott dokumentumokkal együtt",
|
||||
"Invite": "Meghívás",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} hozzáadva a gyűjteményhez",
|
||||
"{{ count }} people added to the collection": "{{ count }} személy hozzáadva a gyűjteményhez",
|
||||
"{{ count }} people added to the collection_plural": "{{ count }} személy hozzáadva a gyűjteményhez",
|
||||
"{{ count }} people and {{ count2 }} groups added to the collection": "{{ count }} személy és {{ count2 }} csoport hozzáadva a gyűjteményhez",
|
||||
"{{ count }} people and {{ count2 }} groups added to the collection_plural": "{{ count }} személy és {{ count2 }} csoport hozzáadva a gyűjteményhez",
|
||||
"Switch to dark": "Switch to dark",
|
||||
"Switch to light": "Switch to light",
|
||||
"Switch to dark": "Váltás sötét módra",
|
||||
"Switch to light": "Váltás világos módra",
|
||||
"Add": "Hozzáadás",
|
||||
"Add or invite": "Add or invite",
|
||||
"Add or invite": "Hozzáadás vagy meghívás",
|
||||
"Viewer": "Megtekintő",
|
||||
"Editor": "Szerkesztő",
|
||||
"Suggestions for invitation": "Suggestions for invitation",
|
||||
"Suggestions for invitation": "Javaslatok meghíváshoz",
|
||||
"No matches": "Nincs találat",
|
||||
"Can view": "Megtekintheti",
|
||||
"Everyone in the collection": "Everyone in the collection",
|
||||
"You have full access": "You have full access",
|
||||
"Everyone in the collection": "Mindenki a gyűjteményben",
|
||||
"You have full access": "Teljes hozzáféréssel rendelkezel",
|
||||
"Created the document": "Dokumentum létrehozása",
|
||||
"Other people": "Other people",
|
||||
"Other workspace members may have access": "Other workspace members may have access",
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to": "This document may be shared with more workspace members through a parent document or collection you do not have access to",
|
||||
"Other people": "Más emberek",
|
||||
"Other workspace members may have access": "A munkaterület más tagjainak is hozzáférése lehet",
|
||||
"This document may be shared with more workspace members through a parent document or collection you do not have access to": "Ez a dokumentum megosztható a munkaterület más tagjaival is egy szülődokumentumon vagy szülőgyűjteményen keresztül",
|
||||
"Access inherited from collection": "Access inherited from collection",
|
||||
"{{ userName }} was removed from the document": "{{ userName }} eltávolítva a dokumentumból",
|
||||
"Could not remove user": "Nem lehet a felhasználót eltávolítani",
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "ID 복사",
|
||||
"Clear IndexedDB cache": "IndexedDB 캐시 삭제",
|
||||
"IndexedDB cache cleared": "IndexedDB 캐시 삭제됨",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "로컬 저장소 지우기",
|
||||
"Local storage cleared": "로컬 저장소 지워짐",
|
||||
"Toggle debug logging": "디버그 로그 표시",
|
||||
"Debug logging enabled": "디버그 로깅 활성화",
|
||||
"Debug logging disabled": "디버그 로깅 비활성화",
|
||||
@@ -257,10 +257,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "죄송합니다, 일부 애플리케이션을 불러올 수 없습니다. 탭을 연 이후 업데이트 되었거나 네트워크 요청이 실패했기 때문일 수 있습니다. 새로고침을 시도해보세요",
|
||||
"Reload": "새로고침",
|
||||
"Something Unexpected Happened": "예기치 않은 오류가 발생했습니다",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "최근 오류가 여러 번 발생했습니다. 오류가 계속되면 캐시를 지우거나 다른 브라우저를 사용해 보세요.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "죄송합니다. 복구할 수 없는 오류가 발생했습니다{{notified}} 페이지를 새로고침해 보세요. 일시적인 오류일 수 있습니다.",
|
||||
"our engineers have been notified": "저희 엔지니어들에게 전달되었습니다",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "캐시 지우기 + 새로고침",
|
||||
"Show detail": "자세히 보기",
|
||||
"{{userName}} archived": "{{userName}} 이(가) 보관함",
|
||||
"{{userName}} restored": "{{userName}} 이(가) 복원함",
|
||||
@@ -605,7 +605,7 @@
|
||||
"mentioned you in": "당신을 언급했습니다",
|
||||
"left a comment on": "댓글을 남겼습니다",
|
||||
"resolved a comment on": "에 대한 댓글이 해결됨",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"reacted {{ emoji }} to your comment on": "{{ emoji }}로 댓글에 반응함",
|
||||
"shared": "공유됨",
|
||||
"invited you to": "당신을 초대하였습니다:",
|
||||
"Choose a date": "날짜를 선택하세요",
|
||||
@@ -690,17 +690,17 @@
|
||||
"Created": "생성됨",
|
||||
"Imported from {{ source }}": "{{ source }} 로부터 불러옴",
|
||||
"Stats": "통계",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} minute read": "{{ number }}분 읽기",
|
||||
"{{ number }} words": "{{ number }}개 단어",
|
||||
"{{ number }} words_plural": "{{ number }}개 단어",
|
||||
"{{ number }} characters": "{{ number }}개 문자",
|
||||
"{{ number }} characters_plural": "{{ number }}개 문자",
|
||||
"{{ number }} emoji": "{{ number }} 이모티콘",
|
||||
"No text selected": "선택된 텍스트 없음",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"{{ number }} words selected": "{{ number }}개 단어 선택됨",
|
||||
"{{ number }} words selected_plural": "{{ number }}개 단어 선택됨",
|
||||
"{{ number }} characters selected": "{{ number }}개 문자 선택됨",
|
||||
"{{ number }} characters selected_plural": "{{ number }}개 문자 선택됨",
|
||||
"Contributors": "기여자",
|
||||
"Creator": "생성자",
|
||||
"Last edited": "마지막으로 수정됨",
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"Mentioned": "언급됨",
|
||||
"Receive a notification when someone mentions you in a document or comment": "다른 사람이 문서나 댓글에서 나를 언급하면 알림을 받습니다.",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "참여한 댓글 스레드가 해결되었을 때 알림을 받습니다.",
|
||||
"Reaction added": "Reaction added",
|
||||
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",
|
||||
"Reaction added": "반응 추가됨",
|
||||
"Receive a notification when someone reacts to your comment": "누군가 내 댓글에 반응하면 알림 받기",
|
||||
"Collection created": "컬렉션 생성됨",
|
||||
"Receive a notification whenever a new collection is created": "새 컬렉션이 생성될 때마다 알림을 받습니다",
|
||||
"Invite accepted": "초대 수락됨",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"New API key": "Ny API-nøkkel",
|
||||
"Delete": "Slett",
|
||||
"Revoke": "Opphev",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke API key": "Tilbakekall API-nøkkel",
|
||||
"Revoke token": "Opphev token",
|
||||
"Open collection": "Åpne samling",
|
||||
"New collection": "Ny samling",
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Kopier ID",
|
||||
"Clear IndexedDB cache": "Tøm indekseringDB cache",
|
||||
"IndexedDB cache cleared": "IndeksertDB cache tømt",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "Tøm lokal lagring",
|
||||
"Local storage cleared": "Lokal lagring tømt",
|
||||
"Toggle debug logging": "Veksle feilsøkingslogging",
|
||||
"Debug logging enabled": "Feilsøkingslogging aktivert",
|
||||
"Debug logging disabled": "Feilsøkingslogging deaktivert",
|
||||
@@ -109,7 +109,7 @@
|
||||
"You have left the shared document": "Du har forlatt det delte dokumentet",
|
||||
"Could not leave document": "Kunne ikke forlate dokument",
|
||||
"Apply template": "Bruk mal",
|
||||
"Disconnect analytics": "Disconnect analytics",
|
||||
"Disconnect analytics": "Koble fra analysedata",
|
||||
"Home": "Hjem",
|
||||
"Drafts": "Utkast",
|
||||
"Search": "Søk",
|
||||
@@ -142,7 +142,7 @@
|
||||
"Change theme": "Endre tema",
|
||||
"Change theme to": "Endre tema til",
|
||||
"Share link copied": "Delingslenke kopiert",
|
||||
"Go to collection": "Go to collection",
|
||||
"Go to collection": "Gå til samling",
|
||||
"Go to document": "Gå til dokument",
|
||||
"Revoke link": "Opphev lenke",
|
||||
"Share link revoked": "Delingslenke opphevet",
|
||||
@@ -211,8 +211,8 @@
|
||||
"Install now": "Installer nå",
|
||||
"Disconnect": "Koble fra",
|
||||
"Disconnecting": "Kobler fra",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Er du sikker på at du vil koble fra <em>{{ service }}</em>-integrasjonen?",
|
||||
"This will stop sending analytics events to the configured instance.": "Dette vil slutte å sende analytiske hendelser til den konfigurerte instansen.",
|
||||
"Deleted Collection": "Slettet samling",
|
||||
"Untitled": "Uten tittel",
|
||||
"Unpin": "Løsne",
|
||||
@@ -257,10 +257,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Beklager, en del av applikasjonen mislyktes i å laste. Dette kan være fordi den ble oppdatert etter du åpnet fanen eller på grunn av en nettverksfeil. Vennligst prøv å laste inn på nytt.",
|
||||
"Reload": "Last inn på nytt",
|
||||
"Something Unexpected Happened": "Noe uventet skjedde",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "En feil har oppstått flere ganger nylig. Hvis det fortsetter, kan du prøve å tømme cachen eller bruke en annen nettleser.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Beklager, en uopprettelig feil oppstod{{notified}}. Vennligst prøv å laste inn siden på nytt, det kan ha vært en midlertidig feil.",
|
||||
"our engineers have been notified": "våre teknologer har blitt varslet",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Tøm cache + last på nytt",
|
||||
"Show detail": "Vis detaljer",
|
||||
"{{userName}} archived": "{{userName}} arkiverte",
|
||||
"{{userName}} restored": "{{userName}} gjenopprettet",
|
||||
@@ -356,7 +356,7 @@
|
||||
"Disable this setting to discourage search engines from indexing the page": "Deaktiver denne innstillingen for å fraråde søkemotorer til å indeksere denne siden",
|
||||
"Show last modified": "Vis sist endret",
|
||||
"Display the last modified timestamp on the shared page": "Vis sist endret tidsstempel på den delte siden",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "Alle dokumenter i denne samlingen deles på nettet, inkludert eventuelle nye dokumenter som legges til senere",
|
||||
"Invite": "Inviter",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} ble lagt til i samlingen",
|
||||
"{{ count }} people added to the collection": "{{ count }} personer lagt til i samlingen",
|
||||
@@ -389,8 +389,8 @@
|
||||
"Active <1></1> ago": "Aktiv <1></1> siden",
|
||||
"Never signed in": "Aldri logget inn",
|
||||
"Leave": "Forlat",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Alle med lenken får tilgang til fordi den inneholder samlingen, <2>{sharedParent.sourceTitle}</2>, deles",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Alle med lenken får tilgang til fordi det overordnede dokumentet, <2>{sharedParent.sourceTitle}</2>, deles",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Underdokumenter deles ikke på nettet. Aktiver deling for å tillate tilgang, dette vil være standard oppførsel i fremtiden",
|
||||
"{{ userName }} was added to the document": "{{ userName }} ble lagt til i dokumentet",
|
||||
"{{ count }} people added to the document": "{{ count }} person er lagt til i dokumentet",
|
||||
@@ -442,7 +442,7 @@
|
||||
"New email": "Ny e-post",
|
||||
"Email can't be empty": "E-post kan ikke være tom",
|
||||
"Your import completed": "Importen din er fullført",
|
||||
"Sorry, invalid embed link": "Sorry, invalid embed link",
|
||||
"Sorry, invalid embed link": "Beklager, ugyldig embed-lenke",
|
||||
"Previous match": "Forrige treff",
|
||||
"Next match": "Neste treff",
|
||||
"Find and replace": "Finn og erstatt",
|
||||
@@ -540,7 +540,7 @@
|
||||
"Outdent": "Utrykk",
|
||||
"Video": "Video",
|
||||
"None": "Ingen",
|
||||
"Delete embed": "Delete embed",
|
||||
"Delete embed": "Slett embed",
|
||||
"Could not import file": "Kunne ikke importere fil",
|
||||
"Unsubscribed from document": "Avsluttet abonnement fra dokument",
|
||||
"Unsubscribed from collection": "Avsluttet abonnement fra samling",
|
||||
@@ -556,7 +556,7 @@
|
||||
"Import": "Importer",
|
||||
"Install": "Installer",
|
||||
"Integrations": "Integrasjoner",
|
||||
"API key": "API key",
|
||||
"API key": "API-nøkkel",
|
||||
"Show path to document": "Vis sti til dokument",
|
||||
"Sort in sidebar": "Sorter i sidemeny",
|
||||
"A-Z sort": "A-Å sortering",
|
||||
@@ -568,7 +568,7 @@
|
||||
"Enable viewer insights": "Aktiver leserinnsikt",
|
||||
"Enable embeds": "Aktiver innebygginger",
|
||||
"Document options": "Dokumentalternativer",
|
||||
"File": "File",
|
||||
"File": "Fil",
|
||||
"Group member options": "Gruppemedlemalternativer",
|
||||
"Group members": "Gruppemedlemmer",
|
||||
"Edit group": "Rediger gruppe",
|
||||
@@ -690,17 +690,17 @@
|
||||
"Created": "Opprettet",
|
||||
"Imported from {{ source }}": "Importert fra {{ source }}",
|
||||
"Stats": "Statistikk",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} minute read": "{{ number }} minutts lesning",
|
||||
"{{ number }} words": "{{ number }} ord",
|
||||
"{{ number }} words_plural": "{{ number }} ord",
|
||||
"{{ number }} characters": "{{ number }} tegn",
|
||||
"{{ number }} characters_plural": "{{ number }} tegn",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "Ingen tekst valgt",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"{{ number }} words selected": "{{ number }} ord valgt",
|
||||
"{{ number }} words selected_plural": "{{ number }} ord valgt",
|
||||
"{{ number }} characters selected": "{{ number }} tegn valgt",
|
||||
"{{ number }} characters selected_plural": "{{ number }} tegn valgt",
|
||||
"Contributors": "Bidragsytere",
|
||||
"Creator": "Oppretter",
|
||||
"Last edited": "Sist redigert",
|
||||
@@ -1005,7 +1005,7 @@
|
||||
"Guest": "Gjest",
|
||||
"Never used": "Aldri brukt",
|
||||
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Er du sikker på at du vil slette {{ appName }} applikasjonen? Dette kan ikke angres.",
|
||||
"Title": "Title",
|
||||
"Title": "Tittel",
|
||||
"Shared by": "Delt av",
|
||||
"Date shared": "Dato delt",
|
||||
"Last accessed": "Sist tilgjengelig",
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"Mentioned": "Nevnt",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Motta en varsling når noen nevner deg i et dokument eller en kommentar",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Motta et varsel når en kommentartråd du deltok i er løst",
|
||||
"Reaction added": "Reaction added",
|
||||
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",
|
||||
"Reaction added": "Reaksjon lagt til",
|
||||
"Receive a notification when someone reacts to your comment": "Motta en notifikasjon når noen reagerer på din kommentar",
|
||||
"Collection created": "Samling opprettet",
|
||||
"Receive a notification whenever a new collection is created": "Motta en varsling når en ny samling opprettes",
|
||||
"Invite accepted": "Invitasjon akseptert",
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Kopieer ID",
|
||||
"Clear IndexedDB cache": "Wis IndexedDB-cache",
|
||||
"IndexedDB cache cleared": "IndexedDB-cache gewist",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "Lokale opslag wissen",
|
||||
"Local storage cleared": "Lokale opslag gewist",
|
||||
"Toggle debug logging": "Debug-logging aan- of uitzetten",
|
||||
"Debug logging enabled": "Debug-logging ingeschakeld",
|
||||
"Debug logging disabled": "Debug-logging uitgeschakeld",
|
||||
@@ -257,10 +257,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, een deel van de toepassing kon niet geladen worden. Dit kan komen doordat het is bijgewerkt sinds het tabblad is geopend of door een mislukt netwerkverzoek. Probeer het opnieuw te laden.",
|
||||
"Reload": "Verversen",
|
||||
"Something Unexpected Happened": "Er gebeurde iets onverwachts",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "Er is onlangs meerdere keren een fout opgetreden. Probeer de cache te wissen of gebruik een andere browser.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, er is een onherstelbare fout opgetreden op{{notified}}. Probeer de pagina opnieuw te laden, dit kan een tijdelijke storing zijn.",
|
||||
"our engineers have been notified": "onze technici zijn op de hoogte gebracht",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Cache wissen + herladen",
|
||||
"Show detail": "Toon detail",
|
||||
"{{userName}} archived": "{{userName}} gearchiveerd",
|
||||
"{{userName}} restored": "{{userName}} hersteld",
|
||||
@@ -605,7 +605,7 @@
|
||||
"mentioned you in": "vermeldde je",
|
||||
"left a comment on": "liet een reactie achter op",
|
||||
"resolved a comment on": "heeft een reactie opgelost op",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"reacted {{ emoji }} to your comment on": "reageerde {{ emoji }} op jouw reactie op",
|
||||
"shared": "gedeeld",
|
||||
"invited you to": "heeft je uitgenodigd voor",
|
||||
"Choose a date": "Kies een datum",
|
||||
@@ -690,17 +690,17 @@
|
||||
"Created": "Aangemaakt",
|
||||
"Imported from {{ source }}": "Geïmporteerd van {{ source }}",
|
||||
"Stats": "Stats",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} minute read": "{{ number }} minuten leestijd",
|
||||
"{{ number }} words": "{{ number }} woord",
|
||||
"{{ number }} words_plural": "{{ number }} woorden",
|
||||
"{{ number }} characters": "{{ number }} teken",
|
||||
"{{ number }} characters_plural": "{{ number }} tekens",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "Geen tekst geselecteerd",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"{{ number }} words selected": "{{ number }} woord geselecteerd",
|
||||
"{{ number }} words selected_plural": "{{ number }} woorden geselecteerd",
|
||||
"{{ number }} characters selected": "{{ number }} teken geselecteerd",
|
||||
"{{ number }} characters selected_plural": "{{ number }} tekens geselecteerd",
|
||||
"Contributors": "Bijdragers",
|
||||
"Creator": "Maker",
|
||||
"Last edited": "Laatst gewijzigd",
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"Mentioned": "Vermeld",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Ontvang een melding wanneer iemand je vermeldt in een document of opmerking",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Ontvang een notificatie wanneer een reactie waar je bij betrokken was is opgelost",
|
||||
"Reaction added": "Reaction added",
|
||||
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",
|
||||
"Reaction added": "Reactie toegevoegd",
|
||||
"Receive a notification when someone reacts to your comment": "Ontvang een melding wanneer iemand reageert op jouw reactie",
|
||||
"Collection created": "Collectie aangemaakt",
|
||||
"Receive a notification whenever a new collection is created": "Ontvang een melding wanneer een nieuwe collectie wordt gemaakt",
|
||||
"Invite accepted": "Uitnodiging geaccepteerd",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"New API key": "Nowy klucz API",
|
||||
"Delete": "Usuń",
|
||||
"Revoke": "Unieważnij",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke API key": "Unieważnij klucz API",
|
||||
"Revoke token": "Unieważnij token",
|
||||
"Open collection": "Otwórz kolekcję",
|
||||
"New collection": "Nowa kolekcja",
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Kopiuj ID",
|
||||
"Clear IndexedDB cache": "Usuń cache IndexedDB",
|
||||
"IndexedDB cache cleared": "Usunięto cache IndexedDB",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Clear local storage": "Wyczyść pamięć lokalną",
|
||||
"Local storage cleared": "Lokalna pamięć została wyczyszczona",
|
||||
"Toggle debug logging": "Przełącz rejestrowanie debugowania",
|
||||
"Debug logging enabled": "Rejestrowanie debugowania włączone",
|
||||
"Debug logging disabled": "Rejestrowanie debugowania wyłączone",
|
||||
@@ -108,8 +108,8 @@
|
||||
"Leave document": "Opuść dokument",
|
||||
"You have left the shared document": "Opuściłeś udostępniony dokument",
|
||||
"Could not leave document": "Nie udało się opuścić dokumentu",
|
||||
"Apply template": "Apply template",
|
||||
"Disconnect analytics": "Disconnect analytics",
|
||||
"Apply template": "Użyj szablonu",
|
||||
"Disconnect analytics": "Odłącz analizę",
|
||||
"Home": "Strona Główna",
|
||||
"Drafts": "Kopie robocze",
|
||||
"Search": "Szukaj",
|
||||
@@ -133,7 +133,7 @@
|
||||
"Archive all notifications": "Archiwizuj wszystkie powiadomienia",
|
||||
"New App": "Nowa aplikacja",
|
||||
"New Application": "Nowa aplikacja",
|
||||
"This version of the document was deleted": "This version of the document was deleted",
|
||||
"This version of the document was deleted": "Ta wersja dokumentu została usunięta",
|
||||
"Link copied": "Link skopiowany",
|
||||
"Dark": "Ciemny",
|
||||
"Light": "Jasny",
|
||||
@@ -142,7 +142,7 @@
|
||||
"Change theme": "Zmień motyw",
|
||||
"Change theme to": "Zmień motyw na",
|
||||
"Share link copied": "Skopiowano link udostępnienia",
|
||||
"Go to collection": "Go to collection",
|
||||
"Go to collection": "Przejdź do kolekcji",
|
||||
"Go to document": "Przejdź do dokumentu",
|
||||
"Revoke link": "Unieważnij link",
|
||||
"Share link revoked": "Unieważniono link udostępniania",
|
||||
@@ -182,7 +182,7 @@
|
||||
"Public document sharing": "Publiczne udostępnianie dokumentów",
|
||||
"Allow documents within this collection to be shared publicly on the internet.": "Zezwalaj na publiczne udostępnianie dokumentów z tej kolekcji w Internecie.",
|
||||
"Commenting": "Komentowanie",
|
||||
"Allow commenting on documents within this collection.": "Allow commenting on documents within this collection.",
|
||||
"Allow commenting on documents within this collection.": "Zezwalaj na komentowanie dokumentów w tej kolekcji.",
|
||||
"Saving": "Zapisywanie",
|
||||
"Save": "Zapisz",
|
||||
"Creating": "Tworzenie",
|
||||
@@ -211,8 +211,8 @@
|
||||
"Install now": "Zainstaluj teraz",
|
||||
"Disconnect": "Rozłącz",
|
||||
"Disconnecting": "Rozłączanie",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Czy na pewno chcesz odłączyć integrację <em>{{ service }}</em>?",
|
||||
"This will stop sending analytics events to the configured instance.": "To zatrzyma wysyłanie zdarzeń analitycznych do skonfigurowanej instancji.",
|
||||
"Deleted Collection": "Usunięta kolekcja",
|
||||
"Untitled": "Bez tytułu",
|
||||
"Unpin": "Odepnij",
|
||||
@@ -257,10 +257,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Przepraszamy, nie udało się załadować części aplikacji. Może to być spowodowane tym, że został zaktualizowany od momentu otwarcia karty lub z powodu nieudanego żądania sieciowego. Spróbuj ponownie załadować.",
|
||||
"Reload": "Odśwież",
|
||||
"Something Unexpected Happened": "Wystąpił nieoczekiwany błąd",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "W ostatnim czasie wielokrotnie wystąpił błąd. Jeśli problem nadal będzie występować, spróbuj wyczyścić pamięć podręczną lub użyć innej przeglądarki.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Przepraszamy, wystąpił nieodwracalny błąd {{notified}}. Spróbuj ponownie załadować stronę, może to być tymczasowa usterka.",
|
||||
"our engineers have been notified": "nasi inżynierowie zostali powiadomieni",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Clear cache + reload": "Wyczyść pamięć podręczną + przeładuj",
|
||||
"Show detail": "Pokaż szczegóły",
|
||||
"{{userName}} archived": "Użytkownik {{userName}} zarchiwizował",
|
||||
"{{userName}} restored": "Użytkownik {{userName}} przywrócił",
|
||||
@@ -281,8 +281,8 @@
|
||||
"You will receive an email when it's complete.": "Po zakończeniu otrzymasz wiadomość e-mail.",
|
||||
"Include attachments": "Dołącz załączniki",
|
||||
"Including uploaded images and files in the exported data": "Uwzględnianie przesłanych obrazów i plików w eksportowanych danych",
|
||||
"{{count}} more user": "{{count}} more user",
|
||||
"{{count}} more user_plural": "{{count}} more users",
|
||||
"{{count}} more user": "{{count}} więcej użytkownika",
|
||||
"{{count}} more user_plural": "{{count}} więcej użytkowników",
|
||||
"Filter": "Filtr",
|
||||
"No results": "Brak wyników",
|
||||
"{{authorName}} created <3></3>": "{{authorName}} stworzył <3></3>",
|
||||
@@ -336,7 +336,7 @@
|
||||
"Reaction picker": "Selektor reakcji",
|
||||
"Could not load reactions": "Nie można załadować reakcji",
|
||||
"Reaction": "Reakcja",
|
||||
"Revision deleted": "Revision deleted",
|
||||
"Revision deleted": "Usunięto poprawkę",
|
||||
"Current version": "Aktualna wersja",
|
||||
"{{userName}} edited": "Użytkownik {{userName}} edytował",
|
||||
"Results": "Wyniki",
|
||||
@@ -354,9 +354,9 @@
|
||||
"Publish to internet": "Opublikuj w internecie",
|
||||
"Search engine indexing": "Indeksowanie wyszukiwarki",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Wyłącz to ustawienie, aby zniechęcać wyszukiwarki do indeksowania strony",
|
||||
"Show last modified": "Show last modified",
|
||||
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"Show last modified": "Pokaż ostatnio zmodyfikowane",
|
||||
"Display the last modified timestamp on the shared page": "Wyświetl ostatnio zmodyfikowany znacznik czasu na wspólnej stronie",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "Wszystkie dokumenty w tej kolekcji będą udostępniane w sieci, w tym wszelkie nowe dokumenty dodane później",
|
||||
"Invite": "Zaproś",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} został dodany do kolekcji",
|
||||
"{{ count }} people added to the collection": "{{ count }} osób dodanych do kolekcji",
|
||||
@@ -389,8 +389,8 @@
|
||||
"Active <1></1> ago": "Aktywny <1></1> temu",
|
||||
"Never signed in": "Nigdy nie zalogowany",
|
||||
"Leave": "Opuść",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Każdy, kto posiada link, może uzyskać dostęp, ponieważ kolekcja, <2>{sharedParent.sourceTitle}</2>, jest udostępniona",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Każdy, kto posiada link, może uzyskać dostęp, ponieważ kolekcja, <2>{sharedParent.sourceTitle}</2>, jest udostępniona",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Zagnieżdżone dokumenty nie są udostępniane w sieci. Przełącz udostępnianie, aby umożliwić dostęp, będzie to domyślne zachowanie w przyszłości",
|
||||
"{{ userName }} was added to the document": "{{ userName }} został dodany do dokumentu",
|
||||
"{{ count }} people added to the document": "{{ count }} osoba dodanya do dokumentu",
|
||||
@@ -442,7 +442,7 @@
|
||||
"New email": "Nowy e-mail",
|
||||
"Email can't be empty": "Pole adresu e-mail nie może być puste",
|
||||
"Your import completed": "Twój import został zakończony",
|
||||
"Sorry, invalid embed link": "Sorry, invalid embed link",
|
||||
"Sorry, invalid embed link": "Przepraszamy, nieprawidłowy link osadzenia",
|
||||
"Previous match": "Poprzednie dopasowanie",
|
||||
"Next match": "Następne dopasowanie",
|
||||
"Find and replace": "Znajdź i zamień",
|
||||
@@ -479,7 +479,7 @@
|
||||
"Create a new child doc": "Utwórz nowy dokument podrzędny",
|
||||
"Delete table": "Usuń tabelę",
|
||||
"Delete file": "Usuń plik",
|
||||
"Width x Height": "Width x Height",
|
||||
"Width x Height": "Szerokość x Wysokość",
|
||||
"Download file": "Pobierz plik",
|
||||
"Replace file": "Zastąp plik",
|
||||
"Delete image": "Usuń obraz",
|
||||
@@ -525,8 +525,8 @@
|
||||
"Toggle header": "Przełącz nagłówek",
|
||||
"Math inline (LaTeX)": "Matematyka w linii (LaTeX)",
|
||||
"Math block (LaTeX)": "Blok matematyczny (LaTeX)",
|
||||
"Merge cells": "Merge cells",
|
||||
"Split cell": "Split cell",
|
||||
"Merge cells": "Scal komórki",
|
||||
"Split cell": "Podziel komórkę",
|
||||
"Tip": "Porada",
|
||||
"Tip notice": "Zawiadomienie z poradą",
|
||||
"Warning": "Ostrzeżenie",
|
||||
@@ -540,7 +540,7 @@
|
||||
"Outdent": "Zmniejsz wcięcie",
|
||||
"Video": "Wideo",
|
||||
"None": "Brak",
|
||||
"Delete embed": "Delete embed",
|
||||
"Delete embed": "Usuń osadzenie",
|
||||
"Could not import file": "Nie można zaimportować pliku",
|
||||
"Unsubscribed from document": "Wypisano z dokumentu",
|
||||
"Unsubscribed from collection": "Zrezygnowano z subskrypcji kolekcji",
|
||||
@@ -556,7 +556,7 @@
|
||||
"Import": "Importuj",
|
||||
"Install": "Zainstaluj",
|
||||
"Integrations": "Integracje",
|
||||
"API key": "API key",
|
||||
"API key": "Klucz API",
|
||||
"Show path to document": "Pokaż ścieżkę do dokumentu",
|
||||
"Sort in sidebar": "Sortuj w pasku bocznym",
|
||||
"A-Z sort": "Sortuj A-Z",
|
||||
@@ -568,7 +568,7 @@
|
||||
"Enable viewer insights": "Włącz informacje o oglądających",
|
||||
"Enable embeds": "Włącz osadzanie",
|
||||
"Document options": "Opcje dokumentu",
|
||||
"File": "File",
|
||||
"File": "Plik",
|
||||
"Group member options": "Opcje członka grupy",
|
||||
"Group members": "Członkowie grupy",
|
||||
"Edit group": "Edytuj grupę",
|
||||
@@ -605,7 +605,7 @@
|
||||
"mentioned you in": "wspomniał o tobie w",
|
||||
"left a comment on": "zostawił komentarz na",
|
||||
"resolved a comment on": "rozstrzygnął komentarz",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"reacted {{ emoji }} to your comment on": "zareagował {{ emoji }} na Twój komentarz na",
|
||||
"shared": "udostępnione",
|
||||
"invited you to": "zaprosił cię do",
|
||||
"Choose a date": "Wybierz datę",
|
||||
@@ -690,17 +690,17 @@
|
||||
"Created": "Utworzono",
|
||||
"Imported from {{ source }}": "Zaimportowano z {{ source }}",
|
||||
"Stats": "Statystyki",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} minute read": "{{ number }} minuta czytania",
|
||||
"{{ number }} words": "{{ number }} słowo",
|
||||
"{{ number }} words_plural": "{{ number }} słów",
|
||||
"{{ number }} characters": "{{ number }} znak",
|
||||
"{{ number }} characters_plural": "{{ number }} znaków",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "Nie wybrano tekstu",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"{{ number }} words selected": "Zaznaczono {{ number }} słowa",
|
||||
"{{ number }} words selected_plural": "Zaznaczono {{ number }} słów",
|
||||
"{{ number }} characters selected": "Zaznaczono {{ number }} znak",
|
||||
"{{ number }} characters selected_plural": "Zaznaczono {{ number }} znaków",
|
||||
"Contributors": "Współtwórcy",
|
||||
"Creator": "Twórca",
|
||||
"Last edited": "Ostatnio edytowano",
|
||||
@@ -716,7 +716,7 @@
|
||||
"Observing {{ userName }}": "Obserwowanie {{ userName }}",
|
||||
"Backlinks": "Linki zwrotne",
|
||||
"Close": "Zamknij",
|
||||
"This document is large which may affect performance": "This document is large which may affect performance",
|
||||
"This document is large which may affect performance": "Ten dokument jest duży, co może mieć wpływ na wydajność",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Czy na pewno chcesz usunąć szablon <em>{{ documentTitle }}</em>?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Czy jesteś tego pewien? Usunięcie dokumentu <em>{{ documentTitle }}</em> spowoduje usunięcie całej jego historii</em>.",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Czy jesteś tego pewien? Usunięcie dokumentu <em>{{ documentTitle }}</em> spowoduje usunięcie całej jego historii oraz <em>{{ any }} zagnieżdżonych dokumentów</em>.",
|
||||
@@ -748,7 +748,7 @@
|
||||
"Warning Sign": "Znak ostrzegawczy",
|
||||
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Administrator obszaru roboczego (<em>{{ suspendedContactEmail }}</em>) zawiesił Twoje konto. Aby ponownie aktywować konto, skontaktuj się z nim bezpośrednio.",
|
||||
"Something went wrong": "Coś poszło nie tak",
|
||||
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.",
|
||||
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Przepraszamy, wystąpił nieznany błąd podczas ładowania strony. Spróbuj ponownie lub skontaktuj się z pomocą techniczną, jeśli problem będzie się powtarzał.",
|
||||
"Created by me": "Utworzone przeze mnie",
|
||||
"Weird, this shouldn’t ever be empty": "Dziwne, to nigdy nie powinno być puste",
|
||||
"You haven’t created any documents yet": "Nie utworzyłeś jeszcze żadnych dokumentów",
|
||||
@@ -792,8 +792,8 @@
|
||||
"Underline": "Podkreślenie",
|
||||
"Undo": "Cofnij",
|
||||
"Redo": "Ponów",
|
||||
"Move block up": "Move block up",
|
||||
"Move block down": "Move block down",
|
||||
"Move block up": "Przesuń blok w górę",
|
||||
"Move block down": "Przesuń blok w dół",
|
||||
"Lists": "Listy",
|
||||
"Toggle task list item": "Przełącz element listy zadań",
|
||||
"Tab": "Karta",
|
||||
@@ -824,7 +824,7 @@
|
||||
"To continue, enter your workspace’s subdomain.": "Aby kontynuować, wpisz subdomenę swojego obszaru roboczego.",
|
||||
"subdomain": "subdomena",
|
||||
"Continue": "Dalej",
|
||||
"Sorry, the code you entered is invalid or has expired.": "Sorry, the code you entered is invalid or has expired.",
|
||||
"Sorry, the code you entered is invalid or has expired.": "Przepraszamy, wprowadzony kod jest nieprawidłowy lub wygasł.",
|
||||
"The domain associated with your email address has not been allowed for this workspace.": "Domena powiązana z Twoim adresem e-mail nie została zezwolona dla tego obszaru roboczego.",
|
||||
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "Nie można się zalogować. Przejdź do niestandardowego adresu URL swojego obszaru roboczego, a następnie spróbuj ponownie się zalogować.<1></1>Jeśli zostałeś zaproszony do obszaru roboczego, znajdziesz link do niego w e-mailu z zaproszeniem.",
|
||||
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Przepraszamy, nie można utworzyć nowego konta przy użyciu osobistego adresu Gmail.<1></1>Prosimy zamiast tego użyć konta Google Workspaces",
|
||||
@@ -843,11 +843,11 @@
|
||||
"Sorry, an unknown error occurred.": "Przepraszamy, wystąpił nieznany błąd.",
|
||||
"Choose a workspace": "Wybierz obszar roboczy",
|
||||
"Choose an {{ appName }} workspace or login to continue connecting this app": "Wybierz obszar roboczy {{ appName }} lub zaloguj się, aby kontynuować łączenie tej aplikacji",
|
||||
"Create workspace": "Create workspace",
|
||||
"Setup your workspace by providing a name and details for admin login. You can change these later.": "Setup your workspace by providing a name and details for admin login. You can change these later.",
|
||||
"Create workspace": "Stwórz obszar roboczy",
|
||||
"Setup your workspace by providing a name and details for admin login. You can change these later.": "Skonfiguruj swój obszar roboczy, podając nazwę i szczegóły logowania administratora. Możesz je później zmienić.",
|
||||
"Workspace name": "Nazwa obszaru roboczego",
|
||||
"Admin name": "Admin name",
|
||||
"Admin email": "Admin email",
|
||||
"Admin name": "Nazwa administratora",
|
||||
"Admin email": "E-mail administratora",
|
||||
"Login": "Zaloguj się",
|
||||
"Error": "Błąd",
|
||||
"Failed to load configuration.": "Nie udało się załadować konfiguracji.",
|
||||
@@ -858,7 +858,7 @@
|
||||
"Choose workspace": "Wybierz obszar roboczy",
|
||||
"This login method requires choosing your workspace to continue": "Ta metoda logowania wymaga wyboru obszaru roboczego, aby kontynuować",
|
||||
"Check your email": "Sprawdź swoją skrzynkę e-mail",
|
||||
"Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>": "Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>",
|
||||
"Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>": "Wprowadź kod logowania wysłany na e-mail <em>{{ emailLinkSentTo }}</em>",
|
||||
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.": "Magiczny link do logowania został wysłany na adres e-mail <em>{{ emailLinkSentTo }}</em>, jeśli konto istnieje.",
|
||||
"Back to login": "Wróć do logowania",
|
||||
"Get started by choosing a sign-in method for your new workspace below…": "Rozpocznij, wybierając poniżej metodę logowania do nowego obszaru roboczego...",
|
||||
@@ -1005,7 +1005,7 @@
|
||||
"Guest": "Gość",
|
||||
"Never used": "Nigdy nie używany",
|
||||
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Czy na pewno chcesz usunąć aplikację {{ appName }}? Nie można tego cofnąć.",
|
||||
"Title": "Title",
|
||||
"Title": "Tytuł",
|
||||
"Shared by": "Udostępnione przez",
|
||||
"Date shared": "Data udostępnienia",
|
||||
"Last accessed": "Ostatnio otwarto",
|
||||
@@ -1026,15 +1026,15 @@
|
||||
"Display": "Wyświetlanie",
|
||||
"The logo is displayed at the top left of the application.": "Logo jest wyświetlane w lewym górnym rogu aplikacji.",
|
||||
"The workspace name, usually the same as your company name.": "Nazwa przestrzeni roboczej, zazwyczaj taka sama jak nazwa Twojej firmy.",
|
||||
"Description": "Description",
|
||||
"A short description of your workspace.": "A short description of your workspace.",
|
||||
"Description": "Opis",
|
||||
"A short description of your workspace.": "Krótki opis Twojego obszaru roboczego.",
|
||||
"Theme": "Motyw",
|
||||
"Customize the interface look and feel.": "Dostosuj wygląd i uczucie interfejsu.",
|
||||
"Reset theme": "Zresetuj motyw",
|
||||
"Accent color": "Kolor akcentu",
|
||||
"Accent text color": "Kolor tekstu akcentu",
|
||||
"Public branding": "Publiczny branding",
|
||||
"Show your workspace logo, description, and branding on publicly shared pages.": "Show your workspace logo, description, and branding on publicly shared pages.",
|
||||
"Show your workspace logo, description, and branding on publicly shared pages.": "Pokaż logo swojego obszaru roboczego, opis i markę na publicznie udostępnianych stronach.",
|
||||
"Table of contents position": "",
|
||||
"The side to display the table of contents in relation to the main content.": "Część wyświetlająca spis treści w odniesieniu do treści głównej.",
|
||||
"Behavior": "Zachowanie",
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"Mentioned": "Wspomniany",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Otrzymuj powiadomienie, gdy ktoś wspomni o Tobie w dokumencie lub komentarzu",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Otrzymaj powiadomienie, gdy wątek komentarza, w który byłeś zaangażowany, został rozwiązany",
|
||||
"Reaction added": "Reaction added",
|
||||
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",
|
||||
"Reaction added": "Reakcja dodana",
|
||||
"Receive a notification when someone reacts to your comment": "Otrzymuj powiadomienie, gdy ktoś zareaguje na twój komentarz",
|
||||
"Collection created": "Kolekcja utworzona",
|
||||
"Receive a notification whenever a new collection is created": "Otrzymuj powiadomienie o każdym utworzeniu nowej kolekcji",
|
||||
"Invite accepted": "Zaproszenie przyjęte",
|
||||
@@ -1187,7 +1187,7 @@
|
||||
"Measurement ID": "Identyfikator pomiarowy",
|
||||
"Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Utwórz strumień \"Web\" w panelu administratora Google Analytics i skopiuj identyfikator pomiarowy z wygenerowanego fragmentu kodu, aby go zainstalować.",
|
||||
"Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?": "Ups, musisz zaakceptować uprawnienia w Linear, aby połączyć {{appName}} ze swoim obszarem roboczym. Spróbować ponownie?",
|
||||
"Something went wrong while processing your request. Please try again.": "Something went wrong while processing your request. Please try again.",
|
||||
"Something went wrong while processing your request. Please try again.": "Coś poszło nie tak podczas przetwarzania żądania. Spróbuj ponownie.",
|
||||
"Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.": "Włącz podgląd Linear issues w dokumentach, łącząc obszar roboczy Linear z {appName}.",
|
||||
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?": "Odłączenie uniemożliwi podgląd linków Linear z tego obszaru roboczego w dokumentach. Czy na pewno?",
|
||||
"The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "Integracja Linear jest obecnie wyłączona. Aby włączyć integrację, należy ustawić związane z nią zmienne środowiskowe i ponownie uruchomić serwer.",
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} sẽ không được thông báo, bởi vì họ không có quyền truy cập tài liệu này",
|
||||
"Keep as link": "Giữ dạng liên kết",
|
||||
"Mention": "Nhắc đến",
|
||||
"Embed": "Embed",
|
||||
"Embed": "Nhúng",
|
||||
"Add column after": "Thêm cột phía sau",
|
||||
"Add column before": "Thêm cột phía trước",
|
||||
"Add row after": "Thêm hàng bên dưới",
|
||||
@@ -479,9 +479,9 @@
|
||||
"Create a new child doc": "Create a new child doc",
|
||||
"Delete table": "Xóa bảng",
|
||||
"Delete file": "Xóa tệp tin",
|
||||
"Width x Height": "Width x Height",
|
||||
"Download file": "Download file",
|
||||
"Replace file": "Replace file",
|
||||
"Width x Height": "Rộng x Cao",
|
||||
"Download file": "Tải xuống tập tin",
|
||||
"Replace file": "Thay thế tập tin",
|
||||
"Delete image": "Xóa ảnh",
|
||||
"Download image": "Tải ảnh",
|
||||
"Replace image": "Thay thế ảnh",
|
||||
@@ -518,8 +518,8 @@
|
||||
"Strikethrough": "Gạch ngang",
|
||||
"Bold": "Đậm",
|
||||
"Subheading": "Tiêu đề phụ",
|
||||
"Sort ascending": "Sort ascending",
|
||||
"Sort descending": "Sort descending",
|
||||
"Sort ascending": "Sắp xếp tăng dần",
|
||||
"Sort descending": "Sắp xếp giảm dần",
|
||||
"Table": "Bảng",
|
||||
"Export as CSV": "Export as CSV",
|
||||
"Toggle header": "Toggle header",
|
||||
@@ -582,33 +582,33 @@
|
||||
"Revoke {{ appName }}": "Thu hồi {{ appName }}",
|
||||
"Revoking": "Đang thu hồi",
|
||||
"Are you sure you want to revoke access?": "Bạn có chắc muốn thu hồi quyền truy cập?",
|
||||
"Delete app": "Delete app",
|
||||
"Delete app": "Xoá ứng dụng",
|
||||
"Revision options": "Tùy chọn sửa đổi",
|
||||
"Share options": "Tùy chọn chia sẻ",
|
||||
"Headings you add to the document will appear here": "Các tiêu đề bạn thêm vào tài liệu sẽ xuất hiện ở đây",
|
||||
"Contents": "Nội Dung",
|
||||
"Table of contents": "Mục lục",
|
||||
"Change name": "Đổi tên",
|
||||
"Change email": "Change email",
|
||||
"Change email": "Thay đổi email",
|
||||
"Suspend user": "Treo tài khoản",
|
||||
"An error occurred while sending the invite": "Đã xảy ra lỗi khi gửi lời mời",
|
||||
"Change role": "Change role",
|
||||
"Change role": "Thay đổi vai trò",
|
||||
"Resend invite": "Gửi lại lời mời",
|
||||
"Revoke invite": "Thu hồi lời mời",
|
||||
"Activate user": "Activate user",
|
||||
"Activate user": "Kích hoạt người dùng",
|
||||
"User options": "Tùy chọn người dùng",
|
||||
"template": "template",
|
||||
"template": "mẫu",
|
||||
"document": "tài liệu",
|
||||
"published": "published",
|
||||
"published": "đã xuất bản",
|
||||
"edited": "đã chỉnh sửa",
|
||||
"created the collection": "created the collection",
|
||||
"created the collection": "tạo một bộ sưu tập",
|
||||
"mentioned you in": "mentioned you in",
|
||||
"left a comment on": "left a comment on",
|
||||
"resolved a comment on": "resolved a comment on",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"shared": "được chia sẻ",
|
||||
"invited you to": "invited you to",
|
||||
"Choose a date": "Choose a date",
|
||||
"Choose a date": "Chọn ngày",
|
||||
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
|
||||
"Scopes": "Scopes",
|
||||
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
|
||||
|
||||
-7
@@ -8,11 +8,4 @@ declare module "prosemirror-model" {
|
||||
// https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51
|
||||
removeBetween(from: number, to: number): Slice;
|
||||
}
|
||||
|
||||
interface NodeSpec {
|
||||
/**
|
||||
* Defines the text representation of the node when copying to clipboard.
|
||||
*/
|
||||
toPlainText?: PlainTextSerializer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Node, Schema } from "prosemirror-model";
|
||||
import headingToSlug from "../editor/lib/headingToSlug";
|
||||
import textBetween from "../editor/lib/textBetween";
|
||||
import { getTextSerializers } from "../editor/lib/textSerializers";
|
||||
import { ProsemirrorData } from "../types";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
import env from "../env";
|
||||
@@ -91,9 +90,8 @@ export class ProsemirrorHelper {
|
||||
* @param schema The schema to use.
|
||||
* @returns The document content as plain text without formatting.
|
||||
*/
|
||||
static toPlainText(root: Node, schema: Schema) {
|
||||
const textSerializers = getTextSerializers(schema);
|
||||
return textBetween(root, 0, root.content.size, textSerializers);
|
||||
static toPlainText(root: Node) {
|
||||
return textBetween(root, 0, root.content.size);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +100,6 @@ export class ProsemirrorHelper {
|
||||
* @returns True if the editor is empty
|
||||
*/
|
||||
static trim(doc: Node) {
|
||||
const { schema } = doc.type;
|
||||
let index = 0,
|
||||
start = 0,
|
||||
end = doc.nodeSize - 2,
|
||||
@@ -118,7 +115,7 @@ export class ProsemirrorHelper {
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
isEmpty = ProsemirrorHelper.toPlainText(node, schema).trim() === "";
|
||||
isEmpty = ProsemirrorHelper.toPlainText(node).trim() === "";
|
||||
if (isEmpty) {
|
||||
start += node.nodeSize;
|
||||
}
|
||||
@@ -131,7 +128,7 @@ export class ProsemirrorHelper {
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
isEmpty = ProsemirrorHelper.toPlainText(node, schema).trim() === "";
|
||||
isEmpty = ProsemirrorHelper.toPlainText(node).trim() === "";
|
||||
if (isEmpty) {
|
||||
end -= node.nodeSize;
|
||||
}
|
||||
@@ -150,8 +147,6 @@ export class ProsemirrorHelper {
|
||||
return !doc || doc.textContent.trim() === "";
|
||||
}
|
||||
|
||||
const textSerializers = getTextSerializers(schema);
|
||||
|
||||
let empty = true;
|
||||
doc.descendants((child: Node) => {
|
||||
// If we've already found non-empty data, we can stop descending further
|
||||
@@ -159,9 +154,8 @@ export class ProsemirrorHelper {
|
||||
return false;
|
||||
}
|
||||
|
||||
const toPlainText = textSerializers[child.type.name];
|
||||
if (toPlainText) {
|
||||
empty = !toPlainText(child).trim();
|
||||
if (child.type.spec.leafText) {
|
||||
empty = !child.type.spec.leafText(child).trim();
|
||||
} else if (child.isText) {
|
||||
empty = !child.text?.trim();
|
||||
}
|
||||
@@ -331,10 +325,9 @@ export class ProsemirrorHelper {
|
||||
* Iterates through the document to find all of the headings and their level.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @param schema Prosemirror schema
|
||||
* @returns Array<Heading>
|
||||
*/
|
||||
static getHeadings(doc: Node, schema: Schema) {
|
||||
static getHeadings(doc: Node) {
|
||||
const headings: Heading[] = [];
|
||||
const previouslySeen: Record<string, number> = {};
|
||||
|
||||
@@ -356,7 +349,7 @@ export class ProsemirrorHelper {
|
||||
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: ProsemirrorHelper.toPlainText(node, schema),
|
||||
title: ProsemirrorHelper.toPlainText(node),
|
||||
level: node.attrs.level,
|
||||
id: name,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user