page.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. import _ from 'lodash-es'
  2. import { generateError, generateSuccess } from '../../helpers/graph.mjs'
  3. import { parsePath } from '../../helpers/page.mjs'
  4. import tsquery from 'pg-tsquery'
  5. const tsq = tsquery()
  6. const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g
  7. export default {
  8. Query: {
  9. /**
  10. * PAGE HISTORY
  11. */
  12. async pageHistoryById (obj, args, context, info) {
  13. const page = await WIKI.db.pages.query().select('path', 'locale').findById(args.id)
  14. if (WIKI.auth.checkAccess(context.req.user, ['read:history'], {
  15. path: page.path,
  16. locale: page.locale
  17. })) {
  18. return WIKI.db.pageHistory.getHistory({
  19. pageId: args.id,
  20. offsetPage: args.offsetPage || 0,
  21. offsetSize: args.offsetSize || 100
  22. })
  23. } else {
  24. throw new WIKI.Error.PageHistoryForbidden()
  25. }
  26. },
  27. /**
  28. * PAGE VERSION
  29. */
  30. async pageVersionById (obj, args, context, info) {
  31. const page = await WIKI.db.pages.query().select('path', 'locale').findById(args.pageId)
  32. if (WIKI.auth.checkAccess(context.req.user, ['read:history'], {
  33. path: page.path,
  34. locale: page.locale
  35. })) {
  36. return WIKI.db.pageHistory.getVersion({
  37. pageId: args.pageId,
  38. versionId: args.versionId
  39. })
  40. } else {
  41. throw new WIKI.Error.PageHistoryForbidden()
  42. }
  43. },
  44. /**
  45. * SEARCH PAGES
  46. */
  47. async searchPages (obj, args, context) {
  48. const q = args.query.trim()
  49. const hasQuery = q.length > 0
  50. // -> Validate parameters
  51. if (!args.siteId) {
  52. throw new Error('Missing Site ID')
  53. }
  54. if (args.offset && args.offset < 0) {
  55. throw new Error('Invalid offset value.')
  56. }
  57. if (args.limit && (args.limit < 1 || args.limit > 100)) {
  58. throw new Error('Limit must be between 1 and 100.')
  59. }
  60. try {
  61. const dictName = 'english' // TODO: Use provided locale or fallback on site locale
  62. // -> Select Columns
  63. const searchCols = [
  64. 'id',
  65. 'path',
  66. 'locale',
  67. 'title',
  68. 'description',
  69. 'icon',
  70. 'tags',
  71. 'updatedAt',
  72. WIKI.db.knex.raw('count(*) OVER() AS total')
  73. ]
  74. // -> Set relevancy
  75. if (hasQuery) {
  76. searchCols.push(WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'))
  77. } else {
  78. args.orderBy = args.orderBy === 'relevancy' ? 'title' : args.orderBy
  79. }
  80. // -> Add Highlighting if enabled
  81. if (WIKI.config.search.termHighlighting && hasQuery) {
  82. searchCols.push(WIKI.db.knex.raw(`ts_headline(?, "searchContent", query, 'MaxWords=5, MinWords=3, MaxFragments=5') AS highlight`, [dictName]))
  83. }
  84. const results = await WIKI.db.knex
  85. .select(searchCols)
  86. .fromRaw(hasQuery ? 'pages, to_tsquery(?, ?) query' : 'pages', hasQuery ? [dictName, tsq(q)] : [])
  87. .where('siteId', args.siteId)
  88. .where('isSearchableComputed', true)
  89. .where(builder => {
  90. if (args.path) {
  91. builder.where('path', 'ILIKE', `${args.path}%`)
  92. }
  93. if (args.locale?.length > 0) {
  94. builder.whereIn('locale', args.locale)
  95. }
  96. if (args.editor) {
  97. builder.where('editor', args.editor)
  98. }
  99. if (args.publishState) {
  100. builder.where('publishState', args.publishState)
  101. }
  102. if (args.tags) {
  103. builder.where('tags', '@>', args.tags)
  104. }
  105. if (hasQuery) {
  106. builder.whereRaw('query @@ ts')
  107. }
  108. })
  109. .orderBy(args.orderBy || 'relevancy', args.orderByDirection || 'desc')
  110. .offset(args.offset || 0)
  111. .limit(args.limit || 25)
  112. // -> Remove highlights without matches
  113. if (WIKI.config.search.termHighlighting && hasQuery) {
  114. for (const r of results) {
  115. if (r.highlight?.indexOf('<b>') < 0) {
  116. r.highlight = null
  117. }
  118. }
  119. }
  120. return {
  121. results,
  122. totalHits: results?.length > 0 ? results[0].total : 0
  123. }
  124. } catch (err) {
  125. WIKI.logger.warn(`Search Query Error: ${err.message}`)
  126. throw err
  127. }
  128. },
  129. /**
  130. * LIST PAGES
  131. */
  132. async pages (obj, args, context, info) {
  133. let results = await WIKI.db.pages.query().column([
  134. 'pages.id',
  135. 'path',
  136. 'locale',
  137. 'title',
  138. 'description',
  139. 'isPublished',
  140. 'isPrivate',
  141. 'privateNS',
  142. 'contentType',
  143. 'createdAt',
  144. 'updatedAt'
  145. ])
  146. .withGraphJoined('tags')
  147. .modifyGraph('tags', builder => {
  148. builder.select('tag')
  149. })
  150. .modify(queryBuilder => {
  151. if (args.limit) {
  152. queryBuilder.limit(args.limit)
  153. }
  154. if (args.locale) {
  155. queryBuilder.where('locale', args.locale)
  156. }
  157. if (args.creatorId && args.authorId && args.creatorId > 0 && args.authorId > 0) {
  158. queryBuilder.where(function () {
  159. this.where('creatorId', args.creatorId).orWhere('authorId', args.authorId)
  160. })
  161. } else {
  162. if (args.creatorId && args.creatorId > 0) {
  163. queryBuilder.where('creatorId', args.creatorId)
  164. }
  165. if (args.authorId && args.authorId > 0) {
  166. queryBuilder.where('authorId', args.authorId)
  167. }
  168. }
  169. if (args.tags && args.tags.length > 0) {
  170. queryBuilder.whereIn('tags.tag', args.tags.map(t => _.trim(t).toLowerCase()))
  171. }
  172. const orderDir = args.orderByDirection === 'DESC' ? 'desc' : 'asc'
  173. switch (args.orderBy) {
  174. case 'CREATED':
  175. queryBuilder.orderBy('createdAt', orderDir)
  176. break
  177. case 'PATH':
  178. queryBuilder.orderBy('path', orderDir)
  179. break
  180. case 'TITLE':
  181. queryBuilder.orderBy('title', orderDir)
  182. break
  183. case 'UPDATED':
  184. queryBuilder.orderBy('updatedAt', orderDir)
  185. break
  186. default:
  187. queryBuilder.orderBy('pages.id', orderDir)
  188. break
  189. }
  190. })
  191. results = _.filter(results, r => {
  192. return WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
  193. path: r.path,
  194. locale: r.locale
  195. })
  196. }).map(r => ({
  197. ...r,
  198. tags: _.map(r.tags, 'tag')
  199. }))
  200. if (args.tags && args.tags.length > 0) {
  201. results = _.filter(results, r => _.every(args.tags, t => _.includes(r.tags, t)))
  202. }
  203. return results
  204. },
  205. /**
  206. * FETCH SINGLE PAGE BY ID
  207. */
  208. async pageById (obj, args, context, info) {
  209. const page = await WIKI.db.pages.getPageFromDb(args.id)
  210. if (page) {
  211. if (WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
  212. path: page.path,
  213. locale: page.locale
  214. })) {
  215. return {
  216. ...page,
  217. ...page.config,
  218. scriptCss: page.scripts?.css,
  219. scriptJsLoad: page.scripts?.jsLoad,
  220. scriptJsUnload: page.scripts?.jsUnload
  221. }
  222. } else {
  223. throw new Error('ERR_FORBIDDEN')
  224. }
  225. } else {
  226. throw new Error('ERR_PAGE_NOT_FOUND')
  227. }
  228. },
  229. /**
  230. * FETCH SINGLE PAGE BY PATH
  231. */
  232. async pageByPath (obj, args, context, info) {
  233. // console.info(info)
  234. const pageArgs = parsePath(args.path)
  235. const page = await WIKI.db.pages.getPageFromDb({
  236. ...pageArgs,
  237. siteId: args.siteId
  238. })
  239. if (page) {
  240. if (WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
  241. path: page.path,
  242. locale: page.locale
  243. })) {
  244. return {
  245. ...page,
  246. ...page.config,
  247. scriptCss: page.scripts?.css,
  248. scriptJsLoad: page.scripts?.jsLoad,
  249. scriptJsUnload: page.scripts?.jsUnload
  250. }
  251. } else {
  252. throw new Error('ERR_FORBIDDEN')
  253. }
  254. } else {
  255. throw new Error('ERR_PAGE_NOT_FOUND')
  256. }
  257. },
  258. /**
  259. * FETCH PATH FROM ALIAS
  260. */
  261. async pathFromAlias (obj, args, context, info) {
  262. const alias = args.alias?.trim()
  263. if (!alias) {
  264. throw new Error('ERR_PAGE_ALIAS_MISSING')
  265. }
  266. if (!WIKI.sites[args.siteId]) {
  267. throw new Error('ERR_INVALID_SITE')
  268. }
  269. const page = await WIKI.db.pages.query().findOne({
  270. alias: args.alias,
  271. siteId: args.siteId
  272. }).select('id', 'path', 'locale')
  273. if (!page) {
  274. throw new Error('ERR_PAGE_ALIAS_NOT_FOUND')
  275. }
  276. return {
  277. id: page.id,
  278. path: WIKI.sites[args.siteId].config.localeNamespacing ? `${page.locale}/${page.path}` : page.path
  279. }
  280. },
  281. /**
  282. * FETCH TAGS
  283. */
  284. async tags (obj, args, context, info) {
  285. if (!args.siteId) { throw new Error('Missing Site ID') }
  286. const tags = await WIKI.db.knex('tags').where('siteId', args.siteId).orderBy('tag')
  287. // TODO: check permissions
  288. return tags
  289. },
  290. /**
  291. * FETCH PAGE TREE
  292. */
  293. async pageTree (obj, args, context, info) {
  294. let curPage = null
  295. if (!args.locale) { args.locale = WIKI.config.lang.code }
  296. if (args.path && !args.parent) {
  297. curPage = await WIKI.db.knex('pageTree').first('parent', 'ancestors').where({
  298. path: args.path,
  299. locale: args.locale
  300. })
  301. if (curPage) {
  302. args.parent = curPage.parent || 0
  303. } else {
  304. return []
  305. }
  306. }
  307. const results = await WIKI.db.knex('pageTree').where(builder => {
  308. builder.where('locale', args.locale)
  309. switch (args.mode) {
  310. case 'FOLDERS':
  311. builder.andWhere('isFolder', true)
  312. break
  313. case 'PAGES':
  314. builder.andWhereNotNull('pageId')
  315. break
  316. }
  317. if (!args.parent || args.parent < 1) {
  318. builder.whereNull('parent')
  319. } else {
  320. builder.where('parent', args.parent)
  321. if (args.includeAncestors && curPage && curPage.ancestors.length > 0) {
  322. builder.orWhereIn('id', _.isString(curPage.ancestors) ? JSON.parse(curPage.ancestors) : curPage.ancestors)
  323. }
  324. }
  325. }).orderBy([{ column: 'isFolder', order: 'desc' }, 'title'])
  326. return results.filter(r => {
  327. return WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
  328. path: r.path,
  329. locale: r.locale
  330. })
  331. }).map(r => ({
  332. ...r,
  333. parent: r.parent || 0,
  334. locale: r.locale
  335. }))
  336. },
  337. /**
  338. * FETCH PAGE LINKS
  339. */
  340. async pageLinks (obj, args, context, info) {
  341. let results
  342. if (WIKI.config.db.type === 'mysql' || WIKI.config.db.type === 'mariadb' || WIKI.config.db.type === 'sqlite') {
  343. results = await WIKI.db.knex('pages')
  344. .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.locale' })
  345. .leftJoin('pageLinks', 'pages.id', 'pageLinks.pageId')
  346. .where({
  347. 'pages.locale': args.locale
  348. })
  349. .unionAll(
  350. WIKI.db.knex('pageLinks')
  351. .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.locale' })
  352. .leftJoin('pages', 'pageLinks.pageId', 'pages.id')
  353. .where({
  354. 'pages.locale': args.locale
  355. })
  356. )
  357. } else {
  358. results = await WIKI.db.knex('pages')
  359. .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.locale' })
  360. .fullOuterJoin('pageLinks', 'pages.id', 'pageLinks.pageId')
  361. .where({
  362. 'pages.locale': args.locale
  363. })
  364. }
  365. return _.reduce(results, (result, val) => {
  366. // -> Check if user has access to source and linked page
  367. if (
  368. !WIKI.auth.checkAccess(context.req.user, ['read:pages'], { path: val.path, locale: args.locale }) ||
  369. !WIKI.auth.checkAccess(context.req.user, ['read:pages'], { path: val.link, locale: val.locale })
  370. ) {
  371. return result
  372. }
  373. const existingEntry = _.findIndex(result, ['id', val.id])
  374. if (existingEntry >= 0) {
  375. if (val.link) {
  376. result[existingEntry].links.push(`${val.locale}/${val.link}`)
  377. }
  378. } else {
  379. result.push({
  380. id: val.id,
  381. title: val.title,
  382. path: `${args.locale}/${val.path}`,
  383. links: val.link ? [`${val.locale}/${val.link}`] : []
  384. })
  385. }
  386. return result
  387. }, [])
  388. },
  389. /**
  390. * CHECK FOR EDITING CONFLICT
  391. */
  392. async checkConflicts (obj, args, context, info) {
  393. let page = await WIKI.db.pages.query().select('path', 'locale', 'updatedAt').findById(args.id)
  394. if (page) {
  395. if (WIKI.auth.checkAccess(context.req.user, ['write:pages', 'manage:pages'], {
  396. path: page.path,
  397. locale: page.locale
  398. })) {
  399. return page.updatedAt > args.checkoutDate
  400. } else {
  401. throw new WIKI.Error.PageUpdateForbidden()
  402. }
  403. } else {
  404. throw new WIKI.Error.PageNotFound()
  405. }
  406. },
  407. /**
  408. * FETCH LATEST VERSION FOR CONFLICT COMPARISON
  409. */
  410. async checkConflictsLatest (obj, args, context, info) {
  411. let page = await WIKI.db.pages.getPageFromDb(args.id)
  412. if (page) {
  413. if (WIKI.auth.checkAccess(context.req.user, ['write:pages', 'manage:pages'], {
  414. path: page.path,
  415. locale: page.locale
  416. })) {
  417. return {
  418. ...page,
  419. tags: page.tags.map(t => t.tag),
  420. locale: page.locale
  421. }
  422. } else {
  423. throw new WIKI.Error.PageViewForbidden()
  424. }
  425. } else {
  426. throw new WIKI.Error.PageNotFound()
  427. }
  428. }
  429. },
  430. Mutation: {
  431. /**
  432. * CREATE PAGE
  433. */
  434. async createPage(obj, args, context) {
  435. try {
  436. const page = await WIKI.db.pages.createPage({
  437. ...args,
  438. user: context.req.user
  439. })
  440. return {
  441. operation: generateSuccess('Page created successfully.'),
  442. page
  443. }
  444. } catch (err) {
  445. return generateError(err)
  446. }
  447. },
  448. /**
  449. * UPDATE PAGE
  450. */
  451. async updatePage(obj, args, context) {
  452. try {
  453. const page = await WIKI.db.pages.updatePage({
  454. ...args,
  455. user: context.req.user
  456. })
  457. return {
  458. operation: generateSuccess('Page has been updated.'),
  459. page
  460. }
  461. } catch (err) {
  462. return generateError(err)
  463. }
  464. },
  465. /**
  466. * CONVERT PAGE
  467. */
  468. async convertPage(obj, args, context) {
  469. try {
  470. await WIKI.db.pages.convertPage({
  471. ...args,
  472. user: context.req.user
  473. })
  474. return {
  475. operation: generateSuccess('Page has been converted.')
  476. }
  477. } catch (err) {
  478. return generateError(err)
  479. }
  480. },
  481. /**
  482. * RENAME PAGE
  483. */
  484. async renamePage(obj, args, context) {
  485. try {
  486. await WIKI.db.pages.movePage({
  487. ...args,
  488. user: context.req.user
  489. })
  490. return {
  491. operation: generateSuccess('Page has been moved.')
  492. }
  493. } catch (err) {
  494. return generateError(err)
  495. }
  496. },
  497. /**
  498. * DELETE PAGE
  499. */
  500. async deletePage(obj, args, context) {
  501. try {
  502. await WIKI.db.pages.deletePage({
  503. ...args,
  504. user: context.req.user
  505. })
  506. return {
  507. operation: generateSuccess('Page has been deleted.')
  508. }
  509. } catch (err) {
  510. return generateError(err)
  511. }
  512. },
  513. /**
  514. * DELETE TAG
  515. */
  516. async deleteTag (obj, args, context) {
  517. try {
  518. const tagToDel = await WIKI.db.tags.query().findById(args.id)
  519. if (tagToDel) {
  520. await tagToDel.$relatedQuery('pages').unrelate()
  521. await WIKI.db.tags.query().deleteById(args.id)
  522. } else {
  523. throw new Error('This tag does not exist.')
  524. }
  525. return {
  526. operation: generateSuccess('Tag has been deleted.')
  527. }
  528. } catch (err) {
  529. return generateError(err)
  530. }
  531. },
  532. /**
  533. * UPDATE TAG
  534. */
  535. async updateTag (obj, args, context) {
  536. try {
  537. const affectedRows = await WIKI.db.tags.query()
  538. .findById(args.id)
  539. .patch({
  540. tag: _.trim(args.tag).toLowerCase(),
  541. title: _.trim(args.title)
  542. })
  543. if (affectedRows < 1) {
  544. throw new Error('This tag does not exist.')
  545. }
  546. return {
  547. operation: generateSuccess('Tag has been updated successfully.')
  548. }
  549. } catch (err) {
  550. return generateError(err)
  551. }
  552. },
  553. /**
  554. * FLUSH PAGE CACHE
  555. */
  556. async flushCache(obj, args, context) {
  557. try {
  558. await WIKI.db.pages.flushCache()
  559. WIKI.events.outbound.emit('flushCache')
  560. return {
  561. operation: generateSuccess('Pages Cache has been flushed successfully.')
  562. }
  563. } catch (err) {
  564. return generateError(err)
  565. }
  566. },
  567. /**
  568. * MIGRATE ALL PAGES FROM SOURCE LOCALE TO TARGET LOCALE
  569. */
  570. async migrateToLocale(obj, args, context) {
  571. try {
  572. const count = await WIKI.db.pages.migrateToLocale(args)
  573. return {
  574. operation: generateSuccess('Migrated content to target locale successfully.'),
  575. count
  576. }
  577. } catch (err) {
  578. return generateError(err)
  579. }
  580. },
  581. /**
  582. * REBUILD TREE
  583. */
  584. async rebuildPageTree(obj, args, context) {
  585. try {
  586. await WIKI.db.pages.rebuildTree()
  587. return {
  588. operation: generateSuccess('Page tree rebuilt successfully.')
  589. }
  590. } catch (err) {
  591. return generateError(err)
  592. }
  593. },
  594. /**
  595. * RERENDER PAGE
  596. */
  597. async rerenderPage (obj, args, context) {
  598. try {
  599. const page = await WIKI.db.pages.query().findById(args.id)
  600. if (!page) {
  601. throw new WIKI.Error.PageNotFound()
  602. }
  603. await WIKI.db.pages.renderPage(page)
  604. return {
  605. operation: generateSuccess('Page rerendered successfully.')
  606. }
  607. } catch (err) {
  608. return generateError(err)
  609. }
  610. },
  611. /**
  612. * RESTORE PAGE VERSION
  613. */
  614. async restorePage (obj, args, context) {
  615. try {
  616. const page = await WIKI.db.pages.query().select('path', 'locale').findById(args.pageId)
  617. if (!page) {
  618. throw new WIKI.Error.PageNotFound()
  619. }
  620. if (!WIKI.auth.checkAccess(context.req.user, ['write:pages'], {
  621. path: page.path,
  622. locale: page.locale
  623. })) {
  624. throw new WIKI.Error.PageRestoreForbidden()
  625. }
  626. const targetVersion = await WIKI.db.pageHistory.getVersion({ pageId: args.pageId, versionId: args.versionId })
  627. if (!targetVersion) {
  628. throw new WIKI.Error.PageNotFound()
  629. }
  630. await WIKI.db.pages.updatePage({
  631. ...targetVersion,
  632. id: targetVersion.pageId,
  633. user: context.req.user,
  634. action: 'restored'
  635. })
  636. return {
  637. operation: generateSuccess('Page version restored successfully.')
  638. }
  639. } catch (err) {
  640. return generateError(err)
  641. }
  642. },
  643. /**
  644. * Purge history
  645. */
  646. async purgePagesHistory (obj, args, context) {
  647. try {
  648. await WIKI.db.pageHistory.purge(args.olderThan)
  649. return {
  650. operation: generateSuccess('Page history purged successfully.')
  651. }
  652. } catch (err) {
  653. return generateError(err)
  654. }
  655. }
  656. },
  657. Page: {
  658. icon (page) {
  659. return page.icon || 'las la-file-alt'
  660. },
  661. password (page) {
  662. return page.password ? '********' : ''
  663. },
  664. content (page, args, context) {
  665. if (!WIKI.auth.checkAccess(context.req.user, ['read:source', 'write:pages', 'manage:pages'], {
  666. path: page.path,
  667. locale: page.locale
  668. })) {
  669. throw new Error('ERR_FORBIDDEN')
  670. }
  671. return page.content
  672. },
  673. // async tags (page) {
  674. // return WIKI.db.pages.relatedQuery('tags').for(page.id)
  675. // },
  676. tocDepth (page) {
  677. return {
  678. min: page.extra?.tocDepth?.min ?? 1,
  679. max: page.extra?.tocDepth?.max ?? 2
  680. }
  681. }
  682. // comments(pg) {
  683. // return pg.$relatedQuery('comments')
  684. // }
  685. }
  686. }