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:
Tom Moor
2019-07-07 19:25:45 -07:00
committed by GitHub
parent 599e5c8f5d
commit 091e542406
23 changed files with 538 additions and 89 deletions

View File

@ -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;`};
`; `;

View File

@ -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`

View File

@ -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(

View File

@ -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;

View File

@ -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;

View File

@ -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>

View 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;

View 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;

View File

@ -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;

View File

@ -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',

View File

@ -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),

View File

@ -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();

View File

@ -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,
}: { }: {

View 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
View 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;

View File

@ -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;

View File

@ -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,

View File

@ -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>

View 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:
}
}
}

View File

@ -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,

View 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;
}

View 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);
});