Compare commits

..

39 Commits

Author SHA1 Message Date
Tom Moor 8ddccc195a Add missing tooltips 2026-02-14 16:42:34 -05:00
Tom Moor 66b0341cfa fix: Synthetic 'latest' revision fails to load (#11451)
closes #11449
2026-02-14 16:09:10 -05:00
Copilot 057d57e21a Add alphabetic ordered list support to markdown parser (#11446)
* Initial plan

* Add alpha list markdown parsing support

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add integration tests for alpha list parsing and serialization

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback - improve marker matching logic

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add explanatory comment for line offset constant

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-14 16:08:58 -05:00
Tom Moor 13c00c4663 chore: Convert rtl prop to transient, addresses warnings (#11450) 2026-02-14 15:53:06 -05:00
Tom Moor eb584ed6b6 perf: Load translation locale files over CDN URL (#11445)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:34:08 +00:00
Tom Moor 40c81a5e30 fix: Notification badge does not appear until notification popover opened (#11444) 2026-02-14 10:01:19 -05:00
Translate-O-Tron 5e976fe732 New Crowdin updates (#11380)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2026-02-14 09:19:45 -05:00
Tom Moor fe9daa0a75 fix: Collections with the same name overwrite in export (#11443) 2026-02-14 09:19:30 -05:00
Tom Moor 08227ce4da fix/edit-redirect (#11442) 2026-02-14 13:40:27 +00:00
Tom Moor 4f6ee1a00b feat: Add a preference for desktop notification badge off/count/indicator (#11436) 2026-02-13 18:04:10 -05:00
Tom Moor 797c28a12e fix: Edits that only include a mention below edit distance do not trigger mention (#11434) 2026-02-13 18:02:47 -05:00
Salihu 129e872578 filter group members (#11403)
* filter group members

* requested changes
2026-02-13 17:35:54 -05:00
Copilot b4053f344f Add Alt-click to recursively expand/collapse sidebar documents and collections (#11432)
* Initial plan

* Add alt-click to expand/collapse all nested documents

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix callback stability to prevent unnecessary re-renders

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add alt-click expand/collapse support for CollectionLink

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor

* Add support for other link types

* Handle unloaded

* refactor

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-13 17:27:20 -05:00
Tom Moor ffe7cda26b fix: Mispositioned toolbar on first document open (#11437)
closes #11423
2026-02-13 17:13:48 -05:00
Tom Moor 38880f8335 fix: Missing check for disabled group mentions (#11435) 2026-02-13 08:06:24 -05:00
Tom Moor 1caca05876 fix: No longer use public acl for avatars (#11427)
Related #11367
2026-02-12 21:56:21 -05:00
Tom Moor 0722b42613 fix: Potential task queue saturation in Notion importer (#11428)
* fix: Potential task queue saturation in Notion import

* Reduces concurrent Notion API pressure from 3× the recursive call depth down to 1
2026-02-12 21:56:00 -05:00
Tom Moor 5d749efd84 fix: Issue in active context creation due to fallback (#11426) 2026-02-12 20:10:53 -05:00
Copilot 0363481a6a Add "Rename" option to sidebar context menus (#11425)
* Initial plan

* Add Rename option to context menus for sidebar items

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-13 00:25:03 +00:00
Copilot c8fbdc35fb Ignore table_of_contents blocks in Notion import (#11424)
* Initial plan

* feat: Add handler to ignore table_of_contents Notion block

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-12 18:51:43 -05:00
Copilot c382e1233b Convert markdown frontmatter to YAML codeblocks on import (#11420)
* Initial plan

* Add frontmatter to YAML codeblock conversion

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add edge case tests and fix frontmatter regex, install types

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback - improve template literal readability

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-12 18:32:15 -05:00
Tom Moor 3a875d4466 Add more ignore rules (#11419) 2026-02-12 18:27:54 -05:00
Tom Moor 66f9113975 fix: Exporting document with table causes crash (#11422)
* fix: Exporting document with table causes crash

* fix: Same issue for checkbox lists
2026-02-12 18:27:42 -05:00
Tom Moor a52391842f chore: Add application_name to postgres logging (#11415) 2026-02-11 20:59:39 -05:00
Tom Moor 20e84c8e1d chore: Allowlist more methods for CSRF skip (#11414) 2026-02-11 20:38:40 -05:00
Tom Moor 1488341f66 fix: Remove unnecessary loading of authentication rows in userProvisioner (#11413)
* fix: Remove unneccessary loading of authentication rows in userProvisioner

* test
2026-02-11 18:45:47 -05:00
Tom Moor a06174b627 Revert "perf: Reduce database contention in ImportTask (#11361)" (#11411)
This reverts commit 8209f56e56.
2026-02-10 22:59:46 -05:00
Tom Moor 22556b2121 fix: More selection toolbar fixes around link selection (#11408) 2026-02-10 21:26:11 -05:00
Copilot 7252701e9b Preserve alignment and caption when replacing images (#11407)
* Initial plan

* Preserve alignment, caption, and height when replacing images

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-10 18:16:22 -05:00
Tom Moor 5fd6ef646a fix: Sentry error resulting from browser extensions using MobX (#11399) 2026-02-10 06:46:36 -05:00
Copilot 0e9f34bd6a Add hide/show completed items control for checkbox lists (#11379)
* Initial plan

* Add hide/show completed items feature for checkbox lists

- Add id attribute to checkbox_list nodes
- Create CheckboxListNodeView with toggle button
- Store hide state in localStorage per user and list
- Add CSS styles for wrapper and toggle button
- Hide completed items when state is active

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback - improve boolean handling and default values

- Change id default from undefined to null for consistent serialization
- Use !! for boolean coercion instead of === true for Storage.get
- More robust handling of truthy values

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-09 16:35:58 -05:00
Copilot 23177578b2 Add context menu support for table rows in settings (#11378)
* Initial plan

* Add context menu support for table rows in settings

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix file formatting

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add context menu support to all settings tables

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor

* Reuse hooks

* EmojiMenu

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-09 14:43:17 -05:00
Copilot 40bbfc78cd Refactor: Extract Redis cache key generation to RedisPrefixHelper (#11376)
* Initial plan

* Refactor Redis cache keys: delegate CacheHelper to RedisPrefixHelper and update callers

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add JSDoc documentation to getCollectionDocumentsKey method

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Remove unused indirection

* Remove mock

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-09 14:03:02 -05:00
Tom Moor dc9aad99e9 fix: Test snapshot (#11395) 2026-02-08 18:28:35 -05:00
Copilot ea9e9675fb Fix document creation routing to use correct parameter name (#11369)
* Initial plan

* Fix: Use correct route parameter name in DocumentNew

The route parameter is 'collectionSlug', not 'id'. This caused documents
created through /collection/:collectionSlug/new to not have a collectionId,
making them go to drafts instead of the collection.

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-08 18:21:06 -05:00
github-actions[bot] db42af7fe1 chore: Compressed inefficient images automatically (#11394)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2026-02-08 16:26:32 -05:00
Tom Moor eb59aed5b7 test: Fix snap (#11391) 2026-02-07 22:07:41 +00:00
Tom Moor 8209f56e56 perf: Reduce database contention in ImportTask (#11361)
* perf: Reduce database contention in ImportTask

* fix: Reuse transaction when available
2026-02-07 17:02:35 -05:00
Copilot a097676e9c Map Notion toggle blocks to container_toggle nodes (#11371)
* Initial plan

* Add toggle block support to Notion importer

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Support toggle headings

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-07 16:40:06 -05:00
124 changed files with 3701 additions and 1384 deletions
+8 -1
View File
@@ -1,4 +1,3 @@
__mocks__
.git
.vscode
.github
@@ -8,11 +7,19 @@ __mocks__
.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,6 +28,7 @@ import {
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
@@ -132,6 +133,7 @@ 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" ? (
+6 -3
View File
@@ -16,6 +16,7 @@ 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;
@@ -93,9 +94,11 @@ const Modal: React.FC<Props> = ({
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Header>
</Centered>
</Wrapper>
+47
View File
@@ -0,0 +1,47 @@
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);
+2 -21
View File
@@ -8,7 +8,6 @@ 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";
@@ -61,25 +60,7 @@ function Notifications(
);
}, [notifications.active, filter]);
// 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]);
const unreadCount = notifications.approximateUnreadCount;
return (
<ErrorBoundary>
@@ -105,7 +86,7 @@ function Notifications(
short
nude
/>
{notifications.approximateUnreadCount > 0 && (
{unreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
@@ -15,6 +15,7 @@ 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";
@@ -122,6 +123,7 @@ const CollectionLink: React.FC<Props> = ({
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
return (
@@ -165,17 +167,18 @@ const CollectionLink: React.FC<Props> = ({
!isDraggingAnyCollection && (
<Fade>
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<CollectionMenu
collection={collection}
@@ -40,6 +40,10 @@ 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;
@@ -119,6 +123,13 @@ 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();
@@ -132,13 +143,18 @@ function InnerDocumentLink(
}
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(() => {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
}, [setCollapsed, setExpanded, expanded]);
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 handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
@@ -336,7 +352,10 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const contextMenuAction = useDocumentMenuAction({
documentId: node.id,
onRename: handleRename,
});
const labelElement = React.useMemo(
() => (
@@ -464,22 +483,24 @@ function InnerDocumentLink(
}
/>
)}
<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 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>
</ActionContextProvider>
);
}
@@ -13,6 +13,9 @@ 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";
@@ -36,6 +39,10 @@ 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 },
@@ -91,15 +98,22 @@ function DraggableCollectionLink({
locationSidebarContext,
]);
const handleDisclosureClick = useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
const handleDisclosureClick = useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
const displayChildDocuments = expanded && !isDragging;
return (
<>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
@@ -121,7 +135,7 @@ function DraggableCollectionLink({
/>
)}
</Relative>
</>
</SidebarDisclosureContext.Provider>
);
}
+28 -13
View File
@@ -7,6 +7,9 @@ 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 = {
@@ -21,10 +24,20 @@ const GroupLink: React.FC<Props> = ({ group }) => {
locationSidebarContext === sidebarContext
);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleDisclosureClick = React.useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
@@ -42,15 +55,17 @@ const GroupLink: React.FC<Props> = ({ group }) => {
depth={0}
/>
<SidebarContext.Provider value={sidebarContext}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
</SidebarContext.Provider>
</Relative>
);
@@ -9,6 +9,10 @@ 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 = {
@@ -62,6 +66,14 @@ 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);
@@ -72,9 +84,12 @@ function DocumentLink(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
const willExpand = !expanded;
setExpanded(willExpand);
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
onDisclosureClick(willExpand, !!altKey);
},
[expanded]
[expanded, onDisclosureClick]
);
// since we don't have access to the collection sort here, we just put any
@@ -133,22 +148,24 @@ function DocumentLink(
ref={ref}
isActive={() => !!isActiveDocument}
/>
{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 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>
</>
);
}
@@ -22,6 +22,10 @@ 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";
@@ -48,6 +52,12 @@ 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();
@@ -76,13 +86,15 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (expanded) {
setCollapsed();
} else {
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
},
[expanded, setExpanded, setCollapsed]
[expanded, setExpanded, setCollapsed, onDisclosureClick]
);
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -174,20 +186,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
</div>
</Draggable>
</Relative>
<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 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>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
@@ -0,0 +1,127 @@
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,6 +19,9 @@ 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";
@@ -204,6 +207,9 @@ function StarredLink({ star }: Props) {
sidebarContext === locationSidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
@@ -235,9 +241,13 @@ function StarredLink({ star }: Props) {
(ev?: React.MouseEvent<HTMLElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
setExpanded((prevExpanded) => {
const willExpand = !prevExpanded;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[]
[onDisclosureClick]
);
const handlePrefetch = React.useCallback(() => {
@@ -284,39 +294,43 @@ function StarredLink({ star }: Props) {
if (documentId) {
return (
<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 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>
);
}
if (collection) {
return (
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
<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>
);
}
+5 -1
View File
@@ -59,6 +59,7 @@ export type Props<TData> = {
};
rowHeight: number;
stickyOffset?: number;
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
};
function Table<TData>({
@@ -70,6 +71,7 @@ function Table<TData>({
page,
rowHeight,
stickyOffset = 0,
decorateRow,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
@@ -206,7 +208,7 @@ function Table<TData>({
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
const baseRow = (
<TR
role="row"
key={row.id}
@@ -231,6 +233,8 @@ function Table<TData>({
))}
</TR>
);
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
})}
</TBody>
{showPlaceholder && (
+3 -1
View File
@@ -33,6 +33,7 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
autoFocus?: boolean;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
@@ -45,6 +46,7 @@ const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
autoFocus,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
@@ -209,7 +211,7 @@ const LinkEditor: React.FC<Props> = ({
onKeyDown={handleKeyDown}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
autoFocus={autoFocus}
readOnly={!view.editable}
/>
{actions.map((action, index) => {
+44 -30
View File
@@ -87,25 +87,29 @@ 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
);
React.useEffect(() => {
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
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);
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
React.useLayoutEffect(() => {
if (!isActive) {
setActiveToolbar(null);
return;
}
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
@@ -124,22 +128,37 @@ export function SelectionToolbar(props: Props) {
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [readOnly, selection]);
}, [
readOnly,
isActive,
selection,
linkMark,
isEmbedSelection,
isCodeSelection,
isNoticeSelection,
]);
React.useLayoutEffect(() => {
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
setAutoFocusLinkInput(false);
}
}, [activeToolbar]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useEffect(() => {
React.useLayoutEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly
!readOnly &&
isActive
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, view]);
}, [activeToolbar, readOnly, isActive, view]);
React.useEffect(() => {
React.useLayoutEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
@@ -193,11 +212,10 @@ export function SelectionToolbar(props: Props) {
) {
ev.preventDefault();
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
setAutoFocusLinkInput(true);
setActiveToolbar(
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
);
}
},
view.dom,
@@ -218,12 +236,6 @@ 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";
@@ -289,6 +301,7 @@ export function SelectionToolbar(props: Props) {
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setAutoFocusLinkInput(true);
setActiveToolbar(Toolbar.Link);
};
}
@@ -315,10 +328,11 @@ export function SelectionToolbar(props: Props) {
>
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`${selection.from}-${selection.to}`}
key={`link-${selection.anchor}`}
dictionary={dictionary}
autoFocus={autoFocusLinkInput}
view={view}
mark={link ? link.mark : undefined}
mark={linkMark ? linkMark.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
@@ -328,7 +342,7 @@ export function SelectionToolbar(props: Props) {
/>
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
key={`embed-${selection.anchor}`}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
+8 -2
View File
@@ -78,6 +78,11 @@ 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 */
@@ -854,7 +859,7 @@ export class Editor extends React.PureComponent<
column
>
<EditorContainer
rtl={isRTL}
$rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={canUpdate}
@@ -867,6 +872,7 @@ export class Editor extends React.PureComponent<
/>
{this.widgets &&
!this.props.cacheOnly &&
Object.values(this.widgets).map((Widget, index) => (
<Widget
key={String(index)}
@@ -887,7 +893,7 @@ export class Editor extends React.PureComponent<
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={this.view.focus}
onClose={this.view.focus.bind(this.view)}
/>
)}
</EditorContext.Provider>
+5 -1
View File
@@ -10,7 +10,11 @@ if (!window.env) {
);
}
const env: Record<string, any> = {
const env: Record<string, any> & {
isDevelopment: boolean;
isTest: boolean;
isProduction: boolean;
} = {
...window.env,
isDevelopment: window.env.ENVIRONMENT === "development",
isTest: window.env.ENVIRONMENT === "test",
+42 -1
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: stores.ui.activeModels,
activeModels: new Set(stores.ui.activeModels.values()),
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
@@ -84,9 +84,50 @@ 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,6 +26,9 @@ 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
@@ -0,0 +1,91 @@
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
@@ -0,0 +1,115 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,190 @@
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,6 +3,7 @@ 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";
@@ -37,6 +38,13 @@ 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);
+23 -70
View File
@@ -1,75 +1,28 @@
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 { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Emoji from "~/models/Emoji";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
const { t } = useTranslation();
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 (
<Tooltip content={t("Delete Emoji")}>
<IconButton onClick={handleDelete}>
<TrashIcon />
</IconButton>
</Tooltip>
);
};
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
type Props = {
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;
function EmojisMenu({ emoji }: Props) {
const { t } = useTranslation();
const rootAction = useEmojiMenuActions(emoji);
return (
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Emoji options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
export default observer(EmojisMenu);
+3 -91
View File
@@ -1,24 +1,10 @@
import { observer } from "mobx-react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useCallback, useMemo } from "react";
import * as React 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 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";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
group: Group;
@@ -26,81 +12,7 @@ type Props = {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
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);
const rootAction = useGroupMenuActions(group);
return (
<DropdownMenu
+2 -21
View File
@@ -4,14 +4,7 @@ import { useTranslation } from "react-i18next";
import type Share from "~/models/Share";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import { ActionSeparator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
type Props = {
share: Share;
@@ -19,19 +12,7 @@ type Props = {
function ShareMenu({ share }: Props) {
const { t } = useTranslation();
const can = usePolicy(share);
const actions = React.useMemo(
() => [
copyShareUrlFactory({ share }),
goToShareSourceFactory({ share }),
ActionSeparator,
revokeShareFactory({ share, can }),
],
[share, can]
);
const rootAction = useMenuAction(actions);
const rootAction = useShareMenuActions(share);
return (
<DropdownMenu
+2 -147
View File
@@ -1,163 +1,18 @@
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 {
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";
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
type Props = {
user: User;
};
function UserMenu({ user }: Props) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
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);
const rootAction = useUserMenuActions(user);
return (
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
+13 -5
View File
@@ -231,10 +231,14 @@ class User extends ParanoidModel implements Searchable {
* @param key The UserPreference key to retrieve
* @returns The value
*/
getPreference(key: UserPreference, defaultValue = false): boolean {
return (
this.preferences?.[key] ?? UserPreferenceDefaults[key] ?? defaultValue
);
getPreference<K extends UserPreference>(
key: K,
defaultValue?: UserPreferences[K]
): NonNullable<UserPreferences[K]> {
return (this.preferences?.[key] ??
UserPreferenceDefaults[key] ??
defaultValue ??
false) as NonNullable<UserPreferences[K]>;
}
/**
@@ -243,7 +247,11 @@ class User extends ParanoidModel implements Searchable {
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
setPreference(key: UserPreference, value: boolean) {
@action
setPreference<K extends UserPreference>(
key: K,
value: NonNullable<UserPreferences[K]>
) {
this.preferences = {
...this.preferences,
[key]: value,
+14 -9
View File
@@ -104,18 +104,23 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchRevision() {
if (revisionId) {
try {
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
revisionId
);
} catch (err) {
setError(err);
if (!revisionId) {
return;
}
try {
if (revisionId === "latest") {
if (document?.id) {
await revisions.fetchLatest(document.id);
}
} else {
await revisions.fetch(revisionId);
}
} catch (err) {
setError(err);
}
}
void fetchRevision();
}, [revisions, revisionId]);
}, [revisions, revisionId, document?.id]);
React.useEffect(() => {
async function fetchViews() {
@@ -162,7 +167,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 (!can.update && isEditRoute && !document.template) {
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
history.push(document.url);
return;
}
+1 -13
View File
@@ -67,8 +67,6 @@ function DocumentHeader({
revision,
isEditing,
isDraft,
isPublishing,
isSaving,
savingIsDisabled,
publishingIsDisabled,
onSelectTemplate,
@@ -256,10 +254,6 @@ function DocumentHeader({
actions={({ isCompact }) => (
<>
<ObservingBanner />
{!isPublishing && isSaving && user?.separateEditMode && (
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && can.listViews && (
<Collaborators
document={document}
@@ -286,7 +280,7 @@ function DocumentHeader({
{(isEditing || isTemplateEditable) && (
<Action>
<Tooltip
content={t("Save")}
content={isDraft ? t("Save draft") : t("Done editing")}
shortcut={`${metaDisplay}+enter`}
placement="bottom"
>
@@ -376,10 +370,4 @@ 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,6 +317,7 @@ 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<{ id?: string }>();
const match = useRouteMatch<{ collectionSlug?: string }>();
const { t } = useTranslation();
const { documents, collections, userMemberships, groupMemberships } =
useStores();
const id = match.params.id || query.get("collectionId");
const id = match.params.collectionSlug || query.get("collectionId");
useEffect(() => {
async function createDocument() {
-8
View File
@@ -117,14 +117,6 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
),
label: t("Publish document and exit"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>s</Key>
</>
),
label: t("Save document"),
},
{
shortcut: (
<>
+54 -2
View File
@@ -4,7 +4,11 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions as availableLanguages } from "@shared/i18n";
import { TeamPreference, UserPreference } from "@shared/types";
import {
NotificationBadgeType,
TeamPreference,
UserPreference,
} from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
@@ -95,6 +99,39 @@ 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 });
@@ -230,7 +267,6 @@ function Preferences() {
/>
</SettingRow>
<SettingRow
border={false}
name={UserPreference.EnableSmartText}
label={t("Smart text replacements")}
description={t(
@@ -244,6 +280,22 @@ 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 && (
<>
+38 -6
View File
@@ -1,6 +1,7 @@
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";
@@ -10,6 +11,8 @@ 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";
@@ -25,12 +28,38 @@ 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([
@@ -73,12 +102,14 @@ const EmojisTable = observer(function EmojisTable({
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
width: "1fr",
},
{
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
},
canManage
? {
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
}
: undefined,
]),
[t, canManage]
);
@@ -88,6 +119,7 @@ const EmojisTable = observer(function EmojisTable({
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
@@ -16,6 +16,8 @@ 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";
@@ -229,6 +231,10 @@ 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({
@@ -262,6 +268,59 @@ 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 ? (
@@ -304,13 +363,40 @@ 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={users.inGroup(group.id)}
items={filteredUsers}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
empty={
hasActiveFilters ? (
<Empty>{t("No members matching your filters")}</Empty>
) : (
<Empty>{t("This group has no members.")}</Empty>
)
}
renderItem={(user) => (
<GroupMemberListItem
key={user.id}
@@ -1,5 +1,6 @@
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";
@@ -14,6 +15,8 @@ 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";
@@ -29,6 +32,23 @@ 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();
@@ -43,6 +63,15 @@ 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>>([
@@ -136,6 +165,7 @@ 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 } from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import Text from "@shared/components/Text";
import type User from "~/models/User";
@@ -11,6 +11,8 @@ 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";
@@ -26,11 +28,43 @@ 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>>([
@@ -119,6 +153,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
+32 -2
View File
@@ -1,5 +1,6 @@
import compact from "lodash/compact";
import { useMemo } from "react";
import * as React from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -11,6 +12,8 @@ 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";
@@ -22,11 +25,37 @@ 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>>([
@@ -38,7 +67,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}
</>
),
@@ -125,6 +154,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={HEADER_HEIGHT}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
+2 -1
View File
@@ -20,7 +20,8 @@ export default class RevisionsStore extends Store<Revision> {
/**
* Fetches the latest revision for the given document.
*
* @returns A promise that resolves to 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.
*/
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 = new Set<Model>();
activeModels = observable.map<string, Model>();
@observable
observingUserId: string | undefined;
@@ -156,7 +156,7 @@ class UiStore {
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.add(model);
this.activeModels.set(model.id, model);
};
/**
@@ -166,7 +166,7 @@ class UiStore {
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model);
this.activeModels.delete(model.id);
};
/**
@@ -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).filter(
return Array.from(this.activeModels.values()).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);
return this.activeModels.has(model.id);
}
/**
@@ -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));
modelsToRemove.forEach((model) => this.activeModels.delete(model.id));
} else {
this.activeModels.clear();
}
+1 -1
View File
@@ -63,7 +63,7 @@ declare global {
/**
* Set the badge on the app icon.
*/
setNotificationCount: (count: number) => Promise<void>;
setNotificationCount: (count: number | string) => Promise<void>;
/**
* Registers a callback to be called when the window is focused.
+2 -1
View File
@@ -3,6 +3,7 @@ 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";
/**
@@ -25,7 +26,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[]) =>
`/locales/${unicodeBCP47toCLDR(locale[0])}.json`,
cdnPath(`/locales/${unicodeBCP47toCLDR(locale[0])}.json`),
},
interpolation: {
escapeValue: false,
+1
View File
@@ -296,6 +296,7 @@
"@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,9 +44,10 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
const client = new NotionClient(integration.authentication.token);
const parsedPages = await Promise.all(
importTask.input.map(async (item) => this.processPage({ item, client }))
);
const parsedPages: (ParsePageOutput | null)[] = [];
for (const item of importTask.input) {
parsedPages.push(await 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,6 +5,10 @@ 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({
+33 -4
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type {
BookmarkBlockObjectResponse,
BreadcrumbBlockObjectResponse,
@@ -15,6 +16,7 @@ import type {
ImageBlockObjectResponse,
EmbedBlockObjectResponse,
TableBlockObjectResponse,
TableOfContentsBlockObjectResponse,
ToDoBlockObjectResponse,
EquationBlockObjectResponse,
CodeBlockObjectResponse,
@@ -45,7 +47,7 @@ export class NotionConverter {
* Nodes which cannot contain block children in Outline, their children
* will be flattened into the parent.
*/
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
private static nodesWithoutBlockChildren = ["paragraph"];
public static page(item: NotionPage): ProsemirrorDoc {
return {
@@ -66,6 +68,20 @@ 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) &&
@@ -560,10 +576,23 @@ export class NotionConverter {
};
}
private static toggle(item: ToggleBlockObjectResponse) {
private static table_of_contents(_: TableOfContentsBlockObjectResponse) {
return undefined;
}
private static toggle(item: Block<ToggleBlockObjectResponse>) {
return {
type: "paragraph",
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
type: "container_toggle",
attrs: {
id: randomUUID(),
},
content: [
{
type: "paragraph",
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
@@ -608,38 +608,105 @@ exports[`NotionConverter converts a page 1`] = `
},
{
"attrs": {
"level": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
},
"content": [
{
"marks": [],
"text": "Toggleable heading",
"type": "text",
"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",
},
],
"type": "heading",
"type": "container_toggle",
},
{
"attrs": {
"level": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
},
"content": [
{
"marks": [],
"text": "Toggleable heading with a ",
"type": "text",
"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",
},
{
"text": "2025-03-11",
"type": "text",
},
{
"marks": [],
"text": " mention.",
"type": "text",
"content": [
{
"marks": [],
"text": "Some paragraph content within toggleable heading with mention.",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "heading",
"type": "container_toggle",
},
{
"attrs": {
@@ -1875,53 +1942,69 @@ exports[`NotionConverter converts a page 1`] = `
],
"type": "paragraph",
},
{
"content": [
{
"marks": [],
"text": "Toggle list item 1",
"type": "text",
},
],
"type": "paragraph",
},
{
"attrs": {
"style": "info",
"id": "550e8400-e29b-41d4-a716-446655440000",
},
"content": [
{
"content": [
{
"marks": [],
"text": "Callout inside toggle list item 1",
"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",
},
],
"type": "container_toggle",
},
{
"attrs": {
"id": "550e8400-e29b-41d4-a716-446655440000",
},
"content": [
{
"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",
},
],
"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",
"type": "container_toggle",
},
{
"attrs": {
@@ -145,7 +145,6 @@ 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,6 +213,32 @@ 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";
+1 -4
View File
@@ -121,10 +121,7 @@ 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([
"withAuthentications",
"withTeam",
]).findOne({
const existingUser = await User.scope(["withTeam"]).findOne({
where: {
// Email from auth providers may be capitalized
email: {
+106 -1
View File
@@ -1,4 +1,4 @@
import { parser } from ".";
import { parser, serializer } from ".";
test("renders an empty doc", () => {
const ast = parser.parse("");
@@ -8,3 +8,108 @@ 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");
});
+3 -2
View File
@@ -61,6 +61,7 @@ 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";
@@ -347,7 +348,7 @@ class Collection extends ParanoidModel<
}
if (model.changed("documentStructure")) {
await CacheHelper.clearData(
CacheHelper.getCollectionDocumentsKey(model.id)
RedisPrefixHelper.getCollectionDocumentsKey(model.id)
);
}
}
@@ -360,7 +361,7 @@ class Collection extends ParanoidModel<
if (model.changed("documentStructure")) {
const setData = () =>
CacheHelper.setData(
CacheHelper.getCollectionDocumentsKey(model.id),
RedisPrefixHelper.getCollectionDocumentsKey(model.id),
model.documentStructure,
60
);
+10 -5
View File
@@ -411,7 +411,10 @@ class User extends ParanoidModel<
* @param value Sets the preference value
* @returns The current user preferences
*/
public setPreference = (preference: UserPreference, value: boolean) => {
public setPreference = <K extends UserPreference>(
preference: K,
value: NonNullable<UserPreferences[K]>
) => {
if (!this.preferences) {
this.preferences = {};
}
@@ -428,10 +431,12 @@ class User extends ParanoidModel<
* @param preference The user preference to retrieve
* @returns The preference value if set, else the default value.
*/
public getPreference = (preference: UserPreference) =>
this.preferences?.[preference] ??
UserPreferenceDefaults[preference] ??
false;
public getPreference = <K extends UserPreference>(
preference: K
): NonNullable<UserPreferences[K]> =>
(this.preferences?.[preference] ??
UserPreferenceDefaults[preference] ??
false) as NonNullable<UserPreferences[K]>;
/**
* 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,6 +3,7 @@ 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 {
@@ -25,6 +26,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(CacheHelper.getUnfurlKey(integration.teamId));
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
}
}
@@ -3,6 +3,7 @@ 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 {
@@ -26,7 +27,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(CacheHelper.getUnfurlKey(integration.teamId));
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
}
await integration.destroy({ force: true });
+1 -5
View File
@@ -327,11 +327,7 @@ export default abstract class APIImportTask<
const uploadItems = Object.entries(urlToAttachment).map(
([url, attachment]) => ({ attachmentId: attachment.id, url })
);
// publish task after attachments are persisted in DB.
const job = await new UploadAttachmentsForImportTask().schedule(
uploadItems
);
await job.finished();
await new UploadAttachmentsForImportTask().schedule(uploadItems);
} catch (err) {
// upload attachments failure is not critical enough to fail the whole import.
Logger.error(
@@ -1,8 +1,11 @@
import { NotificationEventType } from "@shared/types";
import { v4 as uuidv4 } from "uuid";
import { MentionType, NotificationEventType } from "@shared/types";
import { Notification } from "@server/models";
import {
buildDocument,
buildCollection,
buildGroup,
buildGroupUser,
buildUser,
} from "@server/test/factories";
import DocumentPublishedNotificationsTask from "./DocumentPublishedNotificationsTask";
@@ -119,4 +122,57 @@ 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,13 +230,21 @@ 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,
serializeFilename(collection.name),
root,
format
);
}
+13 -3
View File
@@ -20,13 +20,22 @@ 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
fileOperation.options?.includeAttachments ?? true,
filename
);
}
@@ -57,7 +66,8 @@ export default class ExportJSONTask extends ExportTask {
private async addCollectionToArchive(
zip: JSZip,
collection: Collection,
includeAttachments: boolean
includeAttachments: boolean,
filename: string
) {
const output: CollectionJSONExport = {
collection: {
@@ -167,7 +177,7 @@ export default class ExportJSONTask extends ExportTask {
}
zip.file(
`${serializeFilename(collection.name)}.json`,
`${filename}.json`,
env.isDevelopment
? JSON.stringify(output, null, 2)
: JSON.stringify(output)
@@ -1,3 +1,7 @@
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";
@@ -8,7 +12,12 @@ import {
Notification,
Revision,
} from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import {
buildDocument,
buildGroup,
buildGroupUser,
buildUser,
} from "@server/test/factories";
import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask";
const ip = "127.0.0.1";
@@ -514,4 +523,136 @@ 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,6 +7,7 @@ import env from "@server/env";
import Logger from "@server/logging/Logger";
import {
Document,
Group,
Revision,
Notification,
User,
@@ -34,16 +35,8 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
const before = await revision.before();
// 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
// Send notifications to mentioned users first these must be processed
// regardless of the change threshold as even a small edit can add a mention.
const oldMentions = before
? [...DocumentHelper.parseMentions(before, { type: MentionType.User })]
: [];
@@ -83,7 +76,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 })
: [];
@@ -101,6 +94,13 @@ 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,6 +140,16 @@ 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,
+2 -1
View File
@@ -39,6 +39,7 @@ 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";
@@ -143,7 +144,7 @@ router.post(
authorize(user, "readDocument", collection);
const documentStructure = await CacheHelper.getDataOrSet(
CacheHelper.getCollectionDocumentsKey(collection.id),
RedisPrefixHelper.getCollectionDocumentsKey(collection.id),
async () =>
(
await Collection.findByPk(collection.id, {
+3 -2
View File
@@ -15,6 +15,7 @@ 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 {
@@ -134,7 +135,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 = CacheHelper.getUnfurlKey(actor.teamId, url);
const cacheKey = RedisPrefixHelper.getUnfurlKey(actor.teamId, url);
const defaultCacheExpiry = 3600;
const unfurlResult = await CacheHelper.getDataOrSet<
@@ -186,7 +187,7 @@ router.post(
const { url } = ctx.input.body;
const result = await CacheHelper.getDataOrSet<EmbedCheckResult>(
CacheHelper.getEmbedCheckKey(url),
RedisPrefixHelper.getEmbedCheckKey(url),
() => checkEmbeddability(url),
Day.seconds
);
+12 -2
View File
@@ -1,5 +1,10 @@
import { z } from "zod";
import { NotificationEventType, UserPreference, UserRole } from "@shared/types";
import {
NotificationBadgeType,
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";
@@ -90,7 +95,12 @@ export const UsersUpdateSchema = BaseSchema.extend({
name: z.string().optional(),
avatarUrl: z.string().nullish(),
language: zodEnumFromObjectKeys(locales).optional(),
preferences: z.record(z.nativeEnum(UserPreference), z.boolean()).optional(),
preferences: z
.record(
z.nativeEnum(UserPreference),
z.union([z.boolean(), z.nativeEnum(NotificationBadgeType)])
)
.optional(),
timezone: zodTimezone().optional(),
}),
});
+5 -4
View File
@@ -1,7 +1,7 @@
import Router from "koa-router";
import type { WhereOptions } from "sequelize";
import { Op, Sequelize } from "sequelize";
import type { UserPreference } from "@shared/types";
import type { UserPreferences } from "@shared/types";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import { settingsPath } from "@shared/utils/routeHelpers";
@@ -332,9 +332,10 @@ router.post(
user.language = language;
}
if (preferences) {
for (const key of Object.keys(preferences) as Array<UserPreference>) {
user.setPreference(key, preferences[key] as boolean);
}
user.preferences = {
...user.preferences,
...(preferences as UserPreferences),
};
}
if (timezone) {
user.timezone = timezone;
+2
View File
@@ -9,6 +9,7 @@ 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.
@@ -64,6 +65,7 @@ export function createDatabaseInstance(
typeValidation: true,
logQueryParameters: env.isDevelopment,
dialectOptions: {
application_name: getConnectionName(),
ssl:
env.isProduction && !isSSLDisabled
? {
+3 -4
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,
...(acl && { acl }),
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
},
Expires: 3600,
};
@@ -103,7 +103,6 @@ export default class S3Storage extends BaseStorage {
body,
contentType,
key,
acl,
}: {
body: Buffer | Uint8Array | string | Readable;
contentLength?: number;
@@ -114,7 +113,7 @@ export default class S3Storage extends BaseStorage {
const upload = new Upload({
client: this.client,
params: {
...(acl && { ACL: acl as ObjectCannedACL }),
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
Bucket: this.getBucket(),
Key: key,
ContentType: contentType,
+2 -8
View File
@@ -3,6 +3,7 @@ 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 */
@@ -42,14 +43,7 @@ export default class RedisAdapter extends Redis {
url: string | undefined,
{ connectionNameSuffix, ...options }: RedisAdapterOptions = {}
) {
/**
* 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}` : "");
const connectionName = getConnectionName(connectionNameSuffix);
if (!url || !url.startsWith("ioredis://")) {
super(
+13
View File
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,14 @@
---
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,31 +141,4 @@ 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,6 +148,114 @@ 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");
});
});
});
+51 -11
View File
@@ -5,6 +5,7 @@ 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";
@@ -201,24 +202,30 @@ export class DocumentConverter {
fileName: string,
mimeType: string
): Promise<string> {
let markdown: string;
switch (mimeType) {
case "text/plain":
case "text/markdown":
return this.bufferToString(content);
markdown = this.bufferToString(content);
break;
case "text/csv":
return this.csvToMarkdown(content);
default:
break;
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`);
}
}
}
const extension = fileName.split(".").pop();
switch (extension) {
case "md":
case "markdown":
return this.bufferToString(content);
default:
throw FileImportError(`File type ${mimeType} not supported`);
}
// Process frontmatter and convert it to a YAML codeblock
return this.processFrontmatter(markdown);
}
/**
@@ -404,4 +411,37 @@ 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
@@ -0,0 +1,61 @@
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
@@ -0,0 +1,35 @@
/**
* 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,20 +44,4 @@ 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,6 +4,7 @@ import {
TeamPreference,
UserPreference,
EmailDisplay,
NotificationBadgeType,
} from "./types";
export const MAX_AVATAR_DISPLAY = 6;
@@ -42,4 +43,5 @@ export const UserPreferenceDefaults: UserPreferences = {
[UserPreference.CodeBlockLineNumers]: true,
[UserPreference.SortCommentsByOrderInDocument]: true,
[UserPreference.EnableSmartText]: true,
[UserPreference.NotificationBadge]: NotificationBadgeType.Count,
};
+4
View File
@@ -35,6 +35,10 @@ 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;
};
};
+47 -3
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,6 +1483,50 @@ 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;
@@ -2351,7 +2395,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,9 +4,13 @@ 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 {
@@ -18,6 +22,9 @@ export default class CheckboxList extends Node {
return {
group: "block list",
content: "checkbox_item+",
attrs: {
id: { default: null },
},
toDOM: () => ["ul", { class: this.name }, 0],
parseDOM: [
{
@@ -27,6 +34,53 @@ 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
@@ -0,0 +1,136 @@
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);
}
}
+17 -6
View File
@@ -1,4 +1,4 @@
import type { Token } from "markdown-it";
import type { PluginSimple, Token } from "markdown-it";
import type {
NodeSpec,
NodeType,
@@ -8,6 +8,7 @@ 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 {
@@ -15,6 +16,10 @@ export default class OrderedList extends Node {
return "ordered_list";
}
get rulePlugins(): PluginSimple[] {
return [alphaListsRule];
}
get schema(): NodeSpec {
return {
attrs: {
@@ -163,11 +168,17 @@ export default class OrderedList extends Node {
getAttrs: (tok: Token) => {
const start = tok.attrGet("start") || "1";
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";
// 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";
}
}
return {
+3
View File
@@ -188,6 +188,9 @@ 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,
},
});
};
+39 -14
View File
@@ -2,6 +2,7 @@ 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(
@@ -18,24 +19,28 @@ export class TableView extends ProsemirrorTableView {
this.scrollable.appendChild(this.table);
this.scrollable.classList.add(EditorStyleHelper.tableScrollable);
this.scrollable.addEventListener(
"scroll",
() => {
this.updateClassList(this.node);
},
{
passive: true,
}
);
if (isBrowser) {
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.
setTimeout(() => {
if (this.dom) {
this.updateClassList(node);
}
}, 0);
if (isBrowser) {
setTimeout(() => {
if (this.dom) {
this.updateClassList(node);
}
}, 0);
}
// Set up sticky header handling
this.setupStickyHeader();
@@ -66,6 +71,10 @@ export class TableView extends ProsemirrorTableView {
}
private updateClassList(node: Node) {
if (!isBrowser) {
return;
}
this.dom.classList.toggle(
EditorStyleHelper.tableFullWidth,
node.attrs.layout === TableLayout.fullWidth
@@ -108,6 +117,10 @@ 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 = () => {
@@ -129,6 +142,10 @@ 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,
@@ -145,6 +162,10 @@ 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;
@@ -179,6 +200,10 @@ 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
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,122 @@
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,6 +59,17 @@ 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,6 +16,8 @@ export default class AuthenticationHelper {
info: Scope.Read,
search: Scope.Read,
documents: Scope.Read,
drafts: Scope.Read,
viewed: Scope.Read,
export: Scope.Read,
};
+31 -18
View File
@@ -550,6 +550,9 @@
"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",
@@ -629,6 +632,14 @@
"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",
@@ -638,26 +649,28 @@
"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)",
"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.",
"Emoji options": "Emoji options",
"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",
@@ -672,14 +685,6 @@
"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",
@@ -1106,21 +1111,25 @@
"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",
@@ -1234,6 +1243,8 @@
"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",
@@ -1248,6 +1259,8 @@
"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",
+31 -18
View File
@@ -550,6 +550,9 @@
"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",
@@ -629,6 +632,14 @@
"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",
@@ -638,26 +649,28 @@
"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",
"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.",
"Emoji options": "Emoji options",
"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",
@@ -672,14 +685,6 @@
"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",
@@ -1106,21 +1111,25 @@
"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",
@@ -1234,6 +1243,8 @@
"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",
@@ -1248,6 +1259,8 @@
"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",
+32 -19
View File
@@ -550,6 +550,9 @@
"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",
@@ -629,6 +632,14 @@
"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",
@@ -638,26 +649,28 @@
"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",
"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.",
"Emoji options": "Emoji-Optionen",
"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",
@@ -672,14 +685,6 @@
"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",
@@ -1106,21 +1111,25 @@
"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",
@@ -1234,6 +1243,8 @@
"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",
@@ -1248,6 +1259,8 @@
"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",
@@ -1364,7 +1377,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.": "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.": "Das Audit-Log protokolliert die Historie von sicherheitsrelevanten und anderen Ereignissen in Ihrer Wissensdatenbank.",
"IP address": "IP-Adresse",
"Actor": "Akteur",
"Event": "Event",
+31 -18
View File
@@ -550,6 +550,9 @@
"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",
@@ -629,6 +632,14 @@
"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",
@@ -638,26 +649,28 @@
"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",
"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.",
"Emoji options": "Emoji options",
"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",
@@ -672,14 +685,6 @@
"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",
@@ -1106,21 +1111,25 @@
"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",
@@ -1234,6 +1243,8 @@
"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",
@@ -1248,6 +1259,8 @@
"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",
+31 -19
View File
@@ -550,6 +550,9 @@
"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",
@@ -629,6 +632,14 @@
"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",
@@ -638,26 +649,28 @@
"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",
"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.",
"Emoji options": "Emoji options",
"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",
@@ -672,14 +685,6 @@
"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",
@@ -880,7 +885,6 @@
"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",
@@ -1106,21 +1110,25 @@
"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",
@@ -1234,6 +1242,8 @@
"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",
@@ -1248,6 +1258,8 @@
"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