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:
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user