Compare commits

..

27 Commits

Author SHA1 Message Date
Tom Moor ce65f27a47 wip: Pull work across from previous branch 2021-08-06 16:39:17 -07:00
Tom Moor a800fe4fd1 authorization 2021-08-06 13:15:48 -07:00
Tom Moor 13d5bc281b Merge branch 'main' into feat/multiplayer 2021-08-06 11:35:55 -07:00
Saumya Pandey 09b73401de fix: Sidebar links highlighting issue when a template is deleted or archived. (#2420) 2021-08-06 23:01:25 +05:30
Saumya Pandey 42b384688d fix: Options to create a document is available when the policies of collection in the context doesn't permits the user (#2424) 2021-08-06 22:58:26 +05:30
Tom Moor 5bdee1204e fix: Copying header results in '#' copied
fix: urls in text become linked when reloading doc
fix: Allow creation of links to anchors from link toolbar
2021-08-06 09:39:03 -07:00
Tom Moor 9db72217af feat: Include more events in document history sidebar (#2334)
closes #2230
2021-08-05 15:03:55 -07:00
Tom Moor 57a2524fbd fix: /public directory missing in new docker releases (#2417)
closes #2416
2021-08-04 09:21:25 -07:00
Tom Moor bd148f4790 fix: Paste handler should default to HTML when paste source is Outline editor
related #2416
2021-08-04 09:20:51 -07:00
Tom Moor 28d32af613 perf: Remove unused database indexes according to a month of data in production (#2395) 2021-08-03 20:51:12 -07:00
Tom Moor f2f550e1d2 fix: Policies missing on documents.viewed endpoint 2021-08-03 20:02:11 -07:00
Translate-O-Tron dad21b2186 New Crowdin updates (#2400) 2021-08-03 19:32:51 -07:00
Tom Moor 5fb5f1e8b5 perf: Remove backup column migration (#2397)
* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column

* Remove migration, will do in 2-step release

* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column
2021-08-03 18:55:52 -07:00
Tom Moor 7bbbfa6bbf Merge main 2021-08-03 15:28:23 -07:00
Tom Moor 2d0690697c 0.58.0 2021-08-03 15:17:06 -07:00
Tom Moor 6b551749d4 chore: Remove version- prefix from docker tags 2021-08-03 14:23:14 -07:00
Jack Baron 52fc861bcf feat: Optimize Dockerfile (#2337)
* feat: optimize dockerfile
use new dockerfile syntaxes
leverage multi-stage builds
strip yarn cache from image
use stricter yarn install command
run as a non-root user

* fix: mark yarn-deduplicate as a required dep
`yarn --production` will fail on a clean install otherwise

* fix: add sequelize required files for migrations

* fix: use correct ARG syntax for multistage builds

* revert: mark yarn-deduplicate as a required dep
no longer required as of 0b3adad751
2021-08-03 13:22:41 -07:00
Tom Moor c81c9a9d2d chore: CI Automated Builds (#2409)
closes #2408
2021-08-02 23:35:13 -07:00
Tom Moor 29c742a673 fix: Settings on 'Security' tab not persisting correctly after refactor (#2407)
* fix: Settings on 'Security' tab not persisting correctly after refactor
closes #2406
2021-08-02 13:37:53 -07:00
Tom Moor dd249021e7 fix: GoogleDrive embeds stopped working with new share urls
closes #2405
2021-08-02 11:09:16 -07:00
Tom Moor 21d3b9c7e0 fix: Formatting of welcome docs :rolleyes: 2021-08-01 13:03:21 -07:00
Tom Moor 6665dfff28 Merge branch 'main' of github.com:outline/outline 2021-08-01 12:55:03 -07:00
Tom Moor cdfe3a7fc3 chore: Add new 'getting started' onboarding document (#2391)
Remove support document
Remove confusing images
Added onboarding checklist
2021-08-01 12:54:41 -07:00
Tom Moor 401c91f90b perf: Correctly parallelize count query in users.list 2021-07-30 12:20:19 -04:00
Tom Moor ed5320507d perf: Separate slow joins (#2394) 2021-07-30 08:50:02 -07:00
Tom Moor f1f8badc8f Merge branch 'main' into feat/multiplayer 2021-07-29 14:49:12 -04:00
Tom Moor a6b43abed1 wip 2021-07-20 14:50:59 -04:00
85 changed files with 1870 additions and 805 deletions
+94 -2
View File
@@ -1,4 +1,12 @@
version: 2
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
working_directory: ~/outline
@@ -40,4 +48,88 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
+4
View File
@@ -11,6 +11,10 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+40 -13
View File
@@ -1,23 +1,50 @@
FROM node:14-alpine
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
COPY package.json ./
COPY yarn.lock ./
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
# ---
FROM node:14-alpine AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
CMD yarn start
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
EXPOSE 3000
CMD ["yarn", "start"]
+5 -1
View File
@@ -66,7 +66,11 @@ const RealButton = styled.button`
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
+124
View File
@@ -0,0 +1,124 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
@@ -1,199 +0,0 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -1,87 +0,0 @@
// @flow
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Revision from "models/Revision";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);
-3
View File
@@ -1,3 +0,0 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
+3 -1
View File
@@ -66,6 +66,7 @@ function DocumentListItem(props: Props, ref) {
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<DocumentLink
@@ -126,7 +127,8 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument && (
can.createDocument &&
canCollection.update && (
<>
<Button
as={Link}
+44
View File
@@ -246,6 +246,50 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
+163
View File
@@ -0,0 +1,163 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: 50%;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;
+52 -23
View File
@@ -1,41 +1,62 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {
type Props = {|
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
};
|};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Content
align={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && <Actions>{actions}</Actions>}
{actions && <Actions $selected={selected}>{actions}</Actions>}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
</Wrapper>
);
};
const Wrapper = styled.li`
const Wrapper = styled.div`
display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
@@ -57,28 +78,36 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
const Content = styled(Flex)`
const Content = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
margin-right: 4px;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default ListItem;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
+25 -10
View File
@@ -1,5 +1,5 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import { format as formatDate, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
@@ -57,6 +57,9 @@ type Props = {
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
relative?: boolean,
format?: string,
tooltip?: boolean,
};
function LocaleTime({
@@ -64,7 +67,10 @@ function LocaleTime({
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
tooltip,
}: Props) {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
@@ -82,25 +88,34 @@ function LocaleTime({
};
}, []);
let content = formatDistanceToNow(Date.parse(dateTime), {
const locale = userLocale ? locales[userLocale] : undefined;
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
locale,
});
if (shorten) {
content = content
relativeContent = relativeContent
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
format || "MMMM do, yyyy h:mm a",
{ locale }
);
const content = children || relative ? relativeContent : tooltipContent;
if (!tooltip) {
return content;
}
return (
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{content}</time>
</Tooltip>
);
}
+26
View File
@@ -0,0 +1,26 @@
// @flow
import * as React from "react";
import { NavLink, Route, type Match } from "react-router-dom";
type Props = {
children?: (match: Match) => React.Node,
exact?: boolean,
to: string,
};
export default function NavLinkWithChildrenFunc({
to,
exact = false,
children,
...rest
}: Props) {
return (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink {...rest} to={to} exact={exact}>
{children ? children(match) : null}
</NavLink>
)}
</Route>
);
}
+1
View File
@@ -12,6 +12,7 @@ const Button = styled.button`
padding: 0;
cursor: pointer;
user-select: none;
color: inherit;
`;
export default React.forwardRef<any, typeof Button>(
+53
View File
@@ -0,0 +1,53 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import PaginatedList from "components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {|
events: Event[],
document: Document,
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
|};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
fetch,
options,
document,
...rest
}: Props) {
return (
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
/>
);
});
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
`;
export default PaginatedEventList;
+41 -4
View File
@@ -4,10 +4,12 @@ import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -15,7 +17,9 @@ type Props = {
heading?: React.Node,
empty?: React.Node,
items: any[],
renderItem: (any) => React.Node,
renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
};
@observer
@@ -101,8 +105,9 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, empty } = this.props;
const { items, heading, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
@@ -119,7 +124,37 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map(this.props.renderItem)}
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(currentDate, this.props.t);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
@@ -136,4 +171,6 @@ class PaginatedList extends React.Component<Props> {
}
}
export default PaginatedList;
export const Component = PaginatedList;
export default withTranslation()<PaginatedList>(PaginatedList);
+1 -1
View File
@@ -4,7 +4,7 @@ import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
+5 -1
View File
@@ -149,7 +149,11 @@ function MainSidebar() {
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: undefined
}
/>
<ArchiveLink documents={documents} />
+4 -17
View File
@@ -1,25 +1,13 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "components/NavLink";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
@@ -53,7 +41,8 @@ const transition = {
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
export default function Tab({ children, ...rest }: Props) {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
@@ -75,5 +64,3 @@ function Tab({ theme, children, ...rest }: Props) {
</TabLink>
);
}
export default withTheme(Tab);
+1
View File
@@ -11,6 +11,7 @@ type Props = {
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
format?: string,
shorten?: boolean,
};
+2 -2
View File
@@ -4,7 +4,7 @@ import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing(.*)"
);
type Props = {|
@@ -29,7 +29,7 @@ export default class GoogleDrive extends React.Component<Props> {
height={16}
/>
}
title="Google Drive Embed"
title="Google Drive"
canonicalUrl={this.props.attrs.href}
border
/>
+6
View File
@@ -3,6 +3,7 @@ import GoogleDrive from "./GoogleDrive";
describe("GoogleDrive", () => {
const match = GoogleDrive.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
@@ -14,6 +15,11 @@ describe("GoogleDrive", () => {
match
)
).toBeTruthy();
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing&resourceKey=BG8k4dEt1p2gisnVdlaSpA".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
+24 -16
View File
@@ -139,6 +139,27 @@ function DocumentMenu({
const collection = collections.get(document.collectionId);
const can = policies.abilities(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
...collections.orderedData.reduce((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
onClick: (ev) => handleRestore(ev, { collectionId: collection.id }),
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
});
}
return filtered;
}, []),
],
[collections.orderedData, handleRestore, policies]
);
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
@@ -230,7 +251,8 @@ function DocumentMenu({
},
{
title: t("Restore"),
visible: !collection && !!can.restore,
visible:
!collection && !!can.restore && restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
@@ -242,21 +264,7 @@ function DocumentMenu({
type: "heading",
title: t("Choose a collection"),
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return {
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
onClick: (ev) =>
handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update,
};
}),
...restoreItems,
],
},
{
+25 -21
View File
@@ -21,20 +21,36 @@ function NewDocumentMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const singleCollection = collections.orderedData.length === 1;
const can = policies.abilities(team.id);
if (!can.createDocument) {
const items = React.useMemo(
() =>
collections.orderedData.reduce((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id),
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
});
}
return filtered;
}, []),
[collections.orderedData, policies]
);
if (!can.createDocument || items.length === 0) {
return null;
}
if (singleCollection) {
if (items.length === 1) {
return (
<Button
as={Link}
to={newDocumentUrl(collections.orderedData[0].id)}
icon={<PlusIcon />}
>
<Button as={Link} to={items[0].to} icon={<PlusIcon />}>
{t("New doc")}
</Button>
);
@@ -51,19 +67,7 @@ function NewDocumentMenu() {
</MenuButton>
<ContextMenu {...menu} aria-label={t("New document")}>
<Header>{t("Choose a collection")}</Header>
<Template
{...menu}
items={collections.orderedData.map((collection) => ({
to: newDocumentUrl(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
}))}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+22 -16
View File
@@ -22,7 +22,27 @@ function NewTemplateMenu() {
const { collections, policies } = useStores();
const can = policies.abilities(team.id);
if (!can.createDocument) {
const items = React.useMemo(
() =>
collections.orderedData.reduce((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id, { template: true }),
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
});
}
return filtered;
}, []),
[collections.orderedData, policies]
);
if (!can.createDocument || items.length === 0) {
return null;
}
@@ -37,21 +57,7 @@ function NewTemplateMenu() {
</MenuButton>
<ContextMenu aria-label={t("New template")} {...menu}>
<Header>{t("Choose a collection")}</Header>
<Template
{...menu}
items={collections.orderedData.map((collection) => ({
to: newDocumentUrl(collection.id, {
template: true,
}),
disabled: !policies.abilities(collection.id).update,
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
}))}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+6 -8
View File
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "models/Document";
import Revision from "models/Revision";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
@@ -16,12 +15,11 @@ import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
revision: Revision,
iconColor?: string,
revisionId: string,
className?: string,
|};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
@@ -30,11 +28,11 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
await document.restore({ revisionId });
showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, showToast, t, document, revision]
[history, showToast, t, document, revisionId]
);
const handleCopy = React.useCallback(() => {
@@ -43,14 +41,14 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
const url = `${window.location.origin}${documentHistoryUrl(
document,
revision.id
revisionId
)}`;
return (
<>
<OverflowMenuButton
className={className}
iconColor={iconColor}
iconColor="currentColor"
aria-label={t("Show menu")}
{...menu}
/>
+21
View File
@@ -261,6 +261,27 @@ export default class Document extends BaseModel {
this.injectTemplate = true;
};
@action
update = async (options: SaveOptions & { title: string }) => {
if (this.isSaving) return this;
this.isSaving = true;
try {
if (options.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
lastRevision: options.lastRevision,
...options,
});
}
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
};
@action
save = async (options: SaveOptions = {}) => {
if (this.isSaving) return this;
-14
View File
@@ -20,20 +20,6 @@ class Event extends BaseModel {
published: boolean,
templateId: string,
};
get model() {
return this.name.split(".")[0];
}
get verb() {
return this.name.split(".")[1];
}
get verbPastTense() {
const v = this.verb;
if (v.endsWith("e")) return `${v}d`;
return `${v}ed`;
}
}
export default Event;
+1
View File
@@ -7,6 +7,7 @@ class Team extends BaseModel {
name: string;
avatarUrl: string;
sharing: boolean;
multiplayerEditor: boolean;
documentEmbeds: boolean;
guestSignin: boolean;
subdomain: ?string;
+55
View File
@@ -0,0 +1,55 @@
// @flow
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import * as Y from "yjs";
export default class MultiplayerExtension extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("prosemirror", Y.XmlFragment);
const assignUser = (tr) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
provider.awareness.setLocalStateField("user", {
color: user.color,
name: user.name,
id: user.id,
});
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}
+5 -7
View File
@@ -22,10 +22,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const Document = React.lazy(() =>
import(/* webpackChunkName: "document" */ "scenes/Document")
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
@@ -66,10 +64,10 @@ export default function AuthenticatedRoutes() {
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
+4 -6
View File
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -37,11 +35,11 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
-25
View File
@@ -1,25 +0,0 @@
// @flow
import { inject } from "mobx-react";
import * as React from "react";
import DataLoader from "./components/DataLoader";
class KeyedDocument extends React.Component<*> {
componentWillUnmount() {
this.props.ui.clearActiveDocument();
}
render() {
const { documentSlug, revisionId } = this.props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
}
}
export default inject("ui")(KeyedDocument);
+61
View File
@@ -0,0 +1,61 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import { useTheme } from "styled-components";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import useStores from "../../hooks/useStores";
import Document from "./components/Document";
import Loading from "./components/Loading";
import { type LocationWithState } from "types";
import { OfflineError } from "utils/errors";
type Props = {|
match: Match,
location: LocationWithState,
|};
export default function SharedDocumentScene(props: Props) {
const theme = useTheme();
const [response, setResponse] = React.useState();
const [error, setError] = React.useState<?Error>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
// ensure the wider page color always matches the theme
React.useEffect(() => {
window.document.body.style.background = theme.background;
}, [theme]);
React.useEffect(() => {
async function fetchData() {
try {
const response = await documents.fetch(documentSlug, {
shareId,
});
setResponse(response);
} catch (err) {
setError(err);
}
}
fetchData();
}, [documents, documentSlug, shareId]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
return <Loading location={props.location} />;
}
return (
<Document
document={response.document}
sharedTree={response.sharedTree}
location={props.location}
shareId={shareId}
readOnly
/>
);
}
+23 -16
View File
@@ -18,16 +18,15 @@ import Document from "models/Document";
import Revision from "models/Revision";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
auth: AuthStore,
location: LocationWithState,
shares: SharesStore,
documents: DocumentsStore,
@@ -36,6 +35,7 @@ type Props = {|
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
const sharedTreeCache = {};
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
};
render() {
const { location, policies, ui } = this.props;
const { location, policies, auth, ui } = this.props;
if (this.error) {
return this.error instanceof OfflineError ? (
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document) {
if (!document || !team) {
return (
<>
<Loading location={location} />
@@ -247,20 +248,26 @@ class DataLoader extends React.Component<Props> {
const abilities = policies.abilities(document.id);
const key = team.multiplayerEditor
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
<React.Fragment key={key}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
{this.props.children({
document,
revision,
abilities,
isEditing: this.isEditing,
readOnly: !this.isEditing || !abilities.update || document.isArchived,
onSearchLink: this.onSearchLink,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
);
}
}
+29 -7
View File
@@ -197,7 +197,7 @@ class DocumentScene extends React.Component<Props> {
autosave?: boolean,
} = {}
) => {
const { document } = this.props;
const { document, auth } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -227,10 +227,22 @@ class DocumentScene extends React.Component<Props> {
document.tasks = getTasks(document.text);
try {
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
let savedDocument = document;
if (auth.team && auth.team.multiplayerEditor) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
// this can be cleaned up to single code path
savedDocument = await document.update({
...options,
lastRevision: this.lastRevision,
});
} else {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -275,6 +287,11 @@ class DocumentScene extends React.Component<Props> {
};
onChange = (getEditorText) => {
const { auth } = this.props;
if (auth.team?.multiplayerEditor) {
return;
}
this.getEditorText = getEditorText;
// document change while read only is presumed to be a checkbox edit,
@@ -332,7 +349,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
<Route
path={`${match.url}/move`}
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
@@ -356,7 +373,11 @@ class DocumentScene extends React.Component<Props> {
{!readOnly && (
<>
<Prompt
when={this.isDirty && !this.isUploading}
when={
this.isDirty &&
!this.isUploading &&
!team?.multiplayerEditor
}
message={t(
`You have unsaved changes.\nAre you sure you want to discard them?`
)}
@@ -444,6 +465,7 @@ class DocumentScene extends React.Component<Props> {
<Editor
id={document.id}
innerRef={this.editor}
multiplayer={team?.multiplayerEditor}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
+5 -1
View File
@@ -17,6 +17,7 @@ import Editor, { type Props as EditorProps } from "components/Editor";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import Star, { AnimatedStar } from "components/Star";
import MultiplayerEditor from "./MultiplayerEditor";
import { isModKey } from "utils/keyboard";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -27,6 +28,7 @@ type Props = {|
document: Document,
isDraft: boolean,
shareId: ?string,
multiplayer?: boolean,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
@@ -107,10 +109,12 @@ class DocumentEditor extends React.Component<Props> {
innerRef,
children,
policies,
multiplayer,
t,
...rest
} = this.props;
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
const can = policies.abilities(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
@@ -162,7 +166,7 @@ class DocumentEditor extends React.Component<Props> {
}
/>
)}
<Editor
<EditorComponent
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
placeholder={t("…the rest is up to you")}
@@ -0,0 +1,65 @@
// @flow
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as React from "react";
import * as Y from "yjs";
import Editor from "components/Editor";
import env from "env";
import useCurrentUser from "hooks/useCurrentUser";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
// TODO: typing
export default function MultiplayerEditor(props: any) {
const user = useCurrentUser();
// TODO
//const [showCachedDocument, setShowCachedDocument] = React.useState(true);
// React.useEffect(() => {
// if (isRemoteSynced) {
// setTimeout(() => setShowCachedDocument(false), 100);
// }
// }, [showCachedDocument, isRemoteSynced]);
const extensions = React.useMemo(() => {
console.log("extensions");
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: env.MULTIPLAYER_URL,
// TODO: pipe documentId
name: "example-document",
document: ydoc,
});
return [
new MultiplayerExtension({
user,
provider,
document: ydoc,
}),
];
}, [user]);
return (
<span style={{ position: "relative" }}>
{true && (
<Editor
{...props}
key="multiplayer"
defaultValue={undefined}
value={undefined}
extensions={extensions}
style={{ position: "absolute", width: "100%" }}
/>
)}
{/* {showCachedDocument && (
<Editor
{...props}
style={{ position: "absolute", width: "100%" }}
readOnly
/>
)} */}
</span>
);
}
+59 -1
View File
@@ -1,3 +1,61 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import DataLoader from "./components/DataLoader";
export default DataLoader;
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import { type LocationWithState } from "types";
type Props = {|
location: LocationWithState,
match: Match,
|};
export default function DocumentScene(props: Props) {
const { ui } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
React.useEffect(() => {
return () => ui.clearActiveDocument();
}, [ui]);
const { documentSlug, revisionId } = props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
const key = [urlId, revisionId].join("/");
const isMultiplayer = team.multiplayerEditor;
return (
<DataLoader key={key} match={props.match}>
{({ document, isEditing, ...rest }) => {
const isActive =
!document.isArchived && !document.isDeleted && !revisionId;
// TODO: Remove once multiplayer is 100% rollout, SocketPresence will
// no longer be required
if (isActive && !isMultiplayer) {
return (
<SocketPresence
documentId={document.id}
userId={user.id}
isEditing={isEditing}
>
<Document document={document} match={props.match} {...rest} />
</SocketPresence>
);
}
return <Document document={document} match={props.match} {...rest} />;
}}
</DataLoader>
);
}
+4 -1
View File
@@ -268,6 +268,7 @@ class Search extends React.Component<Props> {
const showShortcutTip =
!this.pinToTop && location.state && location.state.fromMenu;
const can = policies.abilities(auth.team?.id ? auth.team.id : "");
const canCollection = policies.abilities(this.collectionId || "");
return (
<Container auto>
@@ -335,7 +336,9 @@ class Search extends React.Component<Props> {
{can.createDocument && <Trans>Create a new document?</Trans>}
</HelpText>
<Wrapper>
{this.collectionId && can.createDocument ? (
{this.collectionId &&
can.createDocument &&
canCollection.update ? (
<Button
onClick={this.handleNewDoc}
icon={<PlusIcon />}
+18 -27
View File
@@ -18,38 +18,29 @@ function Security() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { showToast } = useToasts();
const [sharing, setSharing] = useState(team.documentEmbeds);
const [documentEmbeds, setDocumentEmbeds] = useState(team.guestSignin);
const [guestSignin, setGuestSignin] = useState(team.sharing);
const [data, setData] = useState({
sharing: team.sharing,
documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
});
const showSuccessMessage = debounce(() => {
showToast(t("Settings saved"), { type: "success" });
}, 500);
const showSuccessMessage = React.useCallback(
debounce(() => {
showToast(t("Settings saved"), { type: "success" });
}, 250),
[t, showToast]
);
const handleChange = React.useCallback(
async (ev: SyntheticInputEvent<*>) => {
switch (ev.target.name) {
case "sharing":
setSharing(ev.target.checked);
break;
case "documentEmbeds":
setDocumentEmbeds(ev.target.checked);
break;
case "guestSignin":
setGuestSignin(ev.target.checked);
break;
default:
}
const newData = { ...data, [ev.target.name]: ev.target.checked };
setData(newData);
await auth.updateTeam({
sharing,
documentEmbeds,
guestSignin,
});
await auth.updateTeam(newData);
showSuccessMessage();
},
[auth, sharing, documentEmbeds, guestSignin, showSuccessMessage]
[auth, data, showSuccessMessage]
);
return (
@@ -67,14 +58,14 @@ function Security() {
<Checkbox
label={t("Allow email authentication")}
name="guestSignin"
checked={guestSignin}
checked={data.guestSignin}
onChange={handleChange}
note={t("When enabled, users can sign-in using their email address")}
/>
<Checkbox
label={t("Public document sharing")}
name="sharing"
checked={sharing}
checked={data.sharing}
onChange={handleChange}
note={t(
"When enabled, documents can be shared publicly on the internet by any team member"
@@ -83,7 +74,7 @@ function Security() {
<Checkbox
label={t("Rich service embeds")}
name="documentEmbeds"
checked={documentEmbeds}
checked={data.documentEmbeds}
onChange={handleChange}
note={t(
"Links to supported services are shown as rich embeds within your documents"
+23
View File
@@ -0,0 +1,23 @@
// @flow
import { sortBy, filter } from "lodash";
import { computed } from "mobx";
import Event from "models/Event";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
export default class EventsStore extends BaseStore<Event> {
actions = ["list"];
constructor(rootStore: RootStore) {
super(rootStore, Event);
}
@computed
get orderedData(): Event[] {
return sortBy(Array.from(this.data.values()), "createdAt").reverse();
}
inDocument(documentId: string): Event[] {
return filter(this.orderedData, (event) => event.documentId === documentId);
}
}
+4
View File
@@ -5,6 +5,7 @@ import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
import CollectionsStore from "./CollectionsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
import EventsStore from "./EventsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore";
@@ -24,6 +25,7 @@ export default class RootStore {
collections: CollectionsStore;
collectionGroupMemberships: CollectionGroupMembershipsStore;
documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore;
groupMemberships: GroupMembershipsStore;
integrations: IntegrationsStore;
@@ -46,6 +48,7 @@ export default class RootStore {
this.collections = new CollectionsStore(this);
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
this.groups = new GroupsStore(this);
this.groupMemberships = new GroupMembershipsStore(this);
this.integrations = new IntegrationsStore(this);
@@ -66,6 +69,7 @@ export default class RootStore {
this.collections.clear();
this.collectionGroupMemberships.clear();
this.documents.clear();
this.events.clear();
this.groups.clear();
this.groupMemberships.clear();
this.integrations.clear();
+3
View File
@@ -3,6 +3,9 @@
import localStorage from '../../__mocks__/localStorage';
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { initI18n } from "shared/i18n";
initI18n();
Enzyme.configure({ adapter: new Adapter() });
+51
View File
@@ -0,0 +1,51 @@
// @flow
import {
isToday,
isYesterday,
differenceInCalendarWeeks,
differenceInCalendarMonths,
} from "date-fns";
import * as React from "react";
import { type TFunction } from "react-i18next";
import LocaleTime from "components/LocaleTime";
export function dateToHeading(dateTime: string, t: TFunction) {
const date = Date.parse(dateTime);
const now = new Date();
if (isToday(date)) {
return t("Today");
}
if (isYesterday(date)) {
return t("Yesterday");
}
// If the current calendar week but not today or yesterday then return the day
// of the week as a string. We use the LocaleTime component here to gain
// async bundle loading of languages
const weekDiff = differenceInCalendarWeeks(now, date);
if (weekDiff === 0) {
return <LocaleTime dateTime={dateTime} tooltip={false} format="iiii" />;
}
if (weekDiff === 1) {
return t("Last week");
}
const monthDiff = differenceInCalendarMonths(now, date);
if (monthDiff === 0) {
return t("This month");
}
if (monthDiff === 1) {
return t("Last month");
}
if (monthDiff <= 12) {
return t("This year");
}
// If older than the current calendar year then just print the year e.g 2020
return <LocaleTime dateTime={dateTime} tooltip={false} format="y" />;
}
+1
View File
@@ -2,6 +2,7 @@
declare var process: {
exit: (code?: number) => void,
cwd: () => string,
argv: Array<string>,
env: {
[string]: string,
},
+11 -3
View File
@@ -7,10 +7,13 @@
"clean": "rimraf build",
"build:i18n": "i18next 'app/**/*.js' 'server/**/*.js' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n",
"build:server": "babel -d ./build/server ./server && babel -d ./build/shared ./shared && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build",
"build:multiplayer": "babel -d ./build/multiplayer ./multiplayer",
"build:webpack": "webpack --config webpack.config.prod.js",
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
"start": "node ./build/server/index.js",
"start:multiplayer": "node ./build/multiplayer/index.js",
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/ --ignore flow-typed/",
"dev:multiplayer": "nodemon --exec \"yarn build:server && node --inspect=0.0.0.0 build/server/index.js --multiplayer\" -e js --ignore build/ --ignore app/ --ignore flow-typed/",
"lint": "eslint app server shared",
"deploy": "git push heroku master",
"prepare": "yarn yarn-deduplicate yarn.lock",
@@ -45,6 +48,9 @@
"@babel/preset-env": "^7.11.0",
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@hocuspocus/extension-logger": "^1.0.0-alpha.35",
"@hocuspocus/provider": "^1.0.0-alpha.8",
"@hocuspocus/server": "^1.0.0-alpha.60",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@sentry/node": "^6.3.1",
@@ -143,7 +149,7 @@
"react-window": "^1.8.6",
"reakit": "^1.3.8",
"regenerator-runtime": "^0.13.7",
"rich-markdown-editor": "^11.17.0",
"rich-markdown-editor": "^11.17.2",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@@ -165,7 +171,9 @@
"turndown": "^7.1.1",
"utf8": "^3.0.0",
"uuid": "^8.3.2",
"validator": "5.2.0"
"validator": "5.2.0",
"y-prosemirror": "^1.0.9",
"yjs": "^13.5.12"
},
"devDependencies": {
"@babel/cli": "^7.10.5",
@@ -211,5 +219,5 @@
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.57.0"
"version": "0.58.0"
}
+10 -2
View File
@@ -310,9 +310,10 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
const userId = user.id;
const views = await View.findAll({
where: { userId: user.id },
where: { userId },
order: [[sort, direction]],
include: [
{
@@ -325,9 +326,16 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
{
model: Star,
as: "starred",
where: { userId: user.id },
where: { userId },
separate: true,
required: false,
},
{
model: Collection.scope({
method: ["withMembership", userId],
}),
as: "collection",
},
],
},
],
+1
View File
@@ -1349,6 +1349,7 @@ describe("#documents.viewed", () => {
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id);
expect(body.policies[0].abilities.update).toEqual(true);
});
it("should not return recently viewed but deleted documents", async () => {
+4
View File
@@ -17,6 +17,7 @@ router.post("team.update", auth(), async (ctx) => {
sharing,
guestSignin,
documentEmbeds,
multiplayerEditor,
} = ctx.body;
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
@@ -31,6 +32,9 @@ router.post("team.update", auth(), async (ctx) => {
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
if (multiplayerEditor !== undefined) {
team.multiplayerEditor = multiplayerEditor;
}
const changes = team.changed();
const data = {};
+2 -2
View File
@@ -71,13 +71,13 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
}
const [users, total] = await Promise.all([
await User.findAll({
User.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
await User.count({
User.count({
where,
}),
]);
+67
View File
@@ -0,0 +1,67 @@
// @flow
import { uniq } from "lodash";
import { schema, serializer } from "rich-markdown-editor";
import { yDocToProsemirror } from "y-prosemirror";
import * as Y from "yjs";
import { Document, Event } from "../models";
export default async function documentUpdater({
documentId,
ydoc,
userId,
done,
}: {
documentId: string,
ydoc: Y.Doc,
userId: string,
done?: boolean,
}) {
const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc);
const node = yDocToProsemirror(schema, ydoc);
const text = serializer.serialize(node);
// extract collaborators from doc user data
const pud = new Y.PermanentUserData(ydoc);
const pudIds = Array.from(pud.clients.values());
const existingIds = document.collaboratorIds;
const collaboratorIds = uniq([...pudIds, ...existingIds]);
if (document.text === text) {
return;
}
await Document.update(
{
text,
state: Buffer.from(state),
updatedAt: new Date(),
lastModifiedById: userId,
collaboratorIds,
},
{
hooks: false,
where: {
id: document.id,
},
}
);
const event = {
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: userId,
data: {
multiplayer: true,
title: document.title,
},
};
if (done) {
await Event.create(event);
} else {
await Event.add(event);
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
// @flow
// Note: This entire object is stringified in the HTML exposed to the client
// do not add anything here that should be a secret or password
// do not add anything here that would be considered a secret or password
export default {
URL: process.env.URL,
CDN_URL: process.env.CDN_URL || "",
MULTIPLAYER_URL: process.env.MULTIPLAYER_URL || "",
DEPLOYMENT: process.env.DEPLOYMENT,
ENVIRONMENT: process.env.NODE_ENV,
SENTRY_DSN: process.env.SENTRY_DSN,
+36 -32
View File
@@ -1,7 +1,6 @@
// @flow
import * as Sentry from "@sentry/node";
import debug from "debug";
import services from "./services";
import { createQueue } from "./utils/queue";
const log = debug("services");
@@ -195,39 +194,44 @@ export type Event =
const globalEventsQueue = createQueue("global events");
const serviceEventsQueue = createQueue("service events");
// this queue processes global events and hands them off to service hooks
globalEventsQueue.process(async (job) => {
const names = Object.keys(services);
names.forEach((name) => {
const service = services[name];
if (service.on) {
serviceEventsQueue.add(
{ ...job.data, service: name },
{ removeOnComplete: true }
);
}
});
});
// TODO: This is a hack to prevent a require loop from models -> Event -> services -> main
if (!process.argv.includes("--multiplayer")) {
const services = require("./services");
// this queue processes an individual event for a specific service
serviceEventsQueue.process(async (job) => {
const event = job.data;
const service = services[event.service];
if (service.on) {
log(`${event.service} processing ${event.name}`);
service.on(event).catch((error) => {
if (process.env.SENTRY_DSN) {
Sentry.withScope(function (scope) {
scope.setExtra("event", event);
Sentry.captureException(error);
});
} else {
throw error;
// this queue processes global events and hands them off to service hooks
globalEventsQueue.process(async (job) => {
const names = Object.keys(services);
names.forEach((name) => {
const service = services[name];
if (service.on) {
serviceEventsQueue.add(
{ ...job.data, service: name },
{ removeOnComplete: true }
);
}
});
}
});
});
// this queue processes an individual event for a specific service
serviceEventsQueue.process(async (job) => {
const event = job.data;
const service = services[event.service];
if (service.on) {
log(`${event.service} processing ${event.name}`);
service.on(event).catch((error) => {
if (process.env.SENTRY_DSN) {
Sentry.withScope(function (scope) {
scope.setExtra("event", event);
Sentry.captureException(error);
});
} else {
throw error;
}
});
}
});
}
export default globalEventsQueue;
+17 -6
View File
@@ -111,11 +111,22 @@ Is your team enjoying Outline? Consider supporting future development by sponsor
);
}
const { start } = require("./main");
const isMultiplayer = process.argv.includes("--multiplayer");
throng({
worker: start,
if (isMultiplayer) {
Error.stackTraceLimit = Infinity;
// The number of workers to run, defaults to the number of CPUs available
count: process.env.WEB_CONCURRENCY || undefined,
});
const { start } = require("./multiplayer");
// TODO: Not using throng until multiplayer server has multi-process support
start();
} else {
const { start } = require("./main");
throng({
worker: start,
// The number of workers to run, defaults to the number of CPUs available
count: process.env.WEB_CONCURRENCY || undefined,
});
}
@@ -0,0 +1,18 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'state', {
type: Sequelize.BLOB
});
await queryInterface.addColumn('teams', 'multiplayerEditor', {
type: Sequelize.BOOLEAN,
defaultValue: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'state');
await queryInterface.removeColumn('teams', 'multiplayerEditor');
}
};
@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex("documents", "documents_collaborator_ids");
await queryInterface.removeIndex("documents", "documents_id_deleted_at");
await queryInterface.removeIndex("users", "users_slack_id");
await queryInterface.removeIndex("teams", "teams_slack_id");
await queryInterface.removeIndex("teams", "teams_google_id");
await queryInterface.removeIndex("collection_users", "collection_users_permission");
},
down: async (queryInterface, Sequelize) => {
await queryInterface.addIndex("documents", ["collaboratorIds"]);
await queryInterface.addIndex("documents", ["id", "deletedAt"]);
await queryInterface.addIndex("users", ["slackId"]);
await queryInterface.addIndex("teams", ["slackId"]);
await queryInterface.addIndex("teams", ["googleId"]);
await queryInterface.addIndex("collection_users", ["permission"]);
}
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'backup');
await queryInterface.removeColumn('revisions', 'backup');
},
down: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'backup', {
type: Sequelize.TEXT,
allowNull: true,
});
await queryInterface.addColumn('revisions', 'backup', {
type: Sequelize.TEXT,
allowNull: true,
});
}
};
+15 -2
View File
@@ -81,6 +81,7 @@ const Document = sequelize.define(
template: DataTypes.BOOLEAN,
editorVersion: DataTypes.STRING,
text: DataTypes.TEXT,
state: DataTypes.BLOB,
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
archivedAt: DataTypes.DATE,
@@ -192,13 +193,25 @@ Document.associate = (models) => {
return {
include: [
{ model: models.View, as: "views", where: { userId }, required: false },
{
model: models.View,
as: "views",
where: { userId },
required: false,
separate: true,
},
],
};
});
Document.addScope("withStarred", (userId) => ({
include: [
{ model: models.Star, as: "starred", where: { userId }, required: false },
{
model: models.Star,
as: "starred",
where: { userId },
required: false,
separate: true,
},
],
}));
};
+7 -2
View File
@@ -69,6 +69,11 @@ const Team = sequelize.define(
allowNull: false,
defaultValue: true,
},
multiplayerEditor: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
paranoid: true,
@@ -170,9 +175,9 @@ Team.prototype.provisionFirstCollection = async function (userId) {
// For the first collection we go ahead and create some intitial documents to get
// the team started. You can edit these in /server/onboarding/x.md
const onboardingDocs = [
"Support",
"Integrations & API",
"Our Editor",
"Getting Started",
"What is Outline",
];
@@ -182,7 +187,7 @@ Team.prototype.provisionFirstCollection = async function (userId) {
"utf8"
);
const document = await Document.create({
version: 1,
version: 2,
isWelcome: true,
parentDocumentId: null,
collectionId: collection.id,
+6
View File
@@ -7,6 +7,7 @@ import { languages } from "../../shared/i18n";
import { ValidationError } from "../errors";
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
import { DEFAULT_AVATAR_HOST } from "../utils/avatars";
import { palette } from "../utils/color";
import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3";
import {
UserAuthentication,
@@ -74,6 +75,11 @@ const User = sequelize.define(
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
},
color() {
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
const idAsNumber = parseInt(idAsHex, 16);
return palette[idAsNumber % palette.length];
},
},
}
);
+68
View File
@@ -0,0 +1,68 @@
// @flow
import { Logger } from "@hocuspocus/extension-logger";
import { Server } from "@hocuspocus/server";
import debug from "debug";
//import { RocksDB } from "@hocuspocus/extension-rocksdb";
import { AuthenticationError } from "../errors";
//import policy from "../policies";
import { Document } from "../models";
import { getUserForJWT } from "../utils/jwt";
const isProduction = process.env.NODE_ENV === "production";
const log = debug("multiplayer");
//const { can } = policy;
const can = () => true;
const server = Server.configure({
port: process.env.MULTIPLAYER_PORT || process.env.PORT || 80,
async onConnect(data) {
const { requestParameters, documentName } = data;
// allows for different entity types to use this multiplayer provider later
const [, documentId] = documentName.split(".");
const { token } = requestParameters;
if (!token) {
throw new AuthenticationError("Authentication required");
}
const user = await getUserForJWT(token);
if (user.isSuspended) {
throw new AuthenticationError("Account suspended");
}
const document = await Document.findByPk(documentId, { userid: user.id });
if (!can(user, "read", document)) {
throw new AuthenticationError("Authorization required");
}
// set document to read only for the current user, thus changes will not be
// accepted and synced to other clients
if (!can(user, "update", document)) {
data.connection.readOnly = true;
}
return {
user,
};
},
extensions: [
new Logger(),
// new RocksDB({
// path: "./database",
// options: {
// // see available options:
// // https://www.npmjs.com/package/leveldown#options
// createIfMissing: true,
// },
// }),
],
});
export async function start() {
console.log(`Started multiplayer server`);
server.listen();
}
+19
View File
@@ -0,0 +1,19 @@
Some ideas to get you and your team started with learning the basics of Outline, feel free to check them off as you go!
## Learn the basics
- [x] Create an Outline account
- [ ] **Create a collection** from the left sidebar
- [ ] **Create a new doc** from the top right of home or any collection
- [ ] Try drag and drop to nest and move documents
- [ ] Share a document
- [ ] Invite a co-worker 👋
## More to try
- [ ] Setup the [Slack integration](/settings/integrations/slack)
- [ ] **Create a template** to share a writing structure with your team
- [ ] Create a check list to track tasks
- [ ] Try embedding a supported [integration](https://www.getoutline.com/integrations)
+2
View File
@@ -2,8 +2,10 @@
Outline supports many of the most popular tools on the market without any additional settings or configuration. Just paste links to a YouTube video, Figma file, or Google Spreadsheet to get instant live-embeds in your documents. Take a look at the [integrations directory](https://www.getoutline.com/integrations) for a list of all of the tools that are supported.
\
Our integration code is also [open-source](https://github.com/outline/outline) and we encourage third party developers and the community to build support for additional tools!
\
:::info
Most integrations work by simply pasting a link from a supported service into a document.
:::
+9 -15
View File
@@ -1,24 +1,18 @@
The heart of Outline is the document editor. We let you write in whichever way you prefer be it Markdown, WYSIWYG, or taking advantage of the many keyboard shortcuts.
The heart of Outline is the document editor. We let you write in whichever way you prefer be it Markdown, WYSIWYG, or taking advantage of the many keyboard shortcuts (Type `?` to see them all).
## Markdown
If youre comfortable writing markdown then all of the shortcuts you are used to are supported, for example type \*\*bold\*\* to instantly create bold text. If you forget some syntax or are after a quick refresher hit the keyboard icon in the bottom right hand corner for our guide. Learning some of the key Markdown shortcuts will make using Outline faster and more enjoyable!
![The formatting toolbar](/images/screenshots/formatting-toolbar.png)
If youre comfortable writing markdown then all of the shortcuts you are used to are supported, for example type \*\*bold\*\* to instantly create **bold** text. If you forget some syntax or are after a quick refresher hit the keyboard icon in the bottom right hand corner for our guide. Learning some of the key Markdown shortcuts will definitely make using Outline faster and more enjoyable!
\
:::info
You can also paste markdown or html from elsewhere directly into a document.
You can also paste markdown, html, or rich text from elsewhere directly into a document.
:::
## Rich documents
The editor supports a variety of content blocks including images, tables, lists, quotes, videos, and more. Type "/" on an empty line or click on the "+" icon to trigger the block insert menu, you can keep typing to filter it down.
You can also drag and drop images to include them in your document or paste a link to embed content from one of the many supported [integrations](https://www.getoutline.com/integrations)
![The block menu](/images/screenshots/block-menu.png)
## References
Linking to another document automatically creates backlinks which are kept up-to-date and shown at the bottom of the document, so you can create a library of linked information and easily answer the question "which other documents link here?".
Linking to another document automatically creates backlinks which are kept up-to-date and shown at the bottom of the document, so you can create a library of linked information and easily answer the question "which other documents link here?".
## Rich documents
The editor supports a variety of content blocks including images, tables, lists, quotes, task lists, videos, and more. Type `/` on an empty line or click on the `+` icon to trigger the block insert menu, you can keep typing to filter it down. Of course, you can also drag and drop images to include them in your document or paste a link to embed content from one of the many supported [integrations](https://www.getoutline.com/integrations)
-9
View File
@@ -1,9 +0,0 @@
We hate bugs as much as you and do everything possible to keep the application bug-free and performant. If you see any problems with Outline please get in touch with the team you can email [hello@getoutline.com](mailto:hello@getoutline.com) directly and well get back to you (hopefully with a fix!) as soon as possible.
## GitHub
If you have a GitHub account then you can also submit bugs directly to the development team on our public [issue tracker](https://github.com/outline/outline/issues).
## Ideas
Wed love to hear your ideas about how Outline can be improved and features you would like to see built. The best place to let the team know is through [GitHub discussions](https://github.com/outline/outline/discussions).
+7 -6
View File
@@ -1,16 +1,17 @@
Outline is a place to build your team knowledge base, you could think of it like your teams shared library a place for important documentation, notes, and ideas to live and be discovered. Some things you might want to keep in Outline:
Outline is a place to build your team knowledge base, you could think of it like your teams shared library a place for important documentation, notes, and ideas to live and be discovered. Some things you might want to keep in Outline include:
\
- Documentation
- Support knowledge base
- Product plans and RFCs
- Sales playbooks
- Support scripts
- Onboarding
- HR documents
- Onboarding checklists
- Company policies
- Meeting notes
- …and more
## Structure
Outline allows you to organize documents in "collections", for example these could represent topics like Sales, Product, or HR. Within collections documents can be interlinked and deeply nested to easily build relationships within your knowledge base.
Outline allows you to organize documents in "collections", for example these could represent topics like Sales, Product, or HR. You can assign users or groups access to collections. Within collections documents can be interlinked and deeply nested to easily build relationships within your knowledge base.
## Search
+1 -3
View File
@@ -2,9 +2,7 @@
import { CollectionUser, Collection } from "../models";
import { buildUser, buildTeam, buildCollection } from "../test/factories";
import { flushdb } from "../test/support";
import "./";
import serialize from "./serializer";
import { serialize } from "./index";
beforeEach(() => flushdb());
+1 -3
View File
@@ -6,9 +6,7 @@ import {
buildCollection,
} from "../test/factories";
import { flushdb } from "../test/support";
import "./";
import serialize from "./serializer";
import { serialize } from "./index";
beforeEach(() => flushdb());
+33
View File
@@ -1,4 +1,5 @@
// @flow
import { Attachment, Team, User, Collection, Document, Group } from "../models";
import policy from "./policy";
import "./apiKey";
import "./attachment";
@@ -12,4 +13,36 @@ import "./user";
import "./team";
import "./group";
const { can, abilities } = policy;
type Policy = {
[key: string]: boolean,
};
/*
* Given a user and a model output an object which describes the actions the
* user may take against the model. This serialized policy is used for testing
* and sent in API responses to allow clients to adjust which UI is displayed.
*/
export function serialize(
model: User,
target: Attachment | Team | Collection | Document | Group
): Policy {
let output = {};
abilities.forEach((ability) => {
if (model instanceof ability.model && target instanceof ability.target) {
let response = true;
try {
response = can(model, ability.action, target);
} catch (err) {
response = false;
}
output[ability.action] = response;
}
});
return output;
}
export default policy;
+1 -3
View File
@@ -1,9 +1,7 @@
// @flow
import { buildUser, buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
import "./";
import serialize from "./serializer";
import { serialize } from "./index";
beforeEach(() => flushdb());
-31
View File
@@ -1,31 +0,0 @@
// @flow
import policy from "./policy";
const { can, abilities } = policy;
type Policy = {
[key: string]: boolean,
};
/*
* Given a user and a model output an object which describes the actions the
* user may take against the model. This serialized policy is used for testing
* and sent in API responses to allow clients to adjust which UI is displayed.
*/
export default function serialize(model: Object, target: Object): Policy {
let output = {};
abilities.forEach((ability) => {
if (model instanceof ability.model && target instanceof ability.target) {
let response = true;
try {
response = can(model, ability.action, target);
} catch (err) {
response = false;
}
output[ability.action] = response;
}
});
return output;
}
+1 -3
View File
@@ -1,9 +1,7 @@
// @flow
import { buildUser, buildTeam, buildAdmin } from "../test/factories";
import { flushdb } from "../test/support";
import "./";
import serialize from "./serializer";
import { serialize } from "./index";
beforeEach(() => flushdb());
+2 -1
View File
@@ -1,10 +1,11 @@
// @flow
import { User } from "../models";
import serialize from "../policies/serializer";
type Policy = { id: string, abilities: { [key: string]: boolean } };
export default function present(user: User, objects: Object[]): Policy[] {
const { serialize } = require("../policies");
return objects.map((object) => ({
id: object.id,
abilities: serialize(user, object),
+2
View File
@@ -1,4 +1,5 @@
// @flow
import env from "../env";
import { Team } from "../models";
export default function present(team: Team) {
@@ -7,6 +8,7 @@ export default function present(team: Team) {
name: team.name,
avatarUrl: team.logoUrl,
sharing: team.sharing,
multiplayerEditor: team.multiplayerEditor && env.MULTIPLAYER_URL,
documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
subdomain: team.subdomain,
+2
View File
@@ -10,6 +10,7 @@ type UserPresentation = {
name: string,
avatarUrl: ?string,
email?: string,
color: string,
isAdmin: boolean,
isSuspended: boolean,
isViewer: boolean,
@@ -21,6 +22,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.id = user.id;
userData.createdAt = user.createdAt;
userData.name = user.name;
userData.color = user.color;
userData.isAdmin = user.isAdmin;
userData.isViewer = user.isViewer;
userData.isSuspended = user.isSuspended;
+10 -4
View File
@@ -1,6 +1,6 @@
// @flow
import type { DocumentEvent, RevisionEvent } from "../events";
import { Document, Backlink } from "../models";
import { Document, Team, Backlink } from "../models";
import { Op } from "../sequelize";
import parseDocumentIds from "../utils/parseDocumentIds";
import slugify from "../utils/slugify";
@@ -78,13 +78,19 @@ export default class Backlinks {
break;
}
case "documents.title_change": {
const document = await Document.findByPk(event.documentId);
if (!document) return;
// might as well check
const { title, previousTitle } = event.data;
if (!previousTitle || title === previousTitle) break;
const document = await Document.findByPk(event.documentId);
if (!document) return;
// TODO: Handle re-writing of titles into CRDT
const team = await Team.findByPk(document.teamId);
if (team.multiplayerEditor) {
break;
}
// update any link titles in documents that lead to this one
const backlinks = await Backlink.findAll({
where: {
+20
View File
@@ -0,0 +1,20 @@
// @flow
import { darken } from "polished";
import theme from "../../shared/theme";
export const palette = [
theme.brand.red,
theme.brand.blue,
theme.brand.purple,
theme.brand.pink,
theme.brand.marine,
theme.brand.green,
theme.brand.yellow,
darken(0.2, theme.brand.red),
darken(0.2, theme.brand.blue),
darken(0.2, theme.brand.purple),
darken(0.2, theme.brand.pink),
darken(0.2, theme.brand.marine),
darken(0.2, theme.brand.green),
darken(0.2, theme.brand.yellow),
];
+48 -48
View File
@@ -34,11 +34,11 @@
"only you": "Nur Du",
"person": "Person",
"people": "Personen",
"{{ total }} task": "{{ total }} task",
"{{ total }} task_plural": "{{ total }} tasks",
"{{ completed }} task done": "{{ completed }} task done",
"{{ completed }} task done_plural": "{{ completed }} tasks done",
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
"{{ total }} task": "{{ total }} Aufgabe",
"{{ total }} task_plural": "{{ total }} Aufgaben",
"{{ completed }} task done": "{{ completed }} Aufgabe erledigt",
"{{ completed }} task done_plural": "{{ completed }} Aufgaben erledigt",
"{{ completed }} of {{ total }} tasks": "{{ completed }} von {{ total }} Aufgaben",
"Currently editing": "Derzeit in Bearbeitung",
"Currently viewing": "Gerade angezeigt",
"Viewed {{ timeAgo }} ago": "Angesehen vor {{ timeAgo }}",
@@ -50,7 +50,7 @@
"Align left": "Links ausrichten",
"Align right": "Rechts ausrichten",
"Bulleted list": "Punkteliste",
"Todo list": "Task list",
"Todo list": "Aufgabenliste",
"Code block": "Codeblock",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Code": "Code",
@@ -75,7 +75,7 @@
"Divider": "Trennlinie",
"Image": "Bild",
"Sorry, an error occurred uploading the image": "Beim Hochladen des Bildes ist ein Fehler aufgetreten",
"Write a caption": "Write a caption",
"Write a caption": "Untertitel schreiben",
"Info": "Info",
"Info notice": "Info-Hinweis",
"Link": "Link",
@@ -101,20 +101,20 @@
"Tip notice": "Tipp Hinweis",
"Warning": "Warnung",
"Warning notice": "Warnhinweis",
"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.",
"Reload": "Reload",
"Something Unexpected Happened": "Something Unexpected Happened",
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
"our engineers have been notified": "our engineers have been notified",
"Report a Bug": "Report a Bug",
"Show Detail": "Show Detail",
"Module failed to load": "Modul konnte nicht geladen werden",
"Loading Failed": "Laden fehlgeschlagen",
"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.": "Leider konnte ein Teil der Software nicht geladen werden weil der Inhalt sich inzwischen geändert hat oder eine Abfrage nicht erfolgreich abgeschlossen werden konnte. Bitte neu laden.",
"Reload": "Neu Laden",
"Something Unexpected Happened": "Ein unerwarteter Fehler ist aufgetreten",
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Entschuldigung, ein nicht behebbarer Fehler ist aufgetreten{{notified}}. Bitte versuchen Sie die Seite neu zu laden, es könnte inzwischen wieder funktionieren.",
"our engineers have been notified": "unsere Entwickler wurden benachrichtigt",
"Report a Bug": "Fehler melden",
"Show Detail": "Details anzeigen",
"Icon": "Icon",
"Show menu": "Menü anzeigen",
"Choose icon": "Icon auswählen",
"Loading": "Laden",
"Loading editor": "Loading editor",
"Loading editor": "Editor wird geladen",
"Search": "Suche",
"Default access": "Standardzugriff",
"View and edit": "Anzeigen und bearbeiten",
@@ -198,13 +198,13 @@
"Create template": "Vorlage erstellen",
"Duplicate": "Duplizieren",
"Unpublish": "Nicht veröffentlichen",
"Permanently delete": "Permanently delete",
"Permanently delete": "Unwiderruflich löschen",
"Move": "Verschieben",
"History": "Verlauf",
"Download": "Herunterladen",
"Print": "Drucken",
"Move {{ documentName }}": "Verschiebe {{ documentName }}",
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
"Permanently delete {{ documentName }}": "{{ documentName }} endgültig löschen",
"Edit group": "Gruppe bearbeiten",
"Delete group": "Gruppe löschen",
"Group options": "Gruppen-Einstellungen",
@@ -222,8 +222,8 @@
"Share options": "Teilen-Einstellungen",
"Go to document": "Zum Dokument gehen",
"Revoke link": "Link widerrufen",
"Contents": "Contents",
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "Inhalte",
"Headings you add to the document will appear here": "Überschriften, die Sie dem Dokument hinzufügen, werden hier angezeigt",
"Table of contents": "Inhaltsverzeichnis",
"By {{ author }}": "Von {{ author }}",
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Sind Sie sicher, dass Sie {{ userName }} zu einem Administrator machen möchten? Administratoren können Team- und Rechnungsinformationen ändern.",
@@ -237,8 +237,8 @@
"Revoke invite": "Einladung widerrufen",
"Activate account": "Konto aktivieren",
"Suspend account": "Konto sperren",
"API token created": "API token created",
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
"API token created": "API-Token erstellt",
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Nennen Sie Ihren Token so, dass Sie sich leicht daran erinnern können, z. B. \"development\" \"production\" oder \"continuous integration\".",
"Documents": "Dokumente",
"The document archive is empty at the moment.": "Das Dokumentenarchiv ist momentan leer.",
"Search in collection": "Suche in Sammlung",
@@ -254,7 +254,7 @@
"Least recently updated": "Am längsten nicht aktualisiert",
"AZ": "A - Z",
"Drop documents to import": "Dokumente zum Importieren hier ablegen",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Sind Sie sich sicher? Löschung der <em>{{collectionName}}</em> Sammlung ist dauerhaft und nicht wiederrufbar. Die Dokumente selbst werden jedoch dem Papierkorb hinzugefügt.",
"Deleting": "Wird gelöscht",
"Im sure  Delete": "Ich bin mir sicher  Löschen",
"The collection was updated": "Die Sammlung wurde aktualisiert",
@@ -266,9 +266,9 @@
"Public sharing is currently disabled in the team security settings.": "Öffentliches Teilen ist derzeit in den Sicherheitseinstellungen des Teams deaktiviert.",
"Saving": "Speichert",
"Save": "Speichern",
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
"Exporting": "Exporting",
"Export Collection": "Export Collection",
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Der Export der Sammlung <em>{{collectionName}}</em> kann einige Sekunden dauern. Ihre Dokumente werden als ZIP-Datei mit Markdown Textdateien heruntergeladen.",
"Exporting": "Wird exportiert",
"Export Collection": "Sammlung exportieren",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Sammlungen dienen zur Gruppierung von Dokumenten. Sie funktionieren am besten, wenn sie nach einem Thema oder nach internen Teams organisiert sind — z. B. Produkt oder Entwicklung.",
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "Dies ist die Standardstufe des Zugriffs für Teammitglieder. Du kannst bestimmten Nutzern oder Gruppen mehr Zugriff geben, sobald die Sammlung erstellt wurde.",
"Creating": "Wird erstellt",
@@ -311,17 +311,17 @@
"Add specific access for individual groups and team members": "Speziellen Zugriff für einzelne Gruppen und Teammitglieder hinzufügen",
"Add groups to {{ collectionName }}": "Gruppen zu {{ collectionName }} hinzufügen",
"Add people to {{ collectionName }}": "Personen zu {{ collectionName }} hinzufügen",
"Document updated by {{userName}}": "Document updated by {{userName}}",
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
"Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.",
"Archived by {{userName}}": "Archived by {{userName}}",
"Deleted by {{userName}}": "Deleted by {{userName}}",
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
"Start your template…": "Start your template…",
"Start with a title…": "Start with a title…",
"…the rest is up to you": "…the rest is up to you",
"Document updated by {{userName}}": "Dokument aktualisiert durch {{userName}}",
"You have unsaved changes.\nAre you sure you want to discard them?": "Sie haben ungespeicherte Änderungen. Sind Sie sicher, dass Sie abbrechen wollen?",
"Images are still uploading.\nAre you sure you want to discard them?": "Bilder werden noch hochgeladen.\nMöchten Sie sie wirklich verwerfen?",
"Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "Sie bearbeiten eine Vorlage. Markieren Sie eine Textstelle und verwenden Sie die <2></2> -control, um Platzhalter hinzuzufügen, die beim Erstellen neuer Dokumente aus dieser Vorlage ausgefüllt werden können.",
"Archived by {{userName}}": "Archiviert durch {{userName}}",
"Deleted by {{userName}}": "Gelöscht von {{userName}}",
"This template will be permanently deleted in <2></2> unless restored.": "Diese Vorlage wird dauerhaft in <2></2> gelöscht, wenn sie nicht wiederhergestellt wird.",
"This document will be permanently deleted in <2></2> unless restored.": "Dieses Dokument wird dauerhaft in <2></2> gelöscht, wenn es nicht wiederhergestellt wird.",
"Start your template…": "Vorlage starten…",
"Start with a title…": "Mit dem Titel beginnen…",
"…the rest is up to you": "…der Rest liegt bei Ihnen",
"Hide contents": "Inhalt ausblenden",
"Show contents": "Inhalt anzeigen",
"Edit {{noun}}": "{{noun}} bearbeiten",
@@ -347,15 +347,15 @@
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Bist du dir sicher? Durch Löschen des Dokuments <em>{{ documentTitle }}</em>, werden der gesamte Verlauf und alle Unterdokumente gelöscht.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "Wenn du {{noun}} in Zukunft noch referenzieren oder wiederherstellen möchtest, solltest du es stattdessen archivieren.",
"Archiving": "Wird archiviert",
"Document moved": "Document moved",
"Current location": "Current location",
"Choose a new location": "Choose a new location",
"Search collections & documents": "Search collections & documents",
"Document moved": "Dokument verschoben",
"Current location": "Aktueller Speicherort",
"Choose a new location": "Wähle neuen Speicherort",
"Search collections & documents": "Sammlungen und Dokumente durchsuchen",
"Couldnt create the document, try again?": "Dokument konnte nicht erstellt werden. Erneut versuchen?",
"Document permanently deleted": "Document permanently deleted",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Document permanently deleted": "Dokument endgültig gelöscht",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Möchten Sie das Dokument <em>{{ documentTitle }}</em> dauerhaft löschen? Die Löschung erfolgt sofort und kann nicht rückgängig gemacht werden.",
"Template created, go ahead and customize it": "Vorlage erstellt, fortfahren und anpassen",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Das Erstellen einer Vorlage durch <em>{{titleWithDefault}}</em> ist eine non-destruktive Aktion wir erstellen eine Kopie des Dokuments und verwandeln es in eine Vorlage, die als Ausgangspunkt für neue Dokumente verwendet werden kann.",
"Search documents": "Dokumente durchsuchen",
"No documents found for your filters.": "Keine Dokumente anhand Ihre Filter gefunden.",
"Youve not got any drafts at the moment.": "Sie haben im Moment keine Entwürfe.",
@@ -365,8 +365,8 @@
"We were unable to load the document while offline.": "Wir konnten das Dokument nicht offline laden.",
"Your account has been suspended": "Ihr Konto wurde gesperrt",
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Ein Administrator (<em>{{ suspendedContactEmail }}</em>) hat dein Konto gesperrt. Um dein Konto zu reaktivieren, wende dich bitte direkt an diesen.",
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Sind Sie sicher? Durch das Löschen der <em>{{groupName}}</em> verlieren Ihre Teammitglieder den Zugriff auf Sammlungen und Dokumente die mit der Gruppe verknüpft waren.",
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "Sie können den Namen dieser Gruppe jederzeit ändern. Dies kann jedoch Ihre Teammitglieder verwirren.",
"{{userName}} was added to the group": "{{userName}} wurde zur Gruppe hinzugefügt",
"Add team members below to give them access to the group. Need to add someone whos not yet on the team yet?": "Fügen Sie unten Teammitglieder hinzu, um ihnen Zugriff auf die Gruppe zu gewähren. Sie müssen jemanden hinzufügen, der noch nicht im Team ist?",
"Invite them to {{teamName}}": "Personen zu {{teamName}} einladen",
+17 -2
View File
@@ -14,6 +14,8 @@
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"History": "History",
"Oh weird, there's nothing here": "Oh weird, there's nothing here",
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
@@ -110,6 +112,14 @@
"our engineers have been notified": "our engineers have been notified",
"Report a Bug": "Report a Bug",
"Show Detail": "Show Detail",
"Latest version": "Latest version",
"{{userName}} edited": "{{userName}} edited",
"{{userName}} archived": "{{userName}} archived",
"{{userName}} restored": "{{userName}} restored",
"{{userName}} deleted": "{{userName}} deleted",
"{{userName}} moved from trash": "{{userName}} moved from trash",
"{{userName}} published": "{{userName}} published",
"{{userName}} moved": "{{userName}} moved",
"Icon": "Icon",
"Show menu": "Show menu",
"Choose icon": "Choose icon",
@@ -200,7 +210,6 @@
"Unpublish": "Unpublish",
"Permanently delete": "Permanently delete",
"Move": "Move",
"History": "History",
"Download": "Download",
"Print": "Print",
"Move {{ documentName }}": "Move {{ documentName }}",
@@ -552,5 +561,11 @@
"Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.",
"Edit Profile": "Edit Profile",
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet."
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet.",
"Today": "Today",
"Yesterday": "Yesterday",
"Last week": "Last week",
"This month": "This month",
"Last month": "Last month",
"This year": "This year"
}
+3 -3
View File
@@ -1,13 +1,13 @@
{
"currently editing": "momenteel aan het bewerken",
"currently viewing": "momenteen aan het bekijken",
"previously edited": "previously edited",
"previously edited": "eerder bewerkt",
"You": "Jij",
"Viewers": "Kijkers",
"Sorry, an error occurred saving the collection": "Er is een fout opgetreden bij het opslaan van de collectie",
"Add a description": "Voeg een beschrijving toe",
"Collapse": "Collapse",
"Expand": "Expand",
"Collapse": "Inklappen",
"Expand": "Uitklappen",
"Submenu": "Submenu",
"Trash": "Prullenmand",
"Archive": "Archief",
+89 -89
View File
@@ -34,11 +34,11 @@
"only you": "仅您自己",
"person": "人",
"people": "用户",
"{{ total }} task": "{{ total }} task",
"{{ total }} task_plural": "{{ total }} tasks",
"{{ completed }} task done": "{{ completed }} task done",
"{{ completed }} task done_plural": "{{ completed }} tasks done",
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
"{{ total }} task": "一共 {{ total }} 项任务",
"{{ total }} task_plural": "一共 {{ total }} 项任务",
"{{ completed }} task done": "已完成 {{ completed }} 项任务",
"{{ completed }} task done_plural": "已完成 {{ completed }} 项任务",
"{{ completed }} of {{ total }} tasks": "一共 {{ total }} 项任务,已完成 {{ completed }} 项",
"Currently editing": "正在编辑",
"Currently viewing": "正在浏览",
"Viewed {{ timeAgo }} ago": "{{ timeAgo }} 前查看",
@@ -50,7 +50,7 @@
"Align left": "左对齐",
"Align right": "右对齐",
"Bulleted list": "无序列表",
"Todo list": "Task list",
"Todo list": "任务列表",
"Code block": "代码块",
"Copied to clipboard": "已复制到剪切板",
"Code": "代码",
@@ -75,7 +75,7 @@
"Divider": "分割线",
"Image": "图片",
"Sorry, an error occurred uploading the image": "抱歉,上传图片时发生错误",
"Write a caption": "Write a caption",
"Write a caption": "撰写一个标题",
"Info": "信息",
"Info notice": "提示信息",
"Link": "链接",
@@ -101,20 +101,20 @@
"Tip notice": "提示信息",
"Warning": "警告",
"Warning notice": "警告信息",
"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.",
"Reload": "Reload",
"Something Unexpected Happened": "Something Unexpected Happened",
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.",
"our engineers have been notified": "our engineers have been notified",
"Report a Bug": "Report a Bug",
"Show Detail": "Show Detail",
"Module failed to load": "模块加载失败",
"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.": "抱歉,应用程序有一部分加载失败。 这可能是因为在您打开标签后已经更新,或者因为网络请求失败。请尝试重新加载。",
"Reload": "重新加载",
"Something Unexpected Happened": "发生了一些意料之外的错误",
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "抱歉,发生了一个无法恢复的错误{{notified}}。请尝试重新加载页面,这可能是一个暂时的小故障。",
"our engineers have been notified": "我们的工程师已经收到通知",
"Report a Bug": "反馈一个问题",
"Show Detail": "显示详情",
"Icon": "图标",
"Show menu": "显示菜单",
"Choose icon": "选择图标",
"Loading": "加载中",
"Loading editor": "Loading editor",
"Loading editor": "正在加载编辑器",
"Search": "搜索",
"Default access": "默认访问",
"View and edit": "查看并编辑",
@@ -222,8 +222,8 @@
"Share options": "分享选项",
"Go to document": "转到文档",
"Revoke link": "撤消链接",
"Contents": "Contents",
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "目录",
"Headings you add to the document will appear here": "您添加到文档的标题将显示在此处",
"Table of contents": "目录",
"By {{ author }}": "创建人 {{ author }}",
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "您确定要设置 {{ userName }} 为管理员吗?管理员可以修改团队和帐单信息。",
@@ -254,7 +254,7 @@
"Least recently updated": "最近最少更新",
"AZ": "A-Z",
"Drop documents to import": "拖放文档以导入",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "您确定要这样做吗? 删除 <em>{{collectionName}}</em> 文档集是永久性的,且无法还原。不过,文档集中的文件将被移动到回收站。",
"Deleting": "正在删除",
"Im sure  Delete": "确认删除",
"The collection was updated": "文档集已更新",
@@ -266,9 +266,9 @@
"Public sharing is currently disabled in the team security settings.": "公共共享目前在团队安全设置中被禁用。",
"Saving": "保存中",
"Save": "保存",
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.",
"Exporting": "Exporting",
"Export Collection": "Export Collection",
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "导出集合 <em>{{collectionName}}</em> 将会需要几秒钟。您的文档将会以 Markdown 格式放在文件夹中,并压缩成 zip 压缩包以供下载。",
"Exporting": "正在导出……",
"Export Collection": "导出文档集",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "文档集用于对文档进行分组。建议使用文档集管理同一主题或固定团队(比如产品团队或研发团队)的文档。",
"This is the default level of access given to team members, you can give specific users or groups more access once the collection is created.": "这是授予团队成员的默认访问级别,创建集合后,您可以为特定用户或用户组提供更多访问权限。",
"Creating": "正在创建",
@@ -311,17 +311,17 @@
"Add specific access for individual groups and team members": "为个别组和团队成员添加特定访问权限",
"Add groups to {{ collectionName }}": "将群组加到 {{ collectionName }}",
"Add people to {{ collectionName }}": "添加用户到 {{ collectionName }}",
"Document updated by {{userName}}": "Document updated by {{userName}}",
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
"Document updated by {{userName}}": "文档已被 {{userName}} 更新",
"You have unsaved changes.\nAre you sure you want to discard them?": "您有尚未保存的修改。\n您确定要丢弃它们吗?",
"Images are still uploading.\nAre you sure you want to discard them?": "图像仍在上传中。\n您确定要丢弃它们吗?",
"Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.",
"Archived by {{userName}}": "Archived by {{userName}}",
"Deleted by {{userName}}": "Deleted by {{userName}}",
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
"Start your template…": "Start your template…",
"Archived by {{userName}}": "已被 {{userName}} 存档",
"Deleted by {{userName}}": "已被 {{userName}} 删除",
"This template will be permanently deleted in <2></2> unless restored.": "此模板将在 <2></2> 中永久删除,除非手动恢复。",
"This document will be permanently deleted in <2></2> unless restored.": "此文档将在 <2></2> 中永久删除,除非手动恢复。",
"Start your template…": "开始创建模板…",
"Start with a title…": "Start with a title…",
"…the rest is up to you": "…the rest is up to you",
"…the rest is up to you": "…其余部分由您决定。",
"Hide contents": "隐藏内容",
"Show contents": "显示内容",
"Edit {{noun}}": "编辑 {{noun}}",
@@ -347,14 +347,14 @@
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "删除文档 <em>{{ documentTitle }}</em> 也将会删除该文档所有历史版本和子文档,是否确认删除?",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "如果您将来希望引用或还原{{noun}},请考虑将其存档。",
"Archiving": "正在归档",
"Document moved": "Document moved",
"Current location": "Current location",
"Choose a new location": "Choose a new location",
"Search collections & documents": "Search collections & documents",
"Document moved": "文档已被移动",
"Current location": "当前位置",
"Choose a new location": "选择新的位置",
"Search collections & documents": "搜索文档集和文档",
"Couldnt create the document, try again?": "无法创建文档,请重试?",
"Document permanently deleted": "文档已被永久删除",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "您确定要永久地删除 <em>{{ documentTitle }}</em> 文档吗?此操作即时生效且无法撤消。",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Template created, go ahead and customize it": "模板已创建,继续进行自定义",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Search documents": "搜索文档",
"No documents found for your filters.": "没有找到相关文档。",
@@ -374,27 +374,27 @@
"Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.": "Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to.",
"Listing team members in the <em>{{groupName}}</em> group.": "Listing team members in the <em>{{groupName}}</em> group.",
"This group has no members.": "这个群组没有任何成员",
"Add people to {{groupName}}": "Add people to {{groupName}}",
"Add people to {{groupName}}": "添加用户到 {{groupName}}",
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
"Youll be able to add people to the group next.": "Youll be able to add people to the group next.",
"Continue": "Continue",
"Group members": "Group members",
"Youll be able to add people to the group next.": "接下来,您将能够将人员添加到群组中。",
"Continue": "继续操作",
"Group members": "群组成员",
"Recently viewed": "最近浏览",
"Created by me": "由我创建",
"Weird, this shouldnt ever be empty": "Weird, this shouldnt ever be empty",
"Documents youve recently viewed will be here for easy access": "Documents youve recently viewed will be here for easy access",
"We sent out your invites!": "We sent out your invites!",
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.",
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.",
"As an admin you can also <2>enable email sign-in</2>.": "As an admin you can also <2>enable email sign-in</2>.",
"Want a link to share directly with your team?": "Want a link to share directly with your team?",
"Weird, this shouldnt ever be empty": "奇怪,这里不应该是空的",
"Documents youve recently viewed will be here for easy access": "您最近浏览过的文档将放在此处以方便快速访问",
"We sent out your invites!": "我们发送了您的邀请!",
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "抱歉,您一次只能发送 {{MAX_INVITES}} 个邀请",
"Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address.": "邀请团队成员或访客加入您的知识库。团队成员可以使用 {{signinMethods}} 登录或使用他们的电子邮件地址登录。",
"Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}.": "邀请团队成员加入您的知识库。他们需要使用 {{signinMethods}} 登录。",
"As an admin you can also <2>enable email sign-in</2>.": "作为管理员,您还可以<2>启用电子邮件登录</2>.",
"Want a link to share directly with your team?": "想要一个链接直接分享到您的团队吗?",
"Email": "邮箱",
"Full name": "全名",
"Remove invite": "Remove invite",
"Add another": "Add another",
"Inviting": "Inviting",
"Send Invites": "Send Invites",
"Remove invite": "撤消邀请",
"Add another": "再添加一个",
"Inviting": "邀请中……",
"Send Invites": "发送邀请",
"Navigation": "导航",
"Edit current document": "编辑当前文档",
"Move current document": "移动当前文档",
@@ -424,19 +424,19 @@
"Blockquote": "块引用",
"Horizontal divider": "水平分隔线",
"Inline code": "行内代码",
"Back to home": "Back to home",
"Back to website": "Back to website",
"Check your email": "Check your email",
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed.": "A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed.",
"Back to login": "Back to login",
"Create an account": "Create an account",
"Get started by choosing a sign-in method for your new team below…": "Get started by choosing a sign-in method for your new team below…",
"Login to {{ authProviderName }}": "Login to {{ authProviderName }}",
"You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.",
"Already have an account? Go to <1>login</1>.": "Already have an account? Go to <1>login</1>.",
"Sign In": "Sign In",
"Continue with Email": "Continue with Email",
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
"Back to home": "回到主页",
"Back to website": "返回网站",
"Check your email": "请查看您的电子邮件",
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed.": "魔法登录链接已经发送到电子邮箱 <em>{{ emailLinkSentTo }}</em>,不需要密码。",
"Back to login": "回到登录界面",
"Create an account": "创建一个帐户",
"Get started by choosing a sign-in method for your new team below…": "请在下面为您的新团队选择一种登陆方式",
"Login to {{ authProviderName }}": "登录到 {{ authProviderName }}",
"You signed in with {{ authProviderName }} last time.": "您上次登录的方式为 {{ authProviderName }}",
"Already have an account? Go to <1>login</1>.": "已经有帐户了?前往<1>登录</1>.",
"Sign In": "登录",
"Continue with Email": "使用电子邮件继续",
"Continue with {{ authProviderName }}": "使用 {{ authProviderName }} 继续",
"Any collection": "文档集",
"Any time": "任何时间",
"Past day": "过去一天",
@@ -466,14 +466,14 @@
"Active": "活动的",
"Everyone": "所有人",
"Admins": "管理員",
"Settings saved": "Settings saved",
"Unable to upload new logo": "Unable to upload new logo",
"Settings saved": "设置已保存",
"Unable to upload new logo": "无法上传新logo",
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
"Logo": "Logo",
"Crop logo": "Crop logo",
"Logo": "网站标志 (Logo)",
"Crop logo": "裁剪标志",
"Upload": "上传",
"Subdomain": "Subdomain",
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
"Subdomain": "二级域名",
"Your knowledge base will be accessible at": "您的知识库将在",
"New group": "新建群组",
"Groups can be used to organize and manage the people on your team.": "可以使用群组来组织和管理您团队中的人。",
"All groups": "所有群组",
@@ -491,20 +491,20 @@
"Export Requested": "已请求导出",
"Requesting Export": "正在请求导出",
"Export Data": "导出数据",
"Document published": "Document published",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"Document updated": "Document updated",
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
"Collection created": "Collection created",
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
"Getting started": "Getting started",
"Document published": "文档已发布",
"Receive a notification whenever a new document is published": "每当发布新文档时收到通知",
"Document updated": "文档已更新",
"Receive a notification when a document you created is edited": "每当您创建的文档被编辑时收到通知",
"Collection created": "已创建文档集",
"Receive a notification whenever a new collection is created": "每当创建新文档集时收到通知",
"Getting started": "新手指南",
"Tips on getting started with Outline`s features and functionality": "Tips on getting started with Outline`s features and functionality",
"New features": "New features",
"Receive an email when new features of note are added": "Receive an email when new features of note are added",
"Notifications saved": "Notifications saved",
"New features": "新特性",
"Receive an email when new features of note are added": "当笔记增加新功能时收到电子邮件",
"Notifications saved": "通知配置已保存",
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
"Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.": "Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.",
"Email address": "Email address",
"Email address": "电子邮件地址",
"Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "每个登录到大纲的用户都会出现在这里。可能还有其他尚未登录的用户可以通过 {team.signinMethods} 访问。",
"Filter": "筛选",
"Profile saved": "配置已保存",
@@ -517,8 +517,8 @@
"You may delete your account at any time, note that this is unrecoverable": "您可以随时删除您的帐户,请注意删除后将无法恢复该账号",
"Delete account": "删除账户",
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
"Allow email authentication": "Allow email authentication",
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
"Allow email authentication": "允许电子邮件身份验证",
"When enabled, users can sign-in using their email address": "启用后,用户可以使用他们的电子邮件地址登录",
"When enabled, documents can be shared publicly on the internet by any team member": "When enabled, documents can be shared publicly on the internet by any team member",
"Rich service embeds": "Rich service embeds",
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
@@ -540,14 +540,14 @@
"Create a token": "创建令牌",
"Zapier": "Zapier",
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
"Open Zapier": "Open Zapier",
"Open Zapier": "打开 Zapier",
"Youve not starred any documents yet.": "您尚未标记任何文档。",
"There are no templates just yet.": "尚无可用模板。",
"You can create templates to help your team create consistent and accurate documentation.": "您可以创建模板来帮助您的团队创建风格一致和准确的文档。",
"Trash is empty at the moment.": "回收站为空。",
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.",
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
"Delete My Account": "Delete My Account",
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of Outline and all your API tokens will be revoked.": "你确定吗?注销您的帐户将销毁与您的用户相关联的识别数据并且无法撤消。您将立即退出 Outline,并且您的所有 API 令牌都将被撤销。",
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>注意:</em> 重新登录将导致自动重新配置新帐户。",
"Delete My Account": "注销我的帐户",
"You joined": "您已加入",
"Joined": "已加入",
"{{ time }} ago.": "{{ time }} 之前。",
+1
View File
@@ -40,6 +40,7 @@ const colors = {
blue: "#3633FF",
marine: "#2BC2FF",
green: "#42DED1",
yellow: "#F5BE31",
},
};
+95 -5
View File
@@ -1116,6 +1116,36 @@
dependencies:
"@hapi/hoek" "^8.3.0"
"@hocuspocus/extension-logger@^1.0.0-alpha.35":
version "1.0.0-alpha.35"
resolved "https://registry.yarnpkg.com/@hocuspocus/extension-logger/-/extension-logger-1.0.0-alpha.35.tgz#1d13c82b5da9eb91442e121f374f0a7609cd11fd"
integrity sha512-yZ0pEmUvsfor9QhztAVnLOoY1LZpxA8UXJ0ZHr94oYgnmKo+aEK2P7xeiPT7GMH2t+ZYZn5UKoG3z4aaAEt6jg==
dependencies:
"@hocuspocus/server" "^1.0.0-alpha.60"
"@hocuspocus/provider@^1.0.0-alpha.8":
version "1.0.0-alpha.8"
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-1.0.0-alpha.8.tgz#6e6ccb7e29622df84680de542815695e651ffad9"
integrity sha512-7BuHVISzv5RRrX4yU5JJ63/6epP6kkoJc97Th17Jm1rPybFA3/0LpmrLw8eEU35pRWtR0BWIzR9G7hPwjaOo1g==
dependencies:
lib0 "^0.2.42"
y-protocols "^1.0.5"
yjs "^13.5.8"
"@hocuspocus/server@^1.0.0-alpha.60":
version "1.0.0-alpha.60"
resolved "https://registry.yarnpkg.com/@hocuspocus/server/-/server-1.0.0-alpha.60.tgz#28221dfc8e5fa27d4b4e17e377ab766edde2a26e"
integrity sha512-PKLIYHwk5NmssutlZr8DAIvqRWkDONpCMspDBKWnGbA+RiapVA7oIOZLTH37EpkkVOkRFpvhM+GAsOqUU2/K6w==
dependencies:
"@types/async-lock" "^1.1.2"
"@types/uuid" "^8.3.0"
"@types/ws" "^7.4.0"
async-lock "^1.2.8"
lib0 "^0.2.41"
uuid "^8.3.2"
ws "^7.4.3"
yjs "^13.5.0"
"@icons/material@^0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
@@ -2012,6 +2042,11 @@
resolved "https://registry.yarnpkg.com/@tommoor/remove-markdown/-/remove-markdown-0.3.2.tgz#5288ddd0e26b6b173e76ebb31c94653b0dcff45d"
integrity sha512-awcc9hfLZqyyZHOGzAHbnjgZJpQGS1W1oZZ5GXOTTnbKVdKQ4OWYbrRWPUvXI2YAKJazrcS8rxPh67PX3rpGkQ==
"@types/async-lock@^1.1.2":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.3.tgz#0d86017cf87abbcb941c55360e533d37a3f23b3d"
integrity sha512-UpeDcjGKsYEQMeqEbfESm8OWJI305I7b9KE4ji3aBjoKWyN5CTdn8izcA1FM1DVDne30R5fNEnIy89vZw5LXJQ==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.12"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d"
@@ -2187,6 +2222,18 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
"@types/uuid@^8.3.0":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.1.tgz#1a32969cf8f0364b3d8c8af9cc3555b7805df14f"
integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==
"@types/ws@^7.4.0":
version "7.4.7"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
dependencies:
"@types/node" "*"
"@types/yargs-parser@*":
version "15.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d"
@@ -2734,6 +2781,11 @@ async-each@^1.0.1:
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
async-lock@^1.2.8:
version "1.3.0"
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.0.tgz#0fba111bea8b9693020857eba4f9adca173df3e5"
integrity sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg==
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
@@ -7499,6 +7551,11 @@ isomorphic-fetch@^3.0.0:
node-fetch "^2.6.1"
whatwg-fetch "^3.4.1"
isomorphic.js@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.4.tgz#24ca374163ae54a7ce3b86ce63b701b91aa84969"
integrity sha512-Y4NjZceAwaPXctwsHgNsmfuPxR8lJ3f8X7QTAkhltrX4oGIv+eTlgHLXn4tWysC9zGTi929gapnPp+8F8cg7nA==
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -8470,6 +8527,13 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
lib0@^0.2.34, lib0@^0.2.41, lib0@^0.2.42:
version "0.2.42"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.42.tgz#6d8bf1fb8205dec37a953c521c5ee403fd8769b0"
integrity sha512-8BNM4MiokEKzMvSxTOC3gnCBisJH+jL67CnSnqzHv3jli3pUvGC8wz+0DQ2YvGr4wVQdb2R2uNNPw9LEpVvJ4Q==
dependencies:
isomorphic.js "^0.2.4"
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
@@ -11566,10 +11630,10 @@ retry-as-promised@^3.2.0:
dependencies:
any-promise "^1.3.0"
rich-markdown-editor@^11.17.0:
version "11.17.0"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.17.0.tgz#9a39f5bd6518de1d0dc6c6ffa352fd3f7f664d96"
integrity sha512-zCl9F7eeR3T5O2tiSU9iNGDOKhYBfTMqwWMPSY4ADjHcxELNkF1wOdKb0lqM8ZOfM63DkPEZy/N7cSifReyqvg==
rich-markdown-editor@^11.17.2:
version "11.17.2"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.17.2.tgz#dfe37db78f05275a127f3044e37c89e655003927"
integrity sha512-YlforkrZlrvzDWz9C58O7+GU7ulJC8M+UdftMObhkt5LagpzBXXzPvOiMkR0qzlouxsEwSA22/S4J16obJpNWA==
dependencies:
copy-to-clipboard "^3.0.8"
lodash "^4.17.11"
@@ -14228,7 +14292,12 @@ write@1.0.3:
dependencies:
mkdirp "^0.5.1"
ws@^7.2.3, ws@~7.4.2:
ws@^7.2.3, ws@^7.4.3:
version "7.5.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74"
integrity sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==
ws@~7.4.2:
version "7.4.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
@@ -14309,6 +14378,20 @@ xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y-prosemirror@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.0.9.tgz#c0b5bf4e2c6620093ba0658c2aca52055346a683"
integrity sha512-OM12aPx04lwiIy1IOBidb6ONAof2KFxQE/Gww26SEsMQuA2dibrJkjaMwXwY1KnYY7yOpwbIFRdwecdNXLU9yQ==
dependencies:
lib0 "^0.2.34"
y-protocols@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.5.tgz#91d574250060b29fcac8f8eb5e276fbad594245e"
integrity sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==
dependencies:
lib0 "^0.2.42"
y18n@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
@@ -14405,6 +14488,13 @@ yeast@0.1.2:
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
yjs@^13.5.0, yjs@^13.5.12, yjs@^13.5.8:
version "13.5.12"
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.5.12.tgz#7a0cf3119fb368c07243825e989a55de164b3f9c"
integrity sha512-/buy1kh8Ls+t733Lgov9hiNxCsjHSCymTuZNahj2hsPNoGbvnSdDmCz9Z4F19Yr1eUAAXQLJF3q7fiBcvPC6Qg==
dependencies:
lib0 "^0.2.41"
ylru@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"