tree.mjs 14 KB

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