2017-06-04 21:40:27 +00:00
|
|
|
// @flow
|
2020-08-09 05:53:59 +00:00
|
|
|
import removeMarkdown from "@tommoor/remove-markdown";
|
2020-06-20 20:59:15 +00:00
|
|
|
import { map, find, compact, uniq } from "lodash";
|
|
|
|
import randomstring from "randomstring";
|
|
|
|
import Sequelize, { type Transaction } from "sequelize";
|
2020-08-09 05:53:59 +00:00
|
|
|
import MarkdownSerializer from "slate-md-serializer";
|
2020-06-20 20:59:15 +00:00
|
|
|
|
|
|
|
import isUUID from "validator/lib/isUUID";
|
|
|
|
import parseTitle from "../../shared/utils/parseTitle";
|
|
|
|
import unescape from "../../shared/utils/unescape";
|
2020-08-09 05:53:59 +00:00
|
|
|
import { Collection, User } from "../models";
|
|
|
|
import { DataTypes, sequelize } from "../sequelize";
|
2020-06-20 20:59:15 +00:00
|
|
|
import slugify from "../utils/slugify";
|
|
|
|
import Revision from "./Revision";
|
2016-04-29 05:25:37 +00:00
|
|
|
|
2018-05-05 23:16:08 +00:00
|
|
|
const Op = Sequelize.Op;
|
2020-02-12 17:14:42 +00:00
|
|
|
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
2020-05-20 03:39:34 +00:00
|
|
|
const serializer = new MarkdownSerializer();
|
2020-04-05 22:07:34 +00:00
|
|
|
|
2020-05-20 03:39:34 +00:00
|
|
|
export const DOCUMENT_VERSION = 2;
|
2017-06-26 00:21:33 +00:00
|
|
|
|
2018-05-07 05:13:52 +00:00
|
|
|
const createRevision = (doc, options = {}) => {
|
2018-09-30 04:24:07 +00:00
|
|
|
// we don't create revisions for autosaves
|
2018-05-07 05:13:52 +00:00
|
|
|
if (options.autosave) return;
|
|
|
|
|
2018-09-30 04:24:07 +00:00
|
|
|
// we don't create revisions if identical to previous
|
2020-04-20 04:58:42 +00:00
|
|
|
if (
|
2020-06-20 20:59:15 +00:00
|
|
|
doc.text === doc.previous("text") &&
|
|
|
|
doc.title === doc.previous("title")
|
2020-04-20 04:58:42 +00:00
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
2018-09-30 04:24:07 +00:00
|
|
|
|
2019-07-08 02:25:45 +00:00
|
|
|
return Revision.create(
|
|
|
|
{
|
|
|
|
title: doc.title,
|
|
|
|
text: doc.text,
|
|
|
|
userId: doc.lastModifiedById,
|
2020-03-16 15:30:23 +00:00
|
|
|
editorVersion: doc.editorVersion,
|
2020-04-05 22:07:34 +00:00
|
|
|
version: doc.version,
|
2019-07-08 02:25:45 +00:00
|
|
|
documentId: doc.id,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
transaction: options.transaction,
|
|
|
|
}
|
|
|
|
);
|
2016-08-12 13:36:48 +00:00
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
const createUrlId = (doc) => {
|
2017-06-26 00:21:33 +00:00
|
|
|
return (doc.urlId = doc.urlId || randomstring.generate(10));
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
const beforeCreate = async (doc) => {
|
2020-05-20 03:39:34 +00:00
|
|
|
if (doc.version === undefined) {
|
|
|
|
doc.version = DOCUMENT_VERSION;
|
|
|
|
}
|
2020-04-05 22:07:34 +00:00
|
|
|
return beforeSave(doc);
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
const beforeSave = async (doc) => {
|
2020-04-05 22:07:34 +00:00
|
|
|
const { emoji } = parseTitle(doc.text);
|
2017-07-29 23:15:04 +00:00
|
|
|
|
2018-02-01 03:23:33 +00:00
|
|
|
// emoji in the title is split out for easier display
|
2017-07-29 23:15:04 +00:00
|
|
|
doc.emoji = emoji;
|
2016-08-15 10:51:26 +00:00
|
|
|
|
2019-09-23 00:09:11 +00:00
|
|
|
// ensure documents have a title
|
2020-06-20 20:59:15 +00:00
|
|
|
doc.title = doc.title || "";
|
2018-02-01 03:23:33 +00:00
|
|
|
|
2018-09-30 04:24:07 +00:00
|
|
|
// add the current user as a collaborator on this doc
|
|
|
|
if (!doc.collaboratorIds) doc.collaboratorIds = [];
|
|
|
|
doc.collaboratorIds = uniq(doc.collaboratorIds.concat(doc.lastModifiedById));
|
2016-08-15 10:51:26 +00:00
|
|
|
|
2018-02-01 03:23:33 +00:00
|
|
|
// increment revision
|
|
|
|
doc.revisionCount += 1;
|
|
|
|
|
2016-06-26 18:23:03 +00:00
|
|
|
return doc;
|
|
|
|
};
|
|
|
|
|
2017-04-27 04:47:03 +00:00
|
|
|
const Document = sequelize.define(
|
2020-06-20 20:59:15 +00:00
|
|
|
"document",
|
2017-04-27 04:47:03 +00:00
|
|
|
{
|
|
|
|
id: {
|
|
|
|
type: DataTypes.UUID,
|
|
|
|
defaultValue: DataTypes.UUIDV4,
|
|
|
|
primaryKey: true,
|
|
|
|
},
|
2018-08-07 05:22:15 +00:00
|
|
|
urlId: {
|
|
|
|
type: DataTypes.STRING,
|
|
|
|
primaryKey: true,
|
|
|
|
},
|
|
|
|
title: {
|
|
|
|
type: DataTypes.STRING,
|
|
|
|
validate: {
|
|
|
|
len: {
|
|
|
|
args: [0, 100],
|
2020-06-20 20:59:15 +00:00
|
|
|
msg: "Document title must be less than 100 characters",
|
2018-08-07 05:22:15 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2020-04-05 22:07:34 +00:00
|
|
|
version: DataTypes.SMALLINT,
|
2020-08-08 22:18:37 +00:00
|
|
|
template: DataTypes.BOOLEAN,
|
2020-03-16 15:30:23 +00:00
|
|
|
editorVersion: DataTypes.STRING,
|
2017-04-27 04:47:03 +00:00
|
|
|
text: DataTypes.TEXT,
|
2020-05-20 03:39:34 +00:00
|
|
|
|
|
|
|
// backup contains a record of text at the moment it was converted to v2
|
|
|
|
// this is a safety measure during deployment of new editor and will be
|
|
|
|
// dropped in a future update
|
|
|
|
backup: DataTypes.TEXT,
|
2019-07-04 17:33:00 +00:00
|
|
|
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
|
2017-04-27 04:47:03 +00:00
|
|
|
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
2019-04-06 23:20:27 +00:00
|
|
|
archivedAt: DataTypes.DATE,
|
2018-02-28 06:41:12 +00:00
|
|
|
publishedAt: DataTypes.DATE,
|
2017-04-27 04:47:03 +00:00
|
|
|
parentDocumentId: DataTypes.UUID,
|
|
|
|
collaboratorIds: DataTypes.ARRAY(DataTypes.UUID),
|
2016-06-26 18:23:03 +00:00
|
|
|
},
|
2017-04-27 04:47:03 +00:00
|
|
|
{
|
|
|
|
paranoid: true,
|
|
|
|
hooks: {
|
2017-06-26 00:21:33 +00:00
|
|
|
beforeValidate: createUrlId,
|
2020-04-05 22:07:34 +00:00
|
|
|
beforeCreate: beforeCreate,
|
2017-06-26 00:21:33 +00:00
|
|
|
beforeUpdate: beforeSave,
|
|
|
|
afterCreate: createRevision,
|
|
|
|
afterUpdate: createRevision,
|
2016-05-26 05:38:45 +00:00
|
|
|
},
|
2018-11-04 07:59:52 +00:00
|
|
|
getterMethods: {
|
2020-08-09 01:53:11 +00:00
|
|
|
url: function () {
|
2018-11-04 07:59:52 +00:00
|
|
|
const slugifiedTitle = slugify(this.title);
|
|
|
|
return `/doc/${slugifiedTitle}-${this.urlId}`;
|
|
|
|
},
|
|
|
|
},
|
2017-07-12 07:28:18 +00:00
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
// Class methods
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.associate = (models) => {
|
2017-07-12 07:28:18 +00:00
|
|
|
Document.belongsTo(models.Collection, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "collection",
|
|
|
|
foreignKey: "collectionId",
|
|
|
|
onDelete: "cascade",
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
2018-04-04 03:36:25 +00:00
|
|
|
Document.belongsTo(models.Team, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "team",
|
|
|
|
foreignKey: "teamId",
|
2018-04-04 03:36:25 +00:00
|
|
|
});
|
2020-08-08 22:18:37 +00:00
|
|
|
Document.belongsTo(models.Document, {
|
|
|
|
as: "document",
|
|
|
|
foreignKey: "templateId",
|
|
|
|
});
|
2017-07-12 07:28:18 +00:00
|
|
|
Document.belongsTo(models.User, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "createdBy",
|
|
|
|
foreignKey: "createdById",
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
|
|
|
Document.belongsTo(models.User, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "updatedBy",
|
|
|
|
foreignKey: "lastModifiedById",
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
2018-03-01 07:28:36 +00:00
|
|
|
Document.belongsTo(models.User, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "pinnedBy",
|
|
|
|
foreignKey: "pinnedById",
|
2018-03-01 07:28:36 +00:00
|
|
|
});
|
2017-08-29 15:37:17 +00:00
|
|
|
Document.hasMany(models.Revision, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "revisions",
|
|
|
|
onDelete: "cascade",
|
2017-08-29 15:37:17 +00:00
|
|
|
});
|
2019-07-08 02:25:45 +00:00
|
|
|
Document.hasMany(models.Backlink, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "backlinks",
|
|
|
|
onDelete: "cascade",
|
2019-07-08 02:25:45 +00:00
|
|
|
});
|
2017-07-12 07:28:18 +00:00
|
|
|
Document.hasMany(models.Star, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "starred",
|
|
|
|
onDelete: "cascade",
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
2017-07-16 16:24:45 +00:00
|
|
|
Document.hasMany(models.View, {
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "views",
|
2017-07-16 16:24:45 +00:00
|
|
|
});
|
2020-06-20 20:59:15 +00:00
|
|
|
Document.addScope("defaultScope", {
|
2019-10-06 01:42:03 +00:00
|
|
|
include: [
|
2020-06-20 20:59:15 +00:00
|
|
|
{ model: models.User, as: "createdBy", paranoid: false },
|
|
|
|
{ model: models.User, as: "updatedBy", paranoid: false },
|
2019-10-06 01:42:03 +00:00
|
|
|
],
|
|
|
|
where: {
|
|
|
|
publishedAt: {
|
|
|
|
[Op.ne]: null,
|
2018-02-28 06:41:12 +00:00
|
|
|
},
|
2016-06-21 06:12:56 +00:00
|
|
|
},
|
2019-10-06 01:42:03 +00:00
|
|
|
});
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.addScope("withCollection", (userId) => {
|
2019-10-06 01:42:03 +00:00
|
|
|
if (userId) {
|
|
|
|
return {
|
|
|
|
include: [
|
|
|
|
{
|
2020-03-15 03:48:32 +00:00
|
|
|
model: models.Collection.scope({
|
2020-06-20 20:59:15 +00:00
|
|
|
method: ["withMembership", userId],
|
2020-03-15 03:48:32 +00:00
|
|
|
}),
|
2020-06-20 20:59:15 +00:00
|
|
|
as: "collection",
|
2019-10-06 01:42:03 +00:00
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2020-06-20 20:59:15 +00:00
|
|
|
include: [{ model: models.Collection, as: "collection" }],
|
2019-10-06 01:42:03 +00:00
|
|
|
};
|
|
|
|
});
|
2020-06-20 20:59:15 +00:00
|
|
|
Document.addScope("withUnpublished", {
|
2018-02-28 06:41:12 +00:00
|
|
|
include: [
|
2020-06-20 20:59:15 +00:00
|
|
|
{ model: models.User, as: "createdBy", paranoid: false },
|
|
|
|
{ model: models.User, as: "updatedBy", paranoid: false },
|
2018-02-28 06:41:12 +00:00
|
|
|
],
|
|
|
|
});
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.addScope("withViews", (userId) => ({
|
2017-07-16 16:24:45 +00:00
|
|
|
include: [
|
2020-06-20 20:59:15 +00:00
|
|
|
{ model: models.View, as: "views", where: { userId }, required: false },
|
2017-07-16 16:24:45 +00:00
|
|
|
],
|
|
|
|
}));
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.addScope("withStarred", (userId) => ({
|
2017-07-15 23:08:12 +00:00
|
|
|
include: [
|
2020-06-20 20:59:15 +00:00
|
|
|
{ model: models.Star, as: "starred", where: { userId }, required: false },
|
2017-07-15 23:08:12 +00:00
|
|
|
],
|
|
|
|
}));
|
2017-07-12 07:28:18 +00:00
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.findByPk = async function (id, options = {}) {
|
2019-10-06 01:42:03 +00:00
|
|
|
// allow default preloading of collection membership if `userId` is passed in find options
|
|
|
|
// almost every endpoint needs the collection membership to determine policy permissions.
|
2020-06-20 20:59:15 +00:00
|
|
|
const scope = this.scope("withUnpublished", {
|
|
|
|
method: ["withCollection", options.userId],
|
2019-10-06 01:42:03 +00:00
|
|
|
});
|
2018-02-28 06:41:12 +00:00
|
|
|
|
2017-07-12 07:28:18 +00:00
|
|
|
if (isUUID(id)) {
|
2018-02-28 06:41:12 +00:00
|
|
|
return scope.findOne({
|
2017-07-12 07:28:18 +00:00
|
|
|
where: { id },
|
2019-04-06 23:20:27 +00:00
|
|
|
...options,
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
|
|
|
} else if (id.match(URL_REGEX)) {
|
2018-02-28 06:41:12 +00:00
|
|
|
return scope.findOne({
|
2017-07-12 07:28:18 +00:00
|
|
|
where: {
|
|
|
|
urlId: id.match(URL_REGEX)[1],
|
2017-06-26 00:21:33 +00:00
|
|
|
},
|
2019-04-06 23:20:27 +00:00
|
|
|
...options,
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
2016-08-23 06:37:01 +00:00
|
|
|
|
2018-08-05 01:32:56 +00:00
|
|
|
type SearchResult = {
|
|
|
|
ranking: number,
|
|
|
|
context: string,
|
|
|
|
document: Document,
|
|
|
|
};
|
|
|
|
|
2019-09-22 18:52:15 +00:00
|
|
|
type SearchOptions = {
|
|
|
|
limit?: number,
|
|
|
|
offset?: number,
|
|
|
|
collectionId?: string,
|
2020-06-20 20:59:15 +00:00
|
|
|
dateFilter?: "day" | "week" | "month" | "year",
|
2019-09-22 18:52:15 +00:00
|
|
|
collaboratorIds?: string[],
|
|
|
|
includeArchived?: boolean,
|
2020-01-06 01:24:57 +00:00
|
|
|
includeDrafts?: boolean,
|
2019-09-22 18:52:15 +00:00
|
|
|
};
|
|
|
|
|
2020-06-04 06:59:59 +00:00
|
|
|
function escape(query: string): string {
|
|
|
|
// replace "\" with escaped "\\" because sequelize.escape doesn't do it
|
|
|
|
// https://github.com/sequelize/sequelize/issues/2950
|
2020-06-20 20:59:15 +00:00
|
|
|
return sequelize.escape(query).replace("\\", "\\\\");
|
2020-06-04 06:59:59 +00:00
|
|
|
}
|
|
|
|
|
2019-09-22 18:52:15 +00:00
|
|
|
Document.searchForTeam = async (
|
|
|
|
team,
|
|
|
|
query,
|
|
|
|
options: SearchOptions = {}
|
|
|
|
): Promise<SearchResult[]> => {
|
|
|
|
const limit = options.limit || 15;
|
|
|
|
const offset = options.offset || 0;
|
2020-06-04 06:59:59 +00:00
|
|
|
const wildcardQuery = `${escape(query)}:*`;
|
2019-09-22 18:52:15 +00:00
|
|
|
const collectionIds = await team.collectionIds();
|
|
|
|
|
2020-05-11 01:49:57 +00:00
|
|
|
// If the team has access no public collections then shortcircuit the rest of this
|
|
|
|
if (!collectionIds.length) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
2019-09-22 18:52:15 +00:00
|
|
|
// Build the SQL query to get documentIds, ranking, and search term context
|
|
|
|
const sql = `
|
|
|
|
SELECT
|
|
|
|
id,
|
|
|
|
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
|
|
|
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
|
|
|
|
FROM documents
|
|
|
|
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
|
|
|
"teamId" = :teamId AND
|
|
|
|
"collectionId" IN(:collectionIds) AND
|
|
|
|
"deletedAt" IS NULL AND
|
|
|
|
"publishedAt" IS NOT NULL
|
2020-03-15 03:48:32 +00:00
|
|
|
ORDER BY
|
2019-09-22 18:52:15 +00:00
|
|
|
"searchRanking" DESC,
|
|
|
|
"updatedAt" DESC
|
|
|
|
LIMIT :limit
|
|
|
|
OFFSET :offset;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const results = await sequelize.query(sql, {
|
|
|
|
type: sequelize.QueryTypes.SELECT,
|
|
|
|
replacements: {
|
|
|
|
teamId: team.id,
|
|
|
|
query: wildcardQuery,
|
|
|
|
limit,
|
|
|
|
offset,
|
|
|
|
collectionIds,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
// Final query to get associated document data
|
|
|
|
const documents = await Document.findAll({
|
|
|
|
where: {
|
2020-06-20 20:59:15 +00:00
|
|
|
id: map(results, "id"),
|
2019-09-22 18:52:15 +00:00
|
|
|
},
|
|
|
|
include: [
|
2020-06-20 20:59:15 +00:00
|
|
|
{ model: Collection, as: "collection" },
|
|
|
|
{ model: User, as: "createdBy", paranoid: false },
|
|
|
|
{ model: User, as: "updatedBy", paranoid: false },
|
2019-09-22 18:52:15 +00:00
|
|
|
],
|
|
|
|
});
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
return map(results, (result) => ({
|
2019-09-22 18:52:15 +00:00
|
|
|
ranking: result.searchRanking,
|
|
|
|
context: removeMarkdown(unescape(result.searchContext), {
|
|
|
|
stripHTML: false,
|
|
|
|
}),
|
|
|
|
document: find(documents, { id: result.id }),
|
|
|
|
}));
|
|
|
|
};
|
|
|
|
|
2017-12-03 19:04:17 +00:00
|
|
|
Document.searchForUser = async (
|
|
|
|
user,
|
|
|
|
query,
|
2019-09-22 18:52:15 +00:00
|
|
|
options: SearchOptions = {}
|
2018-08-05 01:32:56 +00:00
|
|
|
): Promise<SearchResult[]> => {
|
2017-07-12 07:28:18 +00:00
|
|
|
const limit = options.limit || 15;
|
|
|
|
const offset = options.offset || 0;
|
2020-06-04 06:59:59 +00:00
|
|
|
const wildcardQuery = `${escape(query)}:*`;
|
2017-07-12 07:28:18 +00:00
|
|
|
|
2019-04-23 14:31:20 +00:00
|
|
|
// Ensure we're filtering by the users accessible collections. If
|
|
|
|
// collectionId is passed as an option it is assumed that the authorization
|
|
|
|
// has already been done in the router
|
|
|
|
let collectionIds;
|
|
|
|
if (options.collectionId) {
|
|
|
|
collectionIds = [options.collectionId];
|
|
|
|
} else {
|
|
|
|
collectionIds = await user.collectionIds();
|
|
|
|
}
|
|
|
|
|
2020-05-11 01:49:57 +00:00
|
|
|
// If the user has access to no collections then shortcircuit the rest of this
|
|
|
|
if (!collectionIds.length) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
2019-04-23 14:31:20 +00:00
|
|
|
let dateFilter;
|
|
|
|
if (options.dateFilter) {
|
|
|
|
dateFilter = `1 ${options.dateFilter}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Build the SQL query to get documentIds, ranking, and search term context
|
2017-07-12 07:28:18 +00:00
|
|
|
const sql = `
|
2019-04-23 14:31:20 +00:00
|
|
|
SELECT
|
|
|
|
id,
|
|
|
|
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
|
|
|
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
|
|
|
|
FROM documents
|
|
|
|
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
|
|
|
"teamId" = :teamId AND
|
|
|
|
"collectionId" IN(:collectionIds) AND
|
|
|
|
${
|
2020-06-20 20:59:15 +00:00
|
|
|
options.dateFilter ? '"updatedAt" > now() - interval :dateFilter AND' : ""
|
2019-04-23 14:31:20 +00:00
|
|
|
}
|
|
|
|
${
|
|
|
|
options.collaboratorIds
|
|
|
|
? '"collaboratorIds" @> ARRAY[:collaboratorIds]::uuid[] AND'
|
2020-06-20 20:59:15 +00:00
|
|
|
: ""
|
2019-04-23 14:31:20 +00:00
|
|
|
}
|
2020-06-20 20:59:15 +00:00
|
|
|
${options.includeArchived ? "" : '"archivedAt" IS NULL AND'}
|
2019-04-23 14:31:20 +00:00
|
|
|
"deletedAt" IS NULL AND
|
2020-01-06 01:24:57 +00:00
|
|
|
${
|
|
|
|
options.includeDrafts
|
|
|
|
? '("publishedAt" IS NOT NULL OR "createdById" = :userId)'
|
|
|
|
: '"publishedAt" IS NOT NULL'
|
2020-03-15 03:48:32 +00:00
|
|
|
}
|
|
|
|
ORDER BY
|
2019-04-23 14:31:20 +00:00
|
|
|
"searchRanking" DESC,
|
|
|
|
"updatedAt" DESC
|
|
|
|
LIMIT :limit
|
|
|
|
OFFSET :offset;
|
|
|
|
`;
|
|
|
|
|
2018-05-07 05:13:52 +00:00
|
|
|
const results = await sequelize.query(sql, {
|
2018-08-05 01:32:56 +00:00
|
|
|
type: sequelize.QueryTypes.SELECT,
|
2018-05-07 05:13:52 +00:00
|
|
|
replacements: {
|
2019-04-23 14:31:20 +00:00
|
|
|
teamId: user.teamId,
|
|
|
|
userId: user.id,
|
|
|
|
collaboratorIds: options.collaboratorIds,
|
2019-01-08 05:44:33 +00:00
|
|
|
query: wildcardQuery,
|
2018-05-07 05:13:52 +00:00
|
|
|
limit,
|
|
|
|
offset,
|
2019-01-05 21:37:33 +00:00
|
|
|
collectionIds,
|
2019-04-23 14:31:20 +00:00
|
|
|
dateFilter,
|
2018-05-07 05:13:52 +00:00
|
|
|
},
|
|
|
|
});
|
2017-07-16 16:24:45 +00:00
|
|
|
|
2019-01-05 21:37:33 +00:00
|
|
|
// Final query to get associated document data
|
2019-10-06 01:42:03 +00:00
|
|
|
const documents = await Document.scope(
|
|
|
|
{
|
2020-06-20 20:59:15 +00:00
|
|
|
method: ["withViews", user.id],
|
2019-10-06 01:42:03 +00:00
|
|
|
},
|
|
|
|
{
|
2020-06-20 20:59:15 +00:00
|
|
|
method: ["withCollection", user.id],
|
2019-10-06 01:42:03 +00:00
|
|
|
}
|
|
|
|
).findAll({
|
2019-01-05 21:37:33 +00:00
|
|
|
where: {
|
2020-06-20 20:59:15 +00:00
|
|
|
id: map(results, "id"),
|
2019-01-05 21:37:33 +00:00
|
|
|
},
|
2018-08-05 04:28:37 +00:00
|
|
|
include: [
|
2020-06-20 20:59:15 +00:00
|
|
|
{ model: User, as: "createdBy", paranoid: false },
|
|
|
|
{ model: User, as: "updatedBy", paranoid: false },
|
2018-08-05 04:28:37 +00:00
|
|
|
],
|
2017-07-12 07:28:18 +00:00
|
|
|
});
|
2017-12-03 19:04:17 +00:00
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
return map(results, (result) => ({
|
2018-08-05 01:32:56 +00:00
|
|
|
ranking: result.searchRanking,
|
2018-08-05 05:46:55 +00:00
|
|
|
context: removeMarkdown(unescape(result.searchContext), {
|
|
|
|
stripHTML: false,
|
|
|
|
}),
|
2018-08-05 01:32:56 +00:00
|
|
|
document: find(documents, { id: result.id }),
|
|
|
|
}));
|
2017-07-12 07:28:18 +00:00
|
|
|
};
|
|
|
|
|
2018-01-13 18:46:29 +00:00
|
|
|
// Hooks
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.addHook("beforeSave", async (model) => {
|
2020-08-08 22:18:37 +00:00
|
|
|
if (!model.publishedAt || model.template) {
|
|
|
|
return;
|
|
|
|
}
|
2018-04-04 03:36:25 +00:00
|
|
|
|
2019-06-23 22:49:45 +00:00
|
|
|
const collection = await Collection.findByPk(model.collectionId);
|
2020-08-08 22:18:37 +00:00
|
|
|
if (!collection || collection.type !== "atlas") {
|
|
|
|
return;
|
|
|
|
}
|
2018-04-04 03:36:25 +00:00
|
|
|
|
|
|
|
await collection.updateDocument(model);
|
|
|
|
model.collection = collection;
|
|
|
|
});
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.addHook("afterCreate", async (model) => {
|
2020-08-08 22:18:37 +00:00
|
|
|
if (!model.publishedAt || model.template) {
|
|
|
|
return;
|
|
|
|
}
|
2018-04-04 03:36:25 +00:00
|
|
|
|
2019-06-23 22:49:45 +00:00
|
|
|
const collection = await Collection.findByPk(model.collectionId);
|
2020-08-08 22:18:37 +00:00
|
|
|
if (!collection || collection.type !== "atlas") {
|
|
|
|
return;
|
|
|
|
}
|
2018-04-04 03:36:25 +00:00
|
|
|
|
|
|
|
await collection.addDocumentToStructure(model);
|
|
|
|
model.collection = collection;
|
|
|
|
|
|
|
|
return model;
|
|
|
|
});
|
2018-01-13 18:46:29 +00:00
|
|
|
|
2017-07-12 07:28:18 +00:00
|
|
|
// Instance methods
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.toMarkdown = function () {
|
2020-04-05 22:07:34 +00:00
|
|
|
const text = unescape(this.text);
|
|
|
|
|
|
|
|
if (this.version) {
|
|
|
|
return `# ${this.title}\n\n${text}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
return text;
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.migrateVersion = function () {
|
2020-05-20 03:39:34 +00:00
|
|
|
let migrated = false;
|
|
|
|
|
|
|
|
// migrate from document version 0 -> 1
|
2020-04-05 22:07:34 +00:00
|
|
|
if (!this.version) {
|
2020-05-20 03:39:34 +00:00
|
|
|
// removing the title from the document text attribute
|
2020-06-20 20:59:15 +00:00
|
|
|
this.text = this.text.replace(/^#\s(.*)\n/, "");
|
2020-04-05 22:07:34 +00:00
|
|
|
this.version = 1;
|
2020-05-20 03:39:34 +00:00
|
|
|
migrated = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// migrate from document version 1 -> 2
|
|
|
|
if (this.version === 1) {
|
|
|
|
const nodes = serializer.deserialize(this.text);
|
|
|
|
this.backup = this.text;
|
|
|
|
this.text = serializer.serialize(nodes, { version: 2 });
|
|
|
|
this.version = 2;
|
|
|
|
migrated = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (migrated) {
|
2020-04-05 22:07:34 +00:00
|
|
|
return this.save({ silent: true, hooks: false });
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-04-06 23:20:27 +00:00
|
|
|
// Note: This method marks the document and it's children as deleted
|
2020-07-18 16:33:27 +00:00
|
|
|
// in the database, it does not permanently delete them OR remove
|
2019-04-06 23:20:27 +00:00
|
|
|
// from the collection structure.
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.deleteWithChildren = async function (options) {
|
2019-04-06 23:20:27 +00:00
|
|
|
// Helper to destroy all child documents for a document
|
|
|
|
const loopChildren = async (documentId, opts) => {
|
|
|
|
const childDocuments = await Document.findAll({
|
|
|
|
where: { parentDocumentId: documentId },
|
|
|
|
});
|
2020-08-09 01:53:11 +00:00
|
|
|
childDocuments.forEach(async (child) => {
|
2019-04-06 23:20:27 +00:00
|
|
|
await loopChildren(child.id, opts);
|
|
|
|
await child.destroy(opts);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
await loopChildren(this.id, options);
|
|
|
|
await this.destroy(options);
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.archiveWithChildren = async function (userId, options) {
|
2019-04-06 23:20:27 +00:00
|
|
|
const archivedAt = new Date();
|
|
|
|
|
|
|
|
// Helper to archive all child documents for a document
|
2020-08-09 01:53:11 +00:00
|
|
|
const archiveChildren = async (parentDocumentId) => {
|
2019-04-06 23:20:27 +00:00
|
|
|
const childDocuments = await Document.findAll({
|
|
|
|
where: { parentDocumentId },
|
|
|
|
});
|
2020-08-09 01:53:11 +00:00
|
|
|
childDocuments.forEach(async (child) => {
|
2019-04-06 23:20:27 +00:00
|
|
|
await archiveChildren(child.id);
|
|
|
|
|
|
|
|
child.archivedAt = archivedAt;
|
|
|
|
child.lastModifiedById = userId;
|
|
|
|
await child.save(options);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
await archiveChildren(this.id);
|
|
|
|
this.archivedAt = archivedAt;
|
|
|
|
this.lastModifiedById = userId;
|
|
|
|
return this.save(options);
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.publish = async function (options) {
|
2019-07-08 02:25:45 +00:00
|
|
|
if (this.publishedAt) return this.save(options);
|
2018-04-04 03:36:25 +00:00
|
|
|
|
2019-06-23 22:49:45 +00:00
|
|
|
const collection = await Collection.findByPk(this.collectionId);
|
2020-06-20 20:59:15 +00:00
|
|
|
if (collection.type !== "atlas") return this.save(options);
|
2018-04-04 03:36:25 +00:00
|
|
|
|
|
|
|
await collection.addDocumentToStructure(this);
|
|
|
|
|
|
|
|
this.publishedAt = new Date();
|
2019-07-08 02:25:45 +00:00
|
|
|
await this.save(options);
|
2018-04-04 03:36:25 +00:00
|
|
|
|
|
|
|
return this;
|
|
|
|
};
|
|
|
|
|
2019-04-06 23:20:27 +00:00
|
|
|
// Moves a document from being visible to the team within a collection
|
|
|
|
// to the archived area, where it can be subsequently restored.
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.archive = async function (userId) {
|
2019-04-06 23:20:27 +00:00
|
|
|
// archive any children and remove from the document structure
|
|
|
|
const collection = await this.getCollection();
|
2019-04-09 04:25:13 +00:00
|
|
|
await collection.removeDocumentInStructure(this);
|
2019-04-06 23:20:27 +00:00
|
|
|
this.collection = collection;
|
|
|
|
|
|
|
|
await this.archiveWithChildren(userId);
|
|
|
|
|
|
|
|
return this;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Restore an archived document back to being visible to the team
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.unarchive = async function (userId) {
|
2019-04-06 23:20:27 +00:00
|
|
|
const collection = await this.getCollection();
|
|
|
|
|
|
|
|
// check to see if the documents parent hasn't been archived also
|
|
|
|
// If it has then restore the document to the collection root.
|
|
|
|
if (this.parentDocumentId) {
|
|
|
|
const parent = await Document.findOne({
|
|
|
|
where: {
|
|
|
|
id: this.parentDocumentId,
|
|
|
|
archivedAt: {
|
|
|
|
[Op.eq]: null,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
if (!parent) this.parentDocumentId = undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
await collection.addDocumentToStructure(this);
|
|
|
|
this.collection = collection;
|
|
|
|
|
2019-11-19 02:51:32 +00:00
|
|
|
if (this.deletedAt) {
|
|
|
|
await this.restore();
|
|
|
|
}
|
|
|
|
|
2019-04-06 23:20:27 +00:00
|
|
|
this.archivedAt = null;
|
|
|
|
this.lastModifiedById = userId;
|
|
|
|
await this.save();
|
|
|
|
|
|
|
|
return this;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Delete a document, archived or otherwise.
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.delete = function (options) {
|
2019-04-06 23:20:27 +00:00
|
|
|
return sequelize.transaction(async (transaction: Transaction): Promise<*> => {
|
|
|
|
if (!this.archivedAt) {
|
|
|
|
// delete any children and remove from the document structure
|
|
|
|
const collection = await this.getCollection();
|
|
|
|
if (collection) await collection.deleteDocument(this, { transaction });
|
|
|
|
}
|
|
|
|
|
|
|
|
await Revision.destroy({
|
|
|
|
where: { documentId: this.id },
|
|
|
|
transaction,
|
|
|
|
});
|
|
|
|
|
|
|
|
await this.destroy({ transaction, ...options });
|
|
|
|
|
|
|
|
return this;
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.getTimestamp = function () {
|
2018-01-29 03:37:14 +00:00
|
|
|
return Math.round(new Date(this.updatedAt).getTime() / 1000);
|
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.getSummary = function () {
|
2020-05-20 03:39:34 +00:00
|
|
|
const plain = removeMarkdown(unescape(this.text), {
|
|
|
|
stripHTML: false,
|
|
|
|
});
|
2020-06-20 20:59:15 +00:00
|
|
|
const lines = compact(plain.split("\n"));
|
2020-05-20 03:39:34 +00:00
|
|
|
const notEmpty = lines.length >= 1;
|
|
|
|
|
|
|
|
if (this.version) {
|
2020-06-20 20:59:15 +00:00
|
|
|
return notEmpty ? lines[0] : "";
|
2020-05-20 03:39:34 +00:00
|
|
|
}
|
|
|
|
|
2020-06-20 20:59:15 +00:00
|
|
|
return notEmpty ? lines[1] : "";
|
2017-12-19 03:59:29 +00:00
|
|
|
};
|
|
|
|
|
2020-08-09 01:53:11 +00:00
|
|
|
Document.prototype.toJSON = function () {
|
2017-07-12 07:28:18 +00:00
|
|
|
// Warning: only use for new documents as order of children is
|
|
|
|
// handled in the collection's documentStructure
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
title: this.title,
|
2018-11-04 07:59:52 +00:00
|
|
|
url: this.url,
|
2017-07-12 07:28:18 +00:00
|
|
|
children: [],
|
|
|
|
};
|
|
|
|
};
|
2016-08-23 06:37:01 +00:00
|
|
|
|
2016-05-20 03:46:34 +00:00
|
|
|
export default Document;
|