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';
|
||||
|
||||
const ClickablePadding = styled.div`
|
||||
min-height: 6em;
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
`;
|
||||
|
@ -2,14 +2,13 @@
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Document from 'models/Document';
|
||||
import { StarredIcon } from 'outline-icons';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import { darken } from 'polished';
|
||||
import Flex from 'shared/components/Flex';
|
||||
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 Document from 'models/Document';
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
@ -45,8 +44,8 @@ const StyledDocumentMenu = styled(DocumentMenu)`
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
margin: 8px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
max-height: 50vh;
|
||||
@ -62,7 +61,6 @@ const DocumentLink = styled(Link)`
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
border: 2px solid ${props => props.theme.listItemHoverBorder};
|
||||
outline: none;
|
||||
|
||||
${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`
|
||||
|
@ -3,8 +3,10 @@ import * as React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
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 Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
import isInternalUrl from 'utils/isInternalUrl';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
@ -79,7 +81,7 @@ class Editor extends React.Component<Props> {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<RichMarkdownEditor
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
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} />;
|
||||
|
||||
export default withTheme(
|
||||
|
@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { darken } from 'polished';
|
||||
import styled from 'styled-components';
|
||||
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
import Flex from 'shared/components/Flex';
|
||||
@ -92,13 +91,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
border: 2px solid ${props => props.theme.listItemHoverBorder};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)};
|
||||
}
|
||||
`;
|
||||
|
||||
export default PathToDocument;
|
||||
|
@ -201,13 +201,21 @@ type Props = {
|
||||
offset?: number,
|
||||
};
|
||||
|
||||
const Tooltip = function({ offset = 0, ...rest }: Props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<GlobalStyles offset={offset} />
|
||||
<TooltipTrigger {...rest} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
class Tooltip extends React.Component<Props> {
|
||||
shouldComponentUpdate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { offset = 0, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<GlobalStyles offset={offset} />
|
||||
<TooltipTrigger {...rest} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
|
@ -22,6 +22,7 @@ import { emojiToUrl } from 'utils/emoji';
|
||||
import Header from './components/Header';
|
||||
import DocumentMove from './components/DocumentMove';
|
||||
import Branding from './components/Branding';
|
||||
import Backlinks from './components/Backlinks';
|
||||
import ErrorBoundary from 'components/ErrorBoundary';
|
||||
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
@ -415,6 +416,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
ui={this.props.ui}
|
||||
schema={schema}
|
||||
/>
|
||||
{!this.isEditing && (
|
||||
<Backlinks
|
||||
documents={this.props.documents}
|
||||
document={document}
|
||||
/>
|
||||
)}
|
||||
</MaxWidth>
|
||||
</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
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Editor from 'components/Editor';
|
||||
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
|
||||
import ClickablePadding from 'components/ClickablePadding';
|
||||
import plugins from './plugins';
|
||||
|
||||
@ -33,7 +31,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<StyledEditor
|
||||
<Editor
|
||||
ref={ref => (this.editor = ref)}
|
||||
plugins={plugins}
|
||||
{...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;
|
||||
|
@ -24,6 +24,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
@observable recentlyViewedIds: string[] = [];
|
||||
@observable searchCache: Map<string, SearchResult[]> = new Map();
|
||||
@observable starredIds: Map<string, boolean> = new Map();
|
||||
@observable backlinks: Map<string, string[]> = new Map();
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Document);
|
||||
@ -140,6 +141,28 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
: 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
|
||||
fetchNamedPage = async (
|
||||
request: string = 'list',
|
||||
|
@ -9,10 +9,19 @@ import {
|
||||
presentCollection,
|
||||
presentRevision,
|
||||
} 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 events from '../events';
|
||||
import policy from '../policies';
|
||||
import { sequelize } from '../sequelize';
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
const { authorize, cannot } = policy;
|
||||
@ -22,6 +31,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
||||
const { sort = 'updatedAt' } = ctx.body;
|
||||
const collectionId = ctx.body.collection;
|
||||
const createdById = ctx.body.user;
|
||||
const backlinkDocumentId = ctx.body.backlinkDocumentId;
|
||||
let direction = ctx.body.direction;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
|
||||
@ -50,6 +60,20 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
||||
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
|
||||
const starredScope = { method: ['withStarred', user.id] };
|
||||
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
||||
@ -620,7 +644,7 @@ router.post('documents.update', auth(), async ctx => {
|
||||
|
||||
// Update document
|
||||
if (title) document.title = title;
|
||||
//append to document
|
||||
|
||||
if (append) {
|
||||
document.text += text;
|
||||
} else if (text) {
|
||||
@ -628,28 +652,40 @@ router.post('documents.update', auth(), async ctx => {
|
||||
}
|
||||
document.lastModifiedById = user.id;
|
||||
|
||||
if (publish) {
|
||||
await document.publish();
|
||||
let transaction;
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
events.add({
|
||||
name: 'documents.publish',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
} else {
|
||||
await document.save({ autosave });
|
||||
if (publish) {
|
||||
await document.publish({ transaction });
|
||||
await transaction.commit();
|
||||
|
||||
events.add({
|
||||
name: 'documents.update',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
autosave,
|
||||
done,
|
||||
});
|
||||
events.add({
|
||||
name: 'documents.publish',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
} else {
|
||||
await document.save({ autosave, transaction });
|
||||
await transaction.commit();
|
||||
|
||||
events.add({
|
||||
name: 'documents.update',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
autosave,
|
||||
done,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
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 {
|
||||
buildShare,
|
||||
@ -252,6 +252,31 @@ describe('#documents.list', async () => {
|
||||
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 () => {
|
||||
const res = await server.post('/api/documents.list');
|
||||
const body = await res.json();
|
||||
|
@ -6,7 +6,7 @@ import mailer from '../mailer';
|
||||
|
||||
type Invite = { name: string, email: string };
|
||||
|
||||
export default async function documentMover({
|
||||
export default async function userInviter({
|
||||
user,
|
||||
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
|
||||
if (doc.text === doc.previous('text')) return;
|
||||
|
||||
return Revision.create({
|
||||
title: doc.title,
|
||||
text: doc.text,
|
||||
userId: doc.lastModifiedById,
|
||||
documentId: doc.id,
|
||||
});
|
||||
return Revision.create(
|
||||
{
|
||||
title: doc.title,
|
||||
text: doc.text,
|
||||
userId: doc.lastModifiedById,
|
||||
documentId: doc.id,
|
||||
},
|
||||
{
|
||||
transaction: options.transaction,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createUrlId = doc => {
|
||||
@ -141,6 +146,9 @@ Document.associate = models => {
|
||||
as: 'revisions',
|
||||
onDelete: 'cascade',
|
||||
});
|
||||
Document.hasMany(models.Backlink, {
|
||||
as: 'backlinks',
|
||||
});
|
||||
Document.hasMany(models.Star, {
|
||||
as: 'starred',
|
||||
});
|
||||
@ -363,16 +371,16 @@ Document.prototype.archiveWithChildren = async function(userId, options) {
|
||||
return this.save(options);
|
||||
};
|
||||
|
||||
Document.prototype.publish = async function() {
|
||||
if (this.publishedAt) return this.save();
|
||||
Document.prototype.publish = async function(options) {
|
||||
if (this.publishedAt) return this.save(options);
|
||||
|
||||
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);
|
||||
|
||||
this.publishedAt = new Date();
|
||||
await this.save();
|
||||
await this.save(options);
|
||||
this.collection = collection;
|
||||
|
||||
return this;
|
||||
|
@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import ApiKey from './ApiKey';
|
||||
import Authentication from './Authentication';
|
||||
import Backlink from './Backlink';
|
||||
import Collection from './Collection';
|
||||
import CollectionUser from './CollectionUser';
|
||||
import Document from './Document';
|
||||
@ -18,6 +19,7 @@ import View from './View';
|
||||
const models = {
|
||||
ApiKey,
|
||||
Authentication,
|
||||
Backlink,
|
||||
Collection,
|
||||
CollectionUser,
|
||||
Document,
|
||||
@ -43,6 +45,7 @@ Object.keys(models).forEach(modelName => {
|
||||
export {
|
||||
ApiKey,
|
||||
Authentication,
|
||||
Backlink,
|
||||
Collection,
|
||||
CollectionUser,
|
||||
Document,
|
||||
|
@ -219,6 +219,11 @@ export default function Api() {
|
||||
id="collection"
|
||||
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>
|
||||
</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)',
|
||||
primary: '#1AB6FF',
|
||||
yellow: '#FBCA04',
|
||||
warmGrey: '#EDF2F7',
|
||||
|
||||
danger: '#D0021B',
|
||||
warning: '#f08a24',
|
||||
@ -44,7 +45,6 @@ export const base = {
|
||||
fontFamily:
|
||||
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif",
|
||||
fontWeight: 400,
|
||||
link: colors.primary,
|
||||
backgroundTransition: 'background 100ms ease-in-out',
|
||||
zIndex: 100,
|
||||
};
|
||||
@ -53,12 +53,13 @@ export const light = {
|
||||
...base,
|
||||
background: colors.white,
|
||||
|
||||
link: colors.almostBlack,
|
||||
text: colors.almostBlack,
|
||||
textSecondary: colors.slateDark,
|
||||
textTertiary: colors.slate,
|
||||
placeholder: '#B1BECC',
|
||||
|
||||
sidebarBackground: 'rgb(244, 247, 250)',
|
||||
sidebarBackground: colors.warmGrey,
|
||||
sidebarItemBackground: colors.black05,
|
||||
sidebarText: 'rgb(78, 92, 110)',
|
||||
|
||||
@ -69,8 +70,7 @@ export const light = {
|
||||
inputBorder: colors.slateLight,
|
||||
inputBorderFocused: colors.slate,
|
||||
|
||||
listItemHoverBackground: colors.smoke,
|
||||
listItemHoverBorder: colors.smokeDark,
|
||||
listItemHoverBackground: colors.warmGrey,
|
||||
|
||||
toolbarBackground: colors.lightBlack,
|
||||
toolbarInput: colors.white10,
|
||||
@ -104,6 +104,7 @@ export const dark = {
|
||||
...base,
|
||||
background: colors.almostBlack,
|
||||
|
||||
link: colors.almostWhite,
|
||||
text: colors.almostWhite,
|
||||
textSecondary: lighten(0.2, colors.slate),
|
||||
textTertiary: colors.slate,
|
||||
@ -121,7 +122,6 @@ export const dark = {
|
||||
inputBorderFocused: colors.slate,
|
||||
|
||||
listItemHoverBackground: colors.black50,
|
||||
listItemHoverBorder: colors.black50,
|
||||
|
||||
toolbarBackground: colors.white,
|
||||
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