diff --git a/server/models/Document.js b/server/models/Document.js index 36e3eefd..c542d7c2 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -1,5 +1,4 @@ // @flow -import slug from 'slug'; import { map, find, compact, uniq } from 'lodash'; import randomstring from 'randomstring'; import MarkdownSerializer from 'slate-md-serializer'; @@ -12,6 +11,7 @@ import { Collection, User } from '../models'; import { DataTypes, sequelize } from '../sequelize'; import parseTitle from '../../shared/utils/parseTitle'; import unescape from '../../shared/utils/unescape'; +import slugify from '../utils/slugify'; import Revision from './Revision'; const Op = Sequelize.Op; @@ -20,18 +20,17 @@ const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/; export const DOCUMENT_VERSION = 1; -slug.defaults.mode = 'rfc3986'; -const slugify = text => - slug(text, { - remove: /[.]/g, - }); - const createRevision = (doc, options = {}) => { // we don't create revisions for autosaves if (options.autosave) return; // we don't create revisions if identical to previous - if (doc.text === doc.previous('text')) return; + if ( + doc.text === doc.previous('text') && + doc.title === doc.previous('title') + ) { + return; + } return Revision.create( { diff --git a/server/services/backlinks.js b/server/services/backlinks.js index 2502dd9c..7e7b65bb 100644 --- a/server/services/backlinks.js +++ b/server/services/backlinks.js @@ -3,6 +3,7 @@ import { difference } from 'lodash'; import type { DocumentEvent } from '../events'; import { Document, Revision, Backlink } from '../models'; import parseDocumentIds from '../../shared/utils/parseDocumentIds'; +import slugify from '../utils/slugify'; export default class Backlinks { async on(event: DocumentEvent) { @@ -50,6 +51,7 @@ export default class Backlinks { const addedLinkIds = difference(currentLinkIds, previousLinkIds); const removedLinkIds = difference(previousLinkIds, currentLinkIds); + // add any new backlinks that were created await Promise.all( addedLinkIds.map(async linkId => { const linkedDocument = await Document.findByPk(linkId); @@ -67,6 +69,7 @@ export default class Backlinks { }) ); + // delete any backlinks that were removed await Promise.all( removedLinkIds.map(async linkId => { const document = await Document.findByPk(linkId); @@ -78,6 +81,39 @@ export default class Backlinks { }); }) ); + + if (currentRevision.title === previousRevision.title) { + break; + } + + // update any link titles in documents that lead to this one + const backlinks = await Backlink.findAll({ + where: { + documentId: event.documentId, + }, + include: [{ model: Document, as: 'reverseDocument' }], + }); + + await Promise.all( + backlinks.map(async backlink => { + const previousUrl = `/doc/${slugify(previousRevision.title)}-${ + document.urlId + }`; + + // find links in the other document that lead to this one and have + // the old title as anchor text. Go ahead and update those to the + // new title automatically + backlink.reverseDocument.text = backlink.reverseDocument.text.replace( + `[${previousRevision.title}](${previousUrl})`, + `[${document.title}](${document.url})` + ); + await backlink.reverseDocument.save({ + silent: true, + hooks: false, + }); + }) + ); + break; } case 'documents.delete': { diff --git a/server/services/backlinks.test.js b/server/services/backlinks.test.js new file mode 100644 index 00000000..adef6e5c --- /dev/null +++ b/server/services/backlinks.test.js @@ -0,0 +1,106 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import { flushdb } from '../test/support'; +import BacklinksService from './backlinks'; +import { buildDocument } from '../test/factories'; +import Backlink from '../models/Backlink'; + +const Backlinks = new BacklinksService(); + +beforeEach(flushdb); +beforeEach(jest.resetAllMocks); + +describe('documents.update', () => { + test('should create new backlink records', async () => { + const otherDocument = await buildDocument(); + const document = await buildDocument(); + + document.text = `[this is a link](${otherDocument.url})`; + await document.save(); + + await Backlinks.on({ + name: 'documents.update', + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { autosave: false }, + }); + + const backlinks = await Backlink.findAll({ + where: { reverseDocumentId: document.id }, + }); + + expect(backlinks.length).toBe(1); + }); + + test('should destroy removed backlink records', async () => { + const otherDocument = await buildDocument(); + const document = await buildDocument({ + text: `[this is a link](${otherDocument.url})`, + }); + + await Backlinks.on({ + name: 'documents.publish', + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { autosave: false }, + }); + + document.text = 'Link is gone'; + await document.save(); + + await Backlinks.on({ + name: 'documents.update', + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { autosave: false }, + }); + + const backlinks = await Backlink.findAll({ + where: { reverseDocumentId: document.id }, + }); + + expect(backlinks.length).toBe(0); + }); + + test('should update titles in backlinked documents', async () => { + const newTitle = 'test'; + const document = await buildDocument(); + const otherDocument = await buildDocument(); + + // create a doc with a link back + document.text = `[${otherDocument.title}](${otherDocument.url})`; + await document.save(); + + // ensure the backlinks are created + await Backlinks.on({ + name: 'documents.update', + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { autosave: false }, + }); + + // change the title of the linked doc + otherDocument.title = newTitle; + await otherDocument.save(); + + // does the text get updated with the new title + await Backlinks.on({ + name: 'documents.update', + documentId: otherDocument.id, + collectionId: otherDocument.collectionId, + teamId: otherDocument.teamId, + actorId: otherDocument.createdById, + data: { autosave: false }, + }); + await document.reload(); + + expect(document.text).toBe(`[${newTitle}](${otherDocument.url})`); + }); +}); diff --git a/server/utils/slugify.js b/server/utils/slugify.js new file mode 100644 index 00000000..e8243b7e --- /dev/null +++ b/server/utils/slugify.js @@ -0,0 +1,10 @@ +// @flow +import slug from 'slug'; + +slug.defaults.mode = 'rfc3986'; + +export default function slugify(text: string): string { + return slug(text, { + remove: /[.]/g, + }); +}