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

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

View File

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

View File

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

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

View File

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

View File

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

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