output-files.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
  2. import BibLogParser from '../../../ide/log-parser/bib-log-parser'
  3. import { enablePdfCaching } from './pdf-caching-flags'
  4. import { debugConsole } from '@/utils/debugging'
  5. import { dirname, findEntityByPath } from '@/features/file-tree/util/path'
  6. import '@/utils/readable-stream-async-iterator-polyfill'
  7. import { EDITOR_SESSION_ID } from '@/features/pdf-preview/util/metrics'
  8. // Warnings that may disappear after a second LaTeX pass
  9. const TRANSIENT_WARNING_REGEX = /^(Reference|Citation).+undefined on input line/
  10. const MAX_LOG_SIZE = 1024 * 1024 // 1MB
  11. const MAX_BIB_LOG_SIZE_PER_FILE = MAX_LOG_SIZE
  12. export function handleOutputFiles(outputFiles, projectId, data) {
  13. const outputFile = outputFiles.get('output.pdf')
  14. if (!outputFile) return null
  15. outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID
  16. outputFile.clsiCacheShard = data.clsiCacheShard || 'cache'
  17. // build the URL for viewing the PDF in the preview UI
  18. const params = new URLSearchParams()
  19. if (data.compileGroup) {
  20. params.set('compileGroup', data.compileGroup)
  21. }
  22. if (data.clsiServerId) {
  23. params.set('clsiserverid', data.clsiServerId)
  24. }
  25. if (enablePdfCaching) {
  26. // Tag traffic that uses the pdf caching logic.
  27. params.set('enable_pdf_caching', 'true')
  28. }
  29. outputFile.pdfUrl = `${buildURL(
  30. outputFile,
  31. data.pdfDownloadDomain
  32. )}?${params}`
  33. if (data.fromCache) {
  34. outputFile.pdfDownloadUrl = outputFile.downloadURL
  35. } else {
  36. // build the URL for downloading the PDF
  37. params.set('popupDownload', 'true') // save PDF download as file
  38. outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
  39. }
  40. return outputFile
  41. }
  42. let nextEntryId = 1
  43. function generateEntryKey() {
  44. return 'compile-log-entry-' + nextEntryId++
  45. }
  46. export const handleLogFiles = async (outputFiles, data, signal) => {
  47. const result = {
  48. log: null,
  49. logEntries: {
  50. errors: [],
  51. warnings: [],
  52. typesetting: [],
  53. },
  54. }
  55. function accumulateResults(newEntries, type) {
  56. for (const key in result.logEntries) {
  57. if (newEntries[key]) {
  58. for (const entry of newEntries[key]) {
  59. if (type) {
  60. entry.type = newEntries.type
  61. }
  62. if (entry.file) {
  63. entry.file = normalizeFilePath(entry.file)
  64. }
  65. entry.key = generateEntryKey()
  66. }
  67. result.logEntries[key].push(...newEntries[key])
  68. }
  69. }
  70. }
  71. const logFile = outputFiles.get('output.log')
  72. if (logFile) {
  73. result.log = await fetchFileWithSizeLimit(
  74. buildURL(logFile, data.pdfDownloadDomain),
  75. signal,
  76. MAX_LOG_SIZE
  77. )
  78. try {
  79. let { errors, warnings, typesetting } = HumanReadableLogs.parse(
  80. result.log,
  81. {
  82. ignoreDuplicates: true,
  83. }
  84. )
  85. if (data.status === 'stopped-on-first-error') {
  86. // Hide warnings that could disappear after a second pass
  87. warnings = warnings.filter(warning => !isTransientWarning(warning))
  88. }
  89. accumulateResults({ errors, warnings, typesetting })
  90. } catch (e) {
  91. debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
  92. }
  93. }
  94. const blgFiles = []
  95. for (const [filename, file] of outputFiles) {
  96. if (filename.endsWith('.blg')) {
  97. blgFiles.push(file)
  98. }
  99. }
  100. for (const blgFile of blgFiles) {
  101. const log = await fetchFileWithSizeLimit(
  102. buildURL(blgFile, data.pdfDownloadDomain),
  103. signal,
  104. MAX_BIB_LOG_SIZE_PER_FILE
  105. )
  106. try {
  107. const { errors, warnings } = new BibLogParser(log, {
  108. maxErrors: 100,
  109. }).parse()
  110. accumulateResults({ errors, warnings }, 'BibTeX:')
  111. } catch (e) {
  112. // BibLog parsing errors are ignored
  113. }
  114. }
  115. result.logEntries.all = [
  116. ...result.logEntries.errors,
  117. ...result.logEntries.warnings,
  118. ...result.logEntries.typesetting,
  119. ]
  120. return result
  121. }
  122. /**
  123. * @typedef {import('../../../../../types/annotation').Annotation} Annotation
  124. * @returns {Record<string, Annotation[]>}
  125. */
  126. export function buildLogEntryAnnotations(entries, fileTreeData, rootDocId) {
  127. const rootDocDirname = dirname(fileTreeData, rootDocId)
  128. const logEntryAnnotations = {}
  129. const seenLine = {}
  130. for (const entry of entries) {
  131. if (entry.file) {
  132. entry.file = normalizeFilePath(entry.file, rootDocDirname)
  133. const entity = findEntityByPath(fileTreeData, entry.file)?.entity
  134. if (entity) {
  135. if (!(entity._id in logEntryAnnotations)) {
  136. logEntryAnnotations[entity._id] = []
  137. }
  138. const annotation = {
  139. id: entry.key,
  140. entryIndex: logEntryAnnotations[entity._id].length, // used for maintaining the order of items on the same line
  141. row: entry.line - 1,
  142. type: entry.level === 'error' ? 'error' : 'warning',
  143. text: entry.message,
  144. source: 'compile', // NOTE: this is used in Ace for filtering the annotations
  145. ruleId: entry.ruleId,
  146. command: entry.command,
  147. }
  148. // set firstOnLine for the first non-typesetting annotation on a line
  149. if (entry.level !== 'typesetting') {
  150. if (!seenLine[entry.line]) {
  151. annotation.firstOnLine = true
  152. seenLine[entry.line] = true
  153. }
  154. }
  155. logEntryAnnotations[entity._id].push(annotation)
  156. }
  157. }
  158. }
  159. return logEntryAnnotations
  160. }
  161. export const buildRuleCounts = (entries = []) => {
  162. const counts = {}
  163. for (const entry of entries) {
  164. const key = `${entry.level}_${entry.ruleId}`
  165. counts[key] = counts[key] ? counts[key] + 1 : 1
  166. }
  167. return counts
  168. }
  169. export const buildRuleDeltas = (ruleCounts, previousRuleCounts) => {
  170. const counts = {}
  171. // keys that are defined in the current log entries
  172. for (const [key, value] of Object.entries(ruleCounts)) {
  173. const previousValue = previousRuleCounts[key] ?? 0
  174. counts[`delta_${key}`] = value - previousValue
  175. }
  176. // keys that are no longer defined in the current log entries
  177. for (const [key, value] of Object.entries(previousRuleCounts)) {
  178. if (!(key in ruleCounts)) {
  179. counts[key] = 0
  180. counts[`delta_${key}`] = -value
  181. }
  182. }
  183. return counts
  184. }
  185. function buildURL(file, pdfDownloadDomain) {
  186. if (file.build && pdfDownloadDomain) {
  187. // Downloads from the compiles domain must include a build id.
  188. // The build id is used implicitly for access control.
  189. return `${pdfDownloadDomain}${file.url}`
  190. }
  191. // Go through web instead, which uses mongo for checking project access.
  192. return `${window.origin}${file.url}`
  193. }
  194. function normalizeFilePath(path, rootDocDirname) {
  195. path = path.replace(/\/\//g, '/')
  196. path = path.replace(
  197. /^.*\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
  198. ''
  199. )
  200. path = path.replace(/^\/compile\//, '')
  201. if (rootDocDirname) {
  202. path = path.replace(/^\.\//, rootDocDirname + '/')
  203. }
  204. return path
  205. }
  206. function isTransientWarning(warning) {
  207. return TRANSIENT_WARNING_REGEX.test(warning.message)
  208. }
  209. async function fetchFileWithSizeLimit(url, signal, maxSize) {
  210. let result = ''
  211. try {
  212. const abortController = new AbortController()
  213. // abort fetching the log file if the main signal is aborted
  214. signal.addEventListener('abort', () => {
  215. abortController.abort()
  216. })
  217. const response = await fetch(url, {
  218. signal: abortController.signal,
  219. })
  220. if (!response.ok) {
  221. throw new Error('Failed to fetch log file')
  222. }
  223. const reader = response.body.pipeThrough(new TextDecoderStream())
  224. for await (const chunk of reader) {
  225. result += chunk
  226. if (result.length > maxSize) {
  227. abortController.abort()
  228. }
  229. }
  230. } catch (e) {
  231. debugConsole.warn(e) // ignore failure to fetch the log file, but log a warning
  232. }
  233. return result
  234. }