Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor bee7911bee Types cleanup 2024-11-20 19:12:43 -05:00
Tom Moor 86714a353f fix: Rare loop of storage events between tabs causing flickering UI 2024-11-20 18:51:58 -05:00
126 changed files with 891 additions and 1165 deletions
+3 -3
View File
@@ -45,7 +45,7 @@ import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DuplicateDialog from "~/components/DuplicateDialog";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
@@ -562,7 +562,7 @@ export const duplicateDocument = createAction({
stores.dialogs.openModal({
title: t("Copy document"),
content: (
<DocumentCopy
<DuplicateDialog
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
@@ -1054,7 +1054,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.toggleComments(activeDocumentId);
},
});
+1
View File
@@ -31,6 +31,7 @@ const Actions = styled(Flex)`
left: 0;
border-radius: 3px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
padding: 12px;
backdrop-filter: blur(20px);
+1 -2
View File
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to={logoutPath()} />;
return <Redirect to="/" />;
};
export default observer(Authenticated);
+1 -1
View File
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
ui.commentsExpanded.includes(ui.activeDocumentId) &&
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
+1
View File
@@ -201,6 +201,7 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${s("backgroundTransition")};
&:after {
content: "";
+1
View File
@@ -182,6 +182,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
const Content = styled.span`
background: ${s("background")};
transition: ${s("backgroundTransition")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
+16
View File
@@ -262,6 +262,22 @@ export const Position = styled.div`
transition-property: outline-width;
transition-duration: 0;
outline: none;
&:after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
border-radius: 4px;
outline-color: ${s("accent")};
outline-width: initial;
outline-offset: -1px;
outline-style: solid;
}
}
/*
-149
View File
@@ -1,149 +0,0 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DocumentCopy({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
try {
const result = await document.duplicate({
publish,
recursive,
title: document.title,
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
onSubmit(result);
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={copy}
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
values={{ location: selectedPath.title }}
components={{ em: <strong /> }}
/>
) : (
t("Select a location to copy")
)}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
);
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
`;
export default observer(DocumentCopy);
+5 -27
View File
@@ -31,15 +31,15 @@ import { ancestors, descendants } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -47,25 +47,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
null
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((node) => node.id);
}
}
return [];
});
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[]
>([]);
@@ -107,15 +94,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
+97
View File
@@ -0,0 +1,97 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "./Input";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DuplicateDialog({ document, onSubmit }: Props) {
const { t } = useTranslation();
const defaultTitle = t(`Copy of {{ documentName }}`, {
documentName: document.title,
});
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [title, setTitle] = React.useState<string>(defaultTitle);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const handleTitleChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setTitle(ev.target.value);
},
[]
);
const handleSubmit = async () => {
const result = await document.duplicate({
publish,
recursive,
title,
});
onSubmit(result);
};
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
<Input
autoFocus
autoSelect
name="title"
label={t("Title")}
onChange={handleTitleChange}
maxLength={DocumentValidation.maxTitleLength}
defaultValue={defaultTitle}
/>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</ConfirmationDialog>
);
}
export default observer(DuplicateDialog);
+1 -1
View File
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid rgb(34 40 52);
border-bottom: 1px solid ${s("inputBorder")};
background: ${s("menuBackground")};
}
+1
View File
@@ -94,6 +94,7 @@ const Scene = styled.div`
align-items: flex-start;
width: 350px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
border-radius: 8px;
outline: none;
opacity: 0;
+1
View File
@@ -130,6 +130,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
`};
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: ${HEADER_HEIGHT}px;
justify-content: flex-start;
+1
View File
@@ -76,6 +76,7 @@ const Layout = React.forwardRef(function Layout_(
const Container = styled(Flex)`
background: ${s("background")};
transition: ${s("backgroundTransition")};
position: relative;
width: 100%;
min-height: 100%;
+2
View File
@@ -174,6 +174,7 @@ const Fullscreen = styled.div<FullscreenProps>`
justify-content: center;
align-items: flex-start;
background: ${s("background")};
transition: ${s("backgroundTransition")};
outline: none;
${breakpoint("tablet")`
@@ -264,6 +265,7 @@ const Small = styled.div`
justify-content: center;
align-items: flex-start;
background: ${s("modalBackground")};
transition: ${s("backgroundTransition")};
box-shadow: ${s("modalShadow")};
border-radius: 8px;
outline: none;
+1
View File
@@ -144,6 +144,7 @@ const EmojiButton = styled(NudeButton)<{
height: 28px;
padding: 6px;
border-radius: 12px;
transition: ${s("backgroundTransition")};
background: ${s("backgroundTertiary")};
pointer-events: ${({ disabled }) => disabled && "none"};
+3 -2
View File
@@ -298,8 +298,9 @@ const Container = styled(Flex)<ContainerProps>`
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
@@ -112,9 +112,8 @@ const NavLink = ({
!rest.target &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!isActive,
[rest.target, isActive]
!event.ctrlKey,
[rest.target]
);
const navigateTo = React.useCallback(() => {
@@ -154,13 +153,14 @@ const NavLink = ({
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
// onMouseDown={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onClick={handleClick}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -105,6 +105,7 @@ const Button = styled(Flex)<{
&:hover,
&[aria-expanded="true"] {
color: ${s("sidebarText")};
transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")};
}
@@ -78,6 +78,7 @@ function SidebarLink(
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarActiveBackground,
...style,
@@ -201,10 +202,10 @@ const Link = styled(NavLink)<{
display: flex;
position: relative;
text-overflow: ellipsis;
font-weight: 475;
padding: 6px 16px;
border-radius: 4px;
min-height: 32px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
@@ -48,20 +48,13 @@ function StarredLink({ star }: Props) {
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
} else if (
star.collectionId === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
) {
setExpanded(true);
}
}, [
star.documentId,
star.collectionId,
ui.activeDocumentId,
ui.activeCollectionId,
sidebarContext,
locationSidebarContext,
+1
View File
@@ -31,6 +31,7 @@ const Background = styled.div<{ sticky?: boolean }>`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
+3 -2
View File
@@ -253,7 +253,6 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
white-space: nowrap;
margin: 0 -4px;
padding: 0 4px;
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
&:hover {
background: ${(props) =>
@@ -310,13 +309,15 @@ const Row = styled.tr`
const Head = styled.th`
text-align: left;
padding: 6px 6px 2px;
padding: 6px 6px 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("background")};
transition: ${s("backgroundTransition")};
font-size: 14px;
color: ${s("textSecondary")};
font-weight: 500;
z-index: 1;
cursor: var(--pointer) !important;
:first-child {
padding-left: 0;
+1
View File
@@ -45,6 +45,7 @@ const Sticky = styled.div`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The size to render the indicator, defaults to 24px */
size?: number;
};
/**
* A component to show an animated typing indicator.
*/
export default function Typing({ size = 24 }: Props) {
return (
<Wrapper height={size} width={size}>
<Circle cx={size / 4} cy={size / 2} r="2" />
<Circle cx={size / 2} cy={size / 2} r="2" />
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
</Wrapper>
);
}
const Wrapper = styled.svg`
fill: ${s("textTertiary")};
@keyframes blink {
50% {
fill: transparent;
}
}
`;
const Circle = styled.circle`
animation: 1s blink infinite;
&:nth-child(2) {
animation-delay: 250ms;
}
&:nth-child(3) {
animation-delay: 500ms;
}
`;
+7
View File
@@ -529,6 +529,13 @@ class WebsocketProvider extends React.Component<Props> {
stars.remove(event.modelId);
});
this.socket.on(
"user.typing",
(event: { userId: string; documentId: string; commentId: string }) => {
comments.setTyping(event);
}
);
this.socket.on("collections.add_user", async (event: Membership) => {
memberships.add(event);
await collections.fetch(event.collectionId, {
+4 -6
View File
@@ -131,15 +131,13 @@ function usePosition({
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from);
const element = view.nodeDOM(selection.from) as HTMLElement;
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = element
? (element as HTMLElement).getElementsByClassName(
EditorStyleHelper.imageHandle
)[0]
: undefined;
const imageElement = element.getElementsByClassName(
EditorStyleHelper.imageHandle
)[0];
if (imageElement) {
const { left, top, width } = imageElement.getBoundingClientRect();
+6 -20
View File
@@ -92,10 +92,6 @@ export default class FindAndReplaceExtension extends Extension {
public replace(replace: string): Command {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
// have changed underneath us since the last search.
this.search(state.doc);
const result = this.results[this.currentResultIndex];
if (!result) {
@@ -110,12 +106,7 @@ export default class FindAndReplaceExtension extends Extension {
}
public replaceAll(replace: string): Command {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
// have changed underneath us since the last search.
this.search(state.doc);
const tr = state.tr;
return ({ tr }, dispatch) => {
let offset: number | undefined;
if (!this.results.length) {
@@ -265,17 +256,12 @@ export default class FindAndReplaceExtension extends Extension {
}
// Reconstruct the correct match position
const i = m.index >= text.length ? m.index - text.length : m.index;
const from = pos + i;
const to = from + m[0].length;
const i = m.index > text.length ? m.index - text.length : m.index;
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
continue;
}
this.results.push({ from, to });
this.results.push({
from: pos + i,
to: pos + i + m[0].length,
});
}
} catch (e) {
// Invalid RegExp
+6 -7
View File
@@ -1,4 +1,5 @@
import isEqual from "lodash/isEqual";
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
@@ -103,13 +104,11 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
commands() {
return {
undo: () => undo,
redo: () => redo,
};
}
}
+15
View File
@@ -4,6 +4,7 @@ import { darken, transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { redo, undo } from "prosemirror-history";
import { inputRules, InputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown";
@@ -607,6 +608,20 @@ export class Editor extends React.PureComponent<
this.props
);
/**
* Undo the last change in the editor.
*
* @returns True if the undo was successful
*/
public undo = () => undo(this.view.state, this.view.dispatch, this.view);
/**
* Redo the last change in the editor.
*
* @returns True if the change was successful
*/
public redo = () => redo(this.view.state, this.view.dispatch, this.view);
/**
* Returns true if the trimmed content of the editor is an empty string.
*
+1 -1
View File
@@ -31,7 +31,7 @@ export default function useAutoRefresh() {
return;
}
if (isReloaded) {
Logger.warn("Attempted to reload twice");
Logger.error("lifecycle", new Error("Attempted to reload twice"));
return;
}
+4 -5
View File
@@ -6,7 +6,10 @@ export default function useComponentSize(
width: number;
height: number;
} {
const [size, setSize] = useState({ width: 0, height: 0 });
const [size, setSize] = useState({
width: ref.current?.clientWidth || 0,
height: ref.current?.clientHeight || 0,
});
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
@@ -21,10 +24,6 @@ export default function useComponentSize(
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
+4 -13
View File
@@ -29,7 +29,6 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -88,10 +87,10 @@ const useSettingsConfig = () => {
icon: EmailIcon,
},
{
name: t("API Keys"),
path: settingsPath("personal-api-keys"),
component: PersonalApiKeys,
enabled: can.createApiKey && !can.listApiKeys,
name: t("API"),
path: settingsPath("tokens"),
component: ApiKeys,
enabled: can.createApiKey,
group: t("Account"),
icon: CodeIcon,
},
@@ -144,14 +143,6 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: ShapesIcon,
},
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
},
{
name: t("Shared Links"),
path: settingsPath("shares"),
+2 -2
View File
@@ -3,7 +3,6 @@ import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
@@ -12,6 +11,7 @@ import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { replaceTitleVariables } from "~/utils/date";
type Props = {
document: Document;
@@ -29,7 +29,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
title: replaceTitleVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
+24
View File
@@ -1,6 +1,8 @@
import { subSeconds } from "date-fns";
import invariant from "invariant";
import uniq from "lodash/uniq";
import { action, computed, observable } from "mobx";
import { now } from "mobx-utils";
import { Pagination } from "@shared/constants";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import User from "~/models/User";
@@ -13,6 +15,17 @@ import Relation from "./decorators/Relation";
class Comment extends Model {
static modelName = "Comment";
/**
* Map to keep track of which users are currently typing a reply in this
* comments thread.
*/
@observable
typingUsers: Map<string, Date> = new Map();
@Field
@observable
id: string;
/**
* The Prosemirror data representing the comment content
*/
@@ -94,6 +107,17 @@ class Comment extends Model {
*/
private reactedUsersLoading = false;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@computed
public get currentlyTypingUsers(): User[] {
return Array.from(this.typingUsers.entries())
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
.map(([userId]) => this.store.rootStore.users.get(userId))
.filter(Boolean) as User[];
}
/**
* Whether the comment is resolved
*/
+4 -2
View File
@@ -65,6 +65,10 @@ export default class Document extends ArchivableModel {
store: DocumentsStore;
@Field
@observable
id: string;
@observable.shallow
data: ProsemirrorData;
@@ -573,8 +577,6 @@ export default class Document extends ArchivableModel {
title?: string;
publish?: boolean;
recursive?: boolean;
collectionId?: string | null;
parentDocumentId?: string;
}) => this.store.duplicate(this, options);
/**
+4
View File
@@ -6,6 +6,10 @@ import Field from "./decorators/Field";
class Group extends Model {
static modelName = "Group";
@Field
@observable
id: string;
@Field
@observable
name: string;
+4
View File
@@ -18,6 +18,10 @@ import Relation from "./decorators/Relation";
class Notification extends Model {
static modelName = "Notification";
@Field
@observable
id: string;
/**
* The date the notification was marked as read.
*/
+4
View File
@@ -8,6 +8,10 @@ import Field from "./decorators/Field";
class Team extends Model {
static modelName = "Team";
@Field
@observable
id: string;
@Field
@observable
name: string;
+4
View File
@@ -22,6 +22,10 @@ import Field from "./decorators/Field";
class User extends ParanoidModel {
static modelName = "User";
@Field
@observable
id: string;
@Field
@observable
avatarUrl: string;
+4
View File
@@ -5,6 +5,10 @@ import Field from "./decorators/Field";
class WebhookSubscription extends Model {
static modelName = "WebhookSubscription";
@Field
@observable
id: string;
@Field
@observable
name: string;
@@ -49,6 +49,8 @@ type Props = {
highlightedText?: string;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Callback when the user is typing in the editor */
onTyping?: () => void;
/** Callback when the editor is focused */
onFocus?: () => void;
/** Callback when the editor is blurred */
@@ -61,6 +63,7 @@ function CommentForm({
draft,
onSubmit,
onSaveDraft,
onTyping,
onFocus,
onBlur,
autoFocus,
@@ -179,6 +182,7 @@ function CommentForm({
) => {
const text = value(true, true);
onSaveDraft(text ? value(false, true) : undefined);
onTyping?.();
};
const handleSave = () => {
@@ -1,5 +1,5 @@
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
@@ -10,11 +10,14 @@ import { s } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Typing from "~/components/Typing";
import { WebsocketContext } from "~/components/WebsocketProvider";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -37,12 +40,28 @@ type Props = {
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
/** Number of replies before collapsing */
collapseThreshold?: number;
/** Number of replies to display when collapsed */
collapseNumDisplayed?: number;
};
function useTypingIndicator({
document,
comment,
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
const socket = React.useContext(WebsocketContext);
const setIsTyping = React.useMemo(
() =>
throttle(() => {
socket?.emit("typing", {
documentId: document.id,
commentId: comment.id,
});
}, 500),
[socket, document.id, comment.id]
);
return [undefined, setIsTyping];
}
function CommentThread({
comment: thread,
document,
@@ -50,19 +69,21 @@ function CommentThread({
focused,
enableScroll,
disableScroll,
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const user = useCurrentUser();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const [, setIsTyping] = useTypingIndicator({
document,
comment: thread,
});
const can = usePolicy(document);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
@@ -81,17 +102,6 @@ function CommentThread({
.inThread(thread.id)
.filter((comment) => !comment.isNew);
const [collapse, setCollapse] = React.useState(() => {
const numReplies = commentsInThread.length - 1;
if (numReplies >= collapseThreshold) {
return {
begin: 1,
final: commentsInThread.length - collapseNumDisplayed - 1,
};
}
return null;
});
useOnClickOutside(topRef, (event) => {
if (
focused &&
@@ -119,36 +129,6 @@ function CommentThread({
});
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
ev.stopPropagation();
setCollapse(null);
};
const renderShowMore = (collapse: { begin: number; final: number }) => {
const count = collapse.final - collapse.begin + 1;
const createdBy = commentsInThread
.slice(collapse.begin, collapse.final + 1)
.map((c) => c.createdBy);
const users = Array.from(new Set(createdBy));
const limit = 3;
const overflow = users.length - limit;
return (
<ShowMore onClick={handleClickExpand} key="show-more">
{t("Show {{ count }} reply", { count })}
<Facepile
users={users}
limit={limit}
overflow={overflow}
size={AvatarSize.Medium}
renderAvatar={(item) => (
<Avatar size={AvatarSize.Medium} model={item} />
)}
/>
</ShowMore>
);
};
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocus(false);
@@ -212,17 +192,8 @@ function CommentThread({
onClick={handleClickThread}
>
{commentsInThread.map((comment, index) => {
if (collapse !== null) {
if (index === collapse.begin) {
return renderShowMore(collapse);
} else if (index > collapse.begin && index <= collapse.final) {
return null;
}
}
const firstOfAuthor =
index === 0 ||
(collapse && index === collapse.final + 1) ||
comment.createdById !== commentsInThread[index - 1].createdById;
const lastOfAuthor =
index === commentsInThread.length - 1 ||
@@ -248,6 +219,15 @@ function CommentThread({
);
})}
{thread.currentlyTypingUsers
.filter((typing) => typing.id !== user.id)
.map((typing) => (
<Flex gap={8} key={typing.id}>
<Avatar model={typing} size={24} />
<Typing />
</Flex>
))}
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
{(focused || draft || commentsInThread.length === 0) && canReply && (
<Fade timing={100}>
@@ -257,6 +237,7 @@ function CommentThread({
draft={draft}
documentId={document.id}
thread={thread}
onTyping={setIsTyping}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
@@ -295,29 +276,6 @@ const Reply = styled.button`
`}
`;
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1px;
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
padding: 8px 12px;
color: ${s("textTertiary")};
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
cursor: var(--pointer);
font-size: 13px;
&: ${hover} {
color: ${s("textSecondary")};
background: ${s("backgroundTertiary")};
}
* {
border-color: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
}
`;
const Thread = styled.div<{
$focused: boolean;
$recessed: boolean;
@@ -264,19 +264,17 @@ function CommentThreadItem({
<EventBoundary>
{!isEditing && (
<Actions gap={4} dir={dir}>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
{!comment.isResolved && (
<>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
</>
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
)}
<Action
as={CommentMenu}
@@ -421,7 +419,7 @@ export const Bubble = styled(Flex)<{
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
transition: color 100ms ease-out, background 100ms ease-out;
transition: color 100ms ease-out, ${s("backgroundTransition")};
${({ $lastOfThread, $canReply }) =>
$lastOfThread &&
+2 -2
View File
@@ -44,7 +44,7 @@ function Comments() {
const isAtBottom = React.useRef(true);
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = React.useState(false);
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
useKeyDown("Escape", () => document && ui.collapseComments(document?.id));
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
@@ -126,7 +126,7 @@ function Comments() {
<CommentSortMenu />
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
onClose={() => ui.collapseComments(document?.id)}
scrollable={false}
>
<Scrollable
@@ -87,6 +87,7 @@ const StickyWrapper = styled.div`
border-radius: 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
+10 -18
View File
@@ -26,7 +26,6 @@ import {
TeamPreference,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import RootStore from "~/stores/RootStore";
@@ -45,6 +44,7 @@ import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { SearchResult } from "~/editor/components/LinkEditor";
import { client } from "~/utils/ApiClient";
import { replaceTitleVariables } from "~/utils/date";
import { emojiToUrl } from "~/utils/emoji";
import { isModKey } from "~/utils/keyboard";
@@ -151,13 +151,7 @@ class DocumentScene extends React.Component<Props> {
}
const { view, schema } = editorRef;
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.replaceTemplateVariables(
template.data,
this.props.auth.user!
)
);
const doc = Node.fromJSON(schema, template.data);
if (doc) {
view.dispatch(
@@ -174,9 +168,9 @@ class DocumentScene extends React.Component<Props> {
}
if (!this.title) {
const title = TextHelper.replaceTemplateVariables(
const title = replaceTitleVariables(
template.title,
this.props.auth.user!
this.props.auth.user || undefined
);
this.title = title;
this.props.document.title = title;
@@ -221,15 +215,13 @@ class DocumentScene extends React.Component<Props> {
onUndoRedo = (event: KeyboardEvent) => {
if (isModKey(event)) {
event.preventDefault();
if (event.shiftKey) {
if (!this.props.readOnly) {
this.editor.current?.commands.redo();
if (this.editor.current?.redo()) {
event.preventDefault();
}
} else {
if (!this.props.readOnly) {
this.editor.current?.commands.undo();
if (this.editor.current?.undo()) {
event.preventDefault();
}
}
}
@@ -418,8 +410,7 @@ class DocumentScene extends React.Component<Props> {
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
@@ -704,6 +695,7 @@ const Footer = styled.div`
const Background = styled(Container)`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
const ReferencesWrapper = styled.div`
@@ -46,7 +46,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
onClick={() => ui.toggleComments()}
onClick={() => ui.toggleComments(document.id)}
>
<CommentIcon size={18} />
{commentsCount
+1 -1
View File
@@ -116,7 +116,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
state: { commentId: focusedComment.id },
});
}
ui.set({ commentsExpanded: true });
ui.expandComments(document.id);
}
}, [focusedComment, ui, document.id, history, params]);
+2 -7
View File
@@ -117,8 +117,7 @@ function DocumentHeader({
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
@@ -237,11 +236,7 @@ function DocumentHeader({
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
{toc} <Star document={document} color={theme.textSecondary} />
</DocumentBreadcrumb>
)
}
@@ -273,7 +273,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
<>
{showCache && (
<Editor
editorStyle={props.editorStyle}
embedsDisabled={props.embedsDisabled}
defaultValue={props.defaultValue}
extensions={props.extensions}
@@ -291,8 +290,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
style={
showCache
? {
height: 0,
opacity: 0,
pointerEvents: "none",
}
: undefined
}
@@ -31,13 +31,11 @@ function References({ document }: Props) {
: [];
const showBacklinks = !!backlinks.length;
const showChildDocuments = !!children.length;
const shouldFade = React.useRef(!showBacklinks && !showChildDocuments);
const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments;
const height = Math.max(backlinks.length, children.length) * 40;
const Component = shouldFade.current ? Fade : React.Fragment;
return showBacklinks || showChildDocuments ? (
<Component>
<Fade>
<Tabs>
{showChildDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
@@ -82,7 +80,7 @@ function References({ document }: Props) {
</List>
)}
</Content>
</Component>
</Fade>
) : null;
}
+3 -15
View File
@@ -28,24 +28,12 @@ function DocumentMove({ document }: Props) {
);
const items = React.useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
// Filter out the document itself and its existing parent doc, if any.
const nodes = flatten(collectionTrees.map(flattenTree))
.filter(
(node) =>
node.id !== document.id && node.id !== document.parentDocumentId
)
.map(filterSourceDocument)
// Filter out collections that we don't have permission to create documents in.
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
@@ -120,21 +108,21 @@ function DocumentMove({ document }: Props) {
);
}
export const FlexContainer = styled(Flex)`
const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
export const StyledText = styled(Text)`
const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
+1
View File
@@ -118,6 +118,7 @@ function Home() {
const Documents = styled.div`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
export default observer(Home);
+1 -2
View File
@@ -231,8 +231,7 @@ function Login({ children }: Props) {
config.providers.length === 1 &&
config.providers[0].id === "oidc" &&
!env.OIDC_DISABLE_REDIRECT &&
!query.get("notice") &&
!query.get("logout")
!query.get("notice")
) {
window.location.href = getRedirectUrl(config.providers[0].authUrl);
return null;
+1 -2
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import { Redirect } from "react-router-dom";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { logoutPath } from "~/utils/routeHelpers";
const Logout = () => {
const { auth } = useStores();
@@ -18,7 +17,7 @@ const Logout = () => {
if (env.OIDC_LOGOUT_URI) {
return null; // user will be redirected to logout URI after logout
}
return <Redirect to={logoutPath()} />;
return <Redirect to="/" />;
};
export default Logout;
@@ -59,6 +59,7 @@ const StyledInput = styled.input`
outline: none;
border: 0;
background: ${s("sidebarBackground")};
transition: ${s("backgroundTransition")};
border-radius: 4px;
color: ${s("text")};
+33 -6
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import ApiKey from "~/models/ApiKey";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
@@ -12,17 +13,36 @@ import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
function ApiKeys() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
const context = useActionContext();
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopy = React.useCallback(
(keyId: string) => {
if (copyTimeoutIdRef.current) {
clearTimeout(copyTimeoutIdRef.current);
}
setCopiedKeyId(keyId);
copyTimeoutIdRef.current = setTimeout(() => {
setCopiedKeyId(null);
}, 3000);
toast.message(t("API key copied to clipboard"));
},
[t]
);
return (
<Scene
title={t("API")}
@@ -42,11 +62,12 @@ function ApiKeys() {
</>
}
>
<Heading>{t("API Keys")}</Heading>
<Heading>{t("API")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="API keys can be used to authenticate with the API and programatically control
your workspace's data. For more details see the <em>developer documentation</em>."
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. API keys have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
components={{
em: (
<a
@@ -60,10 +81,16 @@ function ApiKeys() {
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
isCopied={apiKey.id === copiedKeyId}
onCopy={handleCopy}
/>
)}
/>
</Scene>
-77
View File
@@ -1,77 +0,0 @@
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
function PersonalApiKeys() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
title={t("API")}
icon={<CodeIcon />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
</>
}
>
<Heading>{t("API")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. API keys have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
components={{
em: (
<a
href="https://www.getoutline.com/developers"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
</Scene>
);
}
export default observer(PersonalApiKeys);
+3 -2
View File
@@ -220,6 +220,9 @@ function Security() {
</SettingRow>
)}
{!data.inviteRequired && (
<DomainManagement onSuccess={showSuccessMessage} />
)}
{!data.inviteRequired && (
<SettingRow
label={t("Default role")}
@@ -249,8 +252,6 @@ function Security() {
</SettingRow>
)}
<DomainManagement onSuccess={showSuccessMessage} />
<h2>{t("Behavior")}</h2>
<SettingRow
label={t("Public document sharing")}
@@ -12,6 +12,7 @@ export const ActionRow = styled.div`
margin: 0 -50vw;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -1,8 +1,6 @@
import { observer } from "mobx-react";
import { CopyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import ApiKey from "~/models/ApiKey";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
@@ -10,29 +8,24 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
import { dateToExpiry } from "~/utils/date";
type Props = {
/** The API key to display */
apiKey: ApiKey;
isCopied: boolean;
onCopy: (keyId: string) => void;
};
const ApiKeyListItem = ({ apiKey }: Props) => {
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
const { t } = useTranslation();
const userLocale = useUserLocale();
const user = useCurrentUser();
const subtitle = (
<>
<Text type="tertiary">
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
{apiKey.userId === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}{" "}
&middot;{" "}
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> &middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
@@ -48,19 +41,9 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
</>
);
const [copied, setCopied] = React.useState<boolean>(false);
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopy = React.useCallback(() => {
if (copyTimeoutIdRef.current) {
clearTimeout(copyTimeoutIdRef.current);
}
setCopied(true);
copyTimeoutIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
toast.message(t("API key copied to clipboard"));
}, [t]);
onCopy(apiKey.id);
}, [apiKey.id, onCopy]);
return (
<ListItem
@@ -69,10 +52,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
subtitle={subtitle}
actions={
<Flex align="center" gap={8}>
{apiKey.value && handleCopy && (
{apiKey.value && (
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{copied ? t("Copied") : t("Copy")}
{isCopied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
)}
@@ -91,4 +74,4 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
);
};
export default observer(ApiKeyListItem);
export default ApiKeyListItem;
@@ -5,6 +5,7 @@ import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
@@ -109,25 +110,29 @@ function DomainManagement({ onSuccess }: Props) {
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!allowedDomains.length ||
allowedDomains[allowedDomains.length - 1] !== "" ? (
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
<Fade>
<Button type="button" onClick={handleAddDomain} neutral>
{allowedDomains.length ? (
<Trans>Add another</Trans>
) : (
<Trans>Add a domain</Trans>
)}
</Button>
</Fade>
) : (
<span />
)}
{showSaveChanges && (
<Button
type="button"
onClick={handleSaveDomains}
disabled={team.isSaving}
>
<Trans>Save changes</Trans>
</Button>
<Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={team.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
)}
</Flex>
</SettingRow>
+14
View File
@@ -150,6 +150,20 @@ export default class CommentsStore extends Store<Comment> {
return this.data.get(res.data.id) as Comment;
};
@action
setTyping({
commentId,
userId,
}: {
commentId: string;
userId: string;
}): void {
const comment = this.get(commentId);
if (comment) {
comment.typingUsers.set(userId, new Date());
}
}
@computed
get orderedData(): Comment[] {
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
+25 -5
View File
@@ -75,7 +75,7 @@ class UiStore {
sidebarCollapsed = false;
@observable
commentsExpanded = false;
commentsExpanded: string[] = [];
@observable
sidebarIsResizing = false;
@@ -99,7 +99,7 @@ class UiStore {
this.sidebarRightWidth =
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
this.tocVisible = data.tocVisible;
this.commentsExpanded = !!data.commentsExpanded;
this.commentsExpanded = data.commentsExpanded || [];
this.theme = data.theme || Theme.System;
// system theme listeners
@@ -142,9 +142,9 @@ class UiStore {
startViewTransition(() => {
flushSync(() => {
this.theme = theme;
this.persist();
});
});
this.persist();
};
@action
@@ -218,8 +218,28 @@ class UiStore {
};
@action
toggleComments = () => {
this.set({ commentsExpanded: !this.commentsExpanded });
collapseComments = (documentId: string) => {
this.commentsExpanded = this.commentsExpanded.filter(
(id) => id !== documentId
);
this.persist();
};
@action
expandComments = (documentId: string) => {
if (!this.commentsExpanded.includes(documentId)) {
this.commentsExpanded.push(documentId);
}
this.persist();
};
@action
toggleComments = (documentId: string) => {
if (this.commentsExpanded.includes(documentId)) {
this.collapseComments(documentId);
} else {
this.expandComments(documentId);
}
};
@action
+1
View File
@@ -124,6 +124,7 @@ declare module "styled-components" {
backgroundSecondary: string;
backgroundTertiary: string;
backgroundQuaternary: string;
backgroundTransition: string;
accent: string;
accentText: string;
link: string;
+28 -1
View File
@@ -10,7 +10,16 @@ import {
isPast,
} from "date-fns";
import { TFunction } from "i18next";
import { dateLocale, locales } from "@shared/utils/date";
import startCase from "lodash/startCase";
import {
getCurrentDateAsString,
getCurrentDateTimeAsString,
getCurrentTimeAsString,
unicodeCLDRtoBCP47,
dateLocale,
locales,
} from "@shared/utils/date";
import User from "~/models/User";
export function dateToHeading(
dateTime: string,
@@ -112,3 +121,21 @@ export function dateToExpiry(
date: formatDate(date, "MMM dd, yyyy", { locale }),
});
}
/**
* Replaces template variables in the given text with the current date and time.
*
* @param text The text to replace the variables in
* @param user The user to get the language/locale from
* @returns The text with the variables replaced
*/
export function replaceTitleVariables(text: string, user?: User) {
const locales = user?.language
? unicodeCLDRtoBCP47(user.language)
: undefined;
return text
.replace("{date}", startCase(getCurrentDateAsString(locales)))
.replace("{time}", startCase(getCurrentTimeAsString(locales)))
.replace("{datetime}", startCase(getCurrentDateTimeAsString(locales)));
}
-7
View File
@@ -8,13 +8,6 @@ export function homePath(): string {
return env.ROOT_SHARE_ID ? "/" : "/home";
}
export function logoutPath() {
return {
pathname: "/",
search: "logout=true",
};
}
export function draftsPath(): string {
return "/drafts";
}
+2 -2
View File
@@ -240,7 +240,7 @@
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.11",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.13.0",
"ws": "^7.5.10",
@@ -306,7 +306,7 @@
"@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.18",
"@types/readable-stream": "^4.0.15",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.2",
+2 -2
View File
@@ -1,8 +1,8 @@
import { Optional } from "utility-types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { Document, Event, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { APIContext } from "@server/types";
type Props = Optional<
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function documentDuplicator({
};
const duplicated = await documentCreator({
parentDocumentId,
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
icon: document.icon,
color: document.color,
template: document.template,
+13 -16
View File
@@ -1,9 +1,10 @@
import { Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import pinCreator from "./pinCreator";
describe("pinCreator", () => {
const ip = "127.0.0.1";
it("should create pin to home", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -11,13 +12,11 @@ describe("pinCreator", () => {
teamId: user.teamId,
});
const pin = await withAPIContext(user, (ctx) =>
pinCreator({
ctx,
user,
documentId: document.id,
})
);
const pin = await pinCreator({
documentId: document.id,
user,
ip,
});
const event = await Event.findLatest({
teamId: user.teamId,
@@ -37,14 +36,12 @@ describe("pinCreator", () => {
teamId: user.teamId,
});
const pin = await withAPIContext(user, (ctx) =>
pinCreator({
ctx,
user,
documentId: document.id,
collectionId: document.collectionId,
})
);
const pin = await pinCreator({
documentId: document.id,
collectionId: document.collectionId,
user,
ip,
});
const event = await Event.findLatest({
teamId: user.teamId,
+37 -12
View File
@@ -2,12 +2,10 @@ import fractionalIndex from "fractional-index";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { PinValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import { Pin, User } from "@server/models";
import { APIContext } from "@server/types";
import { Pin, User, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The request context */
ctx: APIContext;
/** The user creating the pin */
user: User;
/** The document to pin */
@@ -16,6 +14,8 @@ type Props = {
collectionId?: string | null;
/** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */
index?: string;
/** The IP address of the user creating the pin */
ip: string;
};
/**
@@ -26,10 +26,10 @@ type Props = {
* @returns Pin The pin that was created
*/
export default async function pinCreator({
ctx,
user,
documentId,
collectionId,
ip,
...rest
}: Props): Promise<Pin> {
let { index } = rest;
@@ -62,13 +62,38 @@ export default async function pinCreator({
index = fractionalIndex(pins.length ? pins[0].index : null, null);
}
const pin = await Pin.createWithCtx(ctx, {
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
});
const transaction = await sequelize.transaction();
let pin;
try {
pin = await Pin.create(
{
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
},
{ transaction }
);
await Event.create(
{
name: "pins.create",
modelId: pin.id,
teamId: user.teamId,
actorId: user.id,
documentId,
collectionId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
return pin;
}
-2
View File
@@ -13,8 +13,6 @@ type Props = {
};
/**
* @deprecated use pin.destroyWithCtx instead. This will be removed once document routes migrate to auto event insertion using APIContext.
*
* This command destroys a document pin. This just removes the pin itself and
* does not touch the document
*
+53
View File
@@ -0,0 +1,53 @@
import { Event, Pin, User } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The user updating the pin */
user: User;
/** The existing pin */
pin: Pin;
/** The index to pin the document at */
index: string;
/** The IP address of the user creating the pin */
ip: string;
};
/**
* This command updates a "pinned" document. A pin can only be moved to a new
* index (reordered) once created.
*
* @param Props The properties of the pin to update
* @returns Pin The updated pin
*/
export default async function pinUpdater({
user,
pin,
index,
ip,
}: Props): Promise<Pin> {
const transaction = await sequelize.transaction();
try {
pin.index = index;
await pin.save({ transaction });
await Event.create(
{
name: "pins.update",
modelId: pin.id,
teamId: user.teamId,
actorId: user.id,
documentId: pin.documentId,
collectionId: pin.collectionId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
return pin;
}
+2 -2
View File
@@ -11,7 +11,7 @@ const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
type SendMailOptions = {
to: string;
from: EmailAddress;
from?: EmailAddress | string;
replyTo?: string;
messageId?: string;
references?: string[];
@@ -143,7 +143,7 @@ export class Mailer {
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
const info = await transporter.sendMail({
from: data.from,
from: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
to: data.to,
messageId: data.messageId,
+13 -9
View File
@@ -1,4 +1,4 @@
import addressparser, { EmailAddress } from "addressparser";
import addressparser from "addressparser";
import Bull from "bull";
import invariant from "invariant";
import { Node } from "prosemirror-model";
@@ -184,22 +184,26 @@ export default abstract class BaseEmail<
}
}
private from(props: S & T): EmailAddress {
private from(props: S & T) {
invariant(
env.SMTP_FROM_EMAIL,
"SMTP_FROM_EMAIL is required to send emails"
);
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
const domain = parsedFrom.address.split("@")[1];
const name = this.fromName?.(props);
if (this.category === EmailMessageCategory.Authentication) {
const domain = parsedFrom.address.split("@")[1];
return {
name: name ?? parsedFrom.name,
address: `noreply-${randomstring.generate(24)}@${domain}`,
};
}
return {
name: this.fromName?.(props) ?? parsedFrom.name,
address:
env.isCloudHosted &&
this.category === EmailMessageCategory.Authentication
? `noreply-${randomstring.generate(24)}@${domain}`
: parsedFrom.address,
name: name ?? parsedFrom.name,
address: parsedFrom.address,
};
}
@@ -1,21 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("teams", "approximateTotalAttachmentsSize", {
type: Sequelize.BIGINT,
defaultValue: 0,
}, { transaction });
await queryInterface.addIndex("attachments", ["createdAt"], { transaction });
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex("attachments", ["createdAt"], { transaction });
await queryInterface.removeColumn("teams", "approximateTotalAttachmentsSize", { transaction });
});
},
};
-2
View File
@@ -20,8 +20,6 @@ class Pin extends IdModel<
InferAttributes<Pin>,
Partial<InferCreationAttributes<Pin>>
> {
static eventNamespace = "pins";
@Length({
max: 256,
msg: `index must be 256 characters or less`,
-6
View File
@@ -25,7 +25,6 @@ import {
AfterUpdate,
BeforeUpdate,
BeforeCreate,
IsNumeric,
} from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
@@ -152,11 +151,6 @@ class Team extends ParanoidModel<
@Column(DataType.STRING)
defaultUserRole: UserRole;
/** Approximate size in bytes of all attachments in the team. */
@IsNumeric
@Column(DataType.BIGINT)
approximateTotalAttachmentsSize: number;
@AllowNull
@Column(DataType.JSONB)
preferences: TeamPreferences | null;
@@ -21,7 +21,9 @@ import { schema, parser } from "@server/editor";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import Attachment from "@server/models/Attachment";
import User from "@server/models/User";
import FileStorage from "@server/storage/files";
import { TextHelper } from "./TextHelper";
export type HTMLOptions = {
/** A title, if it should be included */
@@ -262,6 +264,29 @@ export class ProsemirrorHelper {
return removeMarksInner(json);
}
/**
* Replaces all template variables in the node.
*
* @param data The ProsemirrorData object to replace variables in
* @param user The user to use for replacing variables
* @returns The content with variables replaced
*/
static replaceTemplateVariables(data: ProsemirrorData, user: User) {
function replace(node: ProsemirrorData) {
if (node.type === "text" && node.text) {
node.text = TextHelper.replaceTemplateVariables(node.text, user);
}
if (node.content) {
node.content.forEach(replace);
}
return node;
}
return replace(data);
}
static async replaceInternalUrls(
doc: Node | ProsemirrorData,
basePath: string
@@ -1,3 +1,4 @@
import { buildUser } from "@server/test/factories";
import { TextHelper } from "./TextHelper";
describe("TextHelper", () => {
@@ -11,21 +12,18 @@ describe("TextHelper", () => {
});
describe("replaceTemplateVariables", () => {
const user = {
name: "John Doe",
language: "en",
};
it("should replace {time} with current time", async () => {
const user = await buildUser();
const result = TextHelper.replaceTemplateVariables("Hello {time}", user);
expect(result).toBe("Hello 12:00 AM");
expect(result).toBe("Hello 12 00 AM");
});
it("should replace {date} with current date", async () => {
const user = await buildUser();
const result = TextHelper.replaceTemplateVariables("Hello {date}", user);
expect(result).toBe("Hello January 1, 2021");
expect(result).toBe("Hello January 1 2021");
});
});
});
+26
View File
@@ -1,6 +1,13 @@
import chunk from "lodash/chunk";
import escapeRegExp from "lodash/escapeRegExp";
import startCase from "lodash/startCase";
import { AttachmentPreset } from "@shared/types";
import {
getCurrentDateAsString,
getCurrentDateTimeAsString,
getCurrentTimeAsString,
unicodeCLDRtoBCP47,
} from "@shared/utils/date";
import attachmentCreator from "@server/commands/attachmentCreator";
import env from "@server/env";
import { trace } from "@server/logging/tracing";
@@ -12,6 +19,25 @@ import parseImages from "@server/utils/parseImages";
@trace()
export class TextHelper {
/**
* Replaces template variables in the given text with the current date and time.
*
* @param text The text to replace the variables in
* @param user The user to get the language/locale from
* @returns The text with the variables replaced
*/
static replaceTemplateVariables(text: string, user: User) {
const locales = user.language
? unicodeCLDRtoBCP47(user.language)
: undefined;
return text
.replace(/{date}/g, startCase(getCurrentDateAsString(locales)))
.replace(/{time}/g, startCase(getCurrentTimeAsString(locales)))
.replace(/{datetime}/g, startCase(getCurrentDateTimeAsString(locales)))
.replace(/{author}/g, user.name);
}
/**
* Converts attachment urls in documents to signed equivalents that allow
* direct access without a session cookie
+1 -8
View File
@@ -1,13 +1,7 @@
import { TeamPreference } from "@shared/types";
import { ApiKey, User, Team } from "@server/models";
import { allow } from "./cancan";
import {
and,
isCloudHosted,
isOwner,
isTeamModel,
isTeamMutable,
} from "./utils";
import { and, isOwner, isTeamModel, isTeamMutable } from "./utils";
allow(User, "createApiKey", Team, (actor, team) =>
and(
@@ -24,7 +18,6 @@ allow(User, "createApiKey", Team, (actor, team) =>
allow(User, "listApiKeys", Team, (actor, team) =>
and(
//
isCloudHosted(),
isTeamModel(actor, team),
actor.isAdmin
)
@@ -1,31 +0,0 @@
import { Attachment, Team } from "@server/models";
import BaseTask, { TaskPriority } from "./BaseTask";
type Props = {
/** The teamId to operate on */
teamId: string;
};
/**
* A task that updates the team stats.
*/
export default class UpdateTeamAttachmentsSizeTask extends BaseTask<Props> {
public async perform({ teamId }: Props) {
const sizeInBytes = await Attachment.getTotalSizeForTeam(teamId);
if (!sizeInBytes) {
return;
}
await Team.update(
{ approximateTotalAttachmentsSize: sizeInBytes },
{ where: { id: teamId } }
);
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
@@ -1,53 +0,0 @@
import { subDays } from "date-fns";
import { Op } from "sequelize";
import Logger from "@server/logging/Logger";
import { Attachment } from "@server/models";
import { sequelize } from "@server/storage/database";
import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask";
import UpdateTeamAttachmentsSizeTask from "./UpdateTeamAttachmentsSizeTask";
type Props = {
limit: number;
};
export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
static cron = TaskSchedule.Daily;
public async perform({ limit }: Props) {
Logger.info(
"task",
`Recalculating attachment sizes for upto ${limit} teams…`
);
// Find unique attachment teamIds created in the last day, update only
// those teams' approximate attachment sizes
await Attachment.findAllInBatches<Attachment>(
{
attributes: [
[sequelize.fn("DISTINCT", sequelize.col("teamId")), "teamId"],
],
where: {
createdAt: {
[Op.gt]: subDays(new Date(), 1),
},
},
batchLimit: 100,
raw: true,
},
async (rows) => {
const teamIds = rows.map((row) => row.teamId);
for (const teamId of teamIds) {
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
}
}
);
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
+1 -3
View File
@@ -18,9 +18,7 @@ export const AttachmentsCreateSchema = BaseSchema.extend({
contentType: z.string().optional().default("application/octet-stream"),
/** Attachment type */
preset: z
.nativeEnum(AttachmentPreset)
.default(AttachmentPreset.DocumentAttachment),
preset: z.nativeEnum(AttachmentPreset),
}),
});
+14 -25
View File
@@ -1,8 +1,9 @@
import Router from "koa-router";
import { Sequelize, Op, Transaction } from "sequelize";
import { Sequelize, Op } from "sequelize";
import pinCreator from "@server/commands/pinCreator";
import pinDestroyer from "@server/commands/pinDestroyer";
import pinUpdater from "@server/commands/pinUpdater";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Collection, Document, Pin } from "@server/models";
import { authorize } from "@server/policies";
@@ -21,21 +22,18 @@ router.post(
"pins.create",
auth(),
validate(T.PinsCreateSchema),
transaction(),
async (ctx: APIContext<T.PinsCreateReq>) => {
const { documentId, collectionId, index } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const document = await Document.findByPk(documentId, {
userId: user.id,
transaction,
});
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
}).findByPk(collectionId);
authorize(user, "update", collection);
authorize(user, "pin", document);
} else {
@@ -43,10 +41,10 @@ router.post(
}
const pin = await pinCreator({
ctx,
user,
documentId,
collectionId,
ip: ctx.request.ip,
index,
});
@@ -110,20 +108,13 @@ router.post(
"pins.update",
auth(),
validate(T.PinsUpdateSchema),
transaction(),
async (ctx: APIContext<T.PinsUpdateReq>) => {
const { id, index } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const pin = await Pin.findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
const document = await Document.findByPk(pin.documentId, {
userId: user.id,
transaction,
});
if (pin.collectionId) {
@@ -132,7 +123,12 @@ router.post(
authorize(user, "update", pin);
}
await pin.updateWithCtx(ctx, { index });
pin = await pinUpdater({
user,
pin,
ip: ctx.request.ip,
index,
});
ctx.body = {
data: presentPin(pin),
@@ -145,21 +141,14 @@ router.post(
"pins.delete",
auth(),
validate(T.PinsDeleteSchema),
transaction(),
async (ctx: APIContext<T.PinsDeleteReq>) => {
const { id } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const pin = await Pin.findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
const pin = await Pin.findByPk(id, { rejectOnEmpty: true });
const document = await Document.findByPk(pin.documentId, {
userId: user.id,
transaction,
});
if (pin.collectionId) {
@@ -168,7 +157,7 @@ router.post(
authorize(user, "delete", pin);
}
await pin.destroyWithCtx(ctx);
await pinDestroyer({ user, pin, ip: ctx.request.ip });
ctx.body = {
success: true,
+2 -4
View File
@@ -145,13 +145,14 @@ export default class S3Storage extends BaseStorage {
const params = {
Bucket: this.getBucket(),
Key: key,
Expires: expiresIn,
};
if (isDocker) {
return `${this.getPublicEndpoint()}/${key}`;
} else {
const command = new GetObjectCommand(params);
const url = await getSignedUrl(this.client, command, { expiresIn });
const url = await getSignedUrl(this.client, command);
if (env.AWS_S3_ACCELERATE_URL) {
return url.replace(
@@ -230,9 +231,6 @@ export default class S3Storage extends BaseStorage {
if (env.AWS_S3_UPLOAD_BUCKET_NAME) {
const url = new URL(env.AWS_S3_UPLOAD_BUCKET_URL);
if (url.hostname.startsWith(env.AWS_S3_UPLOAD_BUCKET_NAME + ".")) {
Logger.warn(
"AWS_S3_UPLOAD_BUCKET_URL contains the bucket name, this configuration combination will always point to AWS.\nRename your bucket or hostname if not using AWS S3.\nSee: https://github.com/outline/outline/issues/8025"
);
return undefined;
}
}
+2 -4
View File
@@ -2,8 +2,8 @@ import Redlock from "redlock";
import Redis from "@server/storage/redis";
export class MutexLock {
// Default expiry time for acquiring lock in milliseconds
public static defaultLockTimeout = 4000;
// Default expiry time for qcuiring lock in milliseconds
public static defaultLockTimeout = 5000;
/**
* Returns the redlock instance
@@ -11,8 +11,6 @@ export class MutexLock {
public static get lock(): Redlock {
this.redlock ??= new Redlock([Redis.defaultClient], {
retryJitter: 10,
retryCount: 20,
retryDelay: 200,
});
return this.redlock;
+1 -2
View File
@@ -7,7 +7,7 @@ import { getCookieDomain } from "@shared/utils/domains";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { Event, Collection, View } from "@server/models";
import { AuthenticationResult, AuthenticationType } from "@server/types";
import { AuthenticationResult } from "@server/types";
/**
* Parse and return the details from the "sessions" cookie in the request, if
@@ -68,7 +68,6 @@ export async function signIn(
actorId: user.id,
userId: user.id,
teamId: team.id,
authType: AuthenticationType.APP,
data: {
name: user.name,
service,
+3 -8
View File
@@ -52,8 +52,6 @@ const mathStyle = (props: Props) => css`
font-size: 0.95em;
font-family: ${props.theme.fontFamilyMono};
cursor: auto;
white-space: pre-wrap;
overflow-x: auto;
}
.math-node.empty-math .math-render::before {
@@ -703,10 +701,7 @@ img.ProseMirror-separator {
}
.heading-name:first-child,
// Edge case where multiplayer cursor is between start of cell and heading
.heading-name:first-child + .ProseMirror-yjs-cursor,
// Edge case where table grips are between start of cell and heading
.heading-name:first-child + [role=button] + [role=button] {
.heading-name:first-child + .ProseMirror-yjs-cursor {
& + h1,
& + h2,
& + h3,
@@ -1070,11 +1065,11 @@ a:hover {
ul,
ol {
margin: 0 0.1em 0 ${props.staticHTML ? "0" : "-26px"};
margin: 0 0.1em 0 -26px;
padding: 0 0 0 48px;
&:dir(rtl) {
margin: 0 ${props.staticHTML ? "0" : "-26px"} 0 0.1em;
margin: 0 -26px 0 0.1em;
padding: 0 48px 0 0;
}
}
+5 -13
View File
@@ -1,25 +1,17 @@
import { history, undo, redo } from "prosemirror-history";
import { undoInputRule } from "prosemirror-inputrules";
import { Command } from "prosemirror-state";
import Extension, { CommandFactory } from "../lib/Extension";
import Extension from "../lib/Extension";
export default class History extends Extension {
get name() {
return "history";
}
commands(): Record<string, CommandFactory> {
keys() {
return {
undo: () => undo,
redo: () => redo,
};
}
keys(): Record<string, Command | CommandFactory> {
return {
"Mod-z": () => this.editor.commands.undo(),
"Mod-y": () => this.editor.commands.redo(),
"Shift-Mod-z": () => this.editor.commands.redo(),
"Mod-z": undo,
"Mod-y": redo,
"Shift-Mod-z": redo,
Backspace: undoInputRule,
};
}
+4 -2
View File
@@ -5,7 +5,9 @@ import { Command, Plugin } from "prosemirror-state";
import { Primitive } from "utility-types";
import type { Editor } from "../../../app/editor";
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
export type CommandFactory = (
attrs?: Record<string, Primitive>
) => Command | void;
export type WidgetProps = { rtl: boolean; readOnly: boolean | undefined };
@@ -71,7 +73,7 @@ export default class Extension {
keys(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): Record<string, Command | CommandFactory> {
}): Record<string, Command> {
return {};
}
+7 -7
View File
@@ -251,13 +251,13 @@ export default class ExtensionManager {
};
const handle = (_name: string, _value: CommandFactory) => {
const values: CommandFactory[] = Array.isArray(_value)
? _value
: [_value];
// @ts-expect-error FIXME
commands[_name] = (attrs: Record<string, Primitive>) =>
values.forEach((callback) => apply(callback, attrs));
if (Array.isArray(_value)) {
commands[_name] = (attrs: Record<string, Primitive>) =>
_value.forEach((callback) => apply(callback, attrs));
} else if (typeof _value === "function") {
commands[_name] = ((attrs: Record<string, Primitive>) =>
apply(_value, attrs)) as CommandFactory;
}
};
if (typeof value === "object") {
@@ -308,7 +308,6 @@
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Add reaction": "Add reaction",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
@@ -602,8 +601,6 @@
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply_plural": "Show {{ count }} replies",
"Error updating comment": "Chyba při aktualizaci komentáře",
"Document restored": "Dokument obnoven",
"Images are still uploading.\nAre you sure you want to discard them?": "Obrázky se stále nahrávají.\nOpravdu je chcete zahodit?",
@@ -308,7 +308,6 @@
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Add reaction": "Add reaction",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
@@ -602,8 +601,6 @@
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply_plural": "Show {{ count }} replies",
"Error updating comment": "Error updating comment",
"Document restored": "Document restored",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
@@ -308,7 +308,6 @@
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Add reaction": "Add reaction",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
@@ -602,8 +601,6 @@
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply_plural": "Show {{ count }} replies",
"Error updating comment": "Fehler beim Aktualisieren des Kommentars",
"Document restored": "Dokument wiederhergestellt",
"Images are still uploading.\nAre you sure you want to discard them?": "Bilder werden noch hochgeladen.\nMöchtest du sie wirklich verwerfen?",
+7 -14
View File
@@ -196,11 +196,6 @@
"Install now": "Install now",
"Deleted Collection": "Deleted Collection",
"Unpin": "Unpin",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
"Include nested documents": "Include nested documents",
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Search collections & documents": "Search collections & documents",
"No results found": "No results found",
"Untitled": "Untitled",
@@ -232,6 +227,9 @@
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
"Copy of {{ documentName }}": "Copy of {{ documentName }}",
"Title": "Title",
"Include nested documents": "Include nested documents",
"Module failed to load": "Module failed to load",
"Loading Failed": "Loading Failed",
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
@@ -497,7 +495,7 @@
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Account": "Account",
"API Keys": "API Keys",
"API": "API",
"Details": "Details",
"Security": "Security",
"Features": "Features",
@@ -604,8 +602,6 @@
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply_plural": "Show {{ count }} replies",
"Error updating comment": "Error updating comment",
"Document restored": "Document restored",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
@@ -829,12 +825,11 @@
"Something went wrong": "Something went wrong",
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
"No documents found for your search filters.": "No documents found for your search filters.",
"API": "API",
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"by {{ name }}": "by {{ name }}",
"API key copied to clipboard": "API key copied to clipboard",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
"Personal keys": "Personal keys",
"Last used": "Last used",
"No expiry": "No expiry",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
@@ -959,8 +954,6 @@
"Email address": "Email address",
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
"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.",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
"Personal keys": "Personal keys",
"Preferences saved": "Preferences saved",
"Delete account": "Delete account",
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
+62 -65
View File
@@ -12,12 +12,12 @@
"Star": "Marcar como favorito",
"Unstar": "Eliminar de favoritos",
"Archive": "Archivar",
"Archive collection": "Archivar colección",
"Collection archived": "Colección archivada",
"Archive collection": "Archive collection",
"Collection archived": "Collection archived",
"Archiving": "Archivando",
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archivar esta colección también archivará todos los documentos dentro de ella. Los documentos de la colección dejarán de ser visibles en los resultados de búsqueda.",
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
"Restore": "Restaurar",
"Collection restored": "Colección restaurada",
"Collection restored": "Collection restored",
"Delete": "Eliminar",
"Delete collection": "Eliminar colección",
"New template": "Nueva plantilla",
@@ -25,8 +25,8 @@
"Mark as resolved": "Marcar como resuelto",
"Thread resolved": "Hilo resuelto",
"Mark as unresolved": "Marcar como no resuelto",
"View reactions": "Ver reacciones",
"Reactions": "Reacciones",
"View reactions": "View reactions",
"Reactions": "Reactions",
"Copy ID": "Copiar ID",
"Clear IndexedDB cache": "Borrar caché de IndexedDB",
"IndexedDB cache cleared": "Caché de IndexedDB borrado",
@@ -94,9 +94,9 @@
"Insights": "Estadísticas",
"Disable viewer insights": "Deshabilitar estadísticas",
"Enable viewer insights": "Habilitar estadísticas",
"Leave document": "Abandonar documento",
"You have left the shared document": "Has abandonado el documento compartido",
"Could not leave document": "No se pudo abandonar el documento",
"Leave document": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"Home": "Inicio",
"Drafts": "Borradores",
"Trash": "Papelera",
@@ -128,9 +128,9 @@
"Select a workspace": "Seleccionar un espacio de trabajo",
"New workspace": "Nuevo espacio de trabajo",
"Create a workspace": "Crear un espacio de trabajo",
"Login to workspace": "Iniciar sesión al espacio de trabajo",
"Login to workspace": "Iniciar sesión en el área de trabajo",
"Invite people": "Invitar personas",
"Invite to workspace": "Invitar al espacio de trabajo",
"Invite to workspace": "Invitar a área de trabajo",
"Promote to {{ role }}": "Promocionar a {{ role }}",
"Demote to {{ role }}": "Degradar a {{ role }}",
"Update role": "Actualizar rol",
@@ -173,7 +173,7 @@
"Are you sure you want to permanently delete this entire comment thread?": "¿Estás seguro de que quieres eliminar permanentemente todo este hilo de comentarios?",
"Are you sure you want to permanently delete this comment?": "¿Estás seguro de que quieres eliminar este comentario permanentemente?",
"Confirm": "Confirmar",
"manage access": "administrar acceso",
"manage access": "manage access",
"view and edit access": "acceso de lectura y edición",
"view only access": "acceso de solo lectura",
"no access": "sin acceso",
@@ -244,7 +244,7 @@
"{{userName}} restored": "{{userName}} ha restaurado",
"{{userName}} deleted": "{{userName}} ha eliminado",
"{{userName}} added {{addedUserName}}": "{{userName}} añadió {{addedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} eliminó a {{removedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} eliminó {{removedUserName}}",
"{{userName}} moved from trash": "{{userName}} ha movido de la papelera",
"{{userName}} published": "{{userName}} ha publicado",
"{{userName}} unpublished": "{{userName}} ha despublicado",
@@ -268,15 +268,15 @@
"{{authorName}} created <3></3>": "{{authorName}} ha creado <3></3>",
"{{authorName}} opened <3></3>": "{{authorName}} abrió <3></3>",
"Search emoji": "Buscar emoji",
"Search icons": "Buscar íconos",
"Search icons": "Buscar iconos",
"Choose default skin tone": "Elige un tono de piel predeterminado",
"Show menu": "Mostrar menú",
"Icon Picker": "Selector de ícono",
"Icons": "Íconos",
"Icon Picker": "Selector de icono",
"Icons": "Iconos",
"Emojis": "Emojis",
"Remove": "Eliminar",
"All": "Todos",
"Frequently Used": "Usado Frecuentemente",
"Frequently Used": "Usados con frecuencia",
"Search Results": "Resultados de Búsqueda",
"Smileys & People": "Smileys y Personas",
"Animals & Nature": "Animales y Naturaleza",
@@ -304,19 +304,18 @@
"Mark all as read": "Marcar todas como leídas",
"You're all caught up": "Estás al día",
"Documents": "Documentos",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reaccionó con {{ emoji }}",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Add reaction": "Add reaction",
"Reaction picker": "Selector de reacción",
"Could not load reactions": "No se pudo cargar las reacciones",
"Reaction": "Reacción",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
"Results": "Resultados",
"No results for {{query}}": "Sin resultados para {{query}}",
"Manage": "Administrar",
"All members": "Todos los miembros",
"Everyone in the workspace": "Todos en el espacio de trabajo",
"Everyone in the workspace": "Todos en el área de trabajo",
"Invite": "Invitar",
"{{ userName }} was added to the collection": "{{ userName }} fue agregado a la colección",
"{{ count }} people added to the collection": "{{ count }} persona añadida a la colección",
@@ -327,14 +326,14 @@
"Add or invite": "Añadir o invitar",
"Viewer": "Lector",
"Editor": "Editor",
"Suggestions for invitation": "Sugerencias para invitar",
"Suggestions for invitation": "Sugerencias para la invitación",
"No matches": "No hay coincidencias",
"Can view": "Puede visualizar",
"Everyone in the collection": "Todos en la colección",
"You have full access": "Tienes acceso total",
"Created the document": "Documento creado",
"Other people": "Otras personas",
"Other workspace members may have access": "Otros miembros del espacio de trabajo pueden tener acceso",
"Other workspace members may have access": "Otros miembros del área de trabajo pueden tener acceso",
"This document may be shared with more workspace members through a parent document or collection you do not have access to": "Este documento puede ser compartido con más miembros del área de trabajo a través de un documento padre o colección a la que no tienes acceso",
"Access inherited from collection": "Acceso heredado de la colección",
"{{ userName }} was removed from the document": "{{ userName }} fue quitado del documento",
@@ -354,7 +353,7 @@
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Cualquiera con el enlace puede acceder porque el documento padre, <2>{{documentTitle}}</2>, es compartido",
"Allow anyone with the link to access": "Permitir acceso a cualquiera con el enlace",
"Publish to internet": "Publicar en Internet",
"Search engine indexing": "Indexación del motor de búsqueda",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Los documentos anidados no son compartidos en la web. Cambia las reglas de compartir para habilitar el acceso, este será el comportamiento predeterminado en el futuro",
"{{ userName }} was added to the document": "{{ userName }} ha sido añadido al documento",
@@ -363,8 +362,8 @@
"{{ count }} groups added to the document": "{{ count }} grupo invitado al documento",
"{{ count }} groups added to the document_plural": "{{ count }} grupos invitados al documento",
"Logo": "Logo",
"Archived collections": "Colecciones archivadas",
"Change permissions?": "¿Cambiar permisos?",
"Archived collections": "Archived collections",
"Change permissions?": "Change permissions?",
"New doc": "Nuevo doc",
"You can't reorder documents in an alphabetically sorted collection": "No puedes reordenar documentos en una colección ordenada alfabéticamente",
"Empty": "Vacío",
@@ -420,7 +419,7 @@
"Align center": "Centrar",
"Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha",
"Default width": "Ancho predeterminado",
"Default width": "Ancho por Defecto",
"Full width": "Ancho total",
"Bulleted list": "Lista con viñetas",
"Todo list": "Lista de tareas",
@@ -431,7 +430,7 @@
"Create link": "Nuevo enlace",
"Sorry, an error occurred creating the link": "Lo sentimos, se ha producido un error al crear el enlace",
"Create a new doc": "Crea un nuevo documento",
"Create a new child doc": "Crear un nuevo documento hijo",
"Create a new child doc": "Crea un nuevo documento hijo",
"Delete table": "Eliminar tabla",
"Delete file": "Eliminar el archivo",
"Download file": "Descargar el archivo",
@@ -472,11 +471,11 @@
"Strikethrough": "Tachado",
"Bold": "Negrita",
"Subheading": "Sub-encabezado",
"Sort ascending": "Ordenar ascendentemente",
"Sort descending": "Ordenar descendientemente",
"Sort ascending": "Orden ascendente",
"Sort descending": "Orden descendiente",
"Table": "Tabla",
"Export as CSV": "Exportar como CSV",
"Toggle header": "Mostrar/Ocultar cabecera",
"Export as CSV": "Export as CSV",
"Toggle header": "Conmutar cabecera",
"Math inline (LaTeX)": "Fórmula matemática en línea (LaTeX)",
"Math block (LaTeX)": "Bloque de matemáticas (LaTeX)",
"Tip": "Sugerencia",
@@ -513,11 +512,11 @@
"Export collection": "Exportar colección",
"Rename": "Renombrar",
"Sort in sidebar": "Ordenar en barra lateral",
"A-Z sort": "Ordenado A-Z",
"Z-A sort": "Ordenado Z-A",
"A-Z sort": "A-Z sort",
"Z-A sort": "Z-A sort",
"Manual sort": "Orden manual",
"Comment options": "Opciones de los comentarios",
"Show document menu": "Mostrar menú del documento",
"Show document menu": "Mostrar menú de documento",
"{{ documentName }} restored": "{{ documentName }} restaurado",
"Document options": "Opciones del documento",
"Choose a collection": "Elige una colección",
@@ -560,14 +559,14 @@
"Choose a date": "Elige una fecha",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Dale a tu token un nombre que te permita recordar su uso en el futuro, por ejemplo \"desarrollo local\", \"producción\" o \"integración continua\".",
"Expiration": "Expiración",
"Never expires": "Nunca expira",
"Expiration": "Caducidad",
"Never expires": "Nunca caduca",
"7 days": "7 días",
"30 days": "30 días",
"60 days": "60 días",
"90 days": "90 días",
"Custom": "Personalizado",
"No expiration": "Sin expiración",
"No expiration": "Sin caducidad",
"The document archive is empty at the moment.": "El archivo está vacío en este momento.",
"Collection menu": "Menú de la colección",
"Drop documents to import": "Arrastra los documentos para importar",
@@ -597,13 +596,11 @@
"Upload image": "Subir una imagen",
"No resolved comments": "No hay comentarios resueltos",
"No comments yet": "Aún no hay comentarios",
"New comments": "Nuevos comentarios",
"Sort comments": "Ordenar comentarios",
"Most recent": "Más reciente",
"New comments": "New comments",
"Sort comments": "Sort comments",
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resuelto",
"Show {{ count }} reply": "Mostrar {{ count }} respuesta",
"Show {{ count }} reply_plural": "Mostrar {{ count }} respuestas",
"Resolved": "Resolved",
"Error updating comment": "Error actualizando el comentario",
"Document restored": "Documento restaurado",
"Images are still uploading.\nAre you sure you want to discard them?": "Las imágenes aún se están cargando.\n¿Estás seguro de que quieres descartarlas?",
@@ -722,7 +719,7 @@
"Can manage all workspace settings": "Puede administrar todos los ajustes del área de trabajo",
"Can create, edit, and delete documents": "Puede crear, editar y eliminar documentos",
"Can view and comment": "Puede ver y comentar",
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invita personas a unirse a tu espacio de trabajo. Pueden iniciar sesión con {{signinMethods}} o utilizar su dirección de correo electrónico.",
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invita personas a unirse a tu área de trabajo. Pueden iniciar sesión con {{signinMethods}} o usar su dirección de correo electrónico.",
"Invite members to join your workspace. They will need to sign in with {{signinMethods}}.": "Invita a miembros del equipo o invitados a unirse a tu espacio de trabajo. Ellos deberán iniciar sesión con {{signinMethods}}.",
"As an admin you can also <2>enable email sign-in</2>.": "Como administrador, también puedes <2>habilitar el inicio de sesión por correo electrónico</2>.",
"Invite as": "Invitar como",
@@ -762,7 +759,7 @@
"Move list item down": "Mover elemento de la lista abajo",
"Tables": "Tablas",
"Insert row": "Insertar fila",
"Next cell": "Celda siguiente",
"Next cell": "Siguiente celda",
"Previous cell": "Celda anterior",
"Space": "Espacio",
"Numbered list": "Lista numerada",
@@ -969,7 +966,7 @@
"When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "Cuando está activada, los documentos tienen un modo de edición separada. Cuando está desactivada, los documentos son siempre editables si tienes permiso de edición.",
"Remember previous location": "Recordar ubicación anterior",
"Automatically return to the document you were last viewing when the app is re-opened.": "Volver automáticamente al documento que estabas viendo cuando la aplicación se vuelva a abrir.",
"Smart text replacements": "Sustituciones inteligentes de texto",
"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.",
"You may delete your account at any time, note that this is unrecoverable": "Puedes eliminar tu cuenta en cualquier momento, ten en cuenta que esta es una operación irreversible",
"Profile saved": "Perfil guardado",
@@ -1035,11 +1032,11 @@
"This month": "Este mes",
"Last month": "El mes pasado",
"This year": "Este año",
"Expired yesterday": "Expiró ayer",
"Expired {{ date }}": "Expiró el {{ date }}",
"Expires today": "Expira hoy",
"Expires tomorrow": "Expira mañana",
"Expires {{ date }}": "Expira el {{ date }}",
"Expired yesterday": "Caducó ayer",
"Expired {{ date }}": "Caducó {{ date }}",
"Expires today": "Caduca hoy",
"Expires tomorrow": "Caduca mañana",
"Expires {{ date }}": "Caduca el {{ date }}",
"Connect": "Conectar",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Ups, necesitas aceptar los permisos en GitHub para conectar {{appName}} a tu área de trabajo. ¿Intentar de nuevo?",
"Something went wrong while authenticating your request. Please try logging in again.": "Ocurrió un error autenticando tu solicitud. Por favor, intenta iniciar sesión de nuevo.",
@@ -1047,15 +1044,15 @@
"Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.": "Habilitar vistas previas de incidencias de GitHub y pull requests en documentos conectando una organización de GitHub o repositorios específicos a {appName}.",
"Enabled by {{integrationCreatedBy}}": "Habilitado por {{integrationCreatedBy}}",
"Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?": "Desconectar impedirá previsualizar los enlaces de GitHub de esta organización en documentos. ¿Estás seguro?",
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "La integración de GitHub está desactivada. Configura las variables de entorno asociadas y reinicia el servidor para habilitar la integración.",
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "La integración de GitHub está actualmente desactivada. Configura las variables de entorno asociadas y reinicia el servidor para habilitar la integración.",
"Google Analytics": "Google Analytics",
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Añade un ID de seguimiento de Google Analytics 4 para enviar vistas y análisis de documentos desde el espacio de trabajo a tu propia cuenta de Google Analytics.",
"Measurement ID": "ID de seguimiento",
"Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Crea un flujo \"Web\" en tu panel de administración de Google Analytics y copia el ID de seguimiento del fragmento de código generado para instalar.",
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configura una instalación de Matomo para enviar vistas y analíticas desde el espacio de trabajo a tu propia instancia de Matomo.",
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configura una instalación de Matomo para enviar vistas y análisis desde el espacio de trabajo a tu propia instancia de Matomo.",
"Instance URL": "URL de la instancia",
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "La URL de tu instancia Matomo. Si estás usando Matomo Cloud, esta terminará en matomo.cloud/",
"Site ID": "ID del sitio",
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "La URL de tu instancia Matomo. Si estás usando Matomo Cloud terminará en matomo.cloud/",
"Site ID": "Identificador del sitio",
"An ID that uniquely identifies the website in your Matomo instance.": "Un ID que identifica de forma única el sitio web en tu instancia de Matomo.",
"Add to Slack": "Añadir a Slack",
"document published": "documento publicado",
@@ -1067,11 +1064,11 @@
"Personal account": "Cuenta personal",
"Link your {{appName}} account to Slack to enable searching and previewing the documents you have access to, directly within chat.": "Vincula tu cuenta {{appName}} a Slack para permitir buscar y previsualizar los documentos a los que tienes acceso, directamente dentro del chat.",
"Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?": "Desconectar tu cuenta personal impedirá la búsqueda de documentos desde Slack. ¿Estás seguro?",
"Slash command": "Comando de barra inclinada",
"Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Obtén vistas previas enriquecidas de los enlaces de {{ appName }} compartidos en Slack y usa el comando de barra inclinada <em>{{ command }}</em> para buscar documentos sin salir de tu chat.",
"This will remove the Outline slash command from your Slack workspace. Are you sure?": "Esto eliminará el comando de barra inclinada de Outline de tu espacio de trabajo de Slack. ¿Estás seguro?",
"Slash command": "Comando de barra",
"Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Obtén vistas previas enriquecidas de los enlaces de {{ appName }} compartidos en Slack y usa el comando <em>{{ command }}</em> para buscar documentos sin salir de tu chat.",
"This will remove the Outline slash command from your Slack workspace. Are you sure?": "Esto eliminará el comando de barra de Outline de tu espacio de trabajo Slack. ¿Estás seguro?",
"Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Conecta las colecciones de {{appName}} a canales de Slack. Se publicarán mensajes automáticamente en Slack cuando los documentos se publiquen o actualicen.",
"Comment by {{ author }} on \"{{ title }}\"": "Comentario de {{ author }} en \"{{ title }}\"",
"Comment by {{ author }} on \"{{ title }}\"": "Comentario de {{ author }} sobre \"{{ title }}\"",
"How to use {{ command }}": "Cómo utilizar {{ command }}",
"To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.": "Para buscar en tu espacio de trabajo usa {{ command }}. \nEscribe {{ command2 }} help para mostrar este texto de ayuda.",
"Post to Channel": "Publicar en Canal",
@@ -1080,10 +1077,10 @@
"It looks like you havent linked your {{ appName }} account to Slack yet": "Parece que aún no has vinculado tu cuenta {{ appName }} a Slack",
"Link your account": "Vincula tu cuenta",
"Link your account in {{ appName }} settings to search from Slack": "Vincula tu cuenta en la configuración de {{ appName }} para buscar desde Slack",
"Configure a Umami installation to send views and analytics from the workspace to your own Umami instance.": "Configura una instalación de Umami para enviar vistas y analíticas desde el espacio de trabajo a tu propia instancia de Umami.",
"The URL of your Umami instance. If you are using Umami Cloud it will begin with {{ url }}": "La URL de tu instancia Umami. Si estás usando Umami Cloud, esta empezará con {{ url }}",
"Configure a Umami installation to send views and analytics from the workspace to your own Umami instance.": "Configura una instalación de Umami para enviar vistas y análisis desde el espacio de trabajo a tu propia instancia de Umami.",
"The URL of your Umami instance. If you are using Umami Cloud it will begin with {{ url }}": "La URL de tu instancia Umami. Si estás usando Umami Cloud terminará en {{ url }}",
"Script name": "Nombre del script",
"The name of the script file that Umami uses to track analytics.": "El nombre del archivo script que Umami utiliza para el seguimiento de analíticas.",
"The name of the script file that Umami uses to track analytics.": "El nombre del archivo de script que Umami utiliza para el seguimiento de analíticas",
"An ID that uniquely identifies the website in your Umami instance.": "Un ID que identifica de forma única el sitio web en tu instancia de Umami.",
"Are you sure you want to delete the {{ name }} webhook?": "¿Estás seguro de que quieres eliminar el webhook {{ name }}?",
"Webhook updated": "Webhook actualizado",

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