feat: Backlinks (#979)
* feat: backlinks * feat: add backlinkDocumentId to documents.list * chore: refactor fix: create and delete backlink handling * fix: guard against self links * feat: basic frontend fix: race condition * styling * test: fix parse ids * self review * linting * feat: Improved link styling * fix: Increase clickable area at bottom of doc / between references * perf: global styles are SLOW
This commit is contained in:
@ -2,7 +2,7 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
const ClickablePadding = styled.div`
|
const ClickablePadding = styled.div`
|
||||||
min-height: 6em;
|
min-height: 10em;
|
||||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||||
${({ grow }) => grow && `flex-grow: 100;`};
|
${({ grow }) => grow && `flex-grow: 100;`};
|
||||||
`;
|
`;
|
||||||
|
@ -2,14 +2,13 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Document from 'models/Document';
|
import { StarredIcon } from 'outline-icons';
|
||||||
import styled, { withTheme } from 'styled-components';
|
import styled, { withTheme } from 'styled-components';
|
||||||
import { darken } from 'polished';
|
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Highlight from 'components/Highlight';
|
import Highlight from 'components/Highlight';
|
||||||
import { StarredIcon } from 'outline-icons';
|
import PublishingInfo from 'components/PublishingInfo';
|
||||||
import PublishingInfo from './components/PublishingInfo';
|
|
||||||
import DocumentMenu from 'menus/DocumentMenu';
|
import DocumentMenu from 'menus/DocumentMenu';
|
||||||
|
import Document from 'models/Document';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: Document,
|
document: Document,
|
||||||
@ -45,8 +44,8 @@ const StyledDocumentMenu = styled(DocumentMenu)`
|
|||||||
|
|
||||||
const DocumentLink = styled(Link)`
|
const DocumentLink = styled(Link)`
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 -16px;
|
margin: 8px -8px;
|
||||||
padding: 10px 16px;
|
padding: 6px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
@ -62,7 +61,6 @@ const DocumentLink = styled(Link)`
|
|||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
background: ${props => props.theme.listItemHoverBackground};
|
background: ${props => props.theme.listItemHoverBackground};
|
||||||
border: 2px solid ${props => props.theme.listItemHoverBorder};
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
${StyledStar}, ${StyledDocumentMenu} {
|
${StyledStar}, ${StyledDocumentMenu} {
|
||||||
@ -73,10 +71,6 @@ const DocumentLink = styled(Link)`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)};
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Heading = styled.h3`
|
const Heading = styled.h3`
|
||||||
|
@ -3,8 +3,10 @@ import * as React from 'react';
|
|||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { withTheme } from 'styled-components';
|
import { lighten } from 'polished';
|
||||||
|
import styled, { withTheme } from 'styled-components';
|
||||||
import RichMarkdownEditor from 'rich-markdown-editor';
|
import RichMarkdownEditor from 'rich-markdown-editor';
|
||||||
|
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
|
||||||
import { uploadFile } from 'utils/uploadFile';
|
import { uploadFile } from 'utils/uploadFile';
|
||||||
import isInternalUrl from 'utils/isInternalUrl';
|
import isInternalUrl from 'utils/isInternalUrl';
|
||||||
import Tooltip from 'components/Tooltip';
|
import Tooltip from 'components/Tooltip';
|
||||||
@ -79,7 +81,7 @@ class Editor extends React.Component<Props> {
|
|||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RichMarkdownEditor
|
<StyledEditor
|
||||||
ref={this.props.forwardedRef}
|
ref={this.props.forwardedRef}
|
||||||
uploadImage={this.onUploadImage}
|
uploadImage={this.onUploadImage}
|
||||||
onClickLink={this.onClickLink}
|
onClickLink={this.onClickLink}
|
||||||
@ -92,6 +94,38 @@ class Editor extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StyledEditor = styled(RichMarkdownEditor)`
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
transition: ${props => props.theme.backgroundTransition};
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
${Placeholder} {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p:nth-child(2):last-child {
|
||||||
|
${Placeholder} {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
a {
|
||||||
|
color: ${props => props.theme.link};
|
||||||
|
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)};
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom: 1px solid ${props => props.theme.link};
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const EditorTooltip = props => <Tooltip offset={8} {...props} />;
|
const EditorTooltip = props => <Tooltip offset={8} {...props} />;
|
||||||
|
|
||||||
export default withTheme(
|
export default withTheme(
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { darken } from 'polished';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
@ -92,13 +91,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
|
|||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
background: ${props => props.theme.listItemHoverBackground};
|
background: ${props => props.theme.listItemHoverBackground};
|
||||||
border: 2px solid ${props => props.theme.listItemHoverBorder};
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)};
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default PathToDocument;
|
export default PathToDocument;
|
||||||
|
@ -201,13 +201,21 @@ type Props = {
|
|||||||
offset?: number,
|
offset?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Tooltip = function({ offset = 0, ...rest }: Props) {
|
class Tooltip extends React.Component<Props> {
|
||||||
|
shouldComponentUpdate() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { offset = 0, ...rest } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<GlobalStyles offset={offset} />
|
<GlobalStyles offset={offset} />
|
||||||
<TooltipTrigger {...rest} />
|
<TooltipTrigger {...rest} />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default Tooltip;
|
export default Tooltip;
|
||||||
|
@ -22,6 +22,7 @@ import { emojiToUrl } from 'utils/emoji';
|
|||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import DocumentMove from './components/DocumentMove';
|
import DocumentMove from './components/DocumentMove';
|
||||||
import Branding from './components/Branding';
|
import Branding from './components/Branding';
|
||||||
|
import Backlinks from './components/Backlinks';
|
||||||
import ErrorBoundary from 'components/ErrorBoundary';
|
import ErrorBoundary from 'components/ErrorBoundary';
|
||||||
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
@ -415,6 +416,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
ui={this.props.ui}
|
ui={this.props.ui}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
/>
|
/>
|
||||||
|
{!this.isEditing && (
|
||||||
|
<Backlinks
|
||||||
|
documents={this.props.documents}
|
||||||
|
document={document}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MaxWidth>
|
</MaxWidth>
|
||||||
</Container>
|
</Container>
|
||||||
</Container>
|
</Container>
|
||||||
|
68
app/scenes/Document/components/Backlink.js
Normal file
68
app/scenes/Document/components/Backlink.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import PublishingInfo from 'components/PublishingInfo';
|
||||||
|
import Document from 'models/Document';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
document: Document,
|
||||||
|
anchor: string,
|
||||||
|
showCollection?: boolean,
|
||||||
|
ref?: *,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentLink = styled(Link)`
|
||||||
|
display: block;
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
max-height: 50vh;
|
||||||
|
min-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
|
background: ${props => props.theme.listItemHoverBackground};
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h3`
|
||||||
|
max-width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
`;
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class Backlink extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { document, showCollection, anchor, ...rest } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentLink
|
||||||
|
to={{
|
||||||
|
pathname: document.url,
|
||||||
|
hash: `d-${anchor}`,
|
||||||
|
state: { title: document.title },
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Title>{document.title}</Title>
|
||||||
|
<PublishingInfo document={document} showCollection={showCollection} />
|
||||||
|
</DocumentLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Backlink;
|
46
app/scenes/Document/components/Backlinks.js
Normal file
46
app/scenes/Document/components/Backlinks.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import Fade from 'components/Fade';
|
||||||
|
import Subheading from 'components/Subheading';
|
||||||
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
import Document from 'models/Document';
|
||||||
|
import Backlink from './Backlink';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
document: Document,
|
||||||
|
documents: DocumentsStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class Backlinks extends React.Component<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.documents.fetchBacklinks(this.props.document.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { documents, document } = this.props;
|
||||||
|
const backlinks = documents.getBacklinedDocuments(document.id);
|
||||||
|
const showBacklinks = !!backlinks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
showBacklinks && (
|
||||||
|
<Fade>
|
||||||
|
<Subheading>Referenced By</Subheading>
|
||||||
|
{backlinks.map(backlinkedDocument => (
|
||||||
|
<Backlink
|
||||||
|
anchor={document.urlId}
|
||||||
|
key={backlinkedDocument.id}
|
||||||
|
document={backlinkedDocument}
|
||||||
|
showCollection={
|
||||||
|
backlinkedDocument.collectionId !== document.collectionId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Fade>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Backlinks;
|
@ -1,8 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styled from 'styled-components';
|
|
||||||
import Editor from 'components/Editor';
|
import Editor from 'components/Editor';
|
||||||
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
|
|
||||||
import ClickablePadding from 'components/ClickablePadding';
|
import ClickablePadding from 'components/ClickablePadding';
|
||||||
import plugins from './plugins';
|
import plugins from './plugins';
|
||||||
|
|
||||||
@ -33,7 +31,7 @@ class DocumentEditor extends React.Component<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<StyledEditor
|
<Editor
|
||||||
ref={ref => (this.editor = ref)}
|
ref={ref => (this.editor = ref)}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
{...this.props}
|
{...this.props}
|
||||||
@ -47,23 +45,4 @@ class DocumentEditor extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledEditor = styled(Editor)`
|
|
||||||
justify-content: start;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
transition: ${props => props.theme.backgroundTransition};
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
${Placeholder} {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p:nth-child(2):last-child {
|
|
||||||
${Placeholder} {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default DocumentEditor;
|
export default DocumentEditor;
|
||||||
|
@ -24,6 +24,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
@observable recentlyViewedIds: string[] = [];
|
@observable recentlyViewedIds: string[] = [];
|
||||||
@observable searchCache: Map<string, SearchResult[]> = new Map();
|
@observable searchCache: Map<string, SearchResult[]> = new Map();
|
||||||
@observable starredIds: Map<string, boolean> = new Map();
|
@observable starredIds: Map<string, boolean> = new Map();
|
||||||
|
@observable backlinks: Map<string, string[]> = new Map();
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(rootStore: RootStore) {
|
||||||
super(rootStore, Document);
|
super(rootStore, Document);
|
||||||
@ -140,6 +141,28 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchBacklinks = async (documentId: string): Promise<?(Document[])> => {
|
||||||
|
const res = await client.post(`/documents.list`, {
|
||||||
|
backlinkDocumentId: documentId,
|
||||||
|
});
|
||||||
|
invariant(res && res.data, 'Document list not available');
|
||||||
|
const { data } = res;
|
||||||
|
runInAction('DocumentsStore#fetchBacklinks', () => {
|
||||||
|
data.forEach(this.add);
|
||||||
|
this.backlinks.set(documentId, data.map(doc => doc.id));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
getBacklinedDocuments(documentId: string): Document[] {
|
||||||
|
const documentIds = this.backlinks.get(documentId) || [];
|
||||||
|
return orderBy(
|
||||||
|
compact(documentIds.map(id => this.data.get(id))),
|
||||||
|
'updatedAt',
|
||||||
|
'desc'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
fetchNamedPage = async (
|
fetchNamedPage = async (
|
||||||
request: string = 'list',
|
request: string = 'list',
|
||||||
|
@ -9,10 +9,19 @@ import {
|
|||||||
presentCollection,
|
presentCollection,
|
||||||
presentRevision,
|
presentRevision,
|
||||||
} from '../presenters';
|
} from '../presenters';
|
||||||
import { Document, Collection, Share, Star, View, Revision } from '../models';
|
import {
|
||||||
|
Document,
|
||||||
|
Collection,
|
||||||
|
Share,
|
||||||
|
Star,
|
||||||
|
View,
|
||||||
|
Revision,
|
||||||
|
Backlink,
|
||||||
|
} from '../models';
|
||||||
import { InvalidRequestError } from '../errors';
|
import { InvalidRequestError } from '../errors';
|
||||||
import events from '../events';
|
import events from '../events';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
import { sequelize } from '../sequelize';
|
||||||
|
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
const { authorize, cannot } = policy;
|
const { authorize, cannot } = policy;
|
||||||
@ -22,6 +31,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
|||||||
const { sort = 'updatedAt' } = ctx.body;
|
const { sort = 'updatedAt' } = ctx.body;
|
||||||
const collectionId = ctx.body.collection;
|
const collectionId = ctx.body.collection;
|
||||||
const createdById = ctx.body.user;
|
const createdById = ctx.body.user;
|
||||||
|
const backlinkDocumentId = ctx.body.backlinkDocumentId;
|
||||||
let direction = ctx.body.direction;
|
let direction = ctx.body.direction;
|
||||||
if (direction !== 'ASC') direction = 'DESC';
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
|
|
||||||
@ -50,6 +60,20 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
|||||||
where = { ...where, collectionId: collectionIds };
|
where = { ...where, collectionId: collectionIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (backlinkDocumentId) {
|
||||||
|
const backlinks = await Backlink.findAll({
|
||||||
|
attributes: ['reverseDocumentId'],
|
||||||
|
where: {
|
||||||
|
documentId: backlinkDocumentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
where = {
|
||||||
|
...where,
|
||||||
|
id: backlinks.map(backlink => backlink.reverseDocumentId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// add the users starred state to the response by default
|
// add the users starred state to the response by default
|
||||||
const starredScope = { method: ['withStarred', user.id] };
|
const starredScope = { method: ['withStarred', user.id] };
|
||||||
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
||||||
@ -620,7 +644,7 @@ router.post('documents.update', auth(), async ctx => {
|
|||||||
|
|
||||||
// Update document
|
// Update document
|
||||||
if (title) document.title = title;
|
if (title) document.title = title;
|
||||||
//append to document
|
|
||||||
if (append) {
|
if (append) {
|
||||||
document.text += text;
|
document.text += text;
|
||||||
} else if (text) {
|
} else if (text) {
|
||||||
@ -628,8 +652,13 @@ router.post('documents.update', auth(), async ctx => {
|
|||||||
}
|
}
|
||||||
document.lastModifiedById = user.id;
|
document.lastModifiedById = user.id;
|
||||||
|
|
||||||
|
let transaction;
|
||||||
|
try {
|
||||||
|
transaction = await sequelize.transaction();
|
||||||
|
|
||||||
if (publish) {
|
if (publish) {
|
||||||
await document.publish();
|
await document.publish({ transaction });
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
events.add({
|
events.add({
|
||||||
name: 'documents.publish',
|
name: 'documents.publish',
|
||||||
@ -639,7 +668,8 @@ router.post('documents.update', auth(), async ctx => {
|
|||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await document.save({ autosave });
|
await document.save({ autosave, transaction });
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
events.add({
|
events.add({
|
||||||
name: 'documents.update',
|
name: 'documents.update',
|
||||||
@ -651,6 +681,12 @@ router.post('documents.update', auth(), async ctx => {
|
|||||||
done,
|
done,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (transaction) {
|
||||||
|
await transaction.rollback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(document),
|
data: await presentDocument(document),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '../app';
|
import app from '../app';
|
||||||
import { Document, View, Star, Revision } from '../models';
|
import { Document, View, Star, Revision, Backlink } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import {
|
import {
|
||||||
buildShare,
|
buildShare,
|
||||||
@ -252,6 +252,31 @@ describe('#documents.list', async () => {
|
|||||||
expect(body.data.length).toEqual(1);
|
expect(body.data.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return backlinks', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const anotherDoc = await buildDocument({
|
||||||
|
title: 'another document',
|
||||||
|
text: 'random text',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Backlink.create({
|
||||||
|
reverseDocumentId: anotherDoc.id,
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.list', {
|
||||||
|
body: { token: user.getJwtToken(), backlinkDocumentId: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].id).toEqual(anotherDoc.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const res = await server.post('/api/documents.list');
|
const res = await server.post('/api/documents.list');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
@ -6,7 +6,7 @@ import mailer from '../mailer';
|
|||||||
|
|
||||||
type Invite = { name: string, email: string };
|
type Invite = { name: string, email: string };
|
||||||
|
|
||||||
export default async function documentMover({
|
export default async function userInviter({
|
||||||
user,
|
user,
|
||||||
invites,
|
invites,
|
||||||
}: {
|
}: {
|
||||||
|
46
server/migrations/20190706213213-backlinks.js
Normal file
46
server/migrations/20190706213213-backlinks.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('backlinks', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'documents',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reverseDocumentId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'documents',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex('backlinks', ['documentId']);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.dropTable('backlinks');
|
||||||
|
await queryInterface.removeIndex('backlinks', ['documentId']);
|
||||||
|
},
|
||||||
|
};
|
27
server/models/Backlink.js
Normal file
27
server/models/Backlink.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// @flow
|
||||||
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
|
|
||||||
|
const Backlink = sequelize.define('backlink', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Backlink.associate = models => {
|
||||||
|
Backlink.belongsTo(models.Document, {
|
||||||
|
as: 'document',
|
||||||
|
foreignKey: 'documentId',
|
||||||
|
});
|
||||||
|
Backlink.belongsTo(models.Document, {
|
||||||
|
as: 'reverseDocument',
|
||||||
|
foreignKey: 'reverseDocumentId',
|
||||||
|
});
|
||||||
|
Backlink.belongsTo(models.User, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: 'userId',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Backlink;
|
@ -32,12 +32,17 @@ const createRevision = (doc, options = {}) => {
|
|||||||
// we don't create revisions if identical to previous
|
// we don't create revisions if identical to previous
|
||||||
if (doc.text === doc.previous('text')) return;
|
if (doc.text === doc.previous('text')) return;
|
||||||
|
|
||||||
return Revision.create({
|
return Revision.create(
|
||||||
|
{
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
text: doc.text,
|
text: doc.text,
|
||||||
userId: doc.lastModifiedById,
|
userId: doc.lastModifiedById,
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
transaction: options.transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createUrlId = doc => {
|
const createUrlId = doc => {
|
||||||
@ -141,6 +146,9 @@ Document.associate = models => {
|
|||||||
as: 'revisions',
|
as: 'revisions',
|
||||||
onDelete: 'cascade',
|
onDelete: 'cascade',
|
||||||
});
|
});
|
||||||
|
Document.hasMany(models.Backlink, {
|
||||||
|
as: 'backlinks',
|
||||||
|
});
|
||||||
Document.hasMany(models.Star, {
|
Document.hasMany(models.Star, {
|
||||||
as: 'starred',
|
as: 'starred',
|
||||||
});
|
});
|
||||||
@ -363,16 +371,16 @@ Document.prototype.archiveWithChildren = async function(userId, options) {
|
|||||||
return this.save(options);
|
return this.save(options);
|
||||||
};
|
};
|
||||||
|
|
||||||
Document.prototype.publish = async function() {
|
Document.prototype.publish = async function(options) {
|
||||||
if (this.publishedAt) return this.save();
|
if (this.publishedAt) return this.save(options);
|
||||||
|
|
||||||
const collection = await Collection.findByPk(this.collectionId);
|
const collection = await Collection.findByPk(this.collectionId);
|
||||||
if (collection.type !== 'atlas') return this.save();
|
if (collection.type !== 'atlas') return this.save(options);
|
||||||
|
|
||||||
await collection.addDocumentToStructure(this);
|
await collection.addDocumentToStructure(this);
|
||||||
|
|
||||||
this.publishedAt = new Date();
|
this.publishedAt = new Date();
|
||||||
await this.save();
|
await this.save(options);
|
||||||
this.collection = collection;
|
this.collection = collection;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import ApiKey from './ApiKey';
|
import ApiKey from './ApiKey';
|
||||||
import Authentication from './Authentication';
|
import Authentication from './Authentication';
|
||||||
|
import Backlink from './Backlink';
|
||||||
import Collection from './Collection';
|
import Collection from './Collection';
|
||||||
import CollectionUser from './CollectionUser';
|
import CollectionUser from './CollectionUser';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
@ -18,6 +19,7 @@ import View from './View';
|
|||||||
const models = {
|
const models = {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
Authentication,
|
Authentication,
|
||||||
|
Backlink,
|
||||||
Collection,
|
Collection,
|
||||||
CollectionUser,
|
CollectionUser,
|
||||||
Document,
|
Document,
|
||||||
@ -43,6 +45,7 @@ Object.keys(models).forEach(modelName => {
|
|||||||
export {
|
export {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
Authentication,
|
Authentication,
|
||||||
|
Backlink,
|
||||||
Collection,
|
Collection,
|
||||||
CollectionUser,
|
CollectionUser,
|
||||||
Document,
|
Document,
|
||||||
|
@ -219,6 +219,11 @@ export default function Api() {
|
|||||||
id="collection"
|
id="collection"
|
||||||
description="Collection ID to filter by"
|
description="Collection ID to filter by"
|
||||||
/>
|
/>
|
||||||
|
<Argument id="user" description="User ID to filter by" />
|
||||||
|
<Argument
|
||||||
|
id="backlinkDocumentId"
|
||||||
|
description="Backlinked document ID to filter by"
|
||||||
|
/>
|
||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
|
97
server/services/backlinks.js
Normal file
97
server/services/backlinks.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// @flow
|
||||||
|
import { difference } from 'lodash';
|
||||||
|
import type { DocumentEvent } from '../events';
|
||||||
|
import { Document, Revision, Backlink } from '../models';
|
||||||
|
import parseDocumentIds from '../../shared/utils/parseDocumentIds';
|
||||||
|
|
||||||
|
export default class Backlinks {
|
||||||
|
async on(event: DocumentEvent) {
|
||||||
|
switch (event.name) {
|
||||||
|
case 'documents.publish': {
|
||||||
|
const document = await Document.findByPk(event.modelId);
|
||||||
|
const linkIds = parseDocumentIds(document.text);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
linkIds.map(async linkId => {
|
||||||
|
const linkedDocument = await Document.findByPk(linkId);
|
||||||
|
if (linkedDocument.id === event.modelId) return;
|
||||||
|
|
||||||
|
await Backlink.findOrCreate({
|
||||||
|
where: {
|
||||||
|
documentId: linkedDocument.id,
|
||||||
|
reverseDocumentId: event.modelId,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
userId: document.lastModifiedById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'documents.update': {
|
||||||
|
// no-op for now
|
||||||
|
if (event.autosave) return;
|
||||||
|
|
||||||
|
// no-op for drafts
|
||||||
|
const document = await Document.findByPk(event.modelId);
|
||||||
|
if (!document.publishedAt) return;
|
||||||
|
|
||||||
|
const [currentRevision, previsionRevision] = await Revision.findAll({
|
||||||
|
where: { documentId: event.modelId },
|
||||||
|
order: [['createdAt', 'desc']],
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
const previousLinkIds = parseDocumentIds(previsionRevision.text);
|
||||||
|
const currentLinkIds = parseDocumentIds(currentRevision.text);
|
||||||
|
const addedLinkIds = difference(currentLinkIds, previousLinkIds);
|
||||||
|
const removedLinkIds = difference(previousLinkIds, currentLinkIds);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
addedLinkIds.map(async linkId => {
|
||||||
|
const linkedDocument = await Document.findByPk(linkId);
|
||||||
|
if (linkedDocument.id === event.modelId) return;
|
||||||
|
|
||||||
|
await Backlink.findOrCreate({
|
||||||
|
where: {
|
||||||
|
documentId: linkedDocument.id,
|
||||||
|
reverseDocumentId: event.modelId,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
userId: currentRevision.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
removedLinkIds.map(async linkId => {
|
||||||
|
const document = await Document.findByPk(linkId);
|
||||||
|
await Backlink.destroy({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
reverseDocumentId: event.modelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'documents.delete': {
|
||||||
|
await Backlink.destroy({
|
||||||
|
where: {
|
||||||
|
reverseDocumentId: event.modelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Backlink.destroy({
|
||||||
|
where: {
|
||||||
|
documentId: event.modelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,6 +22,7 @@ const colors = {
|
|||||||
black50: 'rgba(0, 0, 0, 0.50)',
|
black50: 'rgba(0, 0, 0, 0.50)',
|
||||||
primary: '#1AB6FF',
|
primary: '#1AB6FF',
|
||||||
yellow: '#FBCA04',
|
yellow: '#FBCA04',
|
||||||
|
warmGrey: '#EDF2F7',
|
||||||
|
|
||||||
danger: '#D0021B',
|
danger: '#D0021B',
|
||||||
warning: '#f08a24',
|
warning: '#f08a24',
|
||||||
@ -44,7 +45,6 @@ export const base = {
|
|||||||
fontFamily:
|
fontFamily:
|
||||||
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif",
|
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
link: colors.primary,
|
|
||||||
backgroundTransition: 'background 100ms ease-in-out',
|
backgroundTransition: 'background 100ms ease-in-out',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
};
|
};
|
||||||
@ -53,12 +53,13 @@ export const light = {
|
|||||||
...base,
|
...base,
|
||||||
background: colors.white,
|
background: colors.white,
|
||||||
|
|
||||||
|
link: colors.almostBlack,
|
||||||
text: colors.almostBlack,
|
text: colors.almostBlack,
|
||||||
textSecondary: colors.slateDark,
|
textSecondary: colors.slateDark,
|
||||||
textTertiary: colors.slate,
|
textTertiary: colors.slate,
|
||||||
placeholder: '#B1BECC',
|
placeholder: '#B1BECC',
|
||||||
|
|
||||||
sidebarBackground: 'rgb(244, 247, 250)',
|
sidebarBackground: colors.warmGrey,
|
||||||
sidebarItemBackground: colors.black05,
|
sidebarItemBackground: colors.black05,
|
||||||
sidebarText: 'rgb(78, 92, 110)',
|
sidebarText: 'rgb(78, 92, 110)',
|
||||||
|
|
||||||
@ -69,8 +70,7 @@ export const light = {
|
|||||||
inputBorder: colors.slateLight,
|
inputBorder: colors.slateLight,
|
||||||
inputBorderFocused: colors.slate,
|
inputBorderFocused: colors.slate,
|
||||||
|
|
||||||
listItemHoverBackground: colors.smoke,
|
listItemHoverBackground: colors.warmGrey,
|
||||||
listItemHoverBorder: colors.smokeDark,
|
|
||||||
|
|
||||||
toolbarBackground: colors.lightBlack,
|
toolbarBackground: colors.lightBlack,
|
||||||
toolbarInput: colors.white10,
|
toolbarInput: colors.white10,
|
||||||
@ -104,6 +104,7 @@ export const dark = {
|
|||||||
...base,
|
...base,
|
||||||
background: colors.almostBlack,
|
background: colors.almostBlack,
|
||||||
|
|
||||||
|
link: colors.almostWhite,
|
||||||
text: colors.almostWhite,
|
text: colors.almostWhite,
|
||||||
textSecondary: lighten(0.2, colors.slate),
|
textSecondary: lighten(0.2, colors.slate),
|
||||||
textTertiary: colors.slate,
|
textTertiary: colors.slate,
|
||||||
@ -121,7 +122,6 @@ export const dark = {
|
|||||||
inputBorderFocused: colors.slate,
|
inputBorderFocused: colors.slate,
|
||||||
|
|
||||||
listItemHoverBackground: colors.black50,
|
listItemHoverBackground: colors.black50,
|
||||||
listItemHoverBorder: colors.black50,
|
|
||||||
|
|
||||||
toolbarBackground: colors.white,
|
toolbarBackground: colors.white,
|
||||||
toolbarInput: colors.black10,
|
toolbarInput: colors.black10,
|
||||||
|
29
shared/utils/parseDocumentIds.js
Normal file
29
shared/utils/parseDocumentIds.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// @flow
|
||||||
|
import MarkdownSerializer from 'slate-md-serializer';
|
||||||
|
const Markdown = new MarkdownSerializer();
|
||||||
|
|
||||||
|
export default function parseDocumentIds(text: string) {
|
||||||
|
const value = Markdown.deserialize(text);
|
||||||
|
let links = [];
|
||||||
|
|
||||||
|
function findLinks(node) {
|
||||||
|
if (node.type === 'link') {
|
||||||
|
const href = node.data.get('href');
|
||||||
|
|
||||||
|
if (href.startsWith('/doc')) {
|
||||||
|
const tokens = href.replace(/\/$/, '').split('/');
|
||||||
|
const lastToken = tokens[tokens.length - 1];
|
||||||
|
links.push(lastToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.nodes.forEach(findLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
findLinks(value.document);
|
||||||
|
return links;
|
||||||
|
}
|
20
shared/utils/parseDocumentIds.test.js
Normal file
20
shared/utils/parseDocumentIds.test.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
|
import parseDocumentIds from './parseDocumentIds';
|
||||||
|
|
||||||
|
it('should return an array of document ids', () => {
|
||||||
|
expect(parseDocumentIds(`# Header`).length).toBe(0);
|
||||||
|
expect(
|
||||||
|
parseDocumentIds(`# Header
|
||||||
|
|
||||||
|
[title](/doc/test-456733)
|
||||||
|
`)[0]
|
||||||
|
).toBe('test-456733');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return non document links', () => {
|
||||||
|
expect(parseDocumentIds(`[title](http://www.google.com)`).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return non document relative links', () => {
|
||||||
|
expect(parseDocumentIds(`[title](/developers)`).length).toBe(0);
|
||||||
|
});
|
Reference in New Issue
Block a user