tree.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import { Model } from 'objection'
  2. import { differenceWith, dropRight, last, nth } from 'lodash-es'
  3. import {
  4. decodeTreePath,
  5. encodeTreePath,
  6. generateHash
  7. } from '../helpers/common.mjs'
  8. import { Site } from './sites.mjs'
  9. const rePathName = /^[a-z0-9-]+$/
  10. const reTitle = /^[^<>"]+$/
  11. /**
  12. * Tree model
  13. */
  14. export class Tree extends Model {
  15. static get tableName () { return 'tree' }
  16. static get jsonSchema () {
  17. return {
  18. type: 'object',
  19. required: ['fileName'],
  20. properties: {
  21. id: { type: 'string' },
  22. folderPath: { type: 'string' },
  23. fileName: { type: 'string' },
  24. type: { type: 'string' },
  25. title: { type: 'string' },
  26. createdAt: { type: 'string' },
  27. updatedAt: { type: 'string' }
  28. }
  29. }
  30. }
  31. static get jsonAttributes () {
  32. return ['meta']
  33. }
  34. static get relationMappings () {
  35. return {
  36. site: {
  37. relation: Model.BelongsToOneRelation,
  38. modelClass: Site,
  39. join: {
  40. from: 'tree.siteId',
  41. to: 'sites.id'
  42. }
  43. }
  44. }
  45. }
  46. $beforeUpdate () {
  47. this.updatedAt = new Date().toISOString()
  48. }
  49. $beforeInsert () {
  50. this.createdAt = new Date().toISOString()
  51. this.updatedAt = new Date().toISOString()
  52. }
  53. /**
  54. * Get a Folder
  55. *
  56. * @param {Object} args - Fetch Properties
  57. * @param {string} [args.id] - UUID of the folder
  58. * @param {string} [args.path] - Path of the folder
  59. * @param {string} [args.locale] - Locale code of the folder (when using path)
  60. * @param {string} [args.siteId] - UUID of the site in which the folder is (when using path)
  61. * @param {boolean} [args.createIfMissing] - Create the folder and its ancestor if it's missing (when using path)
  62. */
  63. static async getFolder ({ id, path, locale, siteId, createIfMissing = false }) {
  64. // Get by ID
  65. if (id) {
  66. const parent = await WIKI.db.knex('tree').where('id', id).first()
  67. if (!parent) {
  68. throw new Error('ERR_INVALID_FOLDER')
  69. }
  70. return parent
  71. } else {
  72. // Get by path
  73. const parentPath = encodeTreePath(path)
  74. const parentPathParts = parentPath.split('.')
  75. const parentFilter = {
  76. folderPath: dropRight(parentPathParts).join('.'),
  77. fileName: last(parentPathParts)
  78. }
  79. const parent = await WIKI.db.knex('tree').where({
  80. ...parentFilter,
  81. type: 'folder',
  82. locale,
  83. siteId
  84. }).first()
  85. if (parent) {
  86. return parent
  87. } else if (createIfMissing) {
  88. return WIKI.db.tree.createFolder({
  89. parentPath: parentFilter.folderPath,
  90. pathName: parentFilter.fileName,
  91. title: parentFilter.fileName,
  92. locale,
  93. siteId
  94. })
  95. } else {
  96. throw new Error('ERR_INVALID_FOLDER')
  97. }
  98. }
  99. }
  100. /**
  101. * Add Page Entry
  102. *
  103. * @param {Object} args - New Page Properties
  104. * @param {string} [args.parentId] - UUID of the parent folder
  105. * @param {string} [args.parentPath] - Path of the parent folder
  106. * @param {string} args.pathName - Path name of the page to add
  107. * @param {string} args.title - Title of the page to add
  108. * @param {string} args.locale - Locale code of the page to add
  109. * @param {string} args.siteId - UUID of the site in which the page will be added
  110. * @param {string[]} [args.tags] - Tags of the assets
  111. * @param {Object} [args.meta] - Extra metadata
  112. */
  113. static async addPage ({ id, parentId, parentPath, fileName, title, locale, siteId, tags = [], meta = {} }) {
  114. const folder = (parentId || parentPath)
  115. ? await WIKI.db.tree.getFolder({
  116. id: parentId,
  117. path: parentPath,
  118. locale,
  119. siteId,
  120. createIfMissing: true
  121. })
  122. : {
  123. folderPath: '',
  124. fileName: ''
  125. }
  126. const folderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
  127. const fullPath = folderPath ? `${folderPath}/${fileName}` : fileName
  128. WIKI.logger.debug(`Adding page ${fullPath} to tree...`)
  129. const pageEntry = await WIKI.db.knex('tree').insert({
  130. id,
  131. folderPath,
  132. fileName,
  133. type: 'page',
  134. title,
  135. hash: generateHash(fullPath),
  136. locale,
  137. siteId,
  138. tags,
  139. meta,
  140. navigationId: siteId
  141. }).returning('*')
  142. return pageEntry[0]
  143. }
  144. /**
  145. * Add Asset Entry
  146. *
  147. * @param {Object} args - New Asset Properties
  148. * @param {string} [args.parentId] - UUID of the parent folder
  149. * @param {string} [args.parentPath] - Path of the parent folder
  150. * @param {string} args.pathName - Path name of the asset to add
  151. * @param {string} args.title - Title of the asset to add
  152. * @param {string} args.locale - Locale code of the asset to add
  153. * @param {string} args.siteId - UUID of the site in which the asset will be added
  154. * @param {string[]} [args.tags] - Tags of the assets
  155. * @param {Object} [args.meta] - Extra metadata
  156. */
  157. static async addAsset ({ id, parentId, parentPath, fileName, title, locale, siteId, tags = [], meta = {} }) {
  158. const folder = (parentId || parentPath)
  159. ? await WIKI.db.tree.getFolder({
  160. id: parentId,
  161. path: parentPath,
  162. locale,
  163. siteId,
  164. createIfMissing: true
  165. })
  166. : {
  167. folderPath: '',
  168. fileName: ''
  169. }
  170. const folderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
  171. const fullPath = folderPath ? `${folderPath}/${fileName}` : fileName
  172. WIKI.logger.debug(`Adding asset ${fullPath} to tree...`)
  173. const assetEntry = await WIKI.db.knex('tree').insert({
  174. id,
  175. folderPath,
  176. fileName,
  177. type: 'asset',
  178. title,
  179. hash: generateHash(fullPath),
  180. locale,
  181. siteId,
  182. tags,
  183. meta
  184. }).returning('*')
  185. return assetEntry[0]
  186. }
  187. /**
  188. * Create New Folder
  189. *
  190. * @param {Object} args - New Folder Properties
  191. * @param {string} [args.parentId] - UUID of the parent folder
  192. * @param {string} [args.parentPath] - Path of the parent folder
  193. * @param {string} args.pathName - Path name of the folder to create
  194. * @param {string} args.title - Title of the folder to create
  195. * @param {string} args.locale - Locale code of the folder to create
  196. * @param {string} args.siteId - UUID of the site in which the folder will be created
  197. */
  198. static async createFolder ({ parentId, parentPath, pathName, title, locale, siteId }) {
  199. // Validate path name
  200. if (!rePathName.test(pathName)) {
  201. throw new Error('ERR_INVALID_PATH')
  202. }
  203. // Validate title
  204. if (!reTitle.test(title)) {
  205. throw new Error('ERR_FOLDER_TITLE_INVALID')
  206. }
  207. parentPath = encodeTreePath(parentPath)
  208. WIKI.logger.debug(`Creating new folder ${pathName}...`)
  209. const parentPathParts = parentPath.split('.')
  210. const parentFilter = {
  211. folderPath: dropRight(parentPathParts).join('.'),
  212. fileName: last(parentPathParts)
  213. }
  214. // Get parent path
  215. let parent = null
  216. if (parentId) {
  217. parent = await WIKI.db.knex('tree').where('id', parentId).first()
  218. if (!parent) {
  219. throw new Error('ERR_FOLDER_PARENT_INVALID')
  220. }
  221. parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
  222. } else if (parentPath) {
  223. parent = await WIKI.db.knex('tree').where(parentFilter).first()
  224. } else {
  225. parentPath = ''
  226. }
  227. // Check for collision
  228. const existingFolder = await WIKI.db.knex('tree').select('id').where({
  229. siteId,
  230. locale,
  231. folderPath: parentPath,
  232. fileName: pathName,
  233. type: 'folder'
  234. }).first()
  235. if (existingFolder) {
  236. throw new Error('ERR_FOLDER_DUPLICATE')
  237. }
  238. // Ensure all ancestors exist
  239. if (parentPath) {
  240. const expectedAncestors = []
  241. const existingAncestors = await WIKI.db.knex('tree').select('folderPath', 'fileName').where(builder => {
  242. const parentPathParts = parentPath.split('.')
  243. for (let i = 1; i <= parentPathParts.length; i++) {
  244. const ancestor = {
  245. folderPath: dropRight(parentPathParts, i).join('.'),
  246. fileName: nth(parentPathParts, i * -1)
  247. }
  248. expectedAncestors.push(ancestor)
  249. builder.orWhere({
  250. ...ancestor,
  251. type: 'folder'
  252. })
  253. }
  254. })
  255. for (const ancestor of differenceWith(expectedAncestors, existingAncestors, (expAnc, exsAnc) => expAnc.folderPath === exsAnc.folderPath && expAnc.fileName === exsAnc.fileName)) {
  256. WIKI.logger.debug(`Creating missing parent folder ${ancestor.fileName} at path /${ancestor.folderPath}...`)
  257. const newAncestorFullPath = ancestor.folderPath ? `${decodeTreePath(ancestor.folderPath)}/${ancestor.fileName}` : ancestor.fileName
  258. const newAncestor = await WIKI.db.knex('tree').insert({
  259. ...ancestor,
  260. type: 'folder',
  261. title: ancestor.fileName,
  262. hash: generateHash(newAncestorFullPath),
  263. locale,
  264. siteId,
  265. meta: {
  266. children: 1
  267. }
  268. }).returning('*')
  269. // Parent didn't exist until now, assign it
  270. if (!parent && ancestor.folderPath === parentFilter.folderPath && ancestor.fileName === parentFilter.fileName) {
  271. parent = newAncestor[0]
  272. }
  273. }
  274. }
  275. // Create folder
  276. const fullPath = parentPath ? `${decodeTreePath(parentPath)}/${pathName}` : pathName
  277. const folder = await WIKI.db.knex('tree').insert({
  278. folderPath: parentPath,
  279. fileName: pathName,
  280. type: 'folder',
  281. title,
  282. hash: generateHash(fullPath),
  283. locale,
  284. siteId,
  285. meta: {
  286. children: 0
  287. }
  288. }).returning('*')
  289. // Update parent ancestor count
  290. if (parent) {
  291. await WIKI.db.knex('tree').where('id', parent.id).update({
  292. meta: {
  293. ...(parent.meta ?? {}),
  294. children: (parent.meta?.children || 0) + 1
  295. }
  296. })
  297. }
  298. WIKI.logger.debug(`Created folder ${folder[0].id} successfully.`)
  299. return folder[0]
  300. }
  301. /**
  302. * Rename a folder
  303. *
  304. * @param {Object} args - Rename Folder Properties
  305. * @param {string} args.folderId - UUID of the folder to rename
  306. * @param {string} args.pathName - New path name of the folder
  307. * @param {string} args.title - New title of the folder
  308. */
  309. static async renameFolder ({ folderId, pathName, title }) {
  310. // Get folder
  311. const folder = await WIKI.db.knex('tree').where('id', folderId).first()
  312. if (!folder) {
  313. throw new Error('ERR_INVALID_FOLDER')
  314. }
  315. // Validate path name
  316. if (!rePathName.test(pathName)) {
  317. throw new Error('ERR_INVALID_PATH')
  318. }
  319. // Validate title
  320. if (!reTitle.test(title)) {
  321. throw new Error('ERR_FOLDER_TITLE_INVALID')
  322. }
  323. WIKI.logger.debug(`Renaming folder ${folder.id} path to ${pathName}...`)
  324. if (pathName !== folder.fileName) {
  325. // Check for collision
  326. const existingFolder = await WIKI.db.knex('tree')
  327. .whereNot('id', folder.id)
  328. .andWhere({
  329. siteId: folder.siteId,
  330. folderPath: folder.folderPath,
  331. fileName: pathName,
  332. type: 'folder'
  333. }).first()
  334. if (existingFolder) {
  335. throw new Error('ERR_FOLDER_DUPLICATE')
  336. }
  337. // Build new paths
  338. const oldFolderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
  339. const newFolderPath = folder.folderPath ? `${folder.folderPath}.${pathName}` : pathName
  340. // Update children nodes
  341. WIKI.logger.debug(`Updating parent path of children nodes from ${oldFolderPath} to ${newFolderPath} ...`)
  342. await WIKI.db.knex('tree').where('siteId', folder.siteId).andWhere('folderPath', oldFolderPath).update({
  343. folderPath: newFolderPath
  344. })
  345. await WIKI.db.knex('tree').where('siteId', folder.siteId).andWhere('folderPath', '<@', oldFolderPath).update({
  346. folderPath: WIKI.db.knex.raw(`'${newFolderPath}' || subpath(tree."folderPath", nlevel('${newFolderPath}'))`)
  347. })
  348. // Rename the folder itself
  349. const fullPath = folder.folderPath ? `${folder.folderPath}/${pathName}` : pathName
  350. await WIKI.db.knex('tree').where('id', folder.id).update({
  351. fileName: pathName,
  352. title,
  353. hash: generateHash(fullPath)
  354. })
  355. } else {
  356. // Update the folder title only
  357. await WIKI.db.knex('tree').where('id', folder.id).update({
  358. title
  359. })
  360. }
  361. WIKI.logger.debug(`Renamed folder ${folder.id} successfully.`)
  362. }
  363. /**
  364. * Delete a folder
  365. *
  366. * @param {String} folderId Folder ID
  367. */
  368. static async deleteFolder (folderId) {
  369. // Get folder
  370. const folder = await WIKI.db.knex('tree').where('id', folderId).first()
  371. if (!folder) {
  372. throw new Error('ERR_INVALID_FOLDER')
  373. }
  374. const folderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
  375. WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`)
  376. // Delete all children
  377. const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '<@', folderPath).del().returning(['id', 'type'])
  378. // Delete folders
  379. const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id)
  380. if (deletedFolders.length > 0) {
  381. WIKI.logger.debug(`Deleted ${deletedFolders.length} children folders.`)
  382. }
  383. // Delete pages
  384. const deletedPages = deletedNodes.filter(n => n.type === 'page').map(n => n.id)
  385. if (deletedPages.length > 0) {
  386. WIKI.logger.debug(`Deleting ${deletedPages.length} children pages...`)
  387. // TODO: Delete page
  388. }
  389. // Delete assets
  390. const deletedAssets = deletedNodes.filter(n => n.type === 'asset').map(n => n.id)
  391. if (deletedAssets.length > 0) {
  392. WIKI.logger.debug(`Deleting ${deletedPages.length} children assets...`)
  393. // TODO: Delete asset
  394. }
  395. // Delete the folder itself
  396. await WIKI.db.knex('tree').where('id', folder.id).del()
  397. // Update parent children count
  398. if (folder.folderPath) {
  399. const parentPathParts = folder.folderPath.split('.')
  400. const parent = await WIKI.db.knex('tree').where({
  401. folderPath: dropRight(parentPathParts).join('.'),
  402. fileName: last(parentPathParts)
  403. }).first()
  404. await WIKI.db.knex('tree').where('id', parent.id).update({
  405. meta: {
  406. ...(parent.meta ?? {}),
  407. children: (parent.meta?.children || 1) - 1
  408. }
  409. })
  410. }
  411. WIKI.logger.debug(`Deleted folder ${folder.id} successfully.`)
  412. }
  413. }