Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 7430647de4 chore: Compressed inefficient images automatically 2026-02-06 11:32:49 +00:00
124 changed files with 1376 additions and 3693 deletions
+1 -8
View File
@@ -1,3 +1,4 @@
__mocks__
.git
.vscode
.github
@@ -7,19 +8,11 @@
.eslint*
.oxlintrc*
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
-2
View File
@@ -28,7 +28,6 @@ import {
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
@@ -133,7 +132,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+3 -3
View File
@@ -170,7 +170,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -219,8 +219,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+1 -1
View File
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
$rtl={props.dir === "rtl"}
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
+3 -3
View File
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
$focused?: boolean;
focused?: boolean;
}>`
flex: 1;
margin: ${(props) =>
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
border-color: ${(props) =>
props.hasError
? props.theme.danger
: props.$focused
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
@@ -224,7 +224,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline $focused={focused} margin={margin}>
<Outline focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
+3 -6
View File
@@ -16,7 +16,6 @@ import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import Tooltip from "./Tooltip";
type Props = {
children?: React.ReactNode;
@@ -94,11 +93,9 @@ const Modal: React.FC<Props> = ({
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Header>
</Centered>
</Wrapper>
-47
View File
@@ -1,47 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { NotificationBadgeType, UserPreference } from "@shared/types";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import Desktop from "~/utils/Desktop";
/**
* Component that keeps the app icon notification badge in sync with unread
* notification count. Renders nothing visible — mount near the app root so it
* stays alive as long as the user is authenticated.
*/
function NotificationBadge() {
const { notifications } = useStores();
const user = useCurrentUser();
const badgeType = user.getPreference(UserPreference.NotificationBadge);
const unreadCount = notifications.approximateUnreadCount;
React.useEffect(() => {
// Desktop app badge
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
void Desktop.bridge.setNotificationCount(0);
} else if (badgeType === NotificationBadgeType.Count) {
void Desktop.bridge.setNotificationCount(unreadCount);
} else {
void Desktop.bridge.setNotificationCount("・");
}
}
// PWA badge
if ("setAppBadge" in navigator) {
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
void navigator.setAppBadge(
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
);
} else {
void navigator.clearAppBadge();
}
}
}, [unreadCount, badgeType]);
return null;
}
export default observer(NotificationBadge);
+21 -2
View File
@@ -8,6 +8,7 @@ import Notification, { type NotificationFilter } from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
import ErrorBoundary from "../ErrorBoundary";
import Flex from "../Flex";
@@ -60,7 +61,25 @@ function Notifications(
);
}, [notifications.active, filter]);
const unreadCount = notifications.approximateUnreadCount;
// Update the notification count in the dock icon, if possible.
React.useEffect(() => {
// Account for old versions of the desktop app that don't have the
// setNotificationCount method on the bridge.
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
void Desktop.bridge.setNotificationCount(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
<ErrorBoundary>
@@ -86,7 +105,7 @@ function Notifications(
short
nude
/>
{unreadCount > 0 && (
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
@@ -15,7 +15,6 @@ import EditableTitle from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -123,7 +122,6 @@ const CollectionLink: React.FC<Props> = ({
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
return (
@@ -167,18 +165,17 @@ const CollectionLink: React.FC<Props> = ({
!isDraggingAnyCollection && (
<Fade>
{can.createDocument && (
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
)}
<CollectionMenu
collection={collection}
@@ -40,10 +40,6 @@ import type UserMembership from "~/models/UserMembership";
import type GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
type Props = {
node: NavigationNode;
@@ -123,13 +119,6 @@ function InnerDocumentLink(
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
if (showChildren) {
setExpanded();
@@ -143,18 +132,13 @@ function InnerDocumentLink(
}
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
},
[setCollapsed, setExpanded, expanded, onDisclosureClick]
);
const handleDisclosureClick = React.useCallback(() => {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
}, [setCollapsed, setExpanded, expanded]);
const handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
@@ -352,10 +336,7 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({
documentId: node.id,
onRename: handleRename,
});
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const labelElement = React.useMemo(
() => (
@@ -483,24 +464,22 @@ function InnerDocumentLink(
}
/>
)}
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</ActionContextProvider>
);
}
@@ -13,9 +13,6 @@ import useStores from "~/hooks/useStores";
import type { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
@@ -39,10 +36,6 @@ function DraggableCollectionLink({
);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnyCollection },
@@ -98,22 +91,15 @@ function DraggableCollectionLink({
locationSidebarContext,
]);
const handleDisclosureClick = useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
const handleDisclosureClick = useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
const displayChildDocuments = expanded && !isDragging;
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
@@ -135,7 +121,7 @@ function DraggableCollectionLink({
/>
)}
</Relative>
</SidebarDisclosureContext.Provider>
</>
);
}
+13 -28
View File
@@ -7,9 +7,6 @@ import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -24,20 +21,10 @@ const GroupLink: React.FC<Props> = ({ group }) => {
locationSidebarContext === sidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleDisclosureClick = React.useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
@@ -55,17 +42,15 @@ const GroupLink: React.FC<Props> = ({ group }) => {
depth={0}
/>
<SidebarContext.Provider value={sidebarContext}>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarContext.Provider>
</Relative>
);
@@ -9,10 +9,6 @@ import type Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedModelPath } from "~/utils/routeHelpers";
import { descendants } from "@shared/utils/tree";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -66,14 +62,6 @@ function DocumentLink(
const [expanded, setExpanded] = React.useState(showChildren);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleExpand = React.useCallback(() => setExpanded(true), []);
const handleCollapse = React.useCallback(() => setExpanded(false), []);
useSidebarDisclosure(handleExpand, handleCollapse);
React.useEffect(() => {
if (showChildren) {
setExpanded(showChildren);
@@ -84,12 +72,9 @@ function DocumentLink(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
const willExpand = !expanded;
setExpanded(willExpand);
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
onDisclosureClick(willExpand, !!altKey);
setExpanded(!expanded);
},
[expanded, onDisclosureClick]
[expanded]
);
// since we don't have access to the collection sort here, we just put any
@@ -148,24 +133,22 @@ function DocumentLink(
ref={ref}
isActive={() => !!isActiveDocument}
/>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
{expanded &&
nodeChildren.map((childNode, index) => (
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
</SidebarDisclosureContext.Provider>
{expanded &&
nodeChildren.map((childNode, index) => (
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
</>
);
}
@@ -22,10 +22,6 @@ import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -52,12 +48,6 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
isActiveDocumentInPath && locationSidebarContext === sidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
setExpanded();
@@ -86,15 +76,13 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
onDisclosureClick(willExpand, ev.altKey);
},
[expanded, setExpanded, setCollapsed, onDisclosureClick]
[expanded, setExpanded, setCollapsed]
);
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -186,22 +174,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
</div>
</Draggable>
</Relative>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
@@ -1,127 +0,0 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
} from "react";
/**
* Represents a recursive expand/collapse event broadcast through context.
*/
export interface SidebarDisclosureEvent {
/** Whether descendants should expand or collapse. */
action: "expand" | "collapse";
/**
* Monotonically increasing counter used to detect new events.
* Each increment represents a distinct user interaction.
*/
generation: number;
}
/**
* Context for broadcasting recursive expand/collapse events from a parent
* (e.g. a collection or document disclosure toggle with alt-click) to all
* descendant DocumentLinks in the sidebar tree.
*
* The nearest provider determines the scope — only descendants within that
* provider react to the event. Each DocumentLink should both consume and
* provide this context so that alt-click at any level only affects its subtree.
*/
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
null
);
/**
* Hook that subscribes to recursive expand/collapse events from an ancestor
* provider. When a new event is detected, the appropriate callback is invoked.
*
* Newly mounted components will also react to the current event, which enables
* cascading: expanding a parent reveals children, which mount and see the
* expand event, then expand themselves to reveal grandchildren, and so on.
*
* @param onExpand - called when a recursive expand event is received.
* @param onCollapse - called when a recursive collapse event is received.
*/
export function useSidebarDisclosure(
onExpand: () => void,
onCollapse: () => void
): void {
const event = useContext(SidebarDisclosureContext);
const lastHandledGeneration = useRef(-1);
useEffect(() => {
if (!event || event.generation === lastHandledGeneration.current) {
return;
}
lastHandledGeneration.current = event.generation;
if (event.action === "expand") {
onExpand();
} else {
onCollapse();
}
}, [event, onExpand, onCollapse]);
}
/**
* Hook for the producing side of the disclosure context. Returns the current
* event value (to pass to a Provider) and a single callback to handle
* alt-click expand/collapse broadcasts.
*
* This hook also reads the parent context and automatically forwards any
* incoming disclosure events so that the cascade propagates through the
* entire tree — even when intermediate nodes each create their own provider.
*
* @returns object with `event` to spread onto the Provider's value and
* `onDisclosureClick` to call from disclosure click handlers.
*/
export function useSidebarDisclosureState() {
const parentEvent = useContext(SidebarDisclosureContext);
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
const lastForwardedParentGeneration = useRef(-1);
// Forward parent disclosure events into our own provider value so that
// grandchildren (and beyond) see the event even though each level creates
// its own independent provider.
useEffect(() => {
if (
!parentEvent ||
parentEvent.generation === lastForwardedParentGeneration.current
) {
return;
}
lastForwardedParentGeneration.current = parentEvent.generation;
setEvent((prev) => ({
action: parentEvent.action,
generation: (prev?.generation ?? 0) + 1,
}));
}, [parentEvent]);
/**
* Call from a disclosure click handler after toggling expand/collapse state.
* When alt is held, broadcasts a recursive expand or collapse event to all
* descendants. Otherwise, clears any stale event.
*
* @param willExpand - whether the node is expanding or collapsing.
* @param altKey - whether the alt/option key was held during the click.
*/
const onDisclosureClick = useCallback(
(willExpand: boolean, altKey: boolean) => {
if (altKey) {
setEvent((prev) => ({
action: willExpand ? "expand" : "collapse",
generation: (prev?.generation ?? 0) + 1,
}));
} else {
setEvent(null);
}
},
[]
);
return { event, onDisclosureClick };
}
export default SidebarDisclosureContext;
@@ -19,9 +19,6 @@ import {
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import DocumentLink from "./DocumentLink";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
@@ -207,9 +204,6 @@ function StarredLink({ star }: Props) {
sidebarContext === locationSidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
@@ -241,13 +235,9 @@ function StarredLink({ star }: Props) {
(ev?: React.MouseEvent<HTMLElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => {
const willExpand = !prevExpanded;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
setExpanded((prevExpanded) => !prevExpanded);
},
[onDisclosureClick]
[]
);
const handlePrefetch = React.useCallback(() => {
@@ -294,43 +284,39 @@ function StarredLink({ star }: Props) {
if (documentId) {
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
</SidebarDisclosureContext.Provider>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
);
}
if (collection) {
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
</SidebarDisclosureContext.Provider>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
);
}
+1 -5
View File
@@ -59,7 +59,6 @@ export type Props<TData> = {
};
rowHeight: number;
stickyOffset?: number;
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
};
function Table<TData>({
@@ -71,7 +70,6 @@ function Table<TData>({
page,
rowHeight,
stickyOffset = 0,
decorateRow,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
@@ -208,7 +206,7 @@ function Table<TData>({
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
const baseRow = (
return (
<TR
role="row"
key={row.id}
@@ -233,8 +231,6 @@ function Table<TData>({
))}
</TR>
);
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
})}
</TBody>
{showPlaceholder && (
+1 -3
View File
@@ -33,7 +33,6 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
autoFocus?: boolean;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
@@ -46,7 +45,6 @@ const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
autoFocus,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
@@ -211,7 +209,7 @@ const LinkEditor: React.FC<Props> = ({
onKeyDown={handleKeyDown}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={autoFocus}
autoFocus={getHref() === ""}
readOnly={!view.editable}
/>
{actions.map((action, index) => {
+30 -44
View File
@@ -87,29 +87,25 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const { state } = view;
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
const isDragging = useIsDragging(state);
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
React.useEffect(() => {
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
React.useLayoutEffect(() => {
if (!isActive) {
setActiveToolbar(null);
return;
}
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
@@ -128,37 +124,22 @@ export function SelectionToolbar(props: Props) {
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [
readOnly,
isActive,
selection,
linkMark,
isEmbedSelection,
isCodeSelection,
isNoticeSelection,
]);
React.useLayoutEffect(() => {
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
setAutoFocusLinkInput(false);
}
}, [activeToolbar]);
}, [readOnly, selection]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useLayoutEffect(() => {
React.useEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly &&
isActive
!readOnly
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, isActive, view]);
}, [activeToolbar, readOnly, view]);
React.useLayoutEffect(() => {
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
@@ -212,10 +193,11 @@ export function SelectionToolbar(props: Props) {
) {
ev.preventDefault();
ev.stopPropagation();
setAutoFocusLinkInput(true);
setActiveToolbar(
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
);
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
}
},
view.dom,
@@ -236,6 +218,12 @@ export function SelectionToolbar(props: Props) {
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
@@ -301,7 +289,6 @@ export function SelectionToolbar(props: Props) {
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setAutoFocusLinkInput(true);
setActiveToolbar(Toolbar.Link);
};
}
@@ -328,11 +315,10 @@ export function SelectionToolbar(props: Props) {
>
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`link-${selection.anchor}`}
key={`${selection.from}-${selection.to}`}
dictionary={dictionary}
autoFocus={autoFocusLinkInput}
view={view}
mark={linkMark ? linkMark.mark : undefined}
mark={link ? link.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
@@ -342,7 +328,7 @@ export function SelectionToolbar(props: Props) {
/>
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.anchor}`}
key={`embed-${selection.from}`}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
+2 -8
View File
@@ -78,11 +78,6 @@ export type Props = {
focusedCommentId?: string;
/** If the editor should not allow editing */
readOnly?: boolean;
/**
* Whether we are rendering a cached version of the document while multiplayer loads.
* This is used to disable some editor functionality
*/
cacheOnly?: boolean;
/** If the editor should still allow editing checkboxes when it is readOnly */
canUpdate?: boolean;
/** If the editor should still allow commenting when it is readOnly */
@@ -859,7 +854,7 @@ export class Editor extends React.PureComponent<
column
>
<EditorContainer
$rtl={isRTL}
rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={canUpdate}
@@ -872,7 +867,6 @@ export class Editor extends React.PureComponent<
/>
{this.widgets &&
!this.props.cacheOnly &&
Object.values(this.widgets).map((Widget, index) => (
<Widget
key={String(index)}
@@ -893,7 +887,7 @@ export class Editor extends React.PureComponent<
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={this.view.focus.bind(this.view)}
onClose={this.view.focus}
/>
)}
</EditorContext.Provider>
+1 -5
View File
@@ -10,11 +10,7 @@ if (!window.env) {
);
}
const env: Record<string, any> & {
isDevelopment: boolean;
isTest: boolean;
isProduction: boolean;
} = {
const env: Record<string, any> = {
...window.env,
isDevelopment: window.env.ENVIRONMENT === "development",
isTest: window.env.ENVIRONMENT === "test",
+1 -42
View File
@@ -74,7 +74,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
.filter((policy): policy is Policy => policy !== undefined),
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: new Set(stores.ui.activeModels.values()),
activeModels: stores.ui.activeModels,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
@@ -84,50 +84,9 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
};
// Merge the parent context with the provided overrides
const activeCollectionId =
value.activeCollectionId ?? baseContext.activeCollectionId;
const activeDocumentId =
value.activeDocumentId ?? baseContext.activeDocumentId;
const getActiveModels = <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => {
// @ts-expect-error modelName
if (activeCollectionId && modelClass.modelName === "Collection") {
const model = stores.collections.get(activeCollectionId);
if (model) {
return [model as unknown as T];
}
}
// @ts-expect-error modelName
if (activeDocumentId && modelClass.modelName === "Document") {
const model = stores.documents.get(activeDocumentId);
if (model) {
return [model as unknown as T];
}
}
return baseContext.getActiveModels(modelClass);
};
const getActiveModel = <T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined => getActiveModels(modelClass)[0];
const getActivePolicies = <T extends Model>(
modelClass: new (...args: any[]) => T
): Policy[] =>
getActiveModels(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined);
const contextValue: ActionContextType = {
...baseContext,
...value,
getActiveModels,
getActiveModel,
getActivePolicies,
};
return (
-3
View File
@@ -26,9 +26,6 @@ export default function useDictionary() {
alignFullWidth: t("Full width"),
bulletList: t("Bulleted list"),
checkboxList: t("Todo list"),
showCompleted: (count: number) =>
t("Show {{ count }} completed", { count }),
hideCompleted: t("Hide completed"),
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
-91
View File
@@ -1,91 +0,0 @@
import * as React from "react";
import { TrashIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import type Emoji from "~/models/Emoji";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { createAction } from "~/actions";
import { EmojiSecion } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for emoji management operations.
*
* @param targetEmoji - the emoji to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if emoji is null.
*/
export function useEmojiMenuActions(targetEmoji: Emoji | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(targetEmoji ?? ({} as Emoji));
const openDeleteDialog = React.useCallback(() => {
if (!targetEmoji) {
return;
}
dialogs.openModal({
title: t("Delete Emoji"),
content: (
<DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetEmoji, dialogs]);
const actionList = React.useMemo(
() =>
!targetEmoji || !can.delete
? []
: [
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: EmojiSecion,
visible: true,
dangerous: true,
perform: openDeleteDialog,
}),
],
[t, targetEmoji, can.delete, openDeleteDialog]
);
return useMenuAction(actionList);
}
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("I'm sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
};
-115
View File
@@ -1,115 +0,0 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
ActionSeparator,
createAction,
createExternalLinkAction,
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for group management operations.
*
* @param targetGroup - the group to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if group is null.
*/
export function useGroupMenuActions(targetGroup: Group | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(targetGroup ?? ({} as Group));
const openMembersDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={targetGroup} />,
});
}, [t, targetGroup, dialogs]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
const openDeleteDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
const actionList = React.useMemo(
() =>
!targetGroup
? []
: [
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(targetGroup && can.read),
perform: openMembersDialog,
}),
ActionSeparator,
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(targetGroup && can.update),
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(targetGroup && can.delete),
dangerous: true,
perform: openDeleteDialog,
}),
ActionSeparator,
createExternalLinkAction({
name: targetGroup.externalId ?? "",
section: GroupSection,
visible: !!targetGroup.externalId,
disabled: true,
url: "",
}),
],
[
t,
targetGroup,
can.read,
can.update,
can.delete,
openMembersDialog,
openEditDialog,
openDeleteDialog,
]
);
return useMenuAction(actionList);
}
-35
View File
@@ -1,35 +0,0 @@
import * as React from "react";
import type Share from "~/models/Share";
import usePolicy from "~/hooks/usePolicy";
import { ActionSeparator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for share management operations.
*
* @param targetShare - the share to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if share is null.
*/
export function useShareMenuActions(targetShare: Share | null) {
const can = usePolicy(targetShare ?? ({} as Share));
const actionList = React.useMemo(
() =>
!targetShare
? []
: [
copyShareUrlFactory({ share: targetShare }),
goToShareSourceFactory({ share: targetShare }),
ActionSeparator,
revokeShareFactory({ share: targetShare, can }),
],
[targetShare, can]
);
return useMenuAction(actionList);
}
-190
View File
@@ -1,190 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import type User from "~/models/User";
import {
ActionSeparator,
createAction,
createActionWithChildren,
} from "~/actions";
import {
deleteUserActionFactory,
updateUserRoleActionFactory,
} from "~/actions/definitions/users";
import { UserSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
/**
* Hook that constructs the action menu for user management operations.
*
* @param targetUser - the user to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if user is null.
*/
export function useUserMenuActions(targetUser: User | null) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(targetUser ?? ({} as User));
const openNameDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openEmailDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openSuspendDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const revokeInvitation = React.useCallback(async () => {
if (!targetUser) {
return;
}
await users.delete(targetUser);
}, [users, targetUser]);
const resendInvitation = React.useCallback(async () => {
if (!targetUser) {
return;
}
try {
await users.resendInvite(targetUser);
toast.success(t(`Invite was resent to ${targetUser.name}`));
} catch (err) {
toast.error(
err.message ?? t(`An error occurred while sending the invite`)
);
}
}, [users, targetUser, t]);
const activateUser = React.useCallback(async () => {
if (!targetUser) {
return;
}
await users.activate(targetUser);
}, [users, targetUser]);
const roleChangeActions = React.useMemo(
() =>
targetUser
? [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(targetUser, role)
)
: [],
[targetUser]
);
const actionList = React.useMemo(
() =>
!targetUser
? []
: [
createActionWithChildren({
name: t("Change role"),
section: UserSection,
visible: can.demote || can.promote,
children: roleChangeActions,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
visible: can.update,
perform: openNameDialog,
}),
createAction({
name: `${t("Change email")}`,
section: UserSection,
visible: can.update,
perform: openEmailDialog,
}),
createAction({
name: t("Resend invite"),
section: UserSection,
visible: can.resendInvite,
perform: resendInvitation,
}),
ActionSeparator,
createAction({
name: `${t("Revoke invite")}`,
section: UserSection,
visible: targetUser.isInvited,
dangerous: true,
perform: revokeInvitation,
}),
createAction({
name: t("Activate user"),
section: UserSection,
visible: !targetUser.isInvited && targetUser.isSuspended,
perform: activateUser,
}),
createAction({
name: `${t("Suspend user")}`,
section: UserSection,
visible: !targetUser.isInvited && !targetUser.isSuspended,
dangerous: true,
perform: openSuspendDialog,
}),
ActionSeparator,
deleteUserActionFactory(targetUser.id),
],
[
t,
targetUser,
can.demote,
can.promote,
can.update,
can.resendInvite,
roleChangeActions,
openNameDialog,
openEmailDialog,
resendInvitation,
revokeInvitation,
activateUser,
openSuspendDialog,
]
);
return useMenuAction(actionList);
}
-8
View File
@@ -3,7 +3,6 @@ import "vite/modulepreload-polyfill";
import { LazyMotion } from "framer-motion";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import { configure as configureMobx } from "mobx";
import { StrictMode } from "react";
import { render } from "react-dom";
import { HelmetProvider } from "react-helmet-async";
@@ -38,13 +37,6 @@ if (env.SENTRY_DSN) {
initSentry(history);
}
configureMobx({
// TODO: Enable these options and fix any resulting warnings
// enforceActions: env.isDevelopment ? "always" : "never",
// computedRequiresReaction: true,
isolateGlobalState: true,
});
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
+68 -21
View File
@@ -1,28 +1,75 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { TrashIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { IconButton } from "~/components/IconPicker/components/IconButton";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import type Emoji from "~/models/Emoji";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
type Props = {
emoji: Emoji;
};
function EmojisMenu({ emoji }: Props) {
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
const { t } = useTranslation();
const rootAction = useEmojiMenuActions(emoji);
const { dialogs } = useStores();
const can = usePolicy(emoji);
const handleDelete = () => {
dialogs.openModal({
title: t("Delete Emoji"),
content: (
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
),
});
};
if (!can.delete) {
return null;
}
return (
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Emoji options")}
>
<OverflowMenuButton />
</DropdownMenu>
<Tooltip content={t("Delete Emoji")}>
<IconButton onClick={handleDelete}>
<TrashIcon />
</IconButton>
</Tooltip>
);
}
};
export default observer(EmojisMenu);
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
};
export default EmojisMenu;
+91 -3
View File
@@ -1,10 +1,24 @@
import { observer } from "mobx-react";
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
ActionSeparator,
createAction,
createExternalLinkAction,
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
group: Group;
@@ -12,7 +26,81 @@ type Props = {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const rootAction = useGroupMenuActions(group);
const { dialogs } = useStores();
const can = usePolicy(group);
const handleViewMembers = useCallback(() => {
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
});
}, [t, group, dialogs]);
const handleEditGroup = useCallback(() => {
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
const handleDeleteGroup = useCallback(() => {
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
const actions = useMemo(
() => [
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(group && can.read),
perform: handleViewMembers,
}),
ActionSeparator,
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(group && can.update),
perform: handleEditGroup,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(group && can.delete),
dangerous: true,
perform: handleDeleteGroup,
}),
ActionSeparator,
createExternalLinkAction({
name: group.externalId ?? "",
section: GroupSection,
visible: !!group.externalId,
disabled: true,
url: "",
}),
],
[
t,
group,
can.read,
can.update,
can.delete,
handleViewMembers,
handleEditGroup,
handleDeleteGroup,
]
);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu
+21 -2
View File
@@ -4,7 +4,14 @@ import { useTranslation } from "react-i18next";
import type Share from "~/models/Share";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
import usePolicy from "~/hooks/usePolicy";
import { ActionSeparator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
share: Share;
@@ -12,7 +19,19 @@ type Props = {
function ShareMenu({ share }: Props) {
const { t } = useTranslation();
const rootAction = useShareMenuActions(share);
const can = usePolicy(share);
const actions = React.useMemo(
() => [
copyShareUrlFactory({ share }),
goToShareSourceFactory({ share }),
ActionSeparator,
revokeShareFactory({ share, can }),
],
[share, can]
);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu
+147 -2
View File
@@ -1,18 +1,163 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import type User from "~/models/User";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
import {
ActionSeparator,
createAction,
createActionWithChildren,
} from "~/actions";
import {
deleteUserActionFactory,
updateUserRoleActionFactory,
} from "~/actions/definitions/users";
import { UserSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
user: User;
};
function UserMenu({ user }: Props) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
const rootAction = useUserMenuActions(user);
const can = usePolicy(user);
const handleChangeName = React.useCallback(() => {
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleChangeEmail = React.useCallback(() => {
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleSuspend = React.useCallback(() => {
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleRevoke = React.useCallback(async () => {
await users.delete(user);
}, [users, user]);
const handleResendInvite = React.useCallback(async () => {
try {
await users.resendInvite(user);
toast.success(t(`Invite was resent to ${user.name}`));
} catch (err) {
toast.error(
err.message ?? t(`An error occurred while sending the invite`)
);
}
}, [users, user, t]);
const handleActivate = React.useCallback(async () => {
await users.activate(user);
}, [users, user]);
const changeRoleActions = React.useMemo(
() =>
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(user, role)
),
[user]
);
const actions = React.useMemo(
() => [
createActionWithChildren({
name: t("Change role"),
section: UserSection,
visible: can.demote || can.promote,
children: changeRoleActions,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
visible: can.update,
perform: handleChangeName,
}),
createAction({
name: `${t("Change email")}`,
section: UserSection,
visible: can.update,
perform: handleChangeEmail,
}),
createAction({
name: t("Resend invite"),
section: UserSection,
visible: can.resendInvite,
perform: handleResendInvite,
}),
ActionSeparator,
createAction({
name: `${t("Revoke invite")}`,
section: UserSection,
visible: user.isInvited,
dangerous: true,
perform: handleRevoke,
}),
createAction({
name: t("Activate user"),
section: UserSection,
visible: !user.isInvited && user.isSuspended,
perform: handleActivate,
}),
createAction({
name: `${t("Suspend user")}`,
section: UserSection,
visible: !user.isInvited && !user.isSuspended,
dangerous: true,
perform: handleSuspend,
}),
ActionSeparator,
deleteUserActionFactory(user.id),
],
[
t,
can.demote,
can.promote,
can.update,
can.resendInvite,
user.id,
user.isInvited,
user.isSuspended,
changeRoleActions,
handleChangeName,
handleChangeEmail,
handleResendInvite,
handleRevoke,
handleActivate,
handleSuspend,
]
);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
+5 -13
View File
@@ -231,14 +231,10 @@ class User extends ParanoidModel implements Searchable {
* @param key The UserPreference key to retrieve
* @returns The value
*/
getPreference<K extends UserPreference>(
key: K,
defaultValue?: UserPreferences[K]
): NonNullable<UserPreferences[K]> {
return (this.preferences?.[key] ??
UserPreferenceDefaults[key] ??
defaultValue ??
false) as NonNullable<UserPreferences[K]>;
getPreference(key: UserPreference, defaultValue = false): boolean {
return (
this.preferences?.[key] ?? UserPreferenceDefaults[key] ?? defaultValue
);
}
/**
@@ -247,11 +243,7 @@ class User extends ParanoidModel implements Searchable {
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
@action
setPreference<K extends UserPreference>(
key: K,
value: NonNullable<UserPreferences[K]>
) {
setPreference(key: UserPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
+9 -14
View File
@@ -104,23 +104,18 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchRevision() {
if (!revisionId) {
return;
}
try {
if (revisionId === "latest") {
if (document?.id) {
await revisions.fetchLatest(document.id);
}
} else {
await revisions.fetch(revisionId);
if (revisionId) {
try {
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
revisionId
);
} catch (err) {
setError(err);
}
} catch (err) {
setError(err);
}
}
void fetchRevision();
}, [revisions, revisionId, document?.id]);
}, [revisions, revisionId]);
React.useEffect(() => {
async function fetchViews() {
@@ -167,7 +162,7 @@ function DataLoader({ match, children }: Props) {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
if (!can.update && isEditRoute && !document.template) {
history.push(document.url);
return;
}
+13 -1
View File
@@ -67,6 +67,8 @@ function DocumentHeader({
revision,
isEditing,
isDraft,
isPublishing,
isSaving,
savingIsDisabled,
publishingIsDisabled,
onSelectTemplate,
@@ -254,6 +256,10 @@ function DocumentHeader({
actions={({ isCompact }) => (
<>
<ObservingBanner />
{!isPublishing && isSaving && user?.separateEditMode && (
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && can.listViews && (
<Collaborators
document={document}
@@ -280,7 +286,7 @@ function DocumentHeader({
{(isEditing || isTemplateEditable) && (
<Action>
<Tooltip
content={isDraft ? t("Save draft") : t("Done editing")}
content={t("Save")}
shortcut={`${metaDisplay}+enter`}
placement="bottom"
>
@@ -370,4 +376,10 @@ const StyledHeader = styled(Header)<{ $hidden: boolean }>`
${(props) => props.$hidden && "opacity: 0;"}
`;
const Status = styled(Action)`
padding-left: 0;
padding-right: 4px;
color: ${(props) => props.theme.slate};
`;
export default observer(DocumentHeader);
@@ -317,7 +317,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
defaultValue={props.defaultValue}
extensions={props.extensions}
scrollTo={props.scrollTo}
cacheOnly
readOnly
ref={ref}
/>
+2 -2
View File
@@ -23,11 +23,11 @@ function DocumentNew({ template }: Props) {
const location = useLocation();
const query = useQuery();
const user = useCurrentUser();
const match = useRouteMatch<{ collectionSlug?: string }>();
const match = useRouteMatch<{ id?: string }>();
const { t } = useTranslation();
const { documents, collections, userMemberships, groupMemberships } =
useStores();
const id = match.params.collectionSlug || query.get("collectionId");
const id = match.params.id || query.get("collectionId");
useEffect(() => {
async function createDocument() {
+8
View File
@@ -117,6 +117,14 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
),
label: t("Publish document and exit"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>s</Key>
</>
),
label: t("Save document"),
},
{
shortcut: (
<>
+2 -54
View File
@@ -4,11 +4,7 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions as availableLanguages } from "@shared/i18n";
import {
NotificationBadgeType,
TeamPreference,
UserPreference,
} from "@shared/types";
import { TeamPreference, UserPreference } from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
@@ -99,39 +95,6 @@ function Preferences() {
[user, t]
);
const notificationBadgeOptions: Option[] = React.useMemo(
() => [
{
type: "item",
label: t("Disabled"),
value: NotificationBadgeType.Disabled,
},
{
type: "item",
label: t("Unread count"),
value: NotificationBadgeType.Count,
},
{
type: "item",
label: t("Unread indicator"),
value: NotificationBadgeType.Indicator,
},
],
[t]
);
const handleNotificationBadgeChange = React.useCallback(
async (value: string) => {
user.setPreference(
UserPreference.NotificationBadge,
value as NotificationBadgeType
);
await user.save();
toast.success(t("Preferences saved"));
},
[user, t]
);
const handleLanguageChange = React.useCallback(
async (language: string) => {
await user.save({ language });
@@ -267,6 +230,7 @@ function Preferences() {
/>
</SettingRow>
<SettingRow
border={false}
name={UserPreference.EnableSmartText}
label={t("Smart text replacements")}
description={t(
@@ -280,22 +244,6 @@ function Preferences() {
onChange={handleEnableSmartTextChange}
/>
</SettingRow>
<SettingRow
border={false}
name={UserPreference.NotificationBadge}
label={t("Notification badge")}
description={t(
"Choose how unread notifications are indicated on the app icon."
)}
>
<InputSelect
options={notificationBadgeOptions}
value={user.getPreference(UserPreference.NotificationBadge)}
onChange={handleNotificationBadgeChange}
label={t("Notification badge")}
hideLabel
/>
</SettingRow>
{can.delete && (
<>
+6 -38
View File
@@ -1,7 +1,6 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type Emoji from "~/models/Emoji";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -11,8 +10,6 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
import Time from "~/components/Time";
import { FILTER_HEIGHT } from "./StickyFilters";
import { CustomEmoji } from "@shared/components/CustomEmoji";
@@ -28,38 +25,12 @@ type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function EmojiRowContextMenu({
emoji,
menuLabel,
children,
}: {
emoji: Emoji;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useEmojiMenuActions(emoji);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
const EmojisTable = observer(function EmojisTable({
canManage,
...rest
}: Props) {
const { t } = useTranslation();
const applyContextMenu = useCallback(
(emoji: Emoji, rowElement: React.ReactNode) => (
<EmojiRowContextMenu emoji={emoji} menuLabel={t("Emoji options")}>
{rowElement}
</EmojiRowContextMenu>
),
[t]
);
const columns = React.useMemo(
(): TableColumn<Emoji>[] =>
compact([
@@ -102,14 +73,12 @@ const EmojisTable = observer(function EmojisTable({
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
width: "1fr",
},
canManage
? {
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
}
: undefined,
{
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
},
]),
[t, canManage]
);
@@ -119,7 +88,6 @@ const EmojisTable = observer(function EmojisTable({
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
@@ -16,8 +16,6 @@ import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import type { Item } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import { ListItem } from "~/components/Sharing/components/ListItem";
@@ -231,10 +229,6 @@ export const ViewGroupMembersDialog = observer(function ({
const { dialogs, users, groupUsers } = useStores();
const { t } = useTranslation();
const can = usePolicy(group);
const [query, setQuery] = React.useState("");
const [permissionFilter, setPermissionFilter] = React.useState<
GroupPermission | "all"
>("all");
const handleAddPeople = React.useCallback(() => {
dialogs.openModal({
@@ -268,59 +262,6 @@ export const ViewGroupMembersDialog = observer(function ({
[t, groupUsers, group.id]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const handlePermissionFilterChange = React.useCallback((value: string) => {
setPermissionFilter(value as GroupPermission | "all");
}, []);
const permissionOptions: Item[] = React.useMemo(
() => [
{
type: "item",
label: t("All permissions"),
value: "all",
},
{
type: "item",
label: t("Group admin"),
value: GroupPermission.Admin,
},
{
type: "item",
label: t("Member"),
value: GroupPermission.Member,
},
],
[t]
);
const filteredUsers = React.useMemo(() => {
let result = users.inGroup(group.id, query);
if (permissionFilter !== "all") {
const groupUserMap = new Map(
groupUsers.orderedData
.filter((gu) => gu.groupId === group.id)
.map((gu) => [gu.userId, gu])
);
result = result.filter((user) => {
const groupUser = groupUserMap.get(user.id);
return groupUser?.permission === permissionFilter;
});
}
return result;
}, [users, group.id, query, permissionFilter, groupUsers.orderedData]);
const hasActiveFilters = query || permissionFilter !== "all";
return (
<Flex column>
{can.update ? (
@@ -363,40 +304,13 @@ export const ViewGroupMembersDialog = observer(function ({
/>
</Text>
)}
{(filteredUsers.length || hasActiveFilters) && (
<Flex gap={8}>
<Input
type="search"
placeholder={`${t("Search by name")}`}
value={query}
onChange={handleFilter}
label={t("Search members")}
labelHidden
flex
/>
<InputSelect
options={permissionOptions}
value={permissionFilter}
onChange={handlePermissionFilterChange}
label={t("Filter by permissions")}
hideLabel
short
/>
</Flex>
)}
<PaginatedList<User>
items={filteredUsers}
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={
hasActiveFilters ? (
<Empty>{t("No members matching your filters")}</Empty>
) : (
<Empty>{t("This group has no members.")}</Empty>
)
}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(user) => (
<GroupMemberListItem
key={user.id}
@@ -1,6 +1,5 @@
import compact from "lodash/compact";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -15,8 +14,6 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
@@ -32,23 +29,6 @@ const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
function GroupRowContextMenu({
group,
menuLabel,
children,
}: {
group: Group;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useGroupMenuActions(group);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
export function GroupsTable(props: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
@@ -63,15 +43,6 @@ export function GroupsTable(props: Props) {
[t, dialogs]
);
const applyContextMenu = useCallback(
(group: Group, rowElement: React.ReactNode) => (
<GroupRowContextMenu group={group} menuLabel={t("Group options")}>
{rowElement}
</GroupRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<Group>[]>(
() =>
compact<TableColumn<Group>>([
@@ -165,7 +136,6 @@ export function GroupsTable(props: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={applyContextMenu}
{...props}
/>
);
@@ -1,5 +1,5 @@
import compact from "lodash/compact";
import { useMemo, useCallback } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Text from "@shared/components/Text";
import type User from "~/models/User";
@@ -11,8 +11,6 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
@@ -28,43 +26,11 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function UserRowContextMenu({
user,
menuLabel,
children,
}: {
user: User;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useUserMenuActions(user);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const applyContextMenu = useCallback(
(user: User, rowElement: React.ReactNode) => {
if (currentUser.id === user.id) {
return rowElement;
}
return (
<UserRowContextMenu user={user} menuLabel={t("User options")}>
{rowElement}
</UserRowContextMenu>
);
},
[currentUser.id, t]
);
const columns = useMemo<TableColumn<User>[]>(
() =>
compact<TableColumn<User>>([
@@ -153,7 +119,6 @@ export function MembersTable({ canManage, ...rest }: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
+2 -32
View File
@@ -1,6 +1,5 @@
import compact from "lodash/compact";
import * as React from "react";
import { useMemo, useCallback } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -12,8 +11,6 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
import Time from "~/components/Time";
import ShareMenu from "~/menus/ShareMenu";
import { useFormatNumber } from "~/hooks/useFormatNumber";
@@ -25,37 +22,11 @@ type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function ShareRowContextMenu({
share,
menuLabel,
children,
}: {
share: Share;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useShareMenuActions(share);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const formatNumber = useFormatNumber();
const hasDomain = data.some((share) => share.domain);
const applyContextMenu = useCallback(
(share: Share, rowElement: React.ReactNode) => (
<ShareRowContextMenu share={share} menuLabel={t("Share options")}>
{rowElement}
</ShareRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<Share>[]>(
() =>
compact<TableColumn<Share>>([
@@ -67,7 +38,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
sortable: false,
component: (share) => (
<>
{share.sourceTitle || t("Untitled")}{" "}
{share.sourceTitle || t("Untitled")}
{share.collectionId ? <Badge>{t("Collection")}</Badge> : null}
</>
),
@@ -154,7 +125,6 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={HEADER_HEIGHT}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
+1 -2
View File
@@ -20,8 +20,7 @@ export default class RevisionsStore extends Store<Revision> {
/**
* Fetches the latest revision for the given document.
*
* @param documentId - the id of the document to fetch the latest revision for.
* @returns A promise that resolves to the latest revision for the given document.
* @returns A promise that resolves to the latest revision for the given document
*/
fetchLatest = async (documentId: string) => {
const res = await client.post(`/revisions.info`, { documentId });
+6 -6
View File
@@ -54,7 +54,7 @@ class UiStore {
systemTheme: SystemTheme;
@observable
activeModels = observable.map<string, Model>();
activeModels = new Set<Model>();
@observable
observingUserId: string | undefined;
@@ -156,7 +156,7 @@ class UiStore {
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.set(model.id, model);
this.activeModels.add(model);
};
/**
@@ -166,7 +166,7 @@ class UiStore {
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model.id);
this.activeModels.delete(model);
};
/**
@@ -176,7 +176,7 @@ class UiStore {
* @returns array of active models of the specified type.
*/
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
return Array.from(this.activeModels.values()).filter(
return Array.from(this.activeModels).filter(
(model) => model.constructor === modelClass
) as T[];
}
@@ -188,7 +188,7 @@ class UiStore {
* @returns true if the model is active.
*/
isModelActive(model: Model): boolean {
return this.activeModels.has(model.id);
return this.activeModels.has(model);
}
/**
@@ -200,7 +200,7 @@ class UiStore {
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
if (modelClass) {
const modelsToRemove = this.getActiveModels(modelClass);
modelsToRemove.forEach((model) => this.activeModels.delete(model.id));
modelsToRemove.forEach((model) => this.activeModels.delete(model));
} else {
this.activeModels.clear();
}
+1 -1
View File
@@ -63,7 +63,7 @@ declare global {
/**
* Set the badge on the app icon.
*/
setNotificationCount: (count: number | string) => Promise<void>;
setNotificationCount: (count: number) => Promise<void>;
/**
* Registers a callback to be called when the window is focused.
+1 -2
View File
@@ -3,7 +3,6 @@ import backend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import { languages } from "@shared/i18n";
import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date";
import { cdnPath } from "@shared/utils/urls";
import Logger from "./Logger";
/**
@@ -26,7 +25,7 @@ export function initI18n(defaultLanguage = "en_US") {
// this must match the path defined in routes. It's the path that the
// frontend UI code will hit to load missing translations.
loadPath: (locale: string[]) =>
cdnPath(`/locales/${unicodeBCP47toCLDR(locale[0])}.json`),
`/locales/${unicodeBCP47toCLDR(locale[0])}.json`,
},
interpolation: {
escapeValue: false,
-1
View File
@@ -296,7 +296,6 @@
"@types/invariant": "^2.2.37",
"@types/ioredis-mock": "^8.2.6",
"@types/jest": "^29.5.14",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.7",
"@types/koa": "^2.15.0",
@@ -44,10 +44,9 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
const client = new NotionClient(integration.authentication.token);
const parsedPages: (ParsePageOutput | null)[] = [];
for (const item of importTask.input) {
parsedPages.push(await this.processPage({ item, client }));
}
const parsedPages = await Promise.all(
importTask.input.map(async (item) => this.processPage({ item, client }))
);
// Filter out any null results (from pages/databases that couldn't be accessed)
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
@@ -5,10 +5,6 @@ import allNodes from "@server/test/fixtures/notion-page.json";
import type { NotionPage } from "./NotionConverter";
import { NotionConverter } from "./NotionConverter";
jest.mock("node:crypto", () => ({
randomUUID: jest.fn(() => "550e8400-e29b-41d4-a716-446655440000"),
}));
describe("NotionConverter", () => {
it("converts a page", () => {
const response = NotionConverter.page({
+4 -33
View File
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import type {
BookmarkBlockObjectResponse,
BreadcrumbBlockObjectResponse,
@@ -16,7 +15,6 @@ import type {
ImageBlockObjectResponse,
EmbedBlockObjectResponse,
TableBlockObjectResponse,
TableOfContentsBlockObjectResponse,
ToDoBlockObjectResponse,
EquationBlockObjectResponse,
CodeBlockObjectResponse,
@@ -47,7 +45,7 @@ export class NotionConverter {
* Nodes which cannot contain block children in Outline, their children
* will be flattened into the parent.
*/
private static nodesWithoutBlockChildren = ["paragraph"];
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
public static page(item: NotionPage): ProsemirrorDoc {
return {
@@ -68,20 +66,6 @@ export class NotionConverter {
if (this[child.type]) {
// @ts-expect-error Not all blocks have an interface
const response = this[child.type](child);
// @ts-expect-error Not all blocks have an interface
const canToggle = child[child.type].is_toggleable === true;
if (canToggle) {
return {
type: "container_toggle",
attrs: {
id: randomUUID(),
},
content: [response, ...this.mapChildren(child)],
};
}
if (
response &&
this.nodesWithoutBlockChildren.includes(response.type) &&
@@ -576,23 +560,10 @@ export class NotionConverter {
};
}
private static table_of_contents(_: TableOfContentsBlockObjectResponse) {
return undefined;
}
private static toggle(item: Block<ToggleBlockObjectResponse>) {
private static toggle(item: ToggleBlockObjectResponse) {
return {
type: "container_toggle",
attrs: {
id: randomUUID(),
},
content: [
{
type: "paragraph",
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
type: "paragraph",
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
};
}
@@ -608,105 +608,38 @@ exports[`NotionConverter converts a page 1`] = `
},
{
"attrs": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"level": 2,
},
"content": [
{
"attrs": {
"level": 2,
},
"content": [
{
"marks": [],
"text": "Toggleable heading",
"type": "text",
},
],
"type": "heading",
},
{
"content": [
{
"marks": [],
"text": "Some paragraph content within toggleable heading.",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
"id": "550e8400-e29b-41d4-a716-446655440000",
},
"content": [
{
"attrs": {
"level": 3,
},
"content": [
{
"marks": [],
"text": "Toggleable heading inside toggleable heading",
"type": "text",
},
],
"type": "heading",
},
{
"content": [
{
"marks": [],
"text": "Some paragraph content within toggleable heading, which is within another toggleable heading.",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "container_toggle",
"marks": [],
"text": "Toggleable heading",
"type": "text",
},
],
"type": "container_toggle",
"type": "heading",
},
{
"attrs": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"level": 2,
},
"content": [
{
"attrs": {
"level": 2,
},
"content": [
{
"marks": [],
"text": "Toggleable heading with a ",
"type": "text",
},
{
"text": "2025-03-11",
"type": "text",
},
{
"marks": [],
"text": " mention.",
"type": "text",
},
],
"type": "heading",
"marks": [],
"text": "Toggleable heading with a ",
"type": "text",
},
{
"content": [
{
"marks": [],
"text": "Some paragraph content within toggleable heading with mention.",
"type": "text",
},
],
"type": "paragraph",
"text": "2025-03-11",
"type": "text",
},
{
"marks": [],
"text": " mention.",
"type": "text",
},
],
"type": "container_toggle",
"type": "heading",
},
{
"attrs": {
@@ -1943,68 +1876,52 @@ exports[`NotionConverter converts a page 1`] = `
"type": "paragraph",
},
{
"attrs": {
"id": "550e8400-e29b-41d4-a716-446655440000",
},
"content": [
{
"content": [
{
"marks": [],
"text": "Toggle list item 1",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
"style": "info",
},
"content": [
{
"content": [
{
"marks": [],
"text": "Callout inside toggle list item 1",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "container_notice",
"marks": [],
"text": "Toggle list item 1",
"type": "text",
},
],
"type": "container_toggle",
"type": "paragraph",
},
{
"attrs": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"style": "info",
},
"content": [
{
"content": [
{
"marks": [],
"text": "Toggle list item 2",
"type": "text",
},
],
"type": "paragraph",
},
{
"content": [
{
"marks": [],
"text": "Some content inside toggle list item 2",
"text": "Callout inside toggle list item 1",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "container_toggle",
"type": "container_notice",
},
{
"content": [
{
"marks": [],
"text": "Toggle list item 2",
"type": "text",
},
],
"type": "paragraph",
},
{
"content": [
{
"marks": [],
"text": "Some content inside toggle list item 2",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
@@ -145,6 +145,7 @@ describe("accountProvisioner", () => {
expect(user.id).toEqual(userWithoutAuth.id);
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
expect(user.authentications.length).toEqual(0);
});
it("should throw an error when authentication provider is disabled", async () => {
-26
View File
@@ -213,32 +213,6 @@ describe("documentImporter", () => {
expect(response.title).toEqual("Title");
});
it("should convert frontmatter to yaml codeblock", async () => {
const user = await buildUser();
const fileName = "markdown-frontmatter.md";
const content = await fs.readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName),
"utf8"
);
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/plain",
fileName,
content,
ctx: createContext({ user, transaction }),
})
);
expect(response.text).toContain("```yaml");
expect(response.text).toContain("title: Test Document");
expect(response.text).toContain("date: 2024-01-15");
expect(response.text).toContain("tags: [test, markdown]");
expect(response.text).toContain("```");
expect(response.text).toContain("This is content after frontmatter");
expect(response.title).toEqual("Heading 1");
});
it("should fallback to extension if mimetype unknown", async () => {
const user = await buildUser();
const fileName = "markdown.md";
+4 -1
View File
@@ -121,7 +121,10 @@ export default async function userProvisioner(
// A `user` record may exist even if there is no existing authentication record.
// This is either an invite or a user that's external to the team
const existingUser = await User.scope(["withTeam"]).findOne({
const existingUser = await User.scope([
"withAuthentications",
"withTeam",
]).findOne({
where: {
// Email from auth providers may be capitalized
email: {
+1 -106
View File
@@ -1,4 +1,4 @@
import { parser, serializer } from ".";
import { parser } from ".";
test("renders an empty doc", () => {
const ast = parser.parse("");
@@ -8,108 +8,3 @@ test("renders an empty doc", () => {
type: "doc",
});
});
test("parses lowercase alpha lists", () => {
const ast = parser.parse("a. First item\nb. Second item");
expect(ast?.toJSON()).toEqual({
content: [
{
attrs: { listStyle: "lower-alpha", order: 1 },
content: [
{
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
type: "list_item",
},
{
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
type: "list_item",
},
],
type: "ordered_list",
},
],
type: "doc",
});
});
test("parses uppercase alpha lists", () => {
const ast = parser.parse("A. First item\nB. Second item");
expect(ast?.toJSON()).toEqual({
content: [
{
attrs: { listStyle: "upper-alpha", order: 1 },
content: [
{
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
type: "list_item",
},
{
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
type: "list_item",
},
],
type: "ordered_list",
},
],
type: "doc",
});
});
test("parses alpha lists with blank lines (issue example)", () => {
const markdown = `## 3. Step Three
a. Do this.
b. Do that.`;
const ast = parser.parse(markdown);
const json = ast?.toJSON();
// Find the ordered_list in the result
const orderedList = json?.content?.find((node: any) => node.type === "ordered_list");
expect(orderedList).toBeDefined();
expect(orderedList?.attrs.listStyle).toBe("lower-alpha");
expect(orderedList?.attrs.order).toBe(1);
expect(orderedList?.content).toHaveLength(2);
});
test("preserves numeric lists", () => {
const ast = parser.parse("1. First item\n2. Second item");
expect(ast?.toJSON()).toEqual({
content: [
{
attrs: { listStyle: "number", order: 1 },
content: [
{
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
type: "list_item",
},
{
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
type: "list_item",
},
],
type: "ordered_list",
},
],
type: "doc",
});
});
test("serializes lowercase alpha lists back to markdown", () => {
const ast = parser.parse("a. First item\nb. Second item");
const output = serializer.serialize(ast);
expect(output.trim()).toBe("a. First item\nb. Second item");
});
test("serializes uppercase alpha lists back to markdown", () => {
const ast = parser.parse("A. First item\nB. Second item");
const output = serializer.serialize(ast);
expect(output.trim()).toBe("A. First item\nB. Second item");
});
+2 -3
View File
@@ -61,7 +61,6 @@ import { CollectionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import type { APIContext } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import removeIndexCollision from "@server/utils/removeIndexCollision";
import { generateUrlId } from "@server/utils/url";
import { ValidateIndex } from "@server/validation";
@@ -348,7 +347,7 @@ class Collection extends ParanoidModel<
}
if (model.changed("documentStructure")) {
await CacheHelper.clearData(
RedisPrefixHelper.getCollectionDocumentsKey(model.id)
CacheHelper.getCollectionDocumentsKey(model.id)
);
}
}
@@ -361,7 +360,7 @@ class Collection extends ParanoidModel<
if (model.changed("documentStructure")) {
const setData = () =>
CacheHelper.setData(
RedisPrefixHelper.getCollectionDocumentsKey(model.id),
CacheHelper.getCollectionDocumentsKey(model.id),
model.documentStructure,
60
);
+5 -10
View File
@@ -411,10 +411,7 @@ class User extends ParanoidModel<
* @param value Sets the preference value
* @returns The current user preferences
*/
public setPreference = <K extends UserPreference>(
preference: K,
value: NonNullable<UserPreferences[K]>
) => {
public setPreference = (preference: UserPreference, value: boolean) => {
if (!this.preferences) {
this.preferences = {};
}
@@ -431,12 +428,10 @@ class User extends ParanoidModel<
* @param preference The user preference to retrieve
* @returns The preference value if set, else the default value.
*/
public getPreference = <K extends UserPreference>(
preference: K
): NonNullable<UserPreferences[K]> =>
(this.preferences?.[preference] ??
UserPreferenceDefaults[preference] ??
false) as NonNullable<UserPreferences[K]>;
public getPreference = (preference: UserPreference) =>
this.preferences?.[preference] ??
UserPreferenceDefaults[preference] ??
false;
/**
* Returns the user's active groups.
+1 -1
View File
@@ -481,7 +481,7 @@ export class ProsemirrorHelper {
<>
{options?.title && <h1 dir={rtl ? "rtl" : "ltr"}>{options.title}</h1>}
{options?.includeStyles !== false ? (
<EditorContainer dir={rtl ? "rtl" : "ltr"} $rtl={rtl} staticHTML>
<EditorContainer dir={rtl ? "rtl" : "ltr"} rtl={rtl} staticHTML>
{content}
</EditorContainer>
) : (
@@ -3,7 +3,6 @@ import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import type { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
export default class IntegrationCreatedProcessor extends BaseProcessor {
@@ -26,6 +25,6 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
});
// Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
}
@@ -3,7 +3,6 @@ import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import type { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import { Hook, PluginManager } from "@server/utils/PluginManager";
export default class IntegrationDeletedProcessor extends BaseProcessor {
@@ -27,7 +26,7 @@ export default class IntegrationDeletedProcessor extends BaseProcessor {
// Clear the cache of unfurled data for the team as it may be stale now.
if (integration.type === IntegrationType.Embed) {
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
await integration.destroy({ force: true });
+5 -1
View File
@@ -327,7 +327,11 @@ export default abstract class APIImportTask<
const uploadItems = Object.entries(urlToAttachment).map(
([url, attachment]) => ({ attachmentId: attachment.id, url })
);
await new UploadAttachmentsForImportTask().schedule(uploadItems);
// publish task after attachments are persisted in DB.
const job = await new UploadAttachmentsForImportTask().schedule(
uploadItems
);
await job.finished();
} catch (err) {
// upload attachments failure is not critical enough to fail the whole import.
Logger.error(
@@ -1,11 +1,8 @@
import { v4 as uuidv4 } from "uuid";
import { MentionType, NotificationEventType } from "@shared/types";
import { NotificationEventType } from "@shared/types";
import { Notification } from "@server/models";
import {
buildDocument,
buildCollection,
buildGroup,
buildGroupUser,
buildUser,
} from "@server/test/factories";
import DocumentPublishedNotificationsTask from "./DocumentPublishedNotificationsTask";
@@ -122,57 +119,4 @@ describe("documents.publish", () => {
});
expect(spy).not.toHaveBeenCalled();
});
test("should not send a notification for group mentions when disableMentions is true", async () => {
const spy = jest.spyOn(Notification, "create");
const actor = await buildUser();
const group = await buildGroup({
teamId: actor.teamId,
disableMentions: true,
});
const member = await buildUser({ teamId: actor.teamId });
await buildGroupUser({ groupId: group.id, userId: member.id });
member.setNotificationEventType(
NotificationEventType.GroupMentionedInDocument
);
await member.save();
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "mention",
attrs: {
id: uuidv4(),
type: MentionType.Group,
label: group.name,
modelId: group.id,
actorId: actor.id,
},
},
],
},
],
},
});
const processor = new DocumentPublishedNotificationsTask();
await processor.perform({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId!,
teamId: document.teamId,
actorId: actor.id,
ip,
});
expect(spy).not.toHaveBeenCalled();
});
});
@@ -230,21 +230,13 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
format: FileOperationFormat
) {
const map = new Map<string, string>();
const usedRoots = new Set<string>();
for (const collection of collections) {
if (collection.documentStructure) {
let root = serializeFilename(collection.name);
let i = 0;
while (usedRoots.has(root)) {
root = `${serializeFilename(collection.name)} (${++i})`;
}
usedRoots.add(root);
this.addDocumentTreeToPathMap(
map,
collection.documentStructure,
root,
serializeFilename(collection.name),
format
);
}
+3 -13
View File
@@ -20,22 +20,13 @@ export default class ExportJSONTask extends ExportTask {
fileOperation: FileOperation
) {
const zip = new JSZip();
const usedFilenames = new Set<string>();
// serial to avoid overloading, slow and steady wins the race
for (const collection of collections) {
let filename = serializeFilename(collection.name);
let i = 0;
while (usedFilenames.has(filename)) {
filename = `${serializeFilename(collection.name)} (${++i})`;
}
usedFilenames.add(filename);
await this.addCollectionToArchive(
zip,
collection,
fileOperation.options?.includeAttachments ?? true,
filename
fileOperation.options?.includeAttachments ?? true
);
}
@@ -66,8 +57,7 @@ export default class ExportJSONTask extends ExportTask {
private async addCollectionToArchive(
zip: JSZip,
collection: Collection,
includeAttachments: boolean,
filename: string
includeAttachments: boolean
) {
const output: CollectionJSONExport = {
collection: {
@@ -177,7 +167,7 @@ export default class ExportJSONTask extends ExportTask {
}
zip.file(
`${filename}.json`,
`${serializeFilename(collection.name)}.json`,
env.isDevelopment
? JSON.stringify(output, null, 2)
: JSON.stringify(output)
@@ -1,7 +1,3 @@
import type { DeepPartial } from "utility-types";
import type { ProsemirrorData } from "@shared/types";
import { v4 as uuidv4 } from "uuid";
import { MentionType, NotificationEventType } from "@shared/types";
import { createContext } from "@server/context";
import { parser } from "@server/editor";
import type { Document } from "@server/models";
@@ -12,12 +8,7 @@ import {
Notification,
Revision,
} from "@server/models";
import {
buildDocument,
buildGroup,
buildGroupUser,
buildUser,
} from "@server/test/factories";
import { buildDocument, buildUser } from "@server/test/factories";
import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask";
const ip = "127.0.0.1";
@@ -523,136 +514,4 @@ describe("revisions.create", () => {
});
expect(spy).not.toHaveBeenCalled();
});
test("should send a mention notification even when change is below threshold", async () => {
const spy = jest.spyOn(Notification, "create");
const actor = await buildUser();
const mentioned = await buildUser({ teamId: actor.teamId, name: "Kim" });
// Build a document with some initial content
let document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
});
await Revision.createFromDocument(createContext({ user: actor }), document);
// Now add a mention the only change is the mention node itself, which
// renders as "@<label>" in plain text and may be below the 5-char
// threshold that gates generic update notifications.
const mentionContent: DeepPartial<ProsemirrorData> = {
type: "doc",
content: [
...(document.content?.content ?? []),
{
type: "paragraph",
content: [
{
type: "mention",
attrs: {
type: MentionType.User,
label: mentioned.name,
modelId: mentioned.id,
actorId: actor.id,
id: "test-mention-id",
},
},
],
},
],
};
document.content = mentionContent as ProsemirrorData;
document.updatedAt = new Date();
await document.save();
const revision = await Revision.createFromDocument(
createContext({ user: actor }),
document
);
const task = new RevisionCreatedNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: actor.id,
modelId: revision.id,
ip,
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
event: NotificationEventType.MentionedInDocument,
userId: mentioned.id,
actorId: actor.id,
documentId: document.id,
})
);
});
test("should not send a notification for group mentions when disableMentions is true", async () => {
const spy = jest.spyOn(Notification, "create");
const actor = await buildUser();
const group = await buildGroup({
teamId: actor.teamId,
disableMentions: true,
});
const member = await buildUser({ teamId: actor.teamId });
await buildGroupUser({ groupId: group.id, userId: member.id });
member.setNotificationEventType(
NotificationEventType.GroupMentionedInDocument
);
await member.save();
let document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
});
await Revision.createFromDocument(createContext({ user: actor }), document);
// Update document to include a group mention
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Updated content with a group mention ",
},
{
type: "mention",
attrs: {
id: uuidv4(),
type: MentionType.Group,
label: group.name,
modelId: group.id,
actorId: actor.id,
},
},
],
},
],
};
document.updatedAt = new Date();
await document.save();
const revision = await Revision.createFromDocument(
createContext({ user: actor }),
document
);
const task = new RevisionCreatedNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: actor.id,
modelId: revision.id,
ip,
});
expect(spy).not.toHaveBeenCalled();
});
});
@@ -7,7 +7,6 @@ import env from "@server/env";
import Logger from "@server/logging/Logger";
import {
Document,
Group,
Revision,
Notification,
User,
@@ -35,8 +34,16 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
const before = await revision.before();
// Send notifications to mentioned users first these must be processed
// regardless of the change threshold as even a small edit can add a mention.
// If the content looks the same, don't send notifications
if (!DocumentHelper.isChangeOverThreshold(before, revision, 5)) {
Logger.info(
"processor",
`suppressing notifications as update has insignificant changes`
);
return;
}
// Send notifications to mentioned users first
const oldMentions = before
? [...DocumentHelper.parseMentions(before, { type: MentionType.User })]
: [];
@@ -76,7 +83,7 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
}
}
// Send notifications to users in mentioned groups
// send notifications to users in mentioned groups
const oldGroupMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.Group })
: [];
@@ -94,13 +101,6 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
if (mentionedGroup.includes(group.modelId)) {
continue;
}
// Check if the group has mentions disabled
const groupModel = await Group.findByPk(group.modelId);
if (groupModel?.disableMentions) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
@@ -140,16 +140,6 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
mentionedGroup.push(group.modelId);
}
// If the content change is insignificant, don't send generic update
// notifications (mention notifications above are still sent).
if (!DocumentHelper.isChangeOverThreshold(before, revision, 5)) {
Logger.info(
"processor",
`suppressing update notifications as change has insignificant edits`
);
return;
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
document,
+1 -2
View File
@@ -39,7 +39,6 @@ import {
} from "@server/presenters";
import type { APIContext } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { collectionIndexing } from "@server/utils/indexing";
import pagination from "../middlewares/pagination";
@@ -144,7 +143,7 @@ router.post(
authorize(user, "readDocument", collection);
const documentStructure = await CacheHelper.getDataOrSet(
RedisPrefixHelper.getCollectionDocumentsKey(collection.id),
CacheHelper.getCollectionDocumentsKey(collection.id),
async () =>
(
await Collection.findByPk(collection.id, {
+2 -3
View File
@@ -15,7 +15,6 @@ import { authorize, can } from "@server/policies";
import presentUnfurl from "@server/presenters/unfurl";
import type { APIContext, Unfurl } from "@server/types";
import { CacheHelper, type CacheResult } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import {
@@ -135,7 +134,7 @@ router.post(
// External resources
// Use getDataOrSet which handles distributed locking to prevent thundering herd
// when multiple clients request the same URL simultaneously
const cacheKey = RedisPrefixHelper.getUnfurlKey(actor.teamId, url);
const cacheKey = CacheHelper.getUnfurlKey(actor.teamId, url);
const defaultCacheExpiry = 3600;
const unfurlResult = await CacheHelper.getDataOrSet<
@@ -187,7 +186,7 @@ router.post(
const { url } = ctx.input.body;
const result = await CacheHelper.getDataOrSet<EmbedCheckResult>(
RedisPrefixHelper.getEmbedCheckKey(url),
CacheHelper.getEmbedCheckKey(url),
() => checkEmbeddability(url),
Day.seconds
);
+2 -12
View File
@@ -1,10 +1,5 @@
import { z } from "zod";
import {
NotificationBadgeType,
NotificationEventType,
UserPreference,
UserRole,
} from "@shared/types";
import { NotificationEventType, UserPreference, UserRole } from "@shared/types";
import { locales } from "@shared/utils/date";
import User from "@server/models/User";
import { zodEnumFromObjectKeys, zodTimezone } from "@server/utils/zod";
@@ -95,12 +90,7 @@ export const UsersUpdateSchema = BaseSchema.extend({
name: z.string().optional(),
avatarUrl: z.string().nullish(),
language: zodEnumFromObjectKeys(locales).optional(),
preferences: z
.record(
z.nativeEnum(UserPreference),
z.union([z.boolean(), z.nativeEnum(NotificationBadgeType)])
)
.optional(),
preferences: z.record(z.nativeEnum(UserPreference), z.boolean()).optional(),
timezone: zodTimezone().optional(),
}),
});
+4 -5
View File
@@ -1,7 +1,7 @@
import Router from "koa-router";
import type { WhereOptions } from "sequelize";
import { Op, Sequelize } from "sequelize";
import type { UserPreferences } from "@shared/types";
import type { UserPreference } from "@shared/types";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import { settingsPath } from "@shared/utils/routeHelpers";
@@ -332,10 +332,9 @@ router.post(
user.language = language;
}
if (preferences) {
user.preferences = {
...user.preferences,
...(preferences as UserPreferences),
};
for (const key of Object.keys(preferences) as Array<UserPreference>) {
user.setPreference(key, preferences[key] as boolean);
}
}
if (timezone) {
user.timezone = timezone;
-2
View File
@@ -9,7 +9,6 @@ import env from "@server/env";
import type Model from "@server/models/base/Model";
import Logger from "../logging/Logger";
import * as models from "../models";
import { getConnectionName } from "./utils";
/**
* Returns database configuration for Sequelize constructor.
@@ -65,7 +64,6 @@ export function createDatabaseInstance(
typeValidation: true,
logQueryParameters: env.isDevelopment,
dialectOptions: {
application_name: getConnectionName(),
ssl:
env.isProduction && !isSSLDisabled
? {
+4 -3
View File
@@ -37,7 +37,7 @@ export default class S3Storage extends BaseStorage {
public async getPresignedPost(
_ctx: AppContext,
key: string,
_acl: string,
acl: string,
maxUploadSize: number,
contentType = "image"
) {
@@ -52,7 +52,7 @@ export default class S3Storage extends BaseStorage {
Fields: {
"Content-Disposition": this.getContentDisposition(contentType),
key,
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
...(acl && { acl }),
},
Expires: 3600,
};
@@ -103,6 +103,7 @@ export default class S3Storage extends BaseStorage {
body,
contentType,
key,
acl,
}: {
body: Buffer | Uint8Array | string | Readable;
contentLength?: number;
@@ -113,7 +114,7 @@ export default class S3Storage extends BaseStorage {
const upload = new Upload({
client: this.client,
params: {
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
...(acl && { ACL: acl as ObjectCannedACL }),
Bucket: this.getBucket(),
Key: key,
ContentType: contentType,
+8 -2
View File
@@ -3,7 +3,6 @@ import Redis from "ioredis";
import defaults from "lodash/defaults";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { getConnectionName } from "./utils";
type RedisAdapterOptions = RedisOptions & {
/** Suffix to append to the connection name that will be displayed in Redis */
@@ -43,7 +42,14 @@ export default class RedisAdapter extends Redis {
url: string | undefined,
{ connectionNameSuffix, ...options }: RedisAdapterOptions = {}
) {
const connectionName = getConnectionName(connectionNameSuffix);
/**
* For debugging. The connection name is based on the services running in
* this process. Note that this does not need to be unique.
*/
const connectionNamePrefix = env.isDevelopment ? process.pid : "outline";
const connectionName =
`${connectionNamePrefix}:${env.SERVICES.join("-")}` +
(connectionNameSuffix ? `:${connectionNameSuffix}` : "");
if (!url || !url.startsWith("ioredis://")) {
super(
-13
View File
@@ -1,13 +0,0 @@
import env from "@server/env";
/**
* For debugging. The connection name is based on the services running in
* this process. Note that this does not need to be unique.
*/
export const getConnectionName = (connectionNameSuffix?: string) => {
const connectionNamePrefix = env.isDevelopment ? process.pid : "outline";
return (
`${connectionNamePrefix}:${env.SERVICES.join("-")}` +
(connectionNameSuffix ? `:${connectionNameSuffix}` : "")
);
};
-14
View File
@@ -1,14 +0,0 @@
---
title: Test Document
date: 2024-01-15
tags: [test, markdown]
author: John Doe
---
# Heading 1
This is content after frontmatter.
## Heading 2
More content here.
+27
View File
@@ -141,4 +141,31 @@ export class CacheHelper {
})
);
}
// keys
/**
* Gets key against which unfurl response for the given url is stored
*
* @param teamId The team ID to generate a key for
* @param url The url to generate a key for
*/
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
/**
* Gets key for caching embed check results. This is a global cache key
* (not team-specific) since embed headers are the same for all users.
*
* @param url The URL to generate a cache key for.
* @returns the cache key string.
*/
public static getEmbedCheckKey(url: string) {
return `embed:${url}`;
}
}
-108
View File
@@ -148,114 +148,6 @@ Jane,24,`;
expect(result.title).toEqual("");
expect(result.text).toContain("Subtitle");
});
it("should convert frontmatter to yaml codeblock", async () => {
const md = `---
title: Test Document
date: 2024-01-15
tags: [test, markdown]
---
# My Title
Content after frontmatter`;
const result = await DocumentConverter.convert(
md,
"test.md",
"text/markdown"
);
// Frontmatter should be converted to a YAML codeblock
expect(result.text).toContain("```yaml");
expect(result.text).toContain("title: Test Document");
expect(result.text).toContain("date: 2024-01-15");
expect(result.text).toContain("tags: [test, markdown]");
expect(result.text).toContain("```");
// Content should still be present
expect(result.text).toContain("Content after frontmatter");
// H1 should be extracted as title
expect(result.title).toEqual("My Title");
});
it("should handle markdown without frontmatter", async () => {
const md = "# Title\n\nRegular content";
const result = await DocumentConverter.convert(
md,
"test.md",
"text/markdown"
);
expect(result.title).toEqual("Title");
expect(result.text).toContain("Regular content");
expect(result.text).not.toContain("```yaml");
});
it("should handle frontmatter with no content after", async () => {
const md = `---
title: Only Frontmatter
---`;
const result = await DocumentConverter.convert(
md,
"test.md",
"text/markdown"
);
expect(result.text).toContain("```yaml");
expect(result.text).toContain("title: Only Frontmatter");
expect(result.text).toContain("```");
expect(result.title).toEqual("");
});
it("should not convert incomplete frontmatter", async () => {
const md = `---
title: Test
Content without closing delimiter`;
const result = await DocumentConverter.convert(
md,
"test.md",
"text/markdown"
);
// Should not convert as it's not proper frontmatter
expect(result.text).not.toContain("```yaml");
expect(result.text).toContain("title: Test");
});
it("should not convert frontmatter if not at start", async () => {
const md = `# Title
Some content
---
title: Test
---
More content`;
const result = await DocumentConverter.convert(
md,
"test.md",
"text/markdown"
);
// Should not convert as frontmatter must be at the start
expect(result.text).not.toContain("```yaml");
});
it("should handle invalid YAML in frontmatter", async () => {
const md = `---
invalid: yaml: content: here
---
Content`;
const result = await DocumentConverter.convert(
md,
"test.md",
"text/markdown"
);
// Should not convert invalid YAML
expect(result.text).not.toContain("```yaml");
});
});
});
+11 -51
View File
@@ -5,7 +5,6 @@ import { simpleParser } from "mailparser";
import mammoth from "mammoth";
import type { Node } from "prosemirror-model";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import yaml from "js-yaml";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { schema, serializer } from "@server/editor";
import { FileImportError } from "@server/errors";
@@ -202,30 +201,24 @@ export class DocumentConverter {
fileName: string,
mimeType: string
): Promise<string> {
let markdown: string;
switch (mimeType) {
case "text/plain":
case "text/markdown":
markdown = this.bufferToString(content);
break;
return this.bufferToString(content);
case "text/csv":
return this.csvToMarkdown(content);
default: {
const extension = fileName.split(".").pop();
switch (extension) {
case "md":
case "markdown":
markdown = this.bufferToString(content);
break;
default:
throw FileImportError(`File type ${mimeType} not supported`);
}
}
default:
break;
}
// Process frontmatter and convert it to a YAML codeblock
return this.processFrontmatter(markdown);
const extension = fileName.split(".").pop();
switch (extension) {
case "md":
case "markdown":
return this.bufferToString(content);
default:
throw FileImportError(`File type ${mimeType} not supported`);
}
}
/**
@@ -411,37 +404,4 @@ export class DocumentConverter {
private static bufferToString(content: Buffer | string): string {
return typeof content === "string" ? content : content.toString("utf8");
}
/**
* Parse and convert frontmatter to a YAML codeblock.
*
* @param content The markdown content that may contain frontmatter.
* @returns The markdown content with frontmatter converted to a YAML codeblock.
*/
private static processFrontmatter(content: string): string {
// Frontmatter must start at the beginning of the document
const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)/;
const match = content.match(frontmatterRegex);
if (!match) {
return content;
}
const frontmatterContent = match[1];
const remainingContent = content.slice(match[0].length);
// Validate that the frontmatter is valid YAML
try {
yaml.load(frontmatterContent);
} catch {
// If it's not valid YAML, return content unchanged
return content;
}
// Convert frontmatter to a YAML codeblock
const codeBlockDelimiter = "```";
const yamlCodeblock = `${codeBlockDelimiter}yaml\n${frontmatterContent}\n${codeBlockDelimiter}\n\n`;
return yamlCodeblock + remainingContent;
}
}
-61
View File
@@ -1,61 +0,0 @@
import { RedisPrefixHelper } from "./RedisPrefixHelper";
describe("RedisPrefixHelper", () => {
describe("getUnfurlKey", () => {
it("should generate key with teamId and url", () => {
const teamId = "team-123";
const url = "https://example.com";
const result = RedisPrefixHelper.getUnfurlKey(teamId, url);
expect(result).toBe("unfurl:team-123:https://example.com");
});
it("should generate key with teamId and empty url", () => {
const teamId = "team-456";
const result = RedisPrefixHelper.getUnfurlKey(teamId);
expect(result).toBe("unfurl:team-456:");
});
it("should handle special characters in url", () => {
const teamId = "team-789";
const url = "https://example.com/path?query=value&other=123";
const result = RedisPrefixHelper.getUnfurlKey(teamId, url);
expect(result).toBe(
"unfurl:team-789:https://example.com/path?query=value&other=123"
);
});
});
describe("getCollectionDocumentsKey", () => {
it("should generate key with collectionId", () => {
const collectionId = "col-abc123";
const result = RedisPrefixHelper.getCollectionDocumentsKey(collectionId);
expect(result).toBe("cd:col-abc123");
});
it("should handle uuid format", () => {
const collectionId = "550e8400-e29b-41d4-a716-446655440000";
const result = RedisPrefixHelper.getCollectionDocumentsKey(collectionId);
expect(result).toBe("cd:550e8400-e29b-41d4-a716-446655440000");
});
});
describe("getEmbedCheckKey", () => {
it("should generate key with url", () => {
const url = "https://example.com/embed";
const result = RedisPrefixHelper.getEmbedCheckKey(url);
expect(result).toBe("embed:https://example.com/embed");
});
it("should handle urls with query parameters", () => {
const url = "https://example.com/video?v=abc123";
const result = RedisPrefixHelper.getEmbedCheckKey(url);
expect(result).toBe("embed:https://example.com/video?v=abc123");
});
it("should handle urls with fragments", () => {
const url = "https://example.com/page#section";
const result = RedisPrefixHelper.getEmbedCheckKey(url);
expect(result).toBe("embed:https://example.com/page#section");
});
});
});
-35
View File
@@ -1,35 +0,0 @@
/**
* Helper class for Redis cache key generation.
*/
export class RedisPrefixHelper {
/**
* Gets key against which unfurl response for the given url is stored.
*
* @param teamId The team ID to generate a key for.
* @param url The url to generate a key for.
*/
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
/**
* Gets key for caching collection documents structure.
*
* @param collectionId The collection ID to generate a key for.
* @returns the cache key string.
*/
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
/**
* Gets key for caching embed check results. This is a global cache key
* (not team-specific) since embed headers are the same for all users.
*
* @param url The URL to generate a cache key for.
* @returns the cache key string.
*/
public static getEmbedCheckKey(url: string) {
return `embed:${url}`;
}
}
+16
View File
@@ -44,4 +44,20 @@ export class CacheHelper {
public static async clearData(_prefix: string) {
return;
}
/**
* These are real methods that don't require mocking as they don't
* interact with Redis directly
*/
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
public static getEmbedCheckKey(url: string) {
return `embed:${url}`;
}
}
-2
View File
@@ -4,7 +4,6 @@ import {
TeamPreference,
UserPreference,
EmailDisplay,
NotificationBadgeType,
} from "./types";
export const MAX_AVATAR_DISPLAY = 6;
@@ -43,5 +42,4 @@ export const UserPreferenceDefaults: UserPreferences = {
[UserPreference.CodeBlockLineNumers]: true,
[UserPreference.SortCommentsByOrderInDocument]: true,
[UserPreference.EnableSmartText]: true,
[UserPreference.NotificationBadge]: NotificationBadgeType.Count,
};
-4
View File
@@ -35,10 +35,6 @@ export type Options = {
width?: number;
/** Height to use when inserting image */
height?: number;
/** Alt text / caption to use when inserting image */
alt?: string | null;
/** Layout class for alignment when inserting image */
layoutClass?: string | null;
};
};
+3 -47
View File
@@ -7,7 +7,7 @@ import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { videoStyle } from "./Video";
export type Props = {
$rtl: boolean;
rtl: boolean;
readOnly?: boolean;
readOnlyWriteCheckboxes?: boolean;
commenting?: boolean;
@@ -1101,7 +1101,7 @@ h6:not(.placeholder)::before {
}
.with-emoji {
margin-${props.$rtl ? "right" : "left"}: -1em;
margin-${props.rtl ? "right" : "left"}: -1em;
}
.emoji img {
@@ -1483,50 +1483,6 @@ ol li {
}
}
.${EditorStyleHelper.checklistWrapper} {
position: relative;
margin: 1em 0;
}
.${EditorStyleHelper.checklistCompletedToggle} {
position: absolute;
top: -8px;
right: 0;
padding: 4px 8px;
font-size: 12px;
background: ${props.theme.background};
border: 1px solid ${props.theme.buttonNeutralBorder};
border-radius: 6px;
color: ${props.theme.textSecondary};
cursor: var(--pointer);
user-select: none;
z-index: 1;
opacity: 0;
transition: all 100ms ease-in-out;
&:${hover} {
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.text};
}
&:active {
transform: scale(0.98);
}
}
.${EditorStyleHelper.checklistWrapper}:${hover} .${EditorStyleHelper.checklistCompletedToggle},
.${EditorStyleHelper.checklistWrapper}:focus-within .${EditorStyleHelper.checklistCompletedToggle} {
opacity: 1;
}
.${EditorStyleHelper.checklistWrapper}.${EditorStyleHelper.checklistCompletedHidden} .${EditorStyleHelper.checklistCompletedToggle} {
opacity: 1;
}
.${EditorStyleHelper.checklistWrapper}.${EditorStyleHelper.checklistCompletedHidden} ul.checkbox_list > li.checked {
display: none;
}
ul.checkbox_list {
padding: 0;
margin-left: -24px;
@@ -2395,7 +2351,7 @@ table {
border: 0;
padding: 0;
margin-top: 1px;
margin-${props.$rtl ? "right" : "left"}: -28px;
margin-${props.rtl ? "right" : "left"}: -28px;
border-radius: 4px;
&:hover,
-54
View File
@@ -4,13 +4,9 @@ import type {
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { v4 as generateUuid } from "uuid";
import toggleList from "../commands/toggleList";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { listWrappingInputRule } from "../lib/listInputRule";
import { findBlockNodes } from "../queries/findChildren";
import { CheckboxListView } from "./CheckboxListView";
import Node from "./Node";
export default class CheckboxList extends Node {
@@ -22,9 +18,6 @@ export default class CheckboxList extends Node {
return {
group: "block list",
content: "checkbox_item+",
attrs: {
id: { default: null },
},
toDOM: () => ["ul", { class: this.name }, 0],
parseDOM: [
{
@@ -34,53 +27,6 @@ export default class CheckboxList extends Node {
};
}
get plugins() {
const userIdentifier = this.editor.props.userId;
const dictionary = this.editor.props.dictionary;
// Plugin to auto-assign IDs to checkbox lists
const assignIdsPlugin = new Plugin({
appendTransaction: (txs, _oldSt, newSt) => {
const hasDocChanges = txs.some((t) => t.docChanged);
if (!hasDocChanges) {
return null;
}
const checkboxLists = findBlockNodes(newSt.doc, true).filter(
(b) => b.node.type.name === this.name && !b.node.attrs.id
);
if (checkboxLists.length === 0) {
return null;
}
let modifyTx = newSt.tr;
checkboxLists.forEach((listBlock) => {
modifyTx.setNodeAttribute(listBlock.pos, "id", generateUuid());
});
return modifyTx;
},
});
// Plugin to provide NodeViews
const nodeViewPlugin = new Plugin({
props: {
nodeViews: {
[this.name]: (node, view, getPos) =>
new CheckboxListView(
node,
view,
getPos,
userIdentifier || "",
dictionary
),
},
},
});
return [assignIdsPlugin, nodeViewPlugin];
}
keys({ type, schema }: { type: NodeType; schema: Schema }) {
return {
"Shift-Ctrl-7": toggleList(type, schema.nodes.checkbox_item),
-136
View File
@@ -1,136 +0,0 @@
import type { Node as ProsemirrorNode } from "prosemirror-model";
import type { EditorView, NodeView } from "prosemirror-view";
import type { Dictionary } from "../../../app/hooks/useDictionary";
import { isBrowser } from "../../utils/browser";
import Storage from "../../utils/Storage";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
/**
* Custom NodeView that wraps checkbox lists with a toggle control for
* showing/hiding completed items.
*/
export class CheckboxListView implements NodeView {
dom: HTMLElement;
contentDOM: HTMLElement;
private toggleControl: HTMLButtonElement;
private node: ProsemirrorNode;
private userIdentifier: string;
private dictionary: Dictionary;
constructor(
node: ProsemirrorNode,
_view: EditorView,
_getPos: () => number | undefined,
userIdentifier: string,
dictionary: Dictionary
) {
this.node = node;
this.userIdentifier = userIdentifier;
this.dictionary = dictionary;
// Build DOM structure
const wrapperElement = document.createElement("div");
wrapperElement.classList.add(EditorStyleHelper.checklistWrapper);
this.toggleControl = document.createElement("button");
this.toggleControl.classList.add(
EditorStyleHelper.checklistCompletedToggle
);
this.toggleControl.contentEditable = "false";
if (isBrowser) {
this.toggleControl.addEventListener("click", this.handleToggleClick);
}
this.contentDOM = document.createElement("ul");
this.contentDOM.classList.add("checkbox_list");
wrapperElement.appendChild(this.toggleControl);
wrapperElement.appendChild(this.contentDOM);
this.dom = wrapperElement;
if (isBrowser) {
this.updateToggleState();
}
}
private handleToggleClick = (clickEvent: Event) => {
if (!isBrowser) {
return;
}
clickEvent.preventDefault();
clickEvent.stopPropagation();
const listId = this.node.attrs.id;
if (!listId) {
return;
}
const storageKey = `checklist-${listId}-${this.userIdentifier}-hidden`;
const currentlyCollapsed = !!Storage.get(storageKey);
Storage.set(storageKey, !currentlyCollapsed);
this.updateToggleState();
};
private updateToggleState() {
if (!isBrowser) {
return;
}
const listId = this.node.attrs.id;
if (!listId) {
this.toggleControl.style.display = "none";
return;
}
const storageKey = `checklist-${listId}-${this.userIdentifier}-hidden`;
const shouldCollapse = !!Storage.get(storageKey);
// Count completed items
let completedItemsCount = 0;
this.node.forEach((childNode) => {
if (childNode.attrs.checked === true) {
completedItemsCount++;
}
});
// Show/hide button based on completed count
if (completedItemsCount === 0) {
this.toggleControl.style.display = "none";
this.dom.classList.remove(EditorStyleHelper.checklistCompletedHidden);
} else {
this.toggleControl.style.display = "inline-block";
this.toggleControl.textContent = shouldCollapse
? this.dictionary.showCompleted(completedItemsCount)
: this.dictionary.hideCompleted;
if (shouldCollapse) {
this.dom.classList.add(EditorStyleHelper.checklistCompletedHidden);
} else {
this.dom.classList.remove(EditorStyleHelper.checklistCompletedHidden);
}
}
}
update(node: ProsemirrorNode) {
if (!isBrowser) {
return false;
}
if (node.type.name !== "checkbox_list") {
return false;
}
this.node = node;
this.updateToggleState();
return true;
}
destroy() {
if (!isBrowser) {
return;
}
this.toggleControl.removeEventListener("click", this.handleToggleClick);
}
}
+6 -17
View File
@@ -1,4 +1,4 @@
import type { PluginSimple, Token } from "markdown-it";
import type { Token } from "markdown-it";
import type {
NodeSpec,
NodeType,
@@ -8,7 +8,6 @@ import type {
import toggleList from "../commands/toggleList";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { listWrappingInputRule } from "../lib/listInputRule";
import alphaListsRule from "../rules/alphaLists";
import Node from "./Node";
export default class OrderedList extends Node {
@@ -16,10 +15,6 @@ export default class OrderedList extends Node {
return "ordered_list";
}
get rulePlugins(): PluginSimple[] {
return [alphaListsRule];
}
get schema(): NodeSpec {
return {
attrs: {
@@ -168,17 +163,11 @@ export default class OrderedList extends Node {
getAttrs: (tok: Token) => {
const start = tok.attrGet("start") || "1";
// Check for data-list-style attribute set by alphaLists plugin
const dataListStyle = tok.attrGet("data-list-style");
let listStyle = dataListStyle || "number";
// Fallback to checking markup if data-list-style is not present
if (!dataListStyle) {
if (tok.markup && /^[a-z]/.test(tok.markup)) {
listStyle = "lower-alpha";
} else if (tok.markup && /^[A-Z]/.test(tok.markup)) {
listStyle = "upper-alpha";
}
let listStyle = "number";
if (tok.markup && /^[a-z]/.test(tok.markup)) {
listStyle = "lower-alpha";
} else if (tok.markup && /^[A-Z]/.test(tok.markup)) {
listStyle = "upper-alpha";
}
return {
-3
View File
@@ -188,9 +188,6 @@ export default class SimpleImage extends Node {
replaceExisting: true,
attrs: {
width: node.attrs.width,
height: node.attrs.height,
alt: node.attrs.alt,
layoutClass: node.attrs.layoutClass,
},
});
};
+14 -39
View File
@@ -2,7 +2,6 @@ import type { Node } from "prosemirror-model";
import { TableView as ProsemirrorTableView } from "prosemirror-tables";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { TableLayout } from "../types";
import { isBrowser } from "../../utils/browser";
export class TableView extends ProsemirrorTableView {
public constructor(
@@ -19,28 +18,24 @@ export class TableView extends ProsemirrorTableView {
this.scrollable.appendChild(this.table);
this.scrollable.classList.add(EditorStyleHelper.tableScrollable);
if (isBrowser) {
this.scrollable.addEventListener(
"scroll",
() => {
this.updateClassList(this.node);
},
{
passive: true,
}
);
}
this.scrollable.addEventListener(
"scroll",
() => {
this.updateClassList(this.node);
},
{
passive: true,
}
);
this.updateClassList(node);
// We need to wait for the next tick to ensure dom is rendered and scroll shadows are correct.
if (isBrowser) {
setTimeout(() => {
if (this.dom) {
this.updateClassList(node);
}
}, 0);
}
setTimeout(() => {
if (this.dom) {
this.updateClassList(node);
}
}, 0);
// Set up sticky header handling
this.setupStickyHeader();
@@ -71,10 +66,6 @@ export class TableView extends ProsemirrorTableView {
}
private updateClassList(node: Node) {
if (!isBrowser) {
return;
}
this.dom.classList.toggle(
EditorStyleHelper.tableFullWidth,
node.attrs.layout === TableLayout.fullWidth
@@ -117,10 +108,6 @@ export class TableView extends ProsemirrorTableView {
* Sets up the scroll listener for sticky header behavior.
*/
private setupStickyHeader() {
if (!isBrowser) {
return;
}
// Defer setup to ensure DOM is fully rendered
setTimeout(() => {
this.scrollHandler = () => {
@@ -142,10 +129,6 @@ export class TableView extends ProsemirrorTableView {
* Cleans up the scroll listener and resets header styles.
*/
private cleanupStickyHeader() {
if (!isBrowser) {
return;
}
if (this.scrollHandler) {
document.removeEventListener("scroll", this.scrollHandler, {
capture: true,
@@ -162,10 +145,6 @@ export class TableView extends ProsemirrorTableView {
* Updates the header row transform to create a sticky effect.
*/
private updateStickyHeader() {
if (!isBrowser) {
return;
}
const headerRow = this.table.querySelector("tr") as HTMLElement | null;
if (!headerRow) {
return;
@@ -200,10 +179,6 @@ export class TableView extends ProsemirrorTableView {
* @returns the offset in pixels from the top of the viewport.
*/
private getHeaderOffset(): number {
if (!isBrowser) {
return TableView.HEADER_HEIGHT;
}
const value = getComputedStyle(document.documentElement).getPropertyValue(
"--header-offset"
);
-75
View File
@@ -1,75 +0,0 @@
import markdownit from "markdown-it";
import alphaListsRule from "../rules/alphaLists";
describe("Alpha Lists Plugin", () => {
it("should parse lowercase alphabetic lists", () => {
const md = markdownit().use(alphaListsRule);
const result = md.parse("a. First item\nb. Second item", {});
// Find ordered_list_open token
const listToken = result.find((t) => t.type === "ordered_list_open");
expect(listToken).toBeDefined();
expect(listToken?.attrGet("data-list-style")).toBe("lower-alpha");
});
it("should parse uppercase alphabetic lists", () => {
const md = markdownit().use(alphaListsRule);
const result = md.parse("A. First item\nB. Second item", {});
// Find ordered_list_open token
const listToken = result.find((t) => t.type === "ordered_list_open");
expect(listToken).toBeDefined();
expect(listToken?.attrGet("data-list-style")).toBe("upper-alpha");
});
it("should preserve numeric lists", () => {
const md = markdownit().use(alphaListsRule);
const result = md.parse("1. First item\n2. Second item", {});
// Find ordered_list_open token
const listToken = result.find((t) => t.type === "ordered_list_open");
expect(listToken).toBeDefined();
expect(listToken?.attrGet("data-list-style")).toBeNull();
});
it("should handle the issue example", () => {
const md = markdownit().use(alphaListsRule);
const text = `## 3. Step Three
a. Do this.
b. Do that.`;
const result = md.parse(text, {});
// Check that we have an ordered list
const listToken = result.find((t) => t.type === "ordered_list_open");
expect(listToken).toBeDefined();
expect(listToken?.attrGet("data-list-style")).toBe("lower-alpha");
// Check that we have two list items
const listItems = result.filter((t) => t.type === "list_item_open");
expect(listItems.length).toBe(2);
});
it("should handle multiple separate alpha lists", () => {
const md = markdownit().use(alphaListsRule);
const text = `a. First list item
b. Second list item
Some text in between
A. Upper list item
B. Upper list item 2`;
const result = md.parse(text, {});
// Check that we have two ordered lists
const listTokens = result.filter((t) => t.type === "ordered_list_open");
expect(listTokens.length).toBe(2);
expect(listTokens[0]?.attrGet("data-list-style")).toBe("lower-alpha");
expect(listTokens[1]?.attrGet("data-list-style")).toBe("upper-alpha");
});
});
-122
View File
@@ -1,122 +0,0 @@
import type MarkdownIt from "markdown-it";
/**
* Markdown-it plugin to enable parsing of alphabetic ordered lists (a., b., c., etc.)
*
* By default, markdown-it only recognizes numeric ordered lists (1., 2., 3.).
* This plugin preprocesses the input to convert alphabetic list markers to numeric
* while preserving marker information in the token attributes.
*/
export default function markdownItAlphaLists(md: MarkdownIt): void {
// Preprocess the source to convert alpha markers to numbers
md.core.ruler.before("normalize", "alpha_lists_preprocess", (state) => {
const lines = state.src.split("\n");
const processedLines: string[] = [];
const lineMarkers: Array<{
lineIndex: number;
marker: string;
listStyle: string;
}> = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match alphabetic list markers with at least one space after the period
const match = line.match(/^(\s*)([a-zA-Z])\.\s+(.*)$/);
if (match) {
const indent = match[1];
const letter = match[2];
const content = match[3];
const isLowercase = letter === letter.toLowerCase();
const num = isLowercase
? letter.charCodeAt(0) - 96 // a=1, b=2
: letter.charCodeAt(0) - 64; // A=1, B=2
const listStyle = isLowercase ? "lower-alpha" : "upper-alpha";
lineMarkers.push({
lineIndex: processedLines.length,
marker: letter,
listStyle,
});
processedLines.push(`${indent}${num}. ${content}`);
} else {
processedLines.push(line);
}
}
// Store marker info for later, including line mapping
if (lineMarkers.length > 0) {
state.env.alphaListMarkers = lineMarkers;
}
state.src = processedLines.join("\n");
});
// Post-process tokens to add the listStyle attribute
md.core.ruler.after("block", "alpha_lists_postprocess", (state) => {
if (!state.env.alphaListMarkers || state.env.alphaListMarkers.length === 0) {
return;
}
const markers = state.env.alphaListMarkers;
// Build a map of line numbers to markers for more reliable matching
const lineToMarkerMap = new Map<number, typeof markers[0]>();
for (const marker of markers) {
lineToMarkerMap.set(marker.lineIndex, marker);
}
// Track which markers we've used to handle multiple lists correctly
const usedMarkers = new Set<number>();
for (let i = 0; i < state.tokens.length; i++) {
const token = state.tokens[i];
// Find ordered_list_open tokens and match them with the first list item
if (token.type === "ordered_list_open") {
// Look ahead to find the first list_item_open token
for (let j = i + 1; j < state.tokens.length; j++) {
const itemToken = state.tokens[j];
if (itemToken.type === "list_item_open" && itemToken.map) {
const itemLine = itemToken.map[0];
// Find the marker for this line or nearby lines.
// We check up to 2 lines back to handle cases where markdown-it's
// line mapping differs slightly from our preprocessing due to blank
// lines or formatting differences in list item content.
const MAX_LINE_OFFSET = 2;
for (let offset = 0; offset <= MAX_LINE_OFFSET; offset++) {
const checkLine = itemLine - offset;
const marker = lineToMarkerMap.get(checkLine);
if (marker && !usedMarkers.has(marker.lineIndex)) {
// Set the markup to the original letter marker
token.markup = marker.marker;
// Add an attribute to indicate this was an alphabetic list
token.attrSet("data-list-style", marker.listStyle);
// Mark this marker as used
usedMarkers.add(marker.lineIndex);
break;
}
}
break;
}
// Stop if we hit another list or go too far
if (
itemToken.type === "ordered_list_open" ||
itemToken.type === "bullet_list_open"
) {
break;
}
}
}
}
// Clean up the environment
delete state.env.alphaListMarkers;
});
}
-11
View File
@@ -59,17 +59,6 @@ export class EditorStyleHelper {
/** Toggle block folded state */
static readonly toggleBlockFolded = "folded";
// Checkbox Lists
/** Checkbox list wrapper */
static readonly checklistWrapper = "checklist-wrapper";
/** Toggle button for showing/hiding completed items */
static readonly checklistCompletedToggle = "checklist-completed-toggle";
/** State when completed items are hidden */
static readonly checklistCompletedHidden = "completed-hidden";
// Tables
/** Table wrapper */
-2
View File
@@ -16,8 +16,6 @@ export default class AuthenticationHelper {
info: Scope.Read,
search: Scope.Read,
documents: Scope.Read,
drafts: Scope.Read,
viewed: Scope.Read,
export: Scope.Read,
};
+18 -31
View File
@@ -550,9 +550,6 @@
"Full width": "Plná šířka",
"Bulleted list": "Odrážkový seznam",
"Todo list": "Seznam úkolů",
"Show {{ count }} completed": "Show {{ count }} completed",
"Show {{ count }} completed_plural": "Show {{ count }} completed",
"Hide completed": "Hide completed",
"Code block": "Blok kódu",
"Copied to clipboard": "Zkopírováno do schránky",
"Code": "Kód",
@@ -632,14 +629,6 @@
"Delete embed": "Odstranit vložený prvek",
"Formatting controls": "Prvky formátování",
"Distribute columns": "Rozložit sloupce",
"Delete Emoji": "Odstranit emoji",
"Emoji deleted": "Emoji bylo odstraněno",
"I'm sure Delete": "I'm sure Delete",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Opravdu chcete odstranit emoji <em>{{emojiName}}</em>? V dokumentech a kolekcích jej již nebude možné používat.",
"Group members": "Členové skupiny",
"Edit group": "Upravit skupinu",
"Delete group": "Odstranit skupinu",
"Members": "Členové",
"Could not import file": "Soubor se nepodařilo importovat",
"Unsubscribed from document": "Odběr upozornění na dokument byl zrušen",
"Unsubscribed from collection": "Odběr upozornění na kolekci byl zrušen",
@@ -649,28 +638,26 @@
"Authentication": "Ověřování",
"Security": "Zabezpečení",
"Features": "Funkce",
"Members": "Členové",
"API Keys": "API klíče",
"Applications": "Aplikace",
"Shared Links": "Sdílené odkazy",
"Import": "Importovat",
"Install": "Instalovat",
"Integrations": "Integrace",
"Change name": "Změnit jméno",
"Change email": "Změnit e-mail",
"Suspend user": "Pozastavit uživatele",
"An error occurred while sending the invite": "Při odesílání pozvánky došlo k chybě.",
"Change role": "Změnit roli",
"Resend invite": "Znovu odeslat pozvánku",
"Revoke invite": "Zrušit pozvání",
"Activate user": "Aktivovat uživatele",
"API key": "API klíč",
"Show path to document": "Zobrazit cestu k dokumentu",
"Collection menu": "Nabídka kolekce",
"Comment options": "Možnosti komentáře",
"Enable viewer insights": "Povolit analytiku zobrazení",
"Enable embeds": "Povolit vkládání (embeds)",
"Emoji options": "Emoji options",
"Delete Emoji": "Odstranit emoji",
"Emoji deleted": "Emoji bylo odstraněno",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Opravdu chcete odstranit emoji <em>{{emojiName}}</em>? V dokumentech a kolekcích jej již nebude možné používat.",
"File": "Soubor",
"Group members": "Členové skupiny",
"Edit group": "Upravit skupinu",
"Delete group": "Odstranit skupinu",
"Group options": "Nastavení skupin",
"Cancel": "Zrušit",
"Import menu options": "Možnosti nabídky importu",
@@ -685,6 +672,14 @@
"Headings you add to the document will appear here": "Zde se zobrazí nadpisy přidané do dokumentu",
"Contents": "Obsah",
"Table of contents": "Obsah",
"Change name": "Změnit jméno",
"Change email": "Změnit e-mail",
"Suspend user": "Pozastavit uživatele",
"An error occurred while sending the invite": "Při odesílání pozvánky došlo k chybě.",
"Change role": "Změnit roli",
"Resend invite": "Znovu odeslat pozvánku",
"Revoke invite": "Zrušit pozvání",
"Activate user": "Aktivovat uživatele",
"User options": "Možnosti uživatele",
"template": "šablona",
"document": "dokument",
@@ -1111,25 +1106,21 @@
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Opravdu chcete pokračovat? Odstraněním skupiny <em>{{groupName}}</em> ztratí její členové přístup ke kolekcím a dokumentům, se kterými je skupina spojena.",
"Add people to {{groupName}}": "Přidat lidi do {{groupName}}",
"{{userName}} was removed from the group": "Uživatel {{userName}} byl ze skupiny odebrán",
"All permissions": "All permissions",
"Group admin": "Správce skupiny",
"Member": "Člen",
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Spravujte členy skupiny <em>{{groupName}}</em>. Členové skupiny budou mít přístup ke všem kolekcím, do kterých je skupina zařazena.",
"Add people": "Přidat lidi",
"Listing members of the <em>{{groupName}}</em> group.": "Seznam členů skupiny <em>{{groupName}}</em>.",
"Search by name": "Hledat podle jména",
"Search members": "Search members",
"Filter by permissions": "Filter by permissions",
"No members matching your filters": "No members matching your filters",
"This group has no members.": "Skupina nemá žádné členy.",
"{{userName}} was added to the group": "Uživatel {{userName}} byl přidán do skupiny",
"Could not add user": "Uživatele se nepodařilo přidat",
"Add members below to give them access to the group. Need to add someone whos not yet a member?": "Přidejte členy do skupiny. Chcete přidat někoho, kdo ještě není členem?",
"Invite them to {{teamName}}": "Pozvat do {{teamName}}",
"Ask an admin to invite them first": "Požádejte administrátora o pozvání uživatele.",
"Search by name": "Hledat podle jména",
"Search people": "Hledat lidi",
"No people matching your search": "Hledání neodpovídají žádní uživatelé",
"No people left to add": "Nezbývají žádní uživatelé k přidání",
"Group admin": "Správce skupiny",
"Member": "Člen",
"Date created": "Datum vytvoření",
"Crop Image": "Oříznout obrázek",
"Crop image": "Oříznout obrázek",
@@ -1243,8 +1234,6 @@
"Manage when and where you receive email notifications.": "Správa e-mailových upozornění.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "E-mailová integrace je deaktivována. Pro povolení upozornění nastavte proměnné prostředí a restartujte server.",
"Preferences saved": "Předvolby byly uloženy",
"Unread count": "Unread count",
"Unread indicator": "Unread indicator",
"Delete account": "Odstranit účet",
"Manage settings that affect your personal experience.": "Spravujte osobní nastavení.",
"Language": "Jazyk",
@@ -1259,8 +1248,6 @@
"Automatically return to the document you were last viewing when the app is re-opened.": "Při otevření aplikace se automaticky vrátit k naposledy zobrazenému dokumentu.",
"Smart text replacements": "Chytré nahrazování textu",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Automatické formátování textu (nahrazování zkratek symboly, pomlčkami, chytrými uvozovkami atd.).",
"Notification badge": "Notification badge",
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
"You may delete your account at any time, note that this is unrecoverable": "Účet můžete kdykoli odstranit. Tuto akci nelze vrátit zpět.",
"Profile saved": "Profil byl uložen",
"Profile picture updated": "Profilový obrázek byl aktualizován",
+18 -31
View File
@@ -550,9 +550,6 @@
"Full width": "Fuld bredde",
"Bulleted list": "Punktliste",
"Todo list": "Opgaveliste",
"Show {{ count }} completed": "Show {{ count }} completed",
"Show {{ count }} completed_plural": "Show {{ count }} completed",
"Hide completed": "Hide completed",
"Code block": "Kodeblok",
"Copied to clipboard": "Kopieret til udklipsholder",
"Code": "Kode",
@@ -632,14 +629,6 @@
"Delete embed": "Delete embed",
"Formatting controls": "Formatting controls",
"Distribute columns": "Distribute columns",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"I'm sure Delete": "I'm sure Delete",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"Group members": "Gruppemedlemmer",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Members": "Members",
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
@@ -649,28 +638,26 @@
"Authentication": "Authentication",
"Security": "Security",
"Features": "Features",
"Members": "Members",
"API Keys": "API Keys",
"Applications": "Applications",
"Shared Links": "Shared Links",
"Import": "Import",
"Install": "Install",
"Integrations": "Integrations",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"API key": "API key",
"Show path to document": "Show path to document",
"Collection menu": "Collection menu",
"Comment options": "Comment options",
"Enable viewer insights": "Aktiver seerindsigter",
"Enable embeds": "Enable embeds",
"Emoji options": "Emoji options",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"File": "File",
"Group members": "Gruppemedlemmer",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Group options": "Group options",
"Cancel": "Cancel",
"Import menu options": "Import menu options",
@@ -685,6 +672,14 @@
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "Contents",
"Table of contents": "Table of contents",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"User options": "User options",
"template": "template",
"document": "document",
@@ -1111,25 +1106,21 @@
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
"Add people to {{groupName}}": "Add people to {{groupName}}",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"All permissions": "All permissions",
"Group admin": "Group admin",
"Member": "Member",
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.",
"Add people": "Add people",
"Listing members of the <em>{{groupName}}</em> group.": "Listing members of the <em>{{groupName}}</em> group.",
"Search by name": "Search by name",
"Search members": "Search members",
"Filter by permissions": "Filter by permissions",
"No members matching your filters": "No members matching your filters",
"This group has no members.": "This group has no members.",
"{{userName}} was added to the group": "{{userName}} was added to the group",
"Could not add user": "Could not add user",
"Add members below to give them access to the group. Need to add someone whos not yet a member?": "Add members below to give them access to the group. Need to add someone whos not yet a member?",
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
"Ask an admin to invite them first": "Ask an admin to invite them first",
"Search by name": "Search by name",
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Group admin": "Group admin",
"Member": "Member",
"Date created": "Date created",
"Crop Image": "Crop Image",
"Crop image": "Crop image",
@@ -1243,8 +1234,6 @@
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
"Preferences saved": "Preferences saved",
"Unread count": "Unread count",
"Unread indicator": "Unread indicator",
"Delete account": "Slet konto",
"Manage settings that affect your personal experience.": "Administrer indstillinger, der påvirker din personlige oplevelse.",
"Language": "Sprog",
@@ -1259,8 +1248,6 @@
"Automatically return to the document you were last viewing when the app is re-opened.": "Returnér automatisk til det dokument, du sidst har set, når appen er genåbnet.",
"Smart text replacements": "Smart tekstudskiftning",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Automatisk formatering af tekst ved at erstatte genveje med symboler, bindestreger, smarte citater og andre typografiske elementer.",
"Notification badge": "Notification badge",
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
"You may delete your account at any time, note that this is unrecoverable": "Du kan til enhver tid slette din konto, bemærk, at dette ikke kan genskabes",
"Profile saved": "Profil gemt",
"Profile picture updated": "Profilbillede opdateret",
+19 -32
View File
@@ -550,9 +550,6 @@
"Full width": "Volle Breite",
"Bulleted list": "Punkteliste",
"Todo list": "Aufgabenliste",
"Show {{ count }} completed": "Zeige {{ count }} abgeschlossenen",
"Show {{ count }} completed_plural": "Zeige {{ count }} abgeschlossene",
"Hide completed": "Hide completed",
"Code block": "Codeblock",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Code": "Code",
@@ -632,14 +629,6 @@
"Delete embed": "Einbindung löschen",
"Formatting controls": "Formatierungssteuerung",
"Distribute columns": "Spalten teilen",
"Delete Emoji": "Emoji löschen",
"Emoji deleted": "Emoji gelöscht",
"I'm sure Delete": "Ich bin mir sicher Löschen",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Sind Sie sicher, dass Sie den <em>{{emojiName}}</em> Emoji löschen möchten? Sie können es nicht mehr in Ihren Dokumenten oder Sammlungen verwenden.",
"Group members": "Gruppenmitglieder",
"Edit group": "Gruppe bearbeiten",
"Delete group": "Gruppe löschen",
"Members": "Mitglieder",
"Could not import file": "Datei konnte nicht importiert werden",
"Unsubscribed from document": "Dokument nicht abonniert",
"Unsubscribed from collection": "Von der Sammlung abgemeldet",
@@ -649,28 +638,26 @@
"Authentication": "Authentifizierung",
"Security": "Sicherheit",
"Features": "Funktionen",
"Members": "Mitglieder",
"API Keys": "API-Schlüssel",
"Applications": "Anwendungen",
"Shared Links": "Geteilte Links",
"Import": "Import",
"Install": "Installieren",
"Integrations": "Integrationen",
"Change name": "Namen ändern",
"Change email": "E-Mail-Adresse ändern",
"Suspend user": "Benutzer sperren",
"An error occurred while sending the invite": "Beim Versand der Einladung trat ein Fehler auf",
"Change role": "Rolle ändern",
"Resend invite": "Einladung erneut senden",
"Revoke invite": "Einladung widerrufen",
"Activate user": "Benutzer aktivieren",
"API key": "API Key",
"Show path to document": "Pfad zum Dokument anzeigen",
"Collection menu": "Sammlungsmenü",
"Comment options": "Kommentar Optionen",
"Enable viewer insights": "Leserstatistiken aktivieren",
"Enable embeds": "Einbettungen aktivieren",
"Emoji options": "Emoji-Optionen",
"Delete Emoji": "Emoji löschen",
"Emoji deleted": "Emoji gelöscht",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Sind Sie sicher, dass Sie den <em>{{emojiName}}</em> Emoji löschen möchten? Sie können es nicht mehr in Ihren Dokumenten oder Sammlungen verwenden.",
"File": "Datei",
"Group members": "Gruppenmitglieder",
"Edit group": "Gruppe bearbeiten",
"Delete group": "Gruppe löschen",
"Group options": "Gruppen-Einstellungen",
"Cancel": "Abbrechen",
"Import menu options": "Menüoptionen importieren",
@@ -685,6 +672,14 @@
"Headings you add to the document will appear here": "Überschriften, die du dem Dokument hinzufügst, werden hier angezeigt",
"Contents": "Inhalte",
"Table of contents": "Inhaltsverzeichnis",
"Change name": "Namen ändern",
"Change email": "E-Mail-Adresse ändern",
"Suspend user": "Benutzer sperren",
"An error occurred while sending the invite": "Beim Versand der Einladung trat ein Fehler auf",
"Change role": "Rolle ändern",
"Resend invite": "Einladung erneut senden",
"Revoke invite": "Einladung widerrufen",
"Activate user": "Benutzer aktivieren",
"User options": "Nutzer-Einstellungen",
"template": "Vorlage",
"document": "Dokument",
@@ -1111,25 +1106,21 @@
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Bist du sicher? Durch das Löschen der <em>{{groupName}}</em> Gruppe verlieren deine Teammitglieder den Zugriff auf Sammlungen und Dokumente die mit der Gruppe verknüpft waren.",
"Add people to {{groupName}}": "Personen zu {{groupName }} hinzufügen",
"{{userName}} was removed from the group": "{{userName}} wurde aus der Gruppe entfernt",
"All permissions": "All permissions",
"Group admin": "Admin Gruppe",
"Member": "Mitglied",
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Hinzufügen und Entfernen von Mitgliedern zur Gruppe <em>{{groupName}}</em>. Mitglieder der Gruppe haben Zugriff auf alle Sammlungen, zu denen diese Gruppe hinzugefügt wurde.",
"Add people": "Personen hinzufügen",
"Listing members of the <em>{{groupName}}</em> group.": "Mitglieder der Gruppe <em>{{groupName}}</em> auflisten.",
"Search by name": "Nach Name suchen",
"Search members": "Search members",
"Filter by permissions": "Filter by permissions",
"No members matching your filters": "No members matching your filters",
"This group has no members.": "Diese Gruppe hat keine Mitglieder.",
"{{userName}} was added to the group": "{{userName}} wurde zur Gruppe hinzugefügt",
"Could not add user": "Benutzer kann nicht hinzugefügt werden",
"Add members below to give them access to the group. Need to add someone whos not yet a member?": "Füge unten Mitglieder hinzu, um ihnen Zugriff auf die Gruppe zu gewähren. Musst du jemanden hinzufügen, der noch kein Mitglied ist?",
"Invite them to {{teamName}}": "Personen zu {{teamName}} einladen",
"Ask an admin to invite them first": "Bitten Sie einen Administrator, sie zuerst einzuladen",
"Search by name": "Nach Name suchen",
"Search people": "Personen suchen",
"No people matching your search": "Keine Personen, die Ihrer Suche entsprechen",
"No people left to add": "Keine Personen übrig zum Hinzufügen",
"Group admin": "Admin Gruppe",
"Member": "Mitglied",
"Date created": "Erstellungsdatum",
"Crop Image": "Bild zuschneiden",
"Crop image": "Bild zuschneiden",
@@ -1243,8 +1234,6 @@
"Manage when and where you receive email notifications.": "Verwalten Sie, wann und wo Sie E-Mail-Benachrichtigungen erhalten.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "Die E-Mail-Integration ist derzeit deaktiviert. Legen Sie die zugehörigen Umgebungsvariablen fest und starten Sie den Server neu, um Benachrichtigungen zu aktivieren.",
"Preferences saved": "Einstellungen gespeichert",
"Unread count": "Unread count",
"Unread indicator": "Unread indicator",
"Delete account": "Konto löschen",
"Manage settings that affect your personal experience.": "Verwalten Sie Einstellungen, die Ihr persönliches Erlebnis beeinflussen.",
"Language": "Sprache",
@@ -1259,8 +1248,6 @@
"Automatically return to the document you were last viewing when the app is re-opened.": "Automatisch zum zuletzt angezeigten Dokument zurückkehren, wenn die App wieder geöffnet wird.",
"Smart text replacements": "Intelligente Textersetzungen",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Text automatisch formatieren, indem Verknüpfungen durch Symbole, Bindestriche, intelligente Anführungszeichen und andere typografische Elemente ersetzt werden.",
"Notification badge": "Notification badge",
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
"You may delete your account at any time, note that this is unrecoverable": "Sie können Ihren Account jederzeit löschen, beachten Sie, dass dies nicht wiederhergestellt werden kann",
"Profile saved": "Profil gespeichert",
"Profile picture updated": "Profilbild wurde aktualisiert",
@@ -1377,7 +1364,7 @@
"List": "Liste",
"Could not load events": "Konnte Ereignisse nicht laden",
"Audit Log": "Audit-Log",
"The audit log details the history of security related and other events across your knowledge base.": "Das Audit-Log protokolliert die Historie von sicherheitsrelevanten und anderen Ereignissen in Ihrer Wissensdatenbank.",
"The audit log details the history of security related and other events across your knowledge base.": "The audit log details the history of security related and other events across your knowledge base.",
"IP address": "IP-Adresse",
"Actor": "Akteur",
"Event": "Event",
+18 -31
View File
@@ -550,9 +550,6 @@
"Full width": "Full width",
"Bulleted list": "Bulleted list",
"Todo list": "Task list",
"Show {{ count }} completed": "Show {{ count }} completed",
"Show {{ count }} completed_plural": "Show {{ count }} completed",
"Hide completed": "Hide completed",
"Code block": "Code block",
"Copied to clipboard": "Copied to clipboard",
"Code": "Code",
@@ -632,14 +629,6 @@
"Delete embed": "Delete embed",
"Formatting controls": "Formatting controls",
"Distribute columns": "Distribute columns",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"I'm sure Delete": "I'm sure Delete",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"Group members": "Group members",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Members": "Members",
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
@@ -649,28 +638,26 @@
"Authentication": "Authentication",
"Security": "Security",
"Features": "Features",
"Members": "Members",
"API Keys": "API Keys",
"Applications": "Applications",
"Shared Links": "Shared Links",
"Import": "Import",
"Install": "Install",
"Integrations": "Integrations",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"API key": "API key",
"Show path to document": "Show path to document",
"Collection menu": "Collection menu",
"Comment options": "Comment options",
"Enable viewer insights": "Enable viewer insights",
"Enable embeds": "Enable embeds",
"Emoji options": "Emoji options",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"File": "File",
"Group members": "Group members",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Group options": "Group options",
"Cancel": "Cancel",
"Import menu options": "Import menu options",
@@ -685,6 +672,14 @@
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "Contents",
"Table of contents": "Table of contents",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"User options": "User options",
"template": "template",
"document": "document",
@@ -1111,25 +1106,21 @@
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
"Add people to {{groupName}}": "Add people to {{groupName}}",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"All permissions": "All permissions",
"Group admin": "Group admin",
"Member": "Member",
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.",
"Add people": "Add people",
"Listing members of the <em>{{groupName}}</em> group.": "Listing members of the <em>{{groupName}}</em> group.",
"Search by name": "Search by name",
"Search members": "Search members",
"Filter by permissions": "Filter by permissions",
"No members matching your filters": "No members matching your filters",
"This group has no members.": "This group has no members.",
"{{userName}} was added to the group": "{{userName}} was added to the group",
"Could not add user": "Could not add user",
"Add members below to give them access to the group. Need to add someone whos not yet a member?": "Add members below to give them access to the group. Need to add someone whos not yet a member?",
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
"Ask an admin to invite them first": "Ask an admin to invite them first",
"Search by name": "Search by name",
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Group admin": "Group admin",
"Member": "Member",
"Date created": "Date created",
"Crop Image": "Crop Image",
"Crop image": "Crop image",
@@ -1243,8 +1234,6 @@
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
"Preferences saved": "Preferences saved",
"Unread count": "Unread count",
"Unread indicator": "Unread indicator",
"Delete account": "Delete account",
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
"Language": "Language",
@@ -1259,8 +1248,6 @@
"Automatically return to the document you were last viewing when the app is re-opened.": "Automatically return to the document you were last viewing when the app is re-opened.",
"Smart text replacements": "Smart text replacements",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
"Notification badge": "Notification badge",
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
"Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated",
+19 -31
View File
@@ -550,9 +550,6 @@
"Full width": "Full width",
"Bulleted list": "Bulleted list",
"Todo list": "Task list",
"Show {{ count }} completed": "Show {{ count }} completed",
"Show {{ count }} completed_plural": "Show {{ count }} completed",
"Hide completed": "Hide completed",
"Code block": "Code block",
"Copied to clipboard": "Copied to clipboard",
"Code": "Code",
@@ -632,14 +629,6 @@
"Delete embed": "Delete embed",
"Formatting controls": "Formatting controls",
"Distribute columns": "Distribute columns",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"I'm sure Delete": "I'm sure Delete",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"Group members": "Group members",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Members": "Members",
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
@@ -649,28 +638,26 @@
"Authentication": "Authentication",
"Security": "Security",
"Features": "Features",
"Members": "Members",
"API Keys": "API Keys",
"Applications": "Applications",
"Shared Links": "Shared Links",
"Import": "Import",
"Install": "Install",
"Integrations": "Integrations",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"API key": "API key",
"Show path to document": "Show path to document",
"Collection menu": "Collection menu",
"Comment options": "Comment options",
"Enable viewer insights": "Enable viewer insights",
"Enable embeds": "Enable embeds",
"Emoji options": "Emoji options",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"File": "File",
"Group members": "Group members",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Group options": "Group options",
"Cancel": "Cancel",
"Import menu options": "Import menu options",
@@ -685,6 +672,14 @@
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "Contents",
"Table of contents": "Table of contents",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"User options": "User options",
"template": "template",
"document": "document",
@@ -885,6 +880,7 @@
"Open this guide": "Open this guide",
"Enter": "Enter",
"Publish document and exit": "Publish document and exit",
"Save document": "Save document",
"Cancel editing": "Cancel editing",
"Collaboration": "Collaboration",
"Formatting": "Formatting",
@@ -1110,25 +1106,21 @@
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
"Add people to {{groupName}}": "Add people to {{groupName}}",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"All permissions": "All permissions",
"Group admin": "Group admin",
"Member": "Member",
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.",
"Add people": "Add people",
"Listing members of the <em>{{groupName}}</em> group.": "Listing members of the <em>{{groupName}}</em> group.",
"Search by name": "Search by name",
"Search members": "Search members",
"Filter by permissions": "Filter by permissions",
"No members matching your filters": "No members matching your filters",
"This group has no members.": "This group has no members.",
"{{userName}} was added to the group": "{{userName}} was added to the group",
"Could not add user": "Could not add user",
"Add members below to give them access to the group. Need to add someone whos not yet a member?": "Add members below to give them access to the group. Need to add someone whos not yet a member?",
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
"Ask an admin to invite them first": "Ask an admin to invite them first",
"Search by name": "Search by name",
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Group admin": "Group admin",
"Member": "Member",
"Date created": "Date created",
"Crop Image": "Crop Image",
"Crop image": "Crop image",
@@ -1242,8 +1234,6 @@
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
"Preferences saved": "Preferences saved",
"Unread count": "Unread count",
"Unread indicator": "Unread indicator",
"Delete account": "Delete account",
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
"Language": "Language",
@@ -1258,8 +1248,6 @@
"Automatically return to the document you were last viewing when the app is re-opened.": "Automatically return to the document you were last viewing when the app is re-opened.",
"Smart text replacements": "Smart text replacements",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
"Notification badge": "Notification badge",
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
"Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated",

Some files were not shown because too many files have changed in this diff Show More