ASpell.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Sanity-check the conversion and remove this comment.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS101: Remove unnecessary use of Array.from
  6. * DS102: Remove unnecessary code created because of implicit returns
  7. * DS207: Consider shorter variations of null checks
  8. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  9. */
  10. import fs from 'node:fs'
  11. import Path from 'node:path'
  12. import { promisify } from 'node:util'
  13. import LRU from 'lru-cache'
  14. import logger from '@overleaf/logger'
  15. import settings from '@overleaf/settings'
  16. import OError from '@overleaf/o-error'
  17. import { ASpellWorkerPool } from './ASpellWorkerPool.js'
  18. let ASPELL_TIMEOUT = 10000
  19. const OneMinute = 60 * 1000
  20. const opts = { max: 10000, maxAge: OneMinute * 60 * 10 }
  21. const cache = new LRU(opts)
  22. const cacheFsPath = Path.resolve(settings.cacheDir, 'spell.cache')
  23. const cacheFsPathTmp = cacheFsPath + '.tmp'
  24. const WorkerPool = new ASpellWorkerPool()
  25. // load any existing cache
  26. try {
  27. const oldCache = fs.readFileSync(cacheFsPath)
  28. cache.load(JSON.parse(oldCache))
  29. } catch (error) {
  30. logger.debug(
  31. OError.tag(error, 'could not load the cache file', { cacheFsPath })
  32. )
  33. }
  34. let cacheDumpInterval
  35. export function startCacheDump() {
  36. // write the cache every 30 minutes
  37. cacheDumpInterval = setInterval(function () {
  38. const dump = JSON.stringify(cache.dump())
  39. return fs.writeFile(cacheFsPathTmp, dump, function (err) {
  40. if (err != null) {
  41. logger.debug(OError.tag(err, 'error writing cache file'))
  42. fs.unlink(cacheFsPathTmp, () => {})
  43. } else {
  44. fs.rename(cacheFsPathTmp, cacheFsPath, err => {
  45. if (err) {
  46. logger.error(OError.tag(err, 'error renaming cache file'))
  47. } else {
  48. logger.debug({ len: dump.length, cacheFsPath }, 'wrote cache file')
  49. }
  50. })
  51. }
  52. })
  53. }, 30 * OneMinute)
  54. }
  55. export function stopCacheDump() {
  56. clearInterval(cacheDumpInterval)
  57. }
  58. class ASpellRunner {
  59. checkWords(language, words, callback) {
  60. if (callback == null) {
  61. callback = () => {}
  62. }
  63. return this.runAspellOnWords(language, words, (error, output) => {
  64. if (error != null) {
  65. return callback(OError.tag(error))
  66. }
  67. // output = @removeAspellHeader(output)
  68. const suggestions = this.getSuggestions(language, output)
  69. const results = []
  70. let hits = 0
  71. const addToCache = {}
  72. for (let i = 0; i < words.length; i++) {
  73. const word = words[i]
  74. const key = language + ':' + word
  75. const cached = cache.get(key)
  76. if (cached != null) {
  77. hits++
  78. if (cached === true) {
  79. // valid word, no need to do anything
  80. continue
  81. } else {
  82. results.push({ index: i, suggestions: cached })
  83. }
  84. } else {
  85. if (suggestions[key] != null) {
  86. addToCache[key] = suggestions[key]
  87. results.push({ index: i, suggestions: suggestions[key] })
  88. } else {
  89. // a valid word, but uncached
  90. addToCache[key] = true
  91. }
  92. }
  93. }
  94. // update the cache after processing all words, to avoid cache
  95. // changing while we use it
  96. for (const k in addToCache) {
  97. const v = addToCache[k]
  98. cache.set(k, v)
  99. }
  100. logger.debug(
  101. {
  102. hits,
  103. total: words.length,
  104. hitrate: (hits / words.length).toFixed(2),
  105. },
  106. 'cache hit rate'
  107. )
  108. return callback(null, results)
  109. })
  110. }
  111. getSuggestions(language, output) {
  112. const lines = output.split('\n')
  113. const suggestions = {}
  114. for (const line of Array.from(lines)) {
  115. let parts, word
  116. if (line[0] === '&') {
  117. // Suggestions found
  118. parts = line.split(' ')
  119. if (parts.length > 1) {
  120. word = parts[1]
  121. const suggestionsString = line.slice(line.indexOf(':') + 2)
  122. suggestions[language + ':' + word] = suggestionsString.split(', ')
  123. }
  124. } else if (line[0] === '#') {
  125. // No suggestions
  126. parts = line.split(' ')
  127. if (parts.length > 1) {
  128. word = parts[1]
  129. suggestions[language + ':' + word] = []
  130. }
  131. }
  132. }
  133. return suggestions
  134. }
  135. // removeAspellHeader: (output) -> output.slice(1)
  136. runAspellOnWords(language, words, callback) {
  137. // send words to aspell, get back string output for those words
  138. // find a free pipe for the language (or start one)
  139. // send the words down the pipe
  140. // send an END marker that will generate a "*" line in the output
  141. // when the output pipe receives the "*" return the data sofar and reset the pipe to be available
  142. //
  143. // @open(language)
  144. // @captureOutput(callback)
  145. // @setTerseMode()
  146. // start = new Date()
  147. if (callback == null) {
  148. callback = () => {}
  149. }
  150. const newWord = {}
  151. for (const word of Array.from(words)) {
  152. if (!newWord[word] && !cache.has(language + ':' + word)) {
  153. newWord[word] = true
  154. }
  155. }
  156. words = Object.keys(newWord)
  157. if (words.length) {
  158. return WorkerPool.check(language, words, ASPELL_TIMEOUT, callback)
  159. } else {
  160. return callback(null, '')
  161. }
  162. }
  163. }
  164. // The description of how to call aspell from another program can be found here:
  165. // http://aspell.net/man-html/Through-A-Pipe.html
  166. export function checkWords(language, words, callback) {
  167. if (callback == null) {
  168. callback = () => {}
  169. }
  170. const runner = new ASpellRunner()
  171. return runner.checkWords(language, words, callback)
  172. }
  173. export const promises = {
  174. checkWords: promisify(checkWords),
  175. }
  176. // for tests
  177. export function setTimeout(timeout) {
  178. ASPELL_TIMEOUT = timeout
  179. }