Compare commits

..

47 Commits

Author SHA1 Message Date
Tom Moor dfc5857e01 permissions 2024-12-02 21:06:07 -05:00
Tom Moor 954a909749 Add api key list for admins 2024-12-01 23:27:34 -05:00
Tom Moor 577de24290 API keys 2024-12-01 21:32:04 -05:00
Tom Moor 8de59f0a2f fix: 'Resolve' button appearing on resolved threads, closes #8047 2024-12-01 09:53:38 -05:00
Tom Moor d8fbe35455 fix: Template variables are not applied on client (#8044)
* fix: Template variables are not applied on client

* test
2024-11-30 07:13:44 -08:00
Tom Moor 514a724d9d fix: Add default value for attachment preset for easier API use 2024-11-29 23:05:30 -05:00
Tom Moor d66f41c854 fix: Improve behavior of LaTeX at small screensizes, closes #8032 2024-11-29 11:20:01 -05:00
Tom Moor b2d6c40ea8 chore: Add warning for problematic selfhosted config, closes #8025 2024-11-29 11:07:23 -05:00
infinite-persistence c98d6aa33a Allow user to select doc-copy destination (#8030)
* DocumentExplorer: make style extensible

* Allow user to select doc-copy dest

The change in `documentDuplicator` essentially alters the fallback from "parent" to "top of collection". But there is only 1 place that uses it so far, so I think it's fine to support this PR.

In the next commit, the caller side will restore the default to "parent".

* Auto select parent as initial target (to retain existing behavior)

Otherwise, user would need to always search/expand the tree. I have a feeling that people might want the last selection to be persistent, but ignoring that for now.

The 50ms timeout feels dirty, but 0 was too fast, at least on my machine. I couldn't find anything in react-window for a "ready" flag.

* Rename: DuplicateDialog -> DocumentCopy

This begins the switch to DocumentCopy's look in the next few commits

* Revert DocumentExplorer style override

No longer needed since we won't be using it under a ConfirmationDialog anymore in the next commit.

* Switch to DocumentMove's style

* initialSelectionId -> defaultValue

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-11-29 10:33:38 -05:00
Hemachandar 554c2a5cdb Simplify determining from email address (#8039)
* Simplify determining from email address

* override only for cloud-hosted
2024-11-29 06:41:48 -08:00
Hemachandar ee426de942 Cleanup random text (#8036) 2024-11-29 08:14:59 -05:00
Tom Moor 746e65e658 fix: Recursively filter source document from explorer, closes #8028 2024-11-27 23:04:37 -05:00
Tom Moor 8a3a3453e7 fix: The operation was unable to achieve a quorum during its retry window 2024-11-27 23:04:37 -05:00
Tom Moor c7d339ded5 Tracking of total uploaded attachments / team (#8031)
* Add column and task to calculate size

* Store in MB, rather than Bytes

* Add cron task to recalculate attachment sizes

* findAllInBatches

* Index createdAt

* fix: Index on incorrect table
2024-11-27 18:42:23 -08:00
Tom Moor ed25554607 fix: Hide TOC on templates 2024-11-27 18:20:49 -05:00
Tom Moor 29329daf15 chore: Record on users.signin event 2024-11-27 17:59:46 -05:00
Tom Moor 3f6390ff18 chore: Remove error on double reload 2024-11-27 17:56:02 -05:00
Translate-O-Tron 54b43c6e6f New Crowdin updates (#8029)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2024-11-27 08:38:31 -08:00
Tom Moor 8c9c83eb5a fix: Improve contrast on context menus in dark mode 2024-11-27 10:16:22 -05:00
Tom Moor 63171e5da2 fix: Incorrect cursor on sortable table headers 2024-11-27 09:33:52 -05:00
Tom Moor bfd84681d7 fix: Jank in domain management screen 2024-11-26 22:29:26 -05:00
Tom Moor 7d6a47ce86 chore: Remove unused undo/redo methods 2024-11-26 20:53:44 -05:00
Tom Moor 68f715b607 chore: Remove unused typing tracking logic 2024-11-26 20:50:57 -05:00
Tom Moor ea2e7a4d0f chore: Remove duplicate ID annotations 2024-11-26 20:43:01 -05:00
Translate-O-Tron 26948af1b8 New Crowdin updates (#7967) 2024-11-26 17:24:29 -08:00
Tom Moor 816a6715c5 chore: Simplify comment sidebar persistence to be per-user (#8022) 2024-11-26 17:24:07 -08:00
Tom Moor 4579594c63 fix: Relayout jank on document references 2024-11-26 09:05:14 -05:00
Tom Moor 88f7705fd4 fix: Starred documents do not expand when focusing, related #7956 2024-11-25 23:30:01 -05:00
Hemachandar 8393847910 Check flag emoji is supported (#8009) 2024-11-25 19:32:41 -08:00
dependabot[bot] b9adfa175d chore(deps-dev): bump @types/readable-stream from 4.0.15 to 4.0.18 (#8019)
Bumps [@types/readable-stream](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/readable-stream) from 4.0.15 to 4.0.18.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/readable-stream)

---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-25 19:31:39 -08:00
dependabot[bot] 7fff8161ff chore(deps): bump vite from 5.4.10 to 5.4.11 (#8021)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.10 to 5.4.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.11/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-25 19:31:30 -08:00
Tom Moor 0ef9f1aea1 fix: Improve fast-click functionality in sidebar 2024-11-25 22:22:21 -05:00
Tom Moor fe63c5d706 fix: JS error in usePosition hook 2024-11-25 20:42:31 -05:00
Tom Moor 7749f0ab9f fix: Undo/redo regression 2024-11-25 20:36:23 -05:00
Tom Moor 763b911dfd fix: Named commands broken, regressed in 921e89d7b7 2024-11-24 23:34:48 -05:00
Tom Moor 99e541ede8 fix: Ensure logout OIDC never immediately relogin 2024-11-24 22:34:16 -05:00
Tom Moor 06f48ec79a Add active/hover state to collapsed thread 2024-11-24 19:59:31 -05:00
infinite-persistence 5566d995bd Comment: collapse long replies (#7941)
* Comment: collapse long replies

## Ticket
Closes 5079

## Review
- For the case of RTL, followed how "Reply" is implemented (assumed that is the desired). If it need to be re-aligned, it can be fixed together with "Reply" later.
- The threshold number can be moved to constants.ts if we don't want to pollute the props.

* Card-style + Facepile
2024-11-24 16:20:46 -08:00
Tom Moor 921e89d7b7 fix: Undo/redo behavior incorrect in multiplayer editor (#8015) 2024-11-24 16:19:52 -08:00
Tom Moor 32602f89dd fix: Flash of styles when printing dark mode (#8010) 2024-11-24 06:15:34 -08:00
Tom Moor 2cce95488c fix: S3 expiry not passed correctly (#8013) 2024-11-24 06:15:19 -08:00
Tom Moor 0663d191fc fix: Lists with negative margin are cut off when printing to PDF. This is a pragmatic fix for the issue closes #7958 2024-11-23 12:00:05 -05:00
Tom Moor 84eb1b801d fix: 'Replace all' functionality replacing offset incorrectly 2024-11-23 00:47:26 -05:00
Hemachandar 5102cfe8eb Persist theme after update (#7997) 2024-11-21 05:18:11 -05:00
Tom Moor 1d0617dbd6 fix: Edge case where heading in first table cell changes margin on focus 2024-11-20 20:52:11 -05:00
Tom Moor eedfd549b3 fix: Rare loop of storage events between tabs causing flickering UI (#7996)
* fix: Rare loop of storage events between tabs causing flickering UI

* Types cleanup
2024-11-20 16:17:28 -08:00
Hemachandar 28cb5aa379 Convert pin mutations to use auto event insertion (#7993) 2024-11-20 16:14:11 -08:00
126 changed files with 1165 additions and 891 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 DuplicateDialog from "~/components/DuplicateDialog";
import DocumentCopy from "~/components/DocumentCopy";
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: (
<DuplicateDialog
<DocumentCopy
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
@@ -1054,7 +1054,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments(activeDocumentId);
stores.ui.toggleComments();
},
});
-1
View File
@@ -31,7 +31,6 @@ const Actions = styled(Flex)`
left: 0;
border-radius: 3px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
padding: 12px;
backdrop-filter: blur(20px);
+2 -1
View File
@@ -5,6 +5,7 @@ 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 = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
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.includes(ui.activeDocumentId) &&
ui.commentsExpanded &&
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
-1
View File
@@ -201,7 +201,6 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${s("backgroundTransition")};
&:after {
content: "";
-1
View File
@@ -182,7 +182,6 @@ 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,22 +262,6 @@ 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
@@ -0,0 +1,149 @@
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);
+27 -5
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 }: Props) {
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -47,12 +47,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
null
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
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 [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[]
>([]);
@@ -94,6 +107,15 @@ function DocumentExplorer({ onSubmit, onSelect, items }: 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
@@ -1,97 +0,0 @@
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 ${s("inputBorder")};
border-bottom: 1px solid rgb(34 40 52);
background: ${s("menuBackground")};
}
-1
View File
@@ -94,7 +94,6 @@ 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,7 +130,6 @@ 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,7 +76,6 @@ 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,7 +174,6 @@ const Fullscreen = styled.div<FullscreenProps>`
justify-content: center;
align-items: flex-start;
background: ${s("background")};
transition: ${s("backgroundTransition")};
outline: none;
${breakpoint("tablet")`
@@ -265,7 +264,6 @@ 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,7 +144,6 @@ const EmojiButton = styled(NudeButton)<{
height: 28px;
padding: 6px;
border-radius: 12px;
transition: ${s("backgroundTransition")};
background: ${s("backgroundTertiary")};
pointer-events: ${({ disabled }) => disabled && "none"};
+2 -3
View File
@@ -298,9 +298,8 @@ const Container = styled(Flex)<ContainerProps>`
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
@@ -112,8 +112,9 @@ const NavLink = ({
!rest.target &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey,
[rest.target]
!event.ctrlKey &&
!isActive,
[rest.target, isActive]
);
const navigateTo = React.useCallback(() => {
@@ -153,14 +154,13 @@ const NavLink = ({
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
// onMouseDown={handleClick}
onClick={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onClick={handleClick}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -105,7 +105,6 @@ const Button = styled(Flex)<{
&:hover,
&[aria-expanded="true"] {
color: ${s("sidebarText")};
transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")};
}
@@ -78,7 +78,6 @@ function SidebarLink(
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarActiveBackground,
...style,
@@ -202,10 +201,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,13 +48,20 @@ 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,7 +31,6 @@ const Background = styled.div<{ sticky?: boolean }>`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
+2 -3
View File
@@ -253,6 +253,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
white-space: nowrap;
margin: 0 -4px;
padding: 0 4px;
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
&:hover {
background: ${(props) =>
@@ -309,15 +310,13 @@ const Row = styled.tr`
const Head = styled.th`
text-align: left;
padding: 6px 6px 0;
padding: 6px 6px 2px;
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,7 +45,6 @@ const Sticky = styled.div`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
-42
View File
@@ -1,42 +0,0 @@
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,13 +529,6 @@ 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, {
+6 -4
View File
@@ -131,13 +131,15 @@ function usePosition({
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from) as HTMLElement;
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = element.getElementsByClassName(
EditorStyleHelper.imageHandle
)[0];
const imageElement = element
? (element as HTMLElement).getElementsByClassName(
EditorStyleHelper.imageHandle
)[0]
: undefined;
if (imageElement) {
const { left, top, width } = imageElement.getBoundingClientRect();
+20 -6
View File
@@ -92,6 +92,10 @@ 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) {
@@ -106,7 +110,12 @@ export default class FindAndReplaceExtension extends Extension {
}
public replaceAll(replace: string): Command {
return ({ tr }, dispatch) => {
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;
let offset: number | undefined;
if (!this.results.length) {
@@ -256,12 +265,17 @@ export default class FindAndReplaceExtension extends Extension {
}
// Reconstruct the correct match position
const i = m.index > text.length ? m.index - text.length : m.index;
const i = m.index >= text.length ? m.index - text.length : m.index;
const from = pos + i;
const to = from + m[0].length;
this.results.push({
from: pos + i,
to: pos + i + m[0].length,
});
// 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 });
}
} catch (e) {
// Invalid RegExp
+7 -6
View File
@@ -1,5 +1,4 @@
import isEqual from "lodash/isEqual";
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
@@ -104,11 +103,13 @@ 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,7 +4,6 @@ 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";
@@ -608,20 +607,6 @@ 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.error("lifecycle", new Error("Attempted to reload twice"));
Logger.warn("Attempted to reload twice");
return;
}
+5 -4
View File
@@ -6,10 +6,7 @@ export default function useComponentSize(
width: number;
height: number;
} {
const [size, setSize] = useState({
width: ref.current?.clientWidth || 0,
height: ref.current?.clientHeight || 0,
});
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
@@ -24,6 +21,10 @@ export default function useComponentSize(
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
+13 -4
View File
@@ -29,6 +29,7 @@ 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"));
@@ -87,10 +88,10 @@ const useSettingsConfig = () => {
icon: EmailIcon,
},
{
name: t("API"),
path: settingsPath("tokens"),
component: ApiKeys,
enabled: can.createApiKey,
name: t("API Keys"),
path: settingsPath("personal-api-keys"),
component: PersonalApiKeys,
enabled: can.createApiKey && !can.listApiKeys,
group: t("Account"),
icon: CodeIcon,
},
@@ -143,6 +144,14 @@ 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,6 +3,7 @@ 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";
@@ -11,7 +12,6 @@ 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: replaceTitleVariables(tmpl.titleWithDefault, user),
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
-24
View File
@@ -1,8 +1,6 @@
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";
@@ -15,17 +13,6 @@ 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
*/
@@ -107,17 +94,6 @@ 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
*/
+2 -4
View File
@@ -65,10 +65,6 @@ export default class Document extends ArchivableModel {
store: DocumentsStore;
@Field
@observable
id: string;
@observable.shallow
data: ProsemirrorData;
@@ -577,6 +573,8 @@ 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,10 +6,6 @@ import Field from "./decorators/Field";
class Group extends Model {
static modelName = "Group";
@Field
@observable
id: string;
@Field
@observable
name: string;
-4
View File
@@ -18,10 +18,6 @@ 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,10 +8,6 @@ import Field from "./decorators/Field";
class Team extends Model {
static modelName = "Team";
@Field
@observable
id: string;
@Field
@observable
name: string;
-4
View File
@@ -22,10 +22,6 @@ import Field from "./decorators/Field";
class User extends ParanoidModel {
static modelName = "User";
@Field
@observable
id: string;
@Field
@observable
avatarUrl: string;
-4
View File
@@ -5,10 +5,6 @@ import Field from "./decorators/Field";
class WebhookSubscription extends Model {
static modelName = "WebhookSubscription";
@Field
@observable
id: string;
@Field
@observable
name: string;
@@ -49,8 +49,6 @@ 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 */
@@ -63,7 +61,6 @@ function CommentForm({
draft,
onSubmit,
onSaveDraft,
onTyping,
onFocus,
onBlur,
autoFocus,
@@ -182,7 +179,6 @@ 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,14 +10,11 @@ import { s } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } 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";
@@ -40,28 +37,12 @@ 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,
@@ -69,21 +50,19 @@ 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>(
@@ -102,6 +81,17 @@ 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 &&
@@ -129,6 +119,36 @@ 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);
@@ -192,8 +212,17 @@ 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 ||
@@ -219,15 +248,6 @@ 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}>
@@ -237,7 +257,6 @@ function CommentThread({
draft={draft}
documentId={document.id}
thread={thread}
onTyping={setIsTyping}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
@@ -276,6 +295,29 @@ 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,17 +264,19 @@ function CommentThreadItem({
<EventBoundary>
{!isEditing && (
<Actions gap={4} dir={dir}>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
{!comment.isResolved && (
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
<>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
</>
)}
<Action
as={CommentMenu}
@@ -419,7 +421,7 @@ export const Bubble = styled(Flex)<{
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
transition: color 100ms ease-out, ${s("backgroundTransition")};
transition: color 100ms ease-out, background 100ms ease-out;
${({ $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.collapseComments(document?.id));
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
@@ -126,7 +126,7 @@ function Comments() {
<CommentSortMenu />
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
onClose={() => ui.set({ commentsExpanded: false })}
scrollable={false}
>
<Scrollable
@@ -87,7 +87,6 @@ const StickyWrapper = styled.div`
border-radius: 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
+18 -10
View File
@@ -26,6 +26,7 @@ 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";
@@ -44,7 +45,6 @@ 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,7 +151,13 @@ class DocumentScene extends React.Component<Props> {
}
const { view, schema } = editorRef;
const doc = Node.fromJSON(schema, template.data);
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.replaceTemplateVariables(
template.data,
this.props.auth.user!
)
);
if (doc) {
view.dispatch(
@@ -168,9 +174,9 @@ class DocumentScene extends React.Component<Props> {
}
if (!this.title) {
const title = replaceTitleVariables(
const title = TextHelper.replaceTemplateVariables(
template.title,
this.props.auth.user || undefined
this.props.auth.user!
);
this.title = title;
this.props.document.title = title;
@@ -215,13 +221,15 @@ class DocumentScene extends React.Component<Props> {
onUndoRedo = (event: KeyboardEvent) => {
if (isModKey(event)) {
event.preventDefault();
if (event.shiftKey) {
if (this.editor.current?.redo()) {
event.preventDefault();
if (!this.props.readOnly) {
this.editor.current?.commands.redo();
}
} else {
if (this.editor.current?.undo()) {
event.preventDefault();
if (!this.props.readOnly) {
this.editor.current?.commands.undo();
}
}
}
@@ -410,7 +418,8 @@ class DocumentScene extends React.Component<Props> {
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
@@ -695,7 +704,6 @@ 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(document.id)}
onClick={() => ui.toggleComments()}
>
<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.expandComments(document.id);
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, history, params]);
+7 -2
View File
@@ -117,7 +117,8 @@ function DocumentHeader({
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const toc = (
<Tooltip
@@ -236,7 +237,11 @@ function DocumentHeader({
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
{document.isTemplate ? null : (
<>
{toc} <Star document={document} color={theme.textSecondary} />
</>
)}
</DocumentBreadcrumb>
)
}
@@ -273,6 +273,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
<>
{showCache && (
<Editor
editorStyle={props.editorStyle}
embedsDisabled={props.embedsDisabled}
defaultValue={props.defaultValue}
extensions={props.extensions}
@@ -290,8 +291,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
style={
showCache
? {
height: 0,
opacity: 0,
pointerEvents: "none",
}
: undefined
}
@@ -31,11 +31,13 @@ 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 ? (
<Fade>
<Component>
<Tabs>
{showChildDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
@@ -80,7 +82,7 @@ function References({ document }: Props) {
</List>
)}
</Content>
</Fade>
</Component>
) : null;
}
+15 -3
View File
@@ -28,12 +28,24 @@ 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
@@ -108,21 +120,21 @@ function DocumentMove({ document }: Props) {
);
}
const FlexContainer = styled(Flex)`
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const Footer = styled(Flex)`
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
const StyledText = styled(Text)`
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
-1
View File
@@ -118,7 +118,6 @@ function Home() {
const Documents = styled.div`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
export default observer(Home);
+2 -1
View File
@@ -231,7 +231,8 @@ function Login({ children }: Props) {
config.providers.length === 1 &&
config.providers[0].id === "oidc" &&
!env.OIDC_DISABLE_REDIRECT &&
!query.get("notice")
!query.get("notice") &&
!query.get("logout")
) {
window.location.href = getRedirectUrl(config.providers[0].authUrl);
return null;
+2 -1
View File
@@ -2,6 +2,7 @@ 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();
@@ -17,7 +18,7 @@ const Logout = () => {
if (env.OIDC_LOGOUT_URI) {
return null; // user will be redirected to logout URI after logout
}
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default Logout;
@@ -59,7 +59,6 @@ const StyledInput = styled.input`
outline: none;
border: 0;
background: ${s("sidebarBackground")};
transition: ${s("backgroundTransition")};
border-radius: 4px;
color: ${s("text")};
+6 -33
View File
@@ -2,7 +2,6 @@ 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";
@@ -13,36 +12,17 @@ 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")}
@@ -62,12 +42,11 @@ function ApiKeys() {
</>
}
>
<Heading>{t("API")}</Heading>
<Heading>{t("API Keys")}</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>."
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>."
components={{
em: (
<a
@@ -81,16 +60,10 @@ function ApiKeys() {
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
isCopied={apiKey.id === copiedKeyId}
onCopy={handleCopy}
/>
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
</Scene>
+77
View File
@@ -0,0 +1,77 @@
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);
+2 -3
View File
@@ -220,9 +220,6 @@ function Security() {
</SettingRow>
)}
{!data.inviteRequired && (
<DomainManagement onSuccess={showSuccessMessage} />
)}
{!data.inviteRequired && (
<SettingRow
label={t("Default role")}
@@ -252,6 +249,8 @@ function Security() {
</SettingRow>
)}
<DomainManagement onSuccess={showSuccessMessage} />
<h2>{t("Behavior")}</h2>
<SettingRow
label={t("Public document sharing")}
@@ -12,7 +12,6 @@ export const ActionRow = styled.div`
margin: 0 -50vw;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -1,6 +1,8 @@
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";
@@ -8,24 +10,29 @@ 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, isCopied, onCopy }: Props) => {
const ApiKeyListItem = ({ apiKey }: Props) => {
const { t } = useTranslation();
const userLocale = useUserLocale();
const user = useCurrentUser();
const subtitle = (
<>
<Text type="tertiary">
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> &middot;{" "}
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
{apiKey.userId === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}{" "}
&middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
@@ -41,9 +48,19 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
</>
);
const [copied, setCopied] = React.useState<boolean>(false);
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopy = React.useCallback(() => {
onCopy(apiKey.id);
}, [apiKey.id, onCopy]);
if (copyTimeoutIdRef.current) {
clearTimeout(copyTimeoutIdRef.current);
}
setCopied(true);
copyTimeoutIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
toast.message(t("API key copied to clipboard"));
}, [t]);
return (
<ListItem
@@ -52,10 +69,10 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
subtitle={subtitle}
actions={
<Flex align="center" gap={8}>
{apiKey.value && (
{apiKey.value && handleCopy && (
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{isCopied ? t("Copied") : t("Copy")}
{copied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
)}
@@ -74,4 +91,4 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
);
};
export default ApiKeyListItem;
export default observer(ApiKeyListItem);
@@ -5,7 +5,6 @@ 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";
@@ -110,29 +109,25 @@ function DomainManagement({ onSuccess }: Props) {
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
{!allowedDomains.length ||
allowedDomains[allowedDomains.length - 1] !== "" ? (
<Fade>
<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>
) : (
<span />
)}
{showSaveChanges && (
<Fade>
<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>
)}
</Flex>
</SettingRow>
-14
View File
@@ -150,20 +150,6 @@ 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");
+5 -25
View File
@@ -75,7 +75,7 @@ class UiStore {
sidebarCollapsed = false;
@observable
commentsExpanded: string[] = [];
commentsExpanded = false;
@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,28 +218,8 @@ class UiStore {
};
@action
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);
}
toggleComments = () => {
this.set({ commentsExpanded: !this.commentsExpanded });
};
@action
-1
View File
@@ -124,7 +124,6 @@ declare module "styled-components" {
backgroundSecondary: string;
backgroundTertiary: string;
backgroundQuaternary: string;
backgroundTransition: string;
accent: string;
accentText: string;
link: string;
+1 -28
View File
@@ -10,16 +10,7 @@ import {
isPast,
} from "date-fns";
import { TFunction } from "i18next";
import startCase from "lodash/startCase";
import {
getCurrentDateAsString,
getCurrentDateTimeAsString,
getCurrentTimeAsString,
unicodeCLDRtoBCP47,
dateLocale,
locales,
} from "@shared/utils/date";
import User from "~/models/User";
import { dateLocale, locales } from "@shared/utils/date";
export function dateToHeading(
dateTime: string,
@@ -121,21 +112,3 @@ 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,6 +8,13 @@ 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.10",
"vite": "^5.4.11",
"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.15",
"@types/readable-stream": "^4.0.18",
"@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 ?? document.parentDocumentId,
parentDocumentId,
icon: document.icon,
color: document.color,
template: document.template,
+16 -13
View File
@@ -1,10 +1,9 @@
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({
@@ -12,11 +11,13 @@ describe("pinCreator", () => {
teamId: user.teamId,
});
const pin = await pinCreator({
documentId: document.id,
user,
ip,
});
const pin = await withAPIContext(user, (ctx) =>
pinCreator({
ctx,
user,
documentId: document.id,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
@@ -36,12 +37,14 @@ describe("pinCreator", () => {
teamId: user.teamId,
});
const pin = await pinCreator({
documentId: document.id,
collectionId: document.collectionId,
user,
ip,
});
const pin = await withAPIContext(user, (ctx) =>
pinCreator({
ctx,
user,
documentId: document.id,
collectionId: document.collectionId,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
+12 -37
View File
@@ -2,10 +2,12 @@ import fractionalIndex from "fractional-index";
import { Sequelize, Op, WhereOptions } from "sequelize";
import { PinValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import { Pin, User, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Pin, User } from "@server/models";
import { APIContext } from "@server/types";
type Props = {
/** The request context */
ctx: APIContext;
/** The user creating the pin */
user: User;
/** The document to pin */
@@ -14,8 +16,6 @@ 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,38 +62,13 @@ export default async function pinCreator({
index = fractionalIndex(pins.length ? pins[0].index : null, null);
}
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;
}
const pin = await Pin.createWithCtx(ctx, {
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
});
return pin;
}
+2
View File
@@ -13,6 +13,8 @@ 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
@@ -1,53 +0,0 @@
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 | string;
from: EmailAddress;
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: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
from: data.from,
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
to: data.to,
messageId: data.messageId,
+9 -13
View File
@@ -1,4 +1,4 @@
import addressparser from "addressparser";
import addressparser, { EmailAddress } from "addressparser";
import Bull from "bull";
import invariant from "invariant";
import { Node } from "prosemirror-model";
@@ -184,26 +184,22 @@ export default abstract class BaseEmail<
}
}
private from(props: S & T) {
private from(props: S & T): EmailAddress {
invariant(
env.SMTP_FROM_EMAIL,
"SMTP_FROM_EMAIL is required to send emails"
);
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
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}`,
};
}
const domain = parsedFrom.address.split("@")[1];
return {
name: name ?? parsedFrom.name,
address: parsedFrom.address,
name: this.fromName?.(props) ?? parsedFrom.name,
address:
env.isCloudHosted &&
this.category === EmailMessageCategory.Authentication
? `noreply-${randomstring.generate(24)}@${domain}`
: parsedFrom.address,
};
}
@@ -0,0 +1,21 @@
"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,6 +20,8 @@ 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,6 +25,7 @@ import {
AfterUpdate,
BeforeUpdate,
BeforeCreate,
IsNumeric,
} from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
@@ -151,6 +152,11 @@ 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,9 +21,7 @@ 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 */
@@ -264,29 +262,6 @@ 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
-26
View File
@@ -1,13 +1,6 @@
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";
@@ -19,25 +12,6 @@ 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
+8 -1
View File
@@ -1,7 +1,13 @@
import { TeamPreference } from "@shared/types";
import { ApiKey, User, Team } from "@server/models";
import { allow } from "./cancan";
import { and, isOwner, isTeamModel, isTeamMutable } from "./utils";
import {
and,
isCloudHosted,
isOwner,
isTeamModel,
isTeamMutable,
} from "./utils";
allow(User, "createApiKey", Team, (actor, team) =>
and(
@@ -18,6 +24,7 @@ allow(User, "createApiKey", Team, (actor, team) =>
allow(User, "listApiKeys", Team, (actor, team) =>
and(
//
isCloudHosted(),
isTeamModel(actor, team),
actor.isAdmin
)
@@ -0,0 +1,31 @@
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,
};
}
}
@@ -0,0 +1,53 @@
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,
};
}
}
+3 -1
View File
@@ -18,7 +18,9 @@ export const AttachmentsCreateSchema = BaseSchema.extend({
contentType: z.string().optional().default("application/octet-stream"),
/** Attachment type */
preset: z.nativeEnum(AttachmentPreset),
preset: z
.nativeEnum(AttachmentPreset)
.default(AttachmentPreset.DocumentAttachment),
}),
});
+25 -14
View File
@@ -1,9 +1,8 @@
import Router from "koa-router";
import { Sequelize, Op } from "sequelize";
import { Sequelize, Op, Transaction } 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";
@@ -22,18 +21,21 @@ 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);
}).findByPk(collectionId, { transaction });
authorize(user, "update", collection);
authorize(user, "pin", document);
} else {
@@ -41,10 +43,10 @@ router.post(
}
const pin = await pinCreator({
ctx,
user,
documentId,
collectionId,
ip: ctx.request.ip,
index,
});
@@ -108,13 +110,20 @@ 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;
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
const { transaction } = ctx.state;
const pin = await Pin.findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
const document = await Document.findByPk(pin.documentId, {
userId: user.id,
transaction,
});
if (pin.collectionId) {
@@ -123,12 +132,7 @@ router.post(
authorize(user, "update", pin);
}
pin = await pinUpdater({
user,
pin,
ip: ctx.request.ip,
index,
});
await pin.updateWithCtx(ctx, { index });
ctx.body = {
data: presentPin(pin),
@@ -141,14 +145,21 @@ 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, { rejectOnEmpty: true });
const pin = await Pin.findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
const document = await Document.findByPk(pin.documentId, {
userId: user.id,
transaction,
});
if (pin.collectionId) {
@@ -157,7 +168,7 @@ router.post(
authorize(user, "delete", pin);
}
await pinDestroyer({ user, pin, ip: ctx.request.ip });
await pin.destroyWithCtx(ctx);
ctx.body = {
success: true,
+4 -2
View File
@@ -145,14 +145,13 @@ 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);
const url = await getSignedUrl(this.client, command, { expiresIn });
if (env.AWS_S3_ACCELERATE_URL) {
return url.replace(
@@ -231,6 +230,9 @@ 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;
}
}
+4 -2
View File
@@ -2,8 +2,8 @@ import Redlock from "redlock";
import Redis from "@server/storage/redis";
export class MutexLock {
// Default expiry time for qcuiring lock in milliseconds
public static defaultLockTimeout = 5000;
// Default expiry time for acquiring lock in milliseconds
public static defaultLockTimeout = 4000;
/**
* Returns the redlock instance
@@ -11,6 +11,8 @@ export class MutexLock {
public static get lock(): Redlock {
this.redlock ??= new Redlock([Redis.defaultClient], {
retryJitter: 10,
retryCount: 20,
retryDelay: 200,
});
return this.redlock;
+2 -1
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 } from "@server/types";
import { AuthenticationResult, AuthenticationType } from "@server/types";
/**
* Parse and return the details from the "sessions" cookie in the request, if
@@ -68,6 +68,7 @@ export async function signIn(
actorId: user.id,
userId: user.id,
teamId: team.id,
authType: AuthenticationType.APP,
data: {
name: user.name,
service,
+8 -3
View File
@@ -52,6 +52,8 @@ 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 {
@@ -701,7 +703,10 @@ img.ProseMirror-separator {
}
.heading-name:first-child,
.heading-name:first-child + .ProseMirror-yjs-cursor {
// 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] {
& + h1,
& + h2,
& + h3,
@@ -1065,11 +1070,11 @@ a:hover {
ul,
ol {
margin: 0 0.1em 0 -26px;
margin: 0 0.1em 0 ${props.staticHTML ? "0" : "-26px"};
padding: 0 0 0 48px;
&:dir(rtl) {
margin: 0 -26px 0 0.1em;
margin: 0 ${props.staticHTML ? "0" : "-26px"} 0 0.1em;
padding: 0 48px 0 0;
}
}
+13 -5
View File
@@ -1,17 +1,25 @@
import { history, undo, redo } from "prosemirror-history";
import { undoInputRule } from "prosemirror-inputrules";
import Extension from "../lib/Extension";
import { Command } from "prosemirror-state";
import Extension, { CommandFactory } from "../lib/Extension";
export default class History extends Extension {
get name() {
return "history";
}
keys() {
commands(): Record<string, CommandFactory> {
return {
"Mod-z": undo,
"Mod-y": redo,
"Shift-Mod-z": redo,
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(),
Backspace: undoInputRule,
};
}
+2 -4
View File
@@ -5,9 +5,7 @@ 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 | void;
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
export type WidgetProps = { rtl: boolean; readOnly: boolean | undefined };
@@ -73,7 +71,7 @@ export default class Extension {
keys(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): Record<string, Command> {
}): Record<string, Command | CommandFactory> {
return {};
}
+7 -7
View File
@@ -251,13 +251,13 @@ export default class ExtensionManager {
};
const handle = (_name: string, _value: CommandFactory) => {
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;
}
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 (typeof value === "object") {
@@ -308,6 +308,7 @@
"{{ 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",
@@ -601,6 +602,8 @@
"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,6 +308,7 @@
"{{ 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",
@@ -601,6 +602,8 @@
"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,6 +308,7 @@
"{{ 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",
@@ -601,6 +602,8 @@
"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?",
+14 -7
View File
@@ -196,6 +196,11 @@
"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",
@@ -227,9 +232,6 @@
"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.",
@@ -495,7 +497,7 @@
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Account": "Account",
"API": "API",
"API Keys": "API Keys",
"Details": "Details",
"Security": "Security",
"Features": "Features",
@@ -602,6 +604,8 @@
"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?",
@@ -825,11 +829,12 @@
"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 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",
"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 }}",
"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?",
@@ -954,6 +959,8 @@
"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.",
+65 -62
View File
@@ -12,12 +12,12 @@
"Star": "Marcar como favorito",
"Unstar": "Eliminar de favoritos",
"Archive": "Archivar",
"Archive collection": "Archive collection",
"Collection archived": "Collection archived",
"Archive collection": "Archivar colección",
"Collection archived": "Colección archivada",
"Archiving": "Archivando",
"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.",
"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.",
"Restore": "Restaurar",
"Collection restored": "Collection restored",
"Collection restored": "Colección restaurada",
"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": "View reactions",
"Reactions": "Reactions",
"View reactions": "Ver reacciones",
"Reactions": "Reacciones",
"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": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"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",
"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 en el área de trabajo",
"Login to workspace": "Iniciar sesión al espacio de trabajo",
"Invite people": "Invitar personas",
"Invite to workspace": "Invitar a área de trabajo",
"Invite to workspace": "Invitar al espacio 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": "manage access",
"manage access": "administrar acceso",
"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ó {{removedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} eliminó a {{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 iconos",
"Search icons": "Buscar íconos",
"Choose default skin tone": "Elige un tono de piel predeterminado",
"Show menu": "Mostrar menú",
"Icon Picker": "Selector de icono",
"Icons": "Iconos",
"Icon Picker": "Selector de ícono",
"Icons": "Íconos",
"Emojis": "Emojis",
"Remove": "Eliminar",
"All": "Todos",
"Frequently Used": "Usados con frecuencia",
"Frequently Used": "Usado Frecuentemente",
"Search Results": "Resultados de Búsqueda",
"Smileys & People": "Smileys y Personas",
"Animals & Nature": "Animales y Naturaleza",
@@ -304,18 +304,19 @@
"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 }} reacted with {{ emoji }}",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reaccionó con {{ 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 }}",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
"Add reaction": "Add reaction",
"Reaction picker": "Selector de reacción",
"Could not load reactions": "No se pudo cargar las reacciones",
"Reaction": "Reacción",
"Results": "Resultados",
"No results for {{query}}": "Sin resultados para {{query}}",
"Manage": "Administrar",
"All members": "Todos los miembros",
"Everyone in the workspace": "Todos en el área de trabajo",
"Everyone in the workspace": "Todos en el espacio 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",
@@ -326,14 +327,14 @@
"Add or invite": "Añadir o invitar",
"Viewer": "Lector",
"Editor": "Editor",
"Suggestions for invitation": "Sugerencias para la invitación",
"Suggestions for invitation": "Sugerencias para invitar",
"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 área de trabajo pueden tener acceso",
"Other workspace members may have access": "Otros miembros del espacio 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",
@@ -353,7 +354,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": "Search engine indexing",
"Search engine indexing": "Indexación del motor de búsqueda",
"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",
@@ -362,8 +363,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": "Archived collections",
"Change permissions?": "Change permissions?",
"Archived collections": "Colecciones archivadas",
"Change permissions?": "¿Cambiar permisos?",
"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",
@@ -419,7 +420,7 @@
"Align center": "Centrar",
"Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha",
"Default width": "Ancho por Defecto",
"Default width": "Ancho predeterminado",
"Full width": "Ancho total",
"Bulleted list": "Lista con viñetas",
"Todo list": "Lista de tareas",
@@ -430,7 +431,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": "Crea un nuevo documento hijo",
"Create a new child doc": "Crear un nuevo documento hijo",
"Delete table": "Eliminar tabla",
"Delete file": "Eliminar el archivo",
"Download file": "Descargar el archivo",
@@ -471,11 +472,11 @@
"Strikethrough": "Tachado",
"Bold": "Negrita",
"Subheading": "Sub-encabezado",
"Sort ascending": "Orden ascendente",
"Sort descending": "Orden descendiente",
"Sort ascending": "Ordenar ascendentemente",
"Sort descending": "Ordenar descendientemente",
"Table": "Tabla",
"Export as CSV": "Export as CSV",
"Toggle header": "Conmutar cabecera",
"Export as CSV": "Exportar como CSV",
"Toggle header": "Mostrar/Ocultar cabecera",
"Math inline (LaTeX)": "Fórmula matemática en línea (LaTeX)",
"Math block (LaTeX)": "Bloque de matemáticas (LaTeX)",
"Tip": "Sugerencia",
@@ -512,11 +513,11 @@
"Export collection": "Exportar colección",
"Rename": "Renombrar",
"Sort in sidebar": "Ordenar en barra lateral",
"A-Z sort": "A-Z sort",
"Z-A sort": "Z-A sort",
"A-Z sort": "Ordenado A-Z",
"Z-A sort": "Ordenado Z-A",
"Manual sort": "Orden manual",
"Comment options": "Opciones de los comentarios",
"Show document menu": "Mostrar menú de documento",
"Show document menu": "Mostrar menú del documento",
"{{ documentName }} restored": "{{ documentName }} restaurado",
"Document options": "Opciones del documento",
"Choose a collection": "Elige una colección",
@@ -559,14 +560,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": "Caducidad",
"Never expires": "Nunca caduca",
"Expiration": "Expiración",
"Never expires": "Nunca expira",
"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 caducidad",
"No expiration": "Sin expiración",
"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",
@@ -596,11 +597,13 @@
"Upload image": "Subir una imagen",
"No resolved comments": "No hay comentarios resueltos",
"No comments yet": "Aún no hay comentarios",
"New comments": "New comments",
"Sort comments": "Sort comments",
"Most recent": "Most recent",
"New comments": "Nuevos comentarios",
"Sort comments": "Ordenar comentarios",
"Most recent": "Más reciente",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Resolved": "Resuelto",
"Show {{ count }} reply": "Mostrar {{ count }} respuesta",
"Show {{ count }} reply_plural": "Mostrar {{ count }} respuestas",
"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?",
@@ -719,7 +722,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 área de trabajo. Pueden iniciar sesión con {{signinMethods}} o usar 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 espacio de trabajo. Pueden iniciar sesión con {{signinMethods}} o utilizar 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",
@@ -759,7 +762,7 @@
"Move list item down": "Mover elemento de la lista abajo",
"Tables": "Tablas",
"Insert row": "Insertar fila",
"Next cell": "Siguiente celda",
"Next cell": "Celda siguiente",
"Previous cell": "Celda anterior",
"Space": "Espacio",
"Numbered list": "Lista numerada",
@@ -966,7 +969,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": "Smart text replacements",
"Smart text replacements": "Sustituciones inteligentes de texto",
"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",
@@ -1032,11 +1035,11 @@
"This month": "Este mes",
"Last month": "El mes pasado",
"This year": "Este año",
"Expired yesterday": "Caducó ayer",
"Expired {{ date }}": "Caducó {{ date }}",
"Expires today": "Caduca hoy",
"Expires tomorrow": "Caduca mañana",
"Expires {{ date }}": "Caduca el {{ date }}",
"Expired yesterday": "Expiró ayer",
"Expired {{ date }}": "Expiró el {{ date }}",
"Expires today": "Expira hoy",
"Expires tomorrow": "Expira mañana",
"Expires {{ date }}": "Expira 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.",
@@ -1044,15 +1047,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á actualmente 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á 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 análisis 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 analíticas 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 terminará en matomo.cloud/",
"Site ID": "Identificador 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, esta terminará en matomo.cloud/",
"Site ID": "ID 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",
@@ -1064,11 +1067,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",
"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?",
"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?",
"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 }} sobre \"{{ title }}\"",
"Comment by {{ author }} on \"{{ title }}\"": "Comentario de {{ author }} en \"{{ 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",
@@ -1077,10 +1080,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 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 }}",
"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 }}",
"Script name": "Nombre del script",
"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",
"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.",
"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",
+10 -7
View File
@@ -1,11 +1,11 @@
{
"New API key": "New API key",
"New API key": "کلید API جدید",
"Open collection": "باز کردن مجموعه",
"New collection": "مجموعه جدید",
"Create a collection": "ایجاد مجموعه",
"Edit": "ویرایش",
"Edit collection": "ویرایش مجموعه",
"Permissions": "دسترسیها",
"Permissions": "دسترسی ها",
"Collection permissions": "دسترسی‌های مجموعه",
"Share this collection": "Share this collection",
"Search in collection": "جستجو در مجموعه",
@@ -22,7 +22,7 @@
"Delete collection": "حذف مجموعه",
"New template": "قالب جدید",
"Delete comment": "Delete comment",
"Mark as resolved": "Mark as resolved",
"Mark as resolved": "",
"Thread resolved": "Thread resolved",
"Mark as unresolved": "Mark as unresolved",
"View reactions": "View reactions",
@@ -81,14 +81,14 @@
"Move": "انتقال",
"Move to collection": "Move to collection",
"Move {{ documentType }}": "انتقال {{ documentType }}",
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
"Are you sure you want to archive this document?": "آیا اطمینان دارید که میخواهید این سند را بایگانی کنید ؟",
"Document archived": "سند آرشیو شد",
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
"Archiving this document will remove it from the collection and search results.": "بایگانی کردن این سند به حذف آن نتایج جستجو و مجموعه های خواهد انجامید.",
"Delete {{ documentName }}": "حذف {{ documentName }}",
"Permanently delete": "حذف برای همیشه",
"Permanently delete {{ documentName }}": "حذف همیشگی {{ documentName }}",
"Empty trash": "خالی کردن زباله دان",
"Permanently delete documents in trash": "Permanently delete documents in trash",
"Permanently delete documents in trash": "",
"Comments": "نظرات",
"History": "تاریخچه",
"Insights": "بینش ها",
@@ -128,7 +128,7 @@
"Select a workspace": "انتخاب فضای کاری",
"New workspace": "فضای کار جدید",
"Create a workspace": "فضای کاری ایجاد کنید",
"Login to workspace": "Login to workspace",
"Login to workspace": "وارد شدن به workspace",
"Invite people": "دعوت از افراد",
"Invite to workspace": "Invite to workspace",
"Promote to {{ role }}": "Promote to {{ role }}",
@@ -308,6 +308,7 @@
"{{ 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",
@@ -601,6 +602,8 @@
"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": "سند بازیابی شد",
"Images are still uploading.\nAre you sure you want to discard them?": "تصاویر هنوز در حال بارگذاری هستند.\nآیا مطمئن هستید که می خواهید آنها را نادیده بگیرید؟",

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