web/models/article.js
const assert = require('assert')
const path = require('path')
const { DataTypes, Op } = require('sequelize')
const ourbigbook = require('ourbigbook')
const ourbigbook_nodejs_webpack_safe = require('ourbigbook/nodejs_webpack_safe')
const config = require('../front/config')
const front_js = require('../front/js')
const convert = require('../convert')
const e = require('cors')
const { execPath } = require('process')
module.exports = (sequelize) => {
function slugTransform(v) {
return v
}
// Each Article contains rendered HTML output, analogous to a .html output file in OurBigBook CLI.
// The input source is stored in the File model. A single file can then generate
// multiple Article if it has multiple headers.
const Article = sequelize.define(
'Article',
{
// E.g. `johnsmith/mathematics`.
slug: {
type: DataTypes.TEXT,
unique: {
message: 'The article ID must be unique.'
},
set(v) {
this.setDataValue('slug', slugTransform(v))
},
allowNull: false,
},
// E.g. for `johnsmith/mathematics` this is just the `mathematics`.
// Can't be called just `id`, sequelize complains that it is not a primary key with that name.
// TODO point to topic ID directly https://docs.ourbigbook.com/todo#ref-file-normalization
topicId: {
type: DataTypes.TEXT,
allowNull: false,
},
// Rendered title. Only contains the inner contents of the toplevel h1's title.
// not the HTML header itself. Used extensively e.g. in article indexes.
titleRender: {
type: DataTypes.TEXT,
allowNull: false,
},
// This was stored here as well as in addition to in File because we previously allowed
// multiple articles per file, just like is done locally. This was later forbidden on Web.
// With multiple articles per file, we may have multiple title sources. And then these can
// get used elsewhere, notably they can appears in places where the rendered output cannot
// be displayed, e.g. <title> tags.
titleSource: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
len: {
args: [1, config.maxArticleTitleSize],
msg: `Titles can have at most ${config.maxArticleTitleSize} characters`
},
}
},
titleSourceLine: {
type: DataTypes.INTEGER,
allowNull: false,
},
// Full rendered body article excluding toplevel h1 render.
render: {
type: DataTypes.TEXT,
allowNull: false,
},
// Rendered toplevel h1.
h1Render: {
type: DataTypes.TEXT,
allowNull: false,
},
// Rendered toplevel h1 as it would look like if it were an h2.
h2Render: {
type: DataTypes.TEXT,
allowNull: false,
},
depth: {
type: DataTypes.INTEGER,
allowNull: true,
},
score: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
author: {
type: DataTypes.VIRTUAL,
get() {
if (this.file) {
return this.file.author
} else {
return null
}
},
set(value) {
throw new Error('cannot set virtual`author` value directly');
}
},
// To fetch the tree recursively on the fly.
// https://stackoverflow.com/questions/192220/what-is-the-most-efficient-elegant-way-to-parse-a-flat-table-into-a-tree/42781302#42781302
nestedSetIndex: {
type: DataTypes.INTEGER,
allowNull: true,
},
// Points to the nestedSetIndex of the next sibling, or where the
// address at which the next sibling would be if it existed.
nestedSetNextSibling: {
type: DataTypes.INTEGER,
allowNull: true,
},
followerCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
issueCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
},
{
// TODO updatedAt lazy to create migration now.
indexes: [
{ fields: ['createdAt'], },
{ fields: ['issueCount'], },
{ fields: ['followerCount'], },
{ fields: ['topicId'], },
{ fields: ['slug'], },
{ fields: ['score'], },
{ fields: ['nestedSetIndex'], },
// For parent searches.
{ fields: ['nestedSetIndex', 'nestedSetNextSibling'], },
// Foreign key indexes https://docs.ourbigbook.com/database-guidelines
{ fields: ['fileId'], },
],
}
)
/**
* Move a contiguous range of siblings and all their descendants to a new location in the tree.
*
* Updates both nested set and Ref representations.
*
* @param {number} nArticlesToplevel - Total toplevel sibling articles to be moved, excluding their descendants.
* @param {number} nArticles - Total articles to be moved, including toplevel siblings and their descendants.
* @param {number} newNestedSetIndex - position where to move the first article of the range to.
*
* This position, together with newNestedSetIndexParent, is relative to the old tree nested set state.
*
* The final new index might therefore be different because we might need to close down space from which
* the article was moved out after moving it out.
*/
Article.treeMoveRangeTo = async function({
depthDelta,
logging,
nArticlesToplevel,
nArticles,
newNestedSetIndex,
newNestedSetIndexParent,
newParentId,
new_to_id_index,
oldNestedSetIndex,
oldNestedSetIndexParent,
oldParentId,
old_to_id_index,
perf,
transaction,
updateNestedSetIndex,
username,
}) {
if (updateNestedSetIndex === undefined) {
updateNestedSetIndex = true
}
if (logging === undefined) {
// Log previous nested set state, and the queries done on it.
logging = false
}
if (logging) {
console.log('Article.treeMoveRangeTo');
console.log({
depthDelta,
nArticlesToplevel,
nArticles,
newNestedSetIndex,
newNestedSetIndexParent,
newParentId,
new_to_id_index,
oldNestedSetIndex,
oldNestedSetIndexParent,
oldParentId,
old_to_id_index,
updateNestedSetIndex,
username,
})
}
return sequelize.transaction({ transaction }, async (transaction) => {
if (
// As an optimization, skip move it position didn't change.
oldParentId !== newParentId ||
old_to_id_index !== new_to_id_index
) {
// Open up destination space.
await sequelize.models.Article.treeOpenSpace({
logging,
parentNestedSetIndex: newNestedSetIndexParent,
nestedSetIndex: newNestedSetIndex,
parentId: newParentId,
perf,
shiftNestedSetBy: nArticles,
shiftRefBy: nArticlesToplevel,
to_id_index: new_to_id_index,
transaction,
updateNestedSetIndex,
username,
})
// Update indices to account for space opened upbefore insertion.
let nestedSetDelta = newNestedSetIndex - oldNestedSetIndex
if (newNestedSetIndex <= oldNestedSetIndex) {
oldNestedSetIndex += nArticles
nestedSetDelta -= nArticles
if (oldParentId === newParentId) {
old_to_id_index += nArticlesToplevel
}
}
if (newNestedSetIndex <= oldNestedSetIndexParent) {
oldNestedSetIndexParent += nArticles
}
// Move articles to new location
if (logging) {
console.log('Article.treeMoveRangeTo move');
console.log(await Article.treeToString({ transaction }))
}
await Promise.all([
updateNestedSetIndex && sequelize.query(`
UPDATE "Article" SET
"nestedSetIndex" = "nestedSetIndex" + :nestedSetDelta,
"nestedSetNextSibling" = "nestedSetNextSibling" + :nestedSetDelta,
"depth" = "depth" + :depthDelta
WHERE
"nestedSetIndex" >= :oldNestedSetIndex AND
"nestedSetIndex" < :oldNestedSetNextSibling AND
"id" IN (
SELECT "Article"."id" from "Article"
INNER JOIN "File"
ON "Article"."fileId" = "File"."id"
INNER JOIN "User"
ON "File"."authorId" = "User"."id"
WHERE "User"."username" = :username
)
`,
{
logging: logging ? console.log : false,
transaction,
replacements: {
depthDelta,
oldNestedSetIndex,
oldNestedSetNextSibling: oldNestedSetIndex + nArticles,
nestedSetDelta,
newNestedSetIndex,
username,
},
},
),
sequelize.models.Ref.update(
{
from_id: newParentId,
to_id_index: sequelize.fn(`${new_to_id_index - old_to_id_index} + `, sequelize.col('to_id_index')),
},
{
logging: logging ? console.log : false,
where: {
from_id: oldParentId,
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
to_id_index: {
[sequelize.Sequelize.Op.gte]: old_to_id_index,
[sequelize.Sequelize.Op.lt]: old_to_id_index + nArticlesToplevel,
},
},
transaction,
}
)
])
// Close up space where articles were removed from.
await sequelize.models.Article.treeOpenSpace({
logging,
nestedSetIndex: oldNestedSetIndex,
parentNestedSetIndex: oldNestedSetIndexParent,
perf,
parentId: oldParentId,
shiftNestedSetBy: -nArticles,
shiftRefBy: -nArticlesToplevel,
to_id_index: old_to_id_index,
transaction,
updateNestedSetIndex,
username,
})
}
})
}
/** Sample output:
*
* nestedSetIndex, nestedSetNextSibling, depth, to_id_index, slug, parentId
* 0, 4, 0, null, user0, null
* 1, 3, 1, 0, user0/physics, @user0
* 3, 4, 1, 1, user0/mathematics, @user0
*/
Article.treeToString = async function(opts={}) {
return 'nestedSetIndex, nestedSetNextSibling, depth, to_id_index, slug, parentId\n' + (
await sequelize.models.Article.treeFindInOrder({ refs: true, transaction: opts.transaction })
).map(a => {
let to_id_index, parentId
const ref = a.file.toplevelId
if (ref === null) {
to_id_index = null
parentId = null
} else {
to_id_index = ref.to[0].to_id_index
parentId = ref.to[0].from_id
}
return `${a.nestedSetIndex}, ${a.nestedSetNextSibling}, ${a.depth}, ${to_id_index}, ${a.slug}, ${parentId}`
}).join('\n')
}
Article.treeRemove = async function({
idid,
logging,
nestedSetIndex,
nestedSetNextSibling,
parentNestedSetIndex,
parentId,
to_id_index,
updateNestedSetIndex,
username,
transaction,
}) {
if (updateNestedSetIndex === undefined) {
updateNestedSetIndex = true
}
if (logging === undefined) {
logging = false
}
return sequelize.transaction({ transaction }, async (transaction) => {
if (logging) {
console.log('Article.treeRemove')
console.log({
idid,
nestedSetIndex,
nestedSetNextSibling,
parentNestedSetIndex,
parentId,
to_id_index,
username,
})
console.log(await Article.treeToString({ transaction }))
}
// Decrement the depth of all descendants of the article
// as they are going to be moved up the tree.
await Promise.all([
updateNestedSetIndex && sequelize.models.Article.decrement('depth', {
logging: logging ? console.log : false,
where: {
nestedSetIndex: {
[sequelize.Sequelize.Op.gt]: nestedSetIndex,
[sequelize.Sequelize.Op.lt]: nestedSetNextSibling,
},
},
transaction,
}),
Article.treeOpenSpace({
logging,
nestedSetIndex,
parentId,
parentNestedSetIndex,
shiftNestedSetBy: -1,
// Number of descendants - 1. -1 Because we are removing the article.
shiftRefBy: nestedSetNextSibling - (nestedSetIndex + 2),
to_id_index,
transaction,
updateNestedSetIndex,
username,
}),
])
// Change parent and increment to_id_index of all child pages
// to fit into their new location.
await sequelize.models.Ref.update(
{
from_id: parentId,
to_id_index: sequelize.fn(`${to_id_index} + `, sequelize.col('to_id_index')),
},
{
logging: logging ? console.log : false,
where: {
from_id: idid,
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
},
transaction,
}
)
})
}
// Remove this article from the nested set altogether.
// Used when deleting the article. All children are reassigned to its parent.
Article.prototype.treeRemove = async function({
logging,
transaction,
updateNestedSetIndex,
}) {
if (logging === undefined) {
logging = false
}
if (updateNestedSetIndex === undefined) {
updateNestedSetIndex = true
}
const parentRef = await this.findParentRef({ transaction })
const parentArticleToplevelId = parentRef.from
const parentArticle = parentArticleToplevelId.toplevelId.file[0]
return Article.treeRemove({
idid: parentRef.to_id,
logging,
nestedSetIndex: this.nestedSetIndex,
nestedSetNextSibling: this.nestedSetNextSibling,
parentId: parentArticleToplevelId.idid,
parentNestedSetIndex: parentArticle.nestedSetIndex,
to_id_index: parentRef.to_id_index,
transaction,
updateNestedSetIndex,
username: (await this.getAuthor({ transaction })).username,
})
}
Article.prototype.findParentRef = async function(opts={}) {
if (this.parentRef) {
// Cached, usually fetched via previous joins.
return this.parentRef
}
return sequelize.models.Ref.findOne({
where: {
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
},
subQuery: false,
required: true,
include: [
{
model: sequelize.models.Id,
as: 'to',
subQuery: false,
required: true,
include: [
{
model: sequelize.models.File,
as: 'toplevelId',
subQuery: false,
required: true,
include: [
{
model: sequelize.models.Article,
as: 'file',
subQuery: false,
required: true,
where: { slug: this.slug }
}
],
}
]
},
{
model: sequelize.models.Id,
as: 'from',
subQuery: false,
required: true,
include: [
{
model: sequelize.models.File,
as: 'toplevelId',
subQuery: false,
required: true,
include: [
{
model: sequelize.models.Article,
as: 'file',
subQuery: false,
required: true,
}
],
}
]
},
],
transaction: opts.transaction,
})
}
/**
* Shift all articles after a given article by a certain ammount.
*
* This is the most fundamental nested set modification primitive, as it is done to:
* * if if the shift ammount is positive: open space for new/move incoming items
* * if the shift ammount is negative: close up space from a deleted item or from which outgoing move items left
*
* This primitive can be used to prepare to move multiple items at once, but
*
* If negative, this removes existing space, for e.g. when removing items.
*
* This function also maintains Ref.to_id_index state to help keep it in sync.
*
* @param {boolean=} logging - enable logging of current state and queries
* @param {number} nestedSetIndex - at which nested set index to insert or remove space from
* @param {string} parentId - idid of the parent article. Used for Ref managment, not nested set.
* TODO https://docs.ourbigbook.com/todo#ref-file-normalization
* @param {number} parentNestedSetIndex - the parent nested set index of the nodes that will
* be inserted/removed from the location. The way nested sets work, we must know this
* information, we can't just say how much space to open at a given location, because
* when inserting after nodes without children, we can either be a child or sibling
* or sibling of an ancestor.
* @param {number} shiftNestedSetBy - how much to shift nested sets by
* @param {number=} shiftRefBy - how much to shift ref to_id_index by
* @param {number} to_id_index - the new to_id_index of the article within its parent. Used for Ref managemnt, not nested set.
* @param {Transaction=} transaction
* @param {string} username - username to take effect on
*/
Article.treeOpenSpace = async function({
logging,
nestedSetIndex,
parentId,
parentNestedSetIndex,
perf,
shiftNestedSetBy,
shiftRefBy,
to_id_index,
transaction,
updateNestedSetIndex,
username,
}) {
if (logging === undefined) {
// Log previous nested set state, and the queries done on it.
logging = false
}
if (updateNestedSetIndex === undefined) {
updateNestedSetIndex = true
}
let t0
if (perf) {
t0 = performance.now();
console.error('perf: treeOpenSpace.start');
}
if (
// Happens for the root node. The root cannot move, so we just skip that case.
parentNestedSetIndex !== undefined
) {
if (logging) {
console.log('Article.treeOpenSpace')
console.log({
nestedSetIndex,
parentId,
parentNestedSetIndex,
shiftNestedSetBy,
shiftRefBy,
to_id_index,
updateNestedSetIndex,
username,
})
console.log(await Article.treeToString({ transaction }))
}
await sequelize.transaction({ transaction }, async (transaction) => {
return Promise.all([
// Increment sibling indexes after point we are inserting from.
sequelize.models.Ref.increment('to_id_index', {
logging: logging ? console.log : false,
by: shiftRefBy,
where: {
from_id: parentId,
to_id_index: { [sequelize.Sequelize.Op.gte]: to_id_index },
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
},
transaction,
}),
// Increase nested set index and next sibling of all nodes that come after.
// We need a raw query because Sequelize does not support UPDATE with JOIN:
// https://github.com/sequelize/sequelize/issues/3957
updateNestedSetIndex && sequelize.query(`
UPDATE "Article" SET
"nestedSetIndex" = "nestedSetIndex" + :shiftNestedSetBy,
"nestedSetNextSibling" = "nestedSetNextSibling" + :shiftNestedSetBy
WHERE
"nestedSetIndex" >= :nestedSetIndex AND
"id" IN (
SELECT "Article"."id" from "Article"
INNER JOIN "File"
ON "Article"."fileId" = "File"."id"
INNER JOIN "User"
ON "File"."authorId" = "User"."id"
WHERE "User"."username" = :username
)
`,
{
logging: logging ? console.log : false,
transaction,
replacements: {
username,
nestedSetIndex,
shiftNestedSetBy,
},
},
),
// Increase nested set next sibling of ancestors. Their index is unchanged.
updateNestedSetIndex && sequelize.query(`
UPDATE "Article" SET
"nestedSetNextSibling" = "nestedSetNextSibling" + :shiftNestedSetBy
WHERE
"nestedSetIndex" <= :parentNestedSetIndex AND
"nestedSetNextSibling" >= :nestedSetIndex AND
"id" IN (
SELECT "Article"."id" from "Article"
INNER JOIN "File"
ON "Article"."fileId" = "File"."id"
INNER JOIN "User"
ON "File"."authorId" = "User"."id"
WHERE "User"."username" = :username
)
`,
{
logging: logging ? console.log : false,
transaction,
replacements: {
username,
nestedSetIndex,
parentNestedSetIndex,
shiftNestedSetBy,
},
},
),
])
})
}
if (perf) {
console.error(`perf: treeOpenSpace.finish ${performance.now() - t0} ms`);
}
}
// TODO https://docs.ourbigbook.com/todo/delete-articles
Article.prototype.destroySideEffects = async function(opts={}) {
return sequelize.transaction({ transaction: opts.transaction }, async (transaction) => {
const [articles, topic, _] = await Promise.all([
sequelize.models.Article.findAll({
where: { topicId: this.topicId },
limit: 2,
order: [['id', 'ASC']],
transaction,
}),
sequelize.models.Topic.findOne({
include: [{
model: sequelize.models.Article,
as: 'article',
where: { topicId: this.topicId },
}],
transaction,
}),
this.treeRemove({
logging: opts.logging,
transaction
}),
])
let otherArticleSameTopic
if (articles.length > 1) {
if (articles[0].id === this.id) {
otherArticleSameTopic = articles[1]
} else {
otherArticleSameTopic = articles[0]
}
}
await this.destroy({ transaction })
return Promise.all([
this.parentRef ? this.parentRef.destroy({ transaction }) : null,
// Has to come after File.destroy finished because the file is needed in order for the
// DELETE ARTICLE trigger to be able to link the article to the author.
//
// Cannot be easily done on CASCADE currently because we had a setup where each File
// could have many Articles, which is basically gone.
this.file.destroy({ transaction }),
otherArticleSameTopic
? // Set it to another article provisorily just in case it points to the current article,
// because we are going to destroy the current article.
topic.update({ articleId: otherArticleSameTopic.id }, { transaction }).then(
// Then update to whatever is actually correct.
() => sequelize.models.Topic.updateTopics([ otherArticleSameTopic ], { deleteArticle: true, transaction })
)
: topic.destroy({ transaction })
])
// TODO move child articles to parent
// destroy issues, and then comments. Not doig this now because our "deletion" is for migration to another article only initially
// This can be done on delete cascade as there are no side effects of issues/comments that are not taken care of by triggers
// Sequelize does not set on delete cascade by default however on belongsTo, it uses on delete set null, which would need changing
})
}
Article.prototype.getAuthor = async function() {
return (await this.getFileCached()).author
}
Article.prototype.getFileCached = async function() {
let file
if (!this.file || this.file.author === undefined) {
file = await this.getFile({ include: [ { model: sequelize.models.User, as: 'author' } ]})
} else {
file = this.file
}
return file
}
// Get a version of the source code of this article that would be
// written to a local file if we were to export it.
Article.prototype.getSourceExport = async function() {
const file = await this.getFileCached()
let ret = front_js.modifyEditorInput(file.titleSource, file.bodySource).new
const children = await this.getChildren()
let include_source = ''
const isToplevelIndex = this.isToplevelIndex()
for (const child of children) {
let inc_orig = this.slug
if (!isToplevelIndex) {
inc_orig = inc_orig.split(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR).slice(0, -1).join(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR)
}
include_source += `\\Include[${path.relative(inc_orig, child.slug)}]\n`
}
if (include_source) {
ret += '\n' + include_source
}
return ret
}
Article.prototype.getChildren = async function() {
return sequelize.models.Article.findAll({
where: {
nestedSetIndex: {
[sequelize.Sequelize.Op.gt]: this.nestedSetIndex,
[sequelize.Sequelize.Op.lt]: this.nestedSetNextSibling,
},
depth: this.depth + 1,
},
include: [{
model: sequelize.models.File,
as: 'file',
required: true,
include: [{
model: sequelize.models.User,
as: 'author',
where: { username: this.file.author.username },
required: true,
}]
}]
})
}
Article.prototype.toJson = async function(loggedInUser) {
const authorPromise = this.file && this.file.author ? this.file.author : this.getAuthor()
// TODO do liked and followed with JOINs on caller, check if it is there and skip this if so.
const [liked, followed, author, likedBy] = await Promise.all([
loggedInUser ? loggedInUser.hasLikedArticle(this.id) : false,
loggedInUser ? loggedInUser.hasFollowedArticle(this.id) : false,
(await authorPromise).toJson(loggedInUser),
this.likedBy ? this.likedBy.toJson(loggedInUser) : undefined,
])
function addToDictWithoutUndefined(target, source, keys) {
for (const prop of keys) {
const val = source[prop]
if (val !== undefined) {
target[prop] = val
}
}
return target
}
const file = {}
if (this.file) {
addToDictWithoutUndefined(file, this.file, ['titleSource', 'bodySource', 'path', 'hash'])
}
const ret = {
followed,
liked,
// Putting it here rather than in the more consistent file.author
// to improve post serialization polymorphism with issues.
author,
file,
}
this.topicCount = this.get('topicCount')
addToDictWithoutUndefined(ret, this, [
'depth',
'followerCount',
'h1Render',
'h2Render',
'id',
'issueCount',
'slug',
'hash',
'topicId',
'titleRender',
'titleSource',
'titleSourceLine',
'score',
'render',
'issueCount',
'topicCount'
])
if (this.createdAt) {
ret.createdAt = this.createdAt.toISOString()
}
if (this.updatedAt) {
ret.updatedAt = this.updatedAt.toISOString()
}
if (likedBy) {
ret.likedBy = likedBy
}
if (this.likedByDate) {
ret.likedByDate = this.likedByDate.toISOString()
}
if (this.parentId) {
ret.parentId = this.parentId.idid
}
if (this.previousSiblingId) {
ret.previousSiblingId = this.previousSiblingId.idid
}
return ret
}
Article.prototype.rerender = async function({ convertOptionsExtra, ignoreErrors, transaction }={}) {
const file = await this.getFileCached()
if (ignoreErrors === undefined)
ignoreErrors = false
await sequelize.transaction({ transaction }, async (transaction) => {
try {
await convert.convertArticle({
author: file.author,
bodySource: file.bodySource,
convertOptionsExtra,
forceNew: false,
path: ourbigbook.pathSplitext(file.path.split(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR).slice(1).join(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR))[0],
parentId: this.file.toplevelId.to[0].from.idid,
render: true,
sequelize,
titleSource: file.titleSource,
transaction,
updateTree: false,
})
} catch(e) {
if (ignoreErrors) {
console.log(e)
} else {
throw e
}
}
})
}
Article.prototype.isToplevelIndex = function() {
return !this.slug.includes(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR)
}
/**
* Return Articles ordered by username and nested sets.
* @return {Article[]}
*/
Article.treeFindInOrder = async function(opts={}) {
const userWhere = {}
const username = opts.username
if (username) {
userWhere.username = username
}
const fileIncludes = [{
model: sequelize.models.User,
as: 'author',
where: userWhere,
required: true,
}]
if (opts.refs) {
fileIncludes.push({
model: sequelize.models.Id,
as: 'toplevelId',
include: [{
model: sequelize.models.Ref,
as: 'to',
where: {
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
},
}]
})
}
const where = {}
if (
// These happen on "updateNestedNsetIndex=false updates"
!opts.includeNulls
) {
where.nestedSetIndex = {[sequelize.Sequelize.Op.ne]: null}
}
const include = [
{
model: sequelize.models.File,
as: 'file',
required: true,
include: fileIncludes,
}
]
return sequelize.models.Article.findAll({
include,
where,
order: [
[
{ model: sequelize.models.File, as: 'file' },
{ model: sequelize.models.User, as: 'author' },
'username',
'ASC'
],
['nestedSetIndex', 'ASC NULLS FIRST'],
// To disambiguate the order of NULLs.
['slug', 'ASC'],
],
transaction: opts.transaction,
})
}
Article.prototype.treeFindAncestors = async function(opts={}) {
return Article.findAll({
attributes: opts.attributes,
where: {
nestedSetIndex: { [sequelize.Sequelize.Op.lt]: this.nestedSetIndex },
nestedSetNextSibling: { [sequelize.Sequelize.Op.gt]: this.nestedSetIndex },
},
include: [{
model: sequelize.models.File,
as: 'file',
required: true,
where: { authorId: this.file.authorId },
}],
order: [['nestedSetIndex', 'ASC']],
})
}
Article.getArticleIncludeParentAndPreviousSiblingFileInclude = function(
sequelize,
) {
// Behold.
// TODO reimplement with the nested index information instead of this megajoin.
return {
model: sequelize.models.Id,
as: 'toplevelId',
subQuery: false,
include: [{
model: sequelize.models.Ref,
as: 'to',
where: {
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
},
subQuery: false,
include: [{
// Parent ID.
model: sequelize.models.Id,
as: 'from',
subQuery: false,
include: [
{
model: sequelize.models.File,
as: 'toplevelId',
include: [
{
model: sequelize.models.Article,
as: 'file',
},
]
},
{
model: sequelize.models.Ref,
as: 'from',
subQuery: false,
on: {
'$file->toplevelId->to->from->from.from_id$': {[Op.eq]: sequelize.col('file->toplevelId->to->from.idid')},
'$file->toplevelId->to->from->from.to_id_index$': {[Op.eq]: sequelize.where(sequelize.col('file->toplevelId->to.to_id_index'), '-', 1)},
},
include: [{
// Previous sibling ID.
model: sequelize.models.Id,
as: 'to',
include: [
{
model: sequelize.models.File,
as: 'toplevelId',
},
],
}],
}
],
}],
}],
}
}
Article.getArticleIncludeParentAndPreviousSiblingAddShortcuts = function(
article,
) {
// Some access helpers, otherwise too convoluted!.
const articleId = article.file.toplevelId
if (articleId) {
const parentRef = articleId.to[0]
const parentId = parentRef.from
article.parentRef = parentRef
article.parentId = parentId
article.idid = articleId.idid
article.parentArticle = parentId.toplevelId.file[0]
const previousSiblingRef = parentId.from[0]
if (previousSiblingRef) {
article.previousSiblingRef = previousSiblingRef
article.previousSiblingId = previousSiblingRef.to
}
}
}
Article.getArticle = async function({
includeIssues,
includeIssueNumber,
includeIssuesOrder,
includeParentAndPreviousSibling,
sequelize,
slug,
}) {
const fileInclude = [
{
model: sequelize.models.User,
as: 'author',
},
{
model: sequelize.models.Render,
where: {
type: sequelize.models.Render.Types[ourbigbook.OUTPUT_FORMAT_HTML],
},
required: false,
},
]
if (includeParentAndPreviousSibling) {
fileInclude.push(Article.getArticleIncludeParentAndPreviousSiblingFileInclude(sequelize))
}
const include = [{
model: sequelize.models.File,
as: 'file',
include: fileInclude,
}]
let order
if (includeIssues) {
const includeIssue = {
model: sequelize.models.Issue,
as: 'issues',
required: false,
include: [{ model: sequelize.models.User, as: 'author' }],
}
if (includeIssueNumber) {
includeIssue.where = { number: includeIssueNumber }
}
include.push(includeIssue)
order = [[
'issues', includeIssuesOrder === undefined ? 'createdAt' : includeIssuesOrder, 'DESC'
]]
}
const article = await sequelize.models.Article.findOne({
where: { slug },
include,
order,
subQuery: false,
})
if (includeParentAndPreviousSibling && article !== null) {
Article.getArticleIncludeParentAndPreviousSiblingAddShortcuts(article)
}
return article
}
// Helper for common queries.
Article.getArticles = async ({
author,
count,
followedBy,
// TODO this is quite broken on true:
// https://docs.ourbigbook.com/todo/fix-parentid-and-previoussiblingid-on-articles-api
includeParentAndPreviousSibling,
likedBy,
limit,
offset,
order,
orderAscDesc,
sequelize,
slug,
topicId,
transaction,
}) => {
assert.notStrictEqual(sequelize, undefined)
if (orderAscDesc === undefined) {
orderAscDesc = 'DESC'
}
if (count === undefined) {
count = true
}
let where = {}
const fileInclude = []
const authorInclude = {
model: sequelize.models.User,
as: 'author',
required: true,
}
if (author) {
authorInclude.where = { username: author }
}
fileInclude.push(authorInclude)
if (includeParentAndPreviousSibling) {
fileInclude.push(Article.getArticleIncludeParentAndPreviousSiblingFileInclude(sequelize))
}
const include = [{
model: sequelize.models.File,
as: 'file',
include: fileInclude,
required: true,
}]
if (followedBy) {
include.push({
model: sequelize.models.User,
as: 'followers',
where: { username: followedBy },
})
}
if (likedBy) {
include.push({
model: sequelize.models.User,
as: 'articleLikedBy',
where: { username: likedBy },
})
}
if (slug) {
where.slug = slug
}
if (topicId) {
where.topicId = topicId
}
const orderList = []
if (order !== undefined) {
orderList.push([order, orderAscDesc])
}
if (order !== 'createdAt') {
// To make results deterministic.
orderList.push(['createdAt', 'DESC'])
}
if (Object.keys(where).length === 0) {
where = undefined;
}
const findArgs = {
include,
limit,
offset,
order: orderList,
transaction,
where,
}
let ret, articles
if (count) {
ret = await sequelize.models.Article.findAndCountAll(findArgs)
articles = ret.rows
} else {
ret = await sequelize.models.Article.findAll(findArgs)
articles = ret
}
if (includeParentAndPreviousSibling) {
for (const article of articles) {
Article.getArticleIncludeParentAndPreviousSiblingAddShortcuts(article)
}
}
return ret;
}
// Maybe try to merge into getArticle one day?
Article.getArticlesInSamePage = async ({
article,
loggedInUser,
// Get just the article itself. This is just as a way to get the number of
// articles on same topic + discussion count which we already get the for the h2,
// which are the main use case of this function.
//
// We don't want to pull both of them together because then we'd be pulling
// both h1 and h2 renders which we don't need. Talk about premature optimization!
h1,
limit,
render,
sequelize,
}) => {
if (render === undefined) {
render = true
}
// // OLD VERSION 1: as much as possible from calls, same article by file.
// const articlesInSamePageAttrs = [
// 'id',
// 'score',
// 'slug',
// 'topicId',
// ]
// const include = [
// {
// model: sequelize.models.File,
// as: 'file',
// required: true,
// attributes: ['id'],
// include: [
// {
// model: sequelize.models.User,
// as: 'author',
// },
// {
// model: sequelize.models.Article,
// as: 'file',
// required: true,
// attributes: ['id'],
// where: { slug },
// }
// ]
// },
// {
// model: sequelize.models.Issue,
// as: 'issues',
// },
// {
// model: sequelize.models.Article,
// as: 'sameTopic',
// attributes: [],
// required: true,
// include: [{
// model: sequelize.models.Topic,
// as: 'article',
// required: true,
// }]
// },
// ]
// // This is the part I don't know how to do here. Analogous for current user liked check.
// // It works, but breaks "do I have my version check".
// // https://github.com/cirosantilli/cirosantilli.github.io/blob/1be5cb8ef7c03d03e54069c6a5329f54e044de9c/nodejs/sequelize/raw/many_to_many.js#L351
// //if (loggedInUser) {
// // include.push({
// // model: sequelize.models.Article,
// // as: 'sameTopic2',
// // //attributes: [],
// // required: true,
// // include: [{
// // model: sequelize.models.File,
// // as: 'file',
// // //attributes: [],
// // required: true,
// // include: [{
// // model: sequelize.models.User,
// // as: 'author',
// // attributes: ['id'],
// // required: false,
// // where: { id: loggedInUser.id },
// // }]
// // }],
// // })
// //}
// return sequelize.models.Article.findAll({
// attributes: articlesInSamePageAttrs.concat([
// [sequelize.fn('COUNT', sequelize.col('issues.id')), 'issueCount'],
// [sequelize.col('sameTopic.article.articleCount'), 'topicCount'],
// // This works for "do I have my version check".
// //[sequelize.fn('max', sequelize.col('sameTopic2.file.author.id')), 'hasSameTopic'],
// ]),
// group: articlesInSamePageAttrs.map(a => `Article.${a}`),
// subQuery: false,
// order: [['topicId', 'ASC']],
// include,
// })
//
// // OLD VERSION 2: same article by file, one megaquery.
// // For a minimal prototype of the difficult SameTopicByLoggedIn part:
// // https://github.com/cirosantilli/cirosantilli.github.io/blob/1be5cb8ef7c03d03e54069c6a5329f54e044de9c/nodejs/sequelize/raw/many_to_many.js#L351
// ;const [rows, meta] = await sequelize.query(`
//SELECT
// "Article"."id" AS "id",
// "Article"."score" AS "score",
// "Article"."slug" AS "slug",
// "Article"."topicId" AS "topicId",
// "Article"."titleSource" AS "titleSource",
// "File.Author"."id" AS "file.author.id",
// "File.Author"."username" AS "file.author.username",
// "SameTopic"."articleCount" AS "topicCount",
// "ArticleSameTopicByLoggedIn"."id" AS "hasSameTopic",
// "UserLikeArticle"."userId" AS "liked",
// COUNT("issues"."id") AS "issueCount"
//FROM
// "Article"
// INNER JOIN "File" ON "Article"."fileId" = "File"."id"
// LEFT OUTER JOIN "User" AS "File.Author" ON "File"."authorId" = "File.Author"."id"
// INNER JOIN "Article" AS "ArticleSameFile"
// ON "File"."id" = "ArticleSameFile"."fileId"
// AND "ArticleSameFile"."slug" = :slug
// INNER JOIN "Article" AS "ArticleSameTopic" ON "Article"."topicId" = "ArticleSameTopic"."topicId"
// INNER JOIN "Topic" AS "SameTopic" ON "ArticleSameTopic"."id" = "SameTopic"."articleId"
// LEFT OUTER JOIN (
// SELECT "Article"."id", "Article"."topicId"
// FROM "Article"
// INNER JOIN "File"
// ON "Article"."fileId" = "File"."id"
// AND "File"."authorId" = :loggedInUserId
// ) AS "ArticleSameTopicByLoggedIn"
// ON "Article"."topicId" = "ArticleSameTopicByLoggedIn"."topicId"
// LEFT OUTER JOIN "UserLikeArticle"
// ON "UserLikeArticle"."articleId" = "Article"."id" AND
// "UserLikeArticle"."userId" = :loggedInUserId
// LEFT OUTER JOIN "Issue" AS "issues" ON "Article"."id" = "issues"."articleId"
//GROUP BY
// "Article"."id",
// "Article"."score",
// "Article"."slug",
// "Article"."topicId",
// "Article"."titleSource",
// "File.Author"."id",
// "File.Author"."username",
// "SameTopic"."articleCount",
// "ArticleSameTopicByLoggedIn"."id",
// "UserLikeArticle"."userId"
//ORDER BY "slug" ASC
//`,
// {
// replacements: {
// loggedInUserId: loggedInUser ? loggedInUser.id : null,
// slug,
// }
// }
// )
//const allFields = {
// 'id'
// 'score',
// 'slug',
// 'topicId',
// 'titleSource',
// 'render',
//}
;const [rows, meta] = await sequelize.query(`
SELECT
"Article"."id" AS "id",
"Article"."score" AS "score",
"Article"."slug" AS "slug",
"Article"."topicId" AS "topicId",
"Article"."issueCount" AS "issueCount",
"Article"."titleSource" AS "titleSource",${render ? `\n "Article"."render" AS "render",` : ''}
${h1 ? '"Article"."h1Render" AS "h1Render"' : '"Article"."h2Render" AS "h2Render"'},
"Article"."topicId" AS "topicId",
"Article"."titleRender" AS "titleRender",
"Article"."depth" AS "depth",
"File.Author"."id" AS "file.author.id",
"File.Author"."username" AS "file.author.username",
"SameTopic"."articleCount" AS "topicCount",
"ArticleSameTopicByLoggedIn"."id" AS "hasSameTopic",
"UserLikeArticle"."userId" AS "liked"
FROM
"Article"
INNER JOIN "File" ON "Article"."fileId" = "File"."id"${h1 ? '\n INNER JOIN "Id" ON "File"."toplevel_id" = "Id"."idid"' : ''}
INNER JOIN "User" AS "File.Author"
ON "File"."authorId" = "File.Author"."id" AND
"File.Author"."username" = :authorUsername
INNER JOIN "Article" AS "ArticleSameTopic" ON "Article"."topicId" = "ArticleSameTopic"."topicId"
INNER JOIN "Topic" AS "SameTopic" ON "ArticleSameTopic"."id" = "SameTopic"."articleId"
LEFT OUTER JOIN (
SELECT "Article"."id", "Article"."topicId"
FROM "Article"
INNER JOIN "File"
ON "Article"."fileId" = "File"."id"
AND "File"."authorId" = :loggedInUserId
) AS "ArticleSameTopicByLoggedIn"
ON "Article"."topicId" = "ArticleSameTopicByLoggedIn"."topicId"
LEFT OUTER JOIN "UserLikeArticle"
ON "UserLikeArticle"."articleId" = "Article"."id" AND
"UserLikeArticle"."userId" = :loggedInUserId
LEFT OUTER JOIN "Issue" AS "issues" ON "Article"."id" = "issues"."articleId"
${h1
? `WHERE "Article"."nestedSetIndex" = :nestedSetIndex`
: `WHERE "Article"."nestedSetIndex" > :nestedSetIndex AND
"Article"."nestedSetIndex" < :nestedSetNextSibling`
}
GROUP BY
"Article"."id",
"Article"."score",
"Article"."slug",
"Article"."topicId",
"Article"."titleSource",
"File.Author"."id",
"File.Author"."username",
"SameTopic"."articleCount",
"ArticleSameTopicByLoggedIn"."id",
"UserLikeArticle"."userId"
ORDER BY "Article"."nestedSetIndex" ASC${limit !== undefined ? `
LIMIT ${limit}` : ''}
`,
{
replacements: {
authorUsername: article.author.username,
loggedInUserId: loggedInUser ? loggedInUser.id : null,
nestedSetIndex: article.nestedSetIndex,
nestedSetNextSibling: article.nestedSetNextSibling,
}
}
)
//sequelize.query(`
//FROM "Article"
//WHERE "nestedSetIndex" > :nestedSetIndex AND "nestedSetIndex" < :nestedSetNextSibling
//ORDER BY "nestedSetIndex" ASC
// {
// replacements: {
// nestedSetIndex: article.nestedSetIndex,
// nestedSetNextSibling: article.nestedSetNextSibling,
// }
// }
// ),
//`)
for (const row of rows) {
row.hasSameTopic = row.hasSameTopic === null ? false : true
row.liked = row.liked === null ? false : true
row.author = {
id: row['file.author.id'],
username: row['file.author.username'],
}
delete row['file.author.id']
delete row['file.author.username']
}
return rows
}
/**
* Calculate nested sets from Ref information and return this data.
* As of creating this function, this was supposed to be incrementally
* done by the convertArticle() internal function. But we are considering
* doing it in bulk to speed things up. And also creating this to quickfix
* a wrong index observed in production for unknown reasons:
* https://docs.ourbigbook.com/subsections-missing-on-web-dynamic-tree
*
* @param {string} username
*/
Article.getNestedSetsFromRefs = async function(username, { transaction }={}) {
const toplevelId = `${ourbigbook.AT_MENTION_CHAR}${username}`
const idRows = await ourbigbook_nodejs_webpack_safe.fetch_header_tree_ids(
sequelize,
[toplevelId],
{
// Saves memory 3x. Still doesn't scale indefinitely, but helps as a workaround.
idAttrs: '"level","from_id","idid"',
transaction,
},
)
let idTreeNode = {
id: toplevelId,
children: [],
nextSibling: undefined,
parent: undefined,
depth: 0,
nestedSetIndex: undefined,
nestedSetNextSibling: undefined,
}
const idToIdTreeNode = {
[toplevelId]: idTreeNode,
}
for (const row of idRows) {
const depth = row.level + 1
const parent = idToIdTreeNode[row.from_id]
const childIdx = parent.children.length
const idTreeNode = {
id: row.idid,
children: [],
childIdx,
depth,
parent,
nestedSetIndex: undefined,
nestedSetNextSibling: undefined,
}
if (childIdx > 0) {
parent.children[childIdx - 1].nextSibling = idTreeNode
}
idToIdTreeNode[row.idid] = idTreeNode
parent.children.push(idTreeNode)
}
const nestedSet = []
// Calculated nestedSetIndex, a simple pre-order traversal.
let i = 0
let todo = [idTreeNode]
while (todo.length) {
const node = todo.pop()
node.nestedSetIndex = i
nestedSet.push(node)
todo.push(...node.children.slice().reverse())
i++
}
// Calculate nestedSetNextSibling.
// Needs a second pass because it relies on the indices of nodes we
// haven't visited yet on the first pass.
todo = [idTreeNode]
while (todo.length) {
const node = todo.pop()
if (node.nextSibling) {
node.nestedSetNextSibling = node.nextSibling.nestedSetIndex
} else if (node.children.length === 0) {
const nextSibling = node.nestedSetIndex + 1
node.nestedSetNextSibling = nextSibling
let ancestor = node.parent
while(
ancestor !== undefined &&
ancestor.nextSibling === undefined
) {
ancestor.nestedSetNextSibling = nextSibling
ancestor = ancestor.parent
}
}
todo.push(...node.children.slice().reverse())
}
return nestedSet
}
Article.findRedirects = async (fromSlugs, { limit, offset } = {}) => {
const refs = await sequelize.models.Ref.findAll({
where: {
type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_SYNONYM],
from_id: fromSlugs.map(s => `${ourbigbook.AT_MENTION_CHAR}${s}`),
},
limit,
offset,
})
const ret = {}
for (const ref of refs) {
ret[ref.from_id.slice(ourbigbook.AT_MENTION_CHAR.length)] = ref.to_id.slice(ourbigbook.AT_MENTION_CHAR.length)
}
return ret
}
/** Re-render multiple articles. */
Article.rerender = async ({
author,
convertOptionsExtra,
ignoreErrors,
log,
slugs
}={}) => {
if (log === undefined)
log = false
const where = {}
if (slugs.length) {
where.slug = slugs
}
const authorWhere = {}
if (author) {
authorWhere.username = author
}
let offset = 0
while (true) {
const articles = await sequelize.models.Article.findAll({
where,
subQuery: false,
include: [
{
model: sequelize.models.File,
as: 'file',
subQuery: false,
required: true,
include: [
{
model: sequelize.models.User,
as: 'author',
subQuery: false,
required: true,
where: authorWhere,
},
{
model: sequelize.models.Id,
as: 'toplevelId',
subQuery: false,
required: true,
include: [{
model: sequelize.models.Ref,
as: 'to',
subQuery: false,
where: { type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT] },
include: [{
model: sequelize.models.Id,
as: 'from',
subQuery: false,
required: true,
}],
}],
}
],
},
],
order: [['slug', 'ASC']],
offset,
limit: config.maxArticlesInMemory,
})
if (articles.length === 0)
break
for (const article of articles) {
if (log)
console.log(article.slug)
await article.rerender({ convertOptionsExtra, ignoreErrors })
}
offset += config.maxArticlesInMemory
}
}
Article.prototype.getSlug = function() {
return this.slug
}
/**
* Calculate nested sets from Ref information update database with those values.
*
* @param {string} username
*/
Article.updateNestedSets = async function(username, { transaction }={}) {
const nestedSet = await Article.getNestedSetsFromRefs(username, { transaction })
const vals = nestedSet.map(s => { return {
slug: s.id.slice(ourbigbook.AT_MENTION_CHAR.length),
nestedSetIndex: s.nestedSetIndex,
nestedSetNextSibling: s.nestedSetNextSibling,
depth: s.depth,
}})
return sequelize.transaction({ transaction }, async (transaction) => {
for (const val of vals) {
await sequelize.models.Article.update(
{
nestedSetIndex: val.nestedSetIndex,
nestedSetNextSibling: val.nestedSetNextSibling,
depth: val.depth,
},
{
transaction,
where: { slug: val.slug },
},
)
}
})
// Would be nice, but doesn't work because of NOT NULL columns:
// https://stackoverflow.com/questions/48816629/on-conflict-do-nothing-in-postgres-with-a-not-null-constraint
//return Article.bulkCreate(
// vals,
// {
// updateOnDuplicate: [
// 'nestedSetIndex',
// 'nestedSetNextSibling',
// ]
// }
//)
}
Article.slugTransform = slugTransform
return Article
}