output-files.ts 9.0 KB

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